Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key> root@<host> …`, 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
101 changes: 101 additions & 0 deletions scripts/gate-tool-coverage-test.cjs
Original file line number Diff line number Diff line change
@@ -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);
})();
2 changes: 1 addition & 1 deletion terminal/claude-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'" } ] }
Expand Down
17 changes: 17 additions & 0 deletions terminal/gate-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key> root@<host> …`
# 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
Expand Down
Loading