From ba8199962136728c6e06160fb3154be05a3bb44e Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Fri, 18 Sep 2026 10:37:19 +0530 Subject: [PATCH] =?UTF-8?q?fix(gate):=20Monitor=20is=20a=20shell=20?= =?UTF-8?q?=E2=80=94=20govern=20it=20(v0.448.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-code's Monitor tool runs an arbitrary shell command and was in neither the hook's routing table nor the PreToolUse matcher, so every call bypassed policy, approval and audit. Route it to shell.exec, refuse the ws form, and add gate-tool-coverage-test to pin matcher/table agreement. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 +++++ package-lock.json | 4 +- package.json | 2 +- scripts/gate-tool-coverage-test.cjs | 101 ++++++++++++++++++++++++++++ terminal/claude-launch.sh | 2 +- terminal/gate-hook.sh | 17 +++++ 6 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 scripts/gate-tool-coverage-test.cjs 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