diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a30445..d4fa7d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ new version heading in the same commit. ## [Unreleased] +## [0.448.1] - 2026-09-18 +### Fixed +- **`Monitor` was an ungoverned shell.** claude-code's `Monitor` tool runs an arbitrary shell command + (it streams a long-running script's stdout back as events), and it appeared in neither + `gate-hook.sh`'s routing table nor the PreToolUse matcher `claude-launch.sh` writes — so it hit the + allow-by-default `*)` arm with no policy check, no approval and no audit row. Live transcripts on two + tenants show agents using it to `until ssh -i ~/.ssh/ root@ …`, to write files and to mint + tokens, all of it invisible to the gateway. `Monitor` is now matched and routed to `shell.exec`: its + `tool_input.command` has the same shape as Bash's, so the enricher computes the identical facts + (host egress, destructive flags) with no server change. Its `ws` form carries no command the gate can + classify and is refused locally rather than passed on as a factless `shell.exec`. + `scripts/gate-tool-coverage-test.cjs` (new, in `test:governance`) drives the real hook against a stub + gate and asserts the matcher and the routing table agree — the pairing nothing checked before. + **For admins:** Commands an agent runs through `Monitor` (waiting on a build, tailing a log) are now + governed exactly like Bash, so a risky one can pause for your approval and all of them show up in + Audit. + ## [0.448.0] - 2026-09-18 ### Added - **A run that only lacks a free account now waits for one instead of crashing.** When every runtime diff --git a/package-lock.json b/package-lock.json index 60b78cde..65f95a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.448.0", + "version": "0.448.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.448.0", + "version": "0.448.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 2e974b9d..bc372f80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.448.0", + "version": "0.448.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/gate-tool-coverage-test.cjs b/scripts/gate-tool-coverage-test.cjs new file mode 100644 index 00000000..05e95484 --- /dev/null +++ b/scripts/gate-tool-coverage-test.cjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/* + * Gate tool coverage — every tool the hook ROUTES must also be MATCHED by the launcher, and every + * exec-capable tool must be routed. + * + * Why this exists: claude-code's `Monitor` tool runs an arbitrary shell command (it streams a + * long-running script's stdout as events). It was in neither `gate-hook.sh`'s routing table nor the + * PreToolUse matcher in `claude-launch.sh`, so it was completely ungoverned — no policy, no approval, + * no audit — and live transcripts show agents reaching remote hosts over ssh through it. The two lists + * have to agree: a tool routed but not matched never reaches the hook (silent bypass), and a tool + * matched but not routed hits the `*)` allow-by-default arm. + * + * Drives the REAL hook as a subprocess against a stub gate, so this pins the wire and not a copy of it. + */ +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const execFileP = promisify(execFile); + +const ROOT = path.resolve(__dirname, '..'); +const HOOK = path.join(ROOT, 'terminal/gate-hook.sh'); +const LAUNCH = fs.readFileSync(path.join(ROOT, 'terminal/claude-launch.sh'), 'utf8'); +const HOOKSH = fs.readFileSync(HOOK, 'utf8'); + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`)); + +// ── 1) the matcher covers every tool the routing table names ────────────────── +console.log('\n\x1b[1m1) the launcher matcher covers every routed tool\x1b[0m'); +const matcher = (LAUNCH.match(/"matcher":\s*"([^"]+)"[^\n]*PreToolUse|"matcher":\s*"([^"]+)"/) || []) + .slice(1).find(Boolean); +const m = LAUNCH.match(/"PreToolUse":\s*\[\s*\{\s*"matcher":\s*"([^"]+)"/); +assert(!!m, 'found the PreToolUse matcher in claude-launch.sh', matcher); +const re = new RegExp(`^(?:${m[1]})$`); + +// Tools named on the left of a `CAP=` arm — the literal names, minus the wildcard arms. +const routed = new Set(); +for (const line of HOOKSH.split('\n')) { + const arm = line.match(/^\s{2}([A-Za-z|_*]+)\)\s*$/) || line.match(/^\s{2}([A-Za-z|_]+)\)\s+CAP=/); + if (!arm) continue; + for (const name of arm[1].split('|')) if (/^[A-Z][A-Za-z]+$/.test(name)) routed.add(name); +} +assert(routed.has('Monitor'), 'Monitor is routed by the hook (it runs a shell command)', [...routed].join(',')); +assert(routed.has('Bash') && routed.has('Write'), 'the table was parsed (Bash + Write found)', [...routed].join(',')); +for (const tool of [...routed].sort()) + assert(re.test(tool), `matcher reaches \`${tool}\` — a routed tool the matcher misses never hits the gate`); + +// ── 2) end to end: the hook classifies Monitor as shell.exec ────────────────── +console.log('\n\x1b[1m2) the hook sends Monitor to the gate as shell.exec\x1b[0m'); +(async () => { + const seen = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + if (req.url === '/api/gate') { + seen.push(JSON.parse(body || '{}')); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ decision: 'allow' })); + return; + } + res.writeHead(404); res.end('{}'); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const AOS_URL = `http://127.0.0.1:${server.address().port}`; + + // ASYNC on purpose: execFileSync would block this process's event loop, so the stub server above + // could never accept the hook's curl — the hook would wait for a gate that cannot answer and the + // test would hang instead of failing. + const run = async (event) => { + const child = execFileP('bash', [HOOK], { + env: { ...process.env, AOS_URL, SESSION: 'ses_t', AGENT: 'tester', AOS_SECRET: 'x', AOS_RUNTIME: 'claude-code' }, + encoding: 'utf8', + timeout: 30000, + }); + child.child.stdin.end(JSON.stringify(event)); + return (await child).stdout; + }; + + const out = await run({ tool_name: 'Monitor', tool_input: { command: 'until ssh root@db.internal true; do sleep 2; done', description: 'wait', timeout_ms: 60000 } }); + const dec = JSON.parse(out).hookSpecificOutput; + assert(dec.permissionDecision === 'allow', 'an allowed Monitor comes back allow', out.trim()); + const call = seen.find((s) => s.args?.tool === 'Monitor'); + assert(!!call, 'the hook actually called /api/gate for Monitor'); + assert(call && call.capability === 'shell.exec', 'classified as shell.exec', call && call.capability); + assert(call && call.args.input.command.includes('ssh root@db.internal'), + 'the command text reaches the enricher (so host-egress facts are computable)'); + + // The ws form has no command to classify → denied locally, never sent as a factless shell.exec. + const wsOut = await run({ tool_name: 'Monitor', tool_input: { ws: { url: 'wss://evil.example.com/x' }, description: 'ws', timeout_ms: 60000 } }); + const wsDec = JSON.parse(wsOut).hookSpecificOutput; + assert(wsDec.permissionDecision === 'deny', 'the ws form is denied', wsOut.trim()); + assert(!seen.some((s) => s.args?.input?.ws), 'and is never sent to the gate as an empty shell.exec'); + + server.close(); + console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m\n`); + process.exit(fail ? 1 : 0); +})(); diff --git a/terminal/claude-launch.sh b/terminal/claude-launch.sh index 60f91596..4662a78c 100755 --- a/terminal/claude-launch.sh +++ b/terminal/claude-launch.sh @@ -243,7 +243,7 @@ $OUTPUT_STYLE_LINE }, "hooks": { "PreToolUse": [ - { "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit|Read|Glob|Grep|NotebookRead|mcp__.*", "hooks": [ { "type": "command", "command": "bash '$HOOK'" } ] } + { "matcher": "Bash|Monitor|Edit|Write|MultiEdit|NotebookEdit|Read|Glob|Grep|NotebookRead|mcp__.*", "hooks": [ { "type": "command", "command": "bash '$HOOK'" } ] } ], "Notification": [ { "hooks": [ { "type": "command", "command": "bash '$NOTIFY_HOOK'" } ] } diff --git a/terminal/gate-hook.sh b/terminal/gate-hook.sh index e309f0e0..6783b087 100755 --- a/terminal/gate-hook.sh +++ b/terminal/gate-hook.sh @@ -101,6 +101,23 @@ esac # names diverge fails loudly at the `*)` arm rather than silently allowing. case "$TOOL" in Bash|shell|local_shell|exec_command|unified_exec) CAP="shell.exec" ;; + # `Monitor` (claude-code) runs a shell command too — it streams a long-running script's stdout as + # events. Its `tool_input.command` has the SAME shape as Bash's, so routing it here gives the enricher + # the identical facts (ssh/curl host egress, destructive flags) with no change on the server. It was + # missing from BOTH this table and the launcher's hook matcher until v0.448.1, so every Monitor call + # was ungoverned: live transcripts show agents using it to `until ssh -i ~/.ssh/ root@ …` + # and to write files, with no policy check, no approval and no audit row. This is the same class as the + # cross-session-messaging channel in CLAUDE.md — a new release adds a tool that reaches outside the + # session, and an allow-by-default `*)` arm lets it through. Re-diff on every claude upgrade. + Monitor) + # The `ws` form opens a WebSocket instead of running a command, so there is no command to classify + # and no host fact the enricher can compute from it. Refuse it rather than pass an empty command to + # the gate (which would classify as a bare, factless shell.exec and read as allowed): the governed + # way to reach a socket is a Bash command, which is gated on its text. + if printf '%s' "$INPUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{let i={};try{i=JSON.parse(d||"{}")}catch(e){};process.exit(!i.command && i.ws ? 0 : 1)})' 2>/dev/null; then + emit deny "Agentric: Monitor's WebSocket form (ws) is not governed — the gate can only classify a shell command. Use Monitor with a \`command\` (e.g. a websocat/curl poll), which is gated like any Bash call." + fi + CAP="shell.exec" ;; # File writes go through the gateway too (the enricher decides inside-vs-outside the agent's folder # from the path in tool_input). The hook stays dumb transport — it only names the capability. # `apply_patch` is Codex's editor tool and DOES fire PreToolUse (verified), so writes are gated there