feat(proxy): PreToolUse-Hook blockiert Bash-curl wenn Skill existiert

Variante A endlich umgesetzt: echter Hard-Block vor Bash-Ausfuehrung.
Anders als 14 seed_rules + Bypass-Lehre, die ARIA ignorieren kann,
ist das ein technisch erzwungener Reject auf claude-CLI-Ebene.

Komponenten:

1. aria-brain main.py: neuer Endpoint POST /skills/can-bash-host
   Bekommt {command}, parst https-URLs raus, prueft gegen aktive Skills
   (stem-match: 'spotify' im Hostname 'api.spotify.com'). Returnt
   {block, host, skill, safe_tool} wenn ein Skill den Host abdeckt.

2. proxy-patches/pre-tool-bash-block.js: Node-Script das vom claude-CLI
   als PreToolUse-Hook fuer das Bash-Tool aufgerufen wird. Liest Tool-
   Use-Payload via stdin, ruft Brain-Endpoint mit kurzem Timeout (3s),
   bei block=true → exit 2 mit Stderr-Message. claude-CLI gibt Stderr
   als tool_use_error an das LLM zurueck — echter Fehler, nicht
   ignorierbar.
   Fail-open bei Brain-Down / Timeout / JSON-Fehler: kein Lockout.

3. proxy-patches/managed-settings.json: claude-CLI Hook-Config mit
   PreToolUse-Matcher 'Bash' der das Node-Script ausfuehrt.
   /etc/claude-code/managed-settings.json hat Vorrang vor User-Settings
   und betrifft NICHT Stefans Host-~/.claude/settings.json.

4. docker-compose.yml: proxy-Command erweitert um
   `mkdir -p /etc/claude-code && cp managed-settings.json dorthin`
   damit beim Container-Start die Hook-Config aktiv ist.

Beobachtung die das motiviert: 14 seed_rules + Bypass-Lehre +
Auto-Scaffold + Safe-Names. ARIA hat trotzdem letzten Test mit 2
verschachtelten Bash-curls bedient statt run_spotify zu rufen
(content_len=73, tool_calls=0). Prompt-Engineering ausgereizt.

ARIA bekommt jetzt:
🚨 BASH GEGEN api.spotify.com BLOCKIERT.
Es existiert bereits ein Skill 'spotify' fuer diesen Host. ...
Konkret: nutze JETZT `run_spotify` mit den passenden Parametern
(method/path/body) statt curl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:49:56 +02:00
parent fef2a32c50
commit 7d8c411f5c
5 changed files with 159 additions and 0 deletions
+1
View File
@@ -402,6 +402,7 @@ jedem Chat-Turn im Hot-Memory-Block auf:
- **oauth-reauth-reflex** — bei 401: ZUERST `oauth_get_token` (Auto-Refresh), nur bei dessen Fehler `oauth_authorize`
- **no-skill-drift** — kein Drift vom Skill zu Ad-hoc-Bash-Befehlen. Skill kaputt? `skill_logs` + `skill_update`. Niemals nur SAGEN „ich baue dir einen Skill", wenn `skill_create` nicht wirklich gefeuert wird
- **runtime-topology** (architektur) — ARIA laeuft als `claude`-CLI-Subprocess IM aria-proxy Container (alpine — kein python3/jq), NICHT im aria-brain. `/data/skills/` und `BRAIN_INTERNAL_URL` existieren dort nicht. Brain-Resources via Brain-Tools (`oauth_get_token`, `memory_search`, `run_<skill>` …), nicht via Bash. SSH zur VM-Host via `ssh aria@host` (Key liegt im Proxy)
- **PreToolUse-Hard-Block** *(claude-CLI Hook)*: `proxy-patches/pre-tool-bash-block.js` ist als PreToolUse-Hook fuer das Bash-Tool im aria-proxy-Container registriert (via `/etc/claude-code/managed-settings.json`). Vor JEDEM Bash-Tool-Call wird Brain-Endpoint `/skills/can-bash-host` gefragt — wenn die URL gegen einen Host laeuft fuer den bereits ein matching Skill existiert, exit 2 + Stderr → claude-CLI lehnt den Tool-Call ab und gibt ARIA einen echten Tool-Error zurueck *„BLOCKED — nutze run_X stattdessen"*. Im Gegensatz zu seed_rules ist das echter Zwang, kein Hinweis den sie ignorieren kann. Fail-open: bei Brain-Timeout/Fehler greift der Block nicht (kein Lockout).
- **scaffold-reflex** — Brain trackt cross-session welche externen Hosts via Bash-curl wiederholt (≥3× in 24h) ohne passenden Skill aufgerufen wurden. Ergebnis landet als `## API-Heuristik`-Block im System-Prompt. **Auto-Scaffold**: bei bekannten Hosts (Spotify, GitHub, OpenAI etc.) legt Brain den Skill automatisch an — ARIA findet ihn beim nächsten Turn vor (author=`aria-auto`) und nutzt `run_<name>` statt curlen. Toggle via ENV `BRAIN_AUTO_SCAFFOLD=false`. **Bypass-Lehre**: wenn ARIA trotz vorhandenem Skill weiter curlt (Skill-Bypass), erkennt Brain das im agent_stream und (1) injiziert einen drastischen `🚨 SKILL-BYPASS`-Hint im aktuellen System-Prompt und (2) speichert ein pinned `type=rule, source=auto-feedback` Memory mit Skill+Host (idempotent via migration_key `auto/skill-bypass/<skill>`) — damit lernt sie es cross-session, nicht nur in der aktuellen Konversation. Data-Source: `agent_stream.jsonl`, Cache 5 min
- **external-api-auth-strategy** — OAuth2 → `oauth_get_token`, sonst `config_schema`, NIEMALS hardcoden
+46
View File
@@ -805,6 +805,52 @@ class SkillScaffold(BaseModel):
author: str = "stefan"
class SkillCanBashHostIn(BaseModel):
command: str
@app.post("/skills/can-bash-host")
def skills_can_bash_host(body: SkillCanBashHostIn):
"""Prueft ob ein Bash-Command gegen einen Host laufen will fuer den
bereits ein matching Skill existiert. Wird vom claude-CLI PreToolUse-
Hook im aria-proxy gefragt — wenn block=True, weist der Hook den
Bash-Call mit Fehlermeldung zurueck und ARIA muss `run_<skill>` nehmen.
Antwort: {block: bool, host?: str, skill?: str, safe_tool?: str}
"""
import re as _re
cmd = (body.command or "").strip()
if not cmd:
return {"block": False}
skills = skills_mod.list_skills(active_only=False)
if not skills:
return {"block": False}
# Stem-Map: jeder Skill-Name als potentieller Hostname-Match
# (yt_dlp_download → 'yt_dlp_download', 'spotify' → 'spotify' etc.)
stem_to_skill = {}
for s in skills:
sname = (s.get("name") or "").lower()
if sname:
stem_to_skill[sname] = sname
# Underscore-Variante auch als Stem akzeptieren
stem_to_skill[sname.replace("_", "-")] = sname
# Alle https-URLs im Command einsammeln + matchen
for url_host in _re.findall(r'https?://([a-zA-Z0-9.\-]+)', cmd):
host_lower = url_host.lower()
for stem, skill_name in stem_to_skill.items():
if stem and stem in host_lower:
safe_tool = "run_" + _re.sub(r"[^a-zA-Z0-9_]", "_", skill_name)
return {
"block": True,
"host": url_host,
"skill": skill_name,
"safe_tool": safe_tool,
}
return {"block": False}
@app.get("/skills/templates")
def skills_templates_list():
"""Liste der verfuegbaren Templates — fuer UI und Dokumentation."""
+2
View File
@@ -16,6 +16,8 @@ services:
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
mkdir -p /etc/claude-code &&
cp /proxy-patches/managed-settings.json /etc/claude-code/managed-settings.json &&
claude-max-api"
volumes:
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node /proxy-patches/pre-tool-bash-block.js"
}
]
}
]
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* ARIA claude-CLI PreToolUse-Hook: blockiert Bash-Calls gegen externe APIs
* fuer die bereits ein matching Skill im Brain existiert.
*
* Wird von claude-CLI PRO Tool-Use vor der Ausfuehrung mit dem Tool-Use-
* JSON via stdin aufgerufen. Wenn wir exit 2 mit Stderr returnen, lehnt
* claude-CLI den Tool-Call ab und gibt die Stderr als tool_use_error
* an das LLM zurueck — ARIA bekommt also eine echte Fehlermeldung und
* MUSS umdenken (nicht nur Prompt-Anweisung die sie ignorieren kann).
*
* Fail-open: bei jeder Art von Fehler (Brain nicht erreichbar, kaputtes
* JSON etc.) exit 0 — wir blockieren Stefan's eigentliche Arbeit nicht
* nur weil der Block-Mechanismus selber haengt.
*/
const http = require("http");
const BRAIN_URL = process.env.BRAIN_INTERNAL_URL || "http://aria-brain:8080";
const BRAIN_TIMEOUT_MS = 3000;
function fail_open(reason) {
if (process.env.HOOK_DEBUG) console.error(`hook-skip: ${reason}`);
process.exit(0);
}
function block(message) {
// exit 2 = block in claude-CLI PreToolUse hook contract
process.stderr.write(message);
process.exit(2);
}
let stdinBuf = "";
process.stdin.on("data", chunk => stdinBuf += chunk);
process.stdin.on("end", () => {
let payload;
try {
payload = JSON.parse(stdinBuf || "{}");
} catch (_) {
return fail_open("stdin not json");
}
// claude-CLI Hook-Format kann je nach Version variieren —
// wir akzeptieren tool_name oder hook_event_name in Kombination
const toolName = payload.tool_name || payload.tool || "";
if (toolName !== "Bash") return fail_open("tool != Bash");
const command = (payload.tool_input && payload.tool_input.command) ||
payload.command || "";
if (!command) return fail_open("no command");
// Schnellfilter: nur wenn ueberhaupt eine URL drin ist
if (!/https?:\/\//i.test(command)) return fail_open("no url");
// Brain fragen ob ein matching Skill existiert
const body = JSON.stringify({ command });
const req = http.request(
BRAIN_URL + "/skills/can-bash-host",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
timeout: BRAIN_TIMEOUT_MS,
},
(res) => {
let chunks = "";
res.on("data", d => chunks += d);
res.on("end", () => {
let r;
try { r = JSON.parse(chunks); } catch (_) { return fail_open("brain bad json"); }
if (!r || !r.block) return fail_open("brain says ok");
const skill = r.skill || "?";
const host = r.host || "?";
const safeTool = r.safe_tool || `run_${skill}`;
const msg =
`🚨 BASH GEGEN ${host} BLOCKIERT.\n\n` +
`Es existiert bereits ein Skill '${skill}' fuer diesen Host. ` +
`Stefan hat das System so eingerichtet dass Skills via ` +
`\`${safeTool}\` direkt aufgerufen werden — das ist 5-10x ` +
`schneller als der Bash-Curl-Wrapper.\n\n` +
`Konkret: nutze JETZT \`${safeTool}\` mit den passenden ` +
`Parametern (method/path/body) statt curl. Wenn der Skill ` +
`nicht das liefert was Du brauchst: skill_update mit Fix, ` +
`nicht zurueck zu Bash.`;
block(msg);
});
}
);
req.on("error", () => fail_open("brain network error"));
req.on("timeout", () => { req.destroy(); fail_open("brain timeout"); });
req.write(body);
req.end();
});
// Falls stdin nie ein 'end' triggert — Timeout damit wir nicht haengen
setTimeout(() => fail_open("stdin timeout"), 4000);