From ff09e08a6ba5488226267595a81bc5e4f270d0f0 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 29 Sep 2026 13:44:03 +0530 Subject: [PATCH] fix(gate): classify the scripts an agent runs, not just the command line (v0.448.7) On globex a billing agent wrote a Stripe teardown (card detach, sub cancel, customer delete) into a .sh file and ran `bash `; the gate saw a green shell call and two live accounts were changed with no human in the loop. A shell call that executes a file changed during the run or in the last 24h now has that body checked against the workspace's custom patterns (line by line) and the unambiguous destructive ops. Scoped from a week of replayed globex traffic: 1,754 decision flips for the broad version, 5 for this one. Linear-time: an unanchored lookahead pattern over a 30 KB blob cost 0.6s of blocked event loop per call. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++ package-lock.json | 4 +- package.json | 4 +- scripts/script-body-gate-test.cjs | 122 ++++++++++++++++++++++ src/governance/enricher.ts | 44 ++++++-- src/governance/script-bodies.ts | 167 ++++++++++++++++++++++++++++++ src/terminal.ts | 22 +++- 7 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 scripts/script-body-gate-test.cjs create mode 100644 src/governance/script-bodies.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e1474c34..2063d7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ new version heading in the same commit. ## [Unreleased] +## [0.448.7] - 2026-09-29 +### Fixed +- **The gate now reads the scripts an agent runs, not just the command that runs them.** On globex a + billing agent wrote its Stripe teardown (card detach, subscription cancel, customer delete) into a + `.sh` file and ran `bash `. The gate classified `bash ` as a harmless shell call, so two + live customer accounts lost their card or Stripe customer with no human involved — the same calls typed + directly would have hit the tenant's guardrail. When a shell call executes a file (`bash x.sh`, + `./x.sh`, `php x.php`, `python3 x.py`, `source x`, `bash -c "…"`, after a `cd`) that was changed during + this run or in the last 24 h, its body is now checked against the workspace's custom governance + patterns (line by line) and the unambiguous destructive ops (DROP/TRUNCATE TABLE, mkfs, dd, terraform + destroy, kubectl delete, force-push), following scripts it runs in turn. The audit row lists + `scriptsInspected`. Scoped that narrowly on purpose: replaying a week of globex traffic, the broad + version flipped 1,754 decisions (every tool script's `rm -rf "$tmp"` cleanup, stable tools' own help + text), the shipped one flips 5. Bounded (8 files, 128 KB each / 384 KB total, 3 levels; comment lines, + syntax checks like `php -l`, missing and binary files skipped) and linear-time — an unanchored + `(?=[\s\S]*…)` pattern over a 30 KB blob had cost 0.6 s of blocked event loop per call. Pinned by + `scripts/script-body-gate-test.cjs`. Text matching, not a sandbox: keep write credentials away from + agents that should only read. + **For admins:** a custom governance rule that blocks a command now also blocks it when an agent writes that command into a script and runs the script. + ## [0.448.6] - 2026-09-22 ### Fixed - **An agent's git commands can no longer reach the Agentric checkout.** An agent's folder usually isn't a diff --git a/package-lock.json b/package-lock.json index 0e40f818..55104543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.448.6", + "version": "0.448.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.448.6", + "version": "0.448.7", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index baaab297..1d675b90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.448.6", + "version": "0.448.7", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/script-body-gate-test.cjs b/scripts/script-body-gate-test.cjs new file mode 100644 index 00000000..e207f2ce --- /dev/null +++ b/scripts/script-body-gate-test.cjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * The gate classifies the BODY of a script an agent runs, not just the line that runs it. + * + * Regression for the instawp billing incidents (FS#3709, FS#3767): billing-ops wrote its Stripe + * teardown into `fsNNNN-execute-stripe-teardown.sh` and ran `CONFIRM=EXECUTE bash `. The gate saw + * `bash ` → green, and the card detach + customer delete inside went through unreviewed. The + * same calls typed directly matched the tenant's guardrail. + * + * Covers: operand parsing (pure), bounded reading (fs), the enricher consuming bodies, and the wired + * TerminalManager.gate end to end (a destructive script is denied; its command line alone is not). + */ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-script-body-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AOS_NO_TTYD = '1'; + +const { executedScripts, readExecutedScripts, stripComments } = require(path.join(ROOT, 'dist/governance/script-bodies.js')); +const { enrichArgs } = require(path.join(ROOT, 'dist/governance/enricher.js')); + +let failed = 0; +const check = (name, ok, detail) => { + if (ok) return console.log(` ok ${name}`); + failed++; + console.log(` FAIL ${name}${detail ? `\n ${detail}` : ''}`); +}; +const files = (cmd, cwd = '/w') => executedScripts(cmd, cwd).map((f) => path.resolve(f.cwd, f.file)); + +console.log('\x1b[1mOperand parsing\x1b[0m'); +check('bash ', files('CONFIRM=EXECUTE bash fs3768-execute-stripe-teardown.sh 2>&1 | tail -40')[0] === '/w/fs3768-execute-stripe-teardown.sh'); +check('./', files('CONFIRM=EXECUTE ./fs3767-execute-stripe-teardown.sh')[0] === '/w/fs3767-execute-stripe-teardown.sh'); +check('cd then run', files('cd /a/billing-ops && bash x.sh')[0] === '/a/billing-ops/x.sh'); +check('relative cd', files('cd sub && sh ./y.sh')[0] === '/w/sub/y.sh'); +check('php with -d value', files('php -d variables_order=EGPCS sdel.php customers/cus_X')[0] === '/w/sdel.php'); +check('python3 file', files('python3 fix.py --apply')[0] === '/w/fix.py'); +check('source / dot', files('source env.sh; . lib.sh').join(',') === '/w/env.sh,/w/lib.sh'); +check('bash -lc follows the inner command', files('bash -lc "./inner.sh"')[0] === '/w/inner.sh'); +check('timeout wrapper', files('timeout 60 bash long.sh')[0] === '/w/long.sh'); +check('php -l / bash -n are syntax checks, not runs', files('php -l app/Http/CardController.php; bash -n x.sh; node --check a.js').length === 0); +check('python -m is not a file', files('python3 -m http.server').length === 0); +check('unresolved $VAR is skipped', files('bash "$S/x.sh"').length === 0); +check('plain commands name no script', files('ls -la; grep -n foo bar.txt; git status').length === 0); +check('comments are dropped', stripComments('# DROP TABLE x\n // rm -rf /\necho hi') === 'echo hi'); + +console.log('\n\x1b[1mReading (bounded, fail-open)\x1b[0m'); +const dir = path.join(HOME, 'agents', 'billing-ops'); +fs.mkdirSync(path.join(dir, 'sub'), { recursive: true }); +fs.writeFileSync(path.join(dir, 'teardown.sh'), '#!/usr/bin/env bash\n# WRITES TO STRIPE\nphp spost.php "payment_methods/pm_1/detach"\nphp sdel.php "customers/cus_X"\nbash sub/inner.sh\n'); +fs.writeFileSync(path.join(dir, 'sub', 'inner.sh'), 'mysql -e "DROP TABLE users"\n'); +fs.writeFileSync(path.join(dir, 'harmless.sh'), '#!/bin/sh\n# never DROP TABLE here, never rm -rf /\nls -la\n'); +fs.writeFileSync(path.join(dir, 'big.sh'), 'x'.repeat(200 * 1024)); +fs.writeFileSync(path.join(dir, 'bin.sh'), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, 2])); +const read = readExecutedScripts('CONFIRM=EXECUTE bash teardown.sh', dir); +check('reads the script', read[0] && read[0].path === path.join(dir, 'teardown.sh')); +check('follows a nested script', read.some((r) => r.path === path.join(dir, 'sub', 'inner.sh'))); +check('oversized file skipped', readExecutedScripts('bash big.sh', dir).length === 0); +check('binary file skipped', readExecutedScripts('./bin.sh', dir).length === 0); +check('missing file skipped', readExecutedScripts('bash nope.sh', dir).length === 0); +check('no cwd → nothing read', readExecutedScripts('bash teardown.sh', undefined).length === 0); +const stale = path.join(dir, 'stale-tool.sh'); +fs.writeFileSync(stale, 'php sdel.php customers/cus_X\n'); +const old = (Date.now() - 3 * 864e5) / 1000; +fs.utimesSync(stale, old, old); +const { freshSince } = require(path.join(ROOT, 'dist/governance/script-bodies.js')); +check('a stable tool (untouched for days) is not read', readExecutedScripts('bash stale-tool.sh', dir, freshSince(Date.now())).length === 0); +check('the same tool is read once it changes', (fs.utimesSync(stale, new Date(), new Date()), readExecutedScripts('bash stale-tool.sh', dir, freshSince(Date.now())).length === 1)); +check('a long run keeps its own start as the cutoff', freshSince(Date.now() - 3 * 864e5) < Date.now() - 2 * 864e5); +check('self-recursion terminates', (fs.writeFileSync(path.join(dir, 'loop.sh'), 'bash loop.sh\n'), readExecutedScripts('bash loop.sh', dir).length === 1)); + +console.log('\n\x1b[1mEnricher consumes the bodies\x1b[0m'); +const PAT = [{ pattern: String.raw`\b(spost|sdel)\.php\b`, fact: 'stripeWrite', scope: 'shell' }]; +const cmd = { tool: 'Bash', input: { command: 'CONFIRM=EXECUTE bash teardown.sh 2>&1 | tail -40' } }; +const blind = enrichArgs('shell.exec', cmd, [], dir, PAT); +const seen = enrichArgs('shell.exec', cmd, [], dir, PAT, undefined, readExecutedScripts(cmd.input.command, dir)); +check('without bodies the command line looks harmless', !blind.stripeWrite && !blind.destructive); +check('with bodies the custom pattern fires', seen.stripeWrite === true); +check('with bodies a nested DROP TABLE is destructive', seen.destructive === true); +check('inspected paths are recorded', Array.isArray(seen.scriptsInspected) && seen.scriptsInspected.length === 2); +const calm = enrichArgs('shell.exec', { tool: 'Bash', input: { command: 'bash harmless.sh' } }, [], dir, PAT, undefined, readExecutedScripts('bash harmless.sh', dir)); +check('comments in a script do not trip destructive', calm.destructive === false); +// The noise a week of real traffic showed: every tool script cleans up `rm -rf "$tmp"`, says "delete" +// somewhere, and PHP source has `Str::truncate()`. None of that may deny a script. +fs.writeFileSync(path.join(dir, 'tool.sh'), 'out=$(mktemp -d)\ncase "$1" in delete) echo deleting prod ;; esac\nphp -r "echo Str::truncate(1);"\nrm -rf "$out"\n'); +const tool = enrichArgs('shell.exec', { tool: 'Bash', input: { command: './tool.sh ssh dev1' } }, [], dir, [], undefined, readExecutedScripts('./tool.sh ssh dev1', dir)); +check('a script\'s rm -rf "$var" cleanup is not destructive', tool.destructive === false); +check('generic risky keywords in a script do not set risky', tool.risky === false); +fs.writeFileSync(path.join(dir, 'trunc.sh'), 'mysql -e "TRUNCATE TABLE users"\n'); +check('TRUNCATE TABLE in a script is destructive', enrichArgs('shell.exec', { tool: 'Bash', input: { command: 'bash trunc.sh' } }, [], dir, [], undefined, readExecutedScripts('bash trunc.sh', dir)).destructive === true); +const fw = enrichArgs('file.write', { tool: 'Write', input: { file_path: path.join(dir, 'a.sh'), content: 'x' } }, [], dir, PAT, undefined, read); +check('bodies are ignored for non-shell capabilities', fw.scriptsInspected === undefined && fw.stripeWrite === undefined); + +// A multi-condition operator pattern (`(?=[\s\S]*A)(?=[\s\S]*B)`) is quadratic over one big blob; the +// gate is synchronous, so a large script must not turn into seconds of blocked event loop. +const bigBody = Array.from({ length: 1500 }, (_, i) => `echo "step ${i} of the report builder"`).join('\n'); +const LOOK = [{ pattern: String.raw`(?=[\s\S]*(?:vite build|npm run build))(?=[\s\S]*app\.example\.com)`, fact: 'prodBuild', scope: 'shell' }]; +const t0 = Date.now(); +enrichArgs('shell.exec', { tool: 'Bash', input: { command: 'bash big.sh' } }, [], dir, LOOK, undefined, [{ path: '/w/big.sh', body: bigBody }]); +const ms = Date.now() - t0; +check(`a ${Math.round(bigBody.length / 1024)} KB script with a lookahead pattern classifies fast (${ms} ms)`, ms < 100); + +console.log('\n\x1b[1mEnd to end — TerminalManager.gate\x1b[0m'); +const { loadAgentOS } = require(path.join(ROOT, 'dist/kernel.js')); +const { TerminalManager } = require(path.join(ROOT, 'dist/terminal.js')); +const aos = loadAgentOS(); +const tm = new TerminalManager(aos, 'http://127.0.0.1:0', path.join(HOME, 'tmux.sock'), 'https://aos.example.com'); +aos.agents.set('billing-ops', { id: 'billing-ops', name: 'billing-ops', runtime: 'claude-code', dir }); +const t = Date.now(); +aos.db.prepare(`INSERT INTO term_sessions (id,agent,title,task,tmux,status,spawned_by,run_as,headless,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run('ses_sb_1', 'billing-ops', 't', 't', 'tsb1', 'running', 'automation:x', null, 1, t, t); +const gate = (command) => tm.gate('ses_sb_1', 'billing-ops', 'shell.exec', { tool: 'Bash', input: { command } }, 'test'); +check('running a destructive script is denied', gate('CONFIRM=EXECUTE bash teardown.sh').decision === 'deny'); +check('running a harmless script is allowed', gate('bash harmless.sh').decision === 'allow'); +check('policy preview agrees with the gate', tm.policyCheck('ses_sb_1', 'billing-ops', 'shell.exec', { tool: 'Bash', input: { command: 'bash teardown.sh' } }).effect === 'deny'); + +tm.stopAll?.(); +fs.rmSync(HOME, { recursive: true, force: true }); +if (failed) { console.log(`\n${failed} failed`); process.exit(1); } +console.log('\nall script-body gate checks passed'); diff --git a/src/governance/enricher.ts b/src/governance/enricher.ts index 0d70ebfe..108dfaff 100644 --- a/src/governance/enricher.ts +++ b/src/governance/enricher.ts @@ -31,6 +31,7 @@ import * as path from 'node:path'; import { ApprovalLevel, EnrichPattern, Role, canApprove } from '../types'; import { computeHostFacts, type HostGrant } from './host-match'; import { screenInjection } from './semantic-guard'; +import type { ScriptBody } from './script-bodies'; const DESTRUCTIVE: RegExp[] = [ /\bdrop\s+(database|table|schema)\b/i, @@ -42,6 +43,21 @@ const DESTRUCTIVE: RegExp[] = [ /\bkubectl\s+delete\b/i, /\bgit\s+push\s+(--force|-f)\b/i, ]; +// What a script BODY an agent runs may contribute (script-bodies.ts). Deliberately narrower than a typed +// command: the unambiguous destructive ops only. Two built-in heuristics are left out because they over- +// fired on a week of real globex traffic (1,700+ flips, 2026-09-29): the `rm -rf` path check can't +// resolve the `rm -rf "$tmp"` cleanup every script ends with, and the RISKY_SHELL keywords +// (`delete`/`prod`/`deploy`) are everywhere in a multi-purpose tool script. A bare `truncate` is also +// out — in code it's `Str::truncate()`, `truncate -s 0 log` — only `TRUNCATE TABLE` counts. +const SCRIPT_DESTRUCTIVE: RegExp[] = [ + /\bdrop\s+(database|table|schema)\b/i, + /\btruncate\s+table\b/i, + /\bmkfs\b/i, + /\bdd\s+(if|of)=/i, + /\bterraform\s+destroy\b/i, + /\bkubectl\s+delete\b/i, + /\bgit\s+push\s+(--force|-f)\b/i, +]; // `rm -rf` is handled separately (path-aware, below): deleting a scratch/tmp/relative dir is routine // agent work, so it's destructive ONLY when a target is a real system/absolute path or unresolvable. const RM_RF = /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r|\brm\s+-r\b/i; @@ -298,6 +314,9 @@ export function applyPatchTargets(command: string): string[] { /** * Compute governance facts and return a NEW args object (original + facts). Pure; no I/O. * `args` is what the gate received: `{ tool?, input?, command?, ...callerFacts }`. + * `scripts` are the bodies of script files a shell command EXECUTES (`bash x.sh`, `./x.sh`), read by the + * caller via `readExecutedScripts` — classified like typed commands, so writing an action into a file and + * running it can't launder it past the gate (script-bodies.ts). Shell only; ignored otherwise. * `orgDomains` are the workspace's internal email domains (lowercased, no `@`) — passed in by the * caller (no I/O here) so an email send can be judged internal (own domain) vs external. */ @@ -308,6 +327,7 @@ export function enrichArgs( workdir?: string, patterns: EnrichPattern[] = [], hostGrants?: HostGrant[] | null, + scripts: ScriptBody[] = [], ): Record { const tool = typeof args.tool === 'string' ? args.tool : ''; const input = (args.input && typeof args.input === 'object' ? args.input : args) as Record; @@ -328,8 +348,10 @@ export function enrichArgs( const isShell = capability === 'shell.exec'; // Intent-match on the command with DATA payloads stripped (PR bodies, commit messages, file heredocs): // a `--body "…npm run build…"` or `grep "delete from"` must not read as an executed build/DELETE. - const sanitizedCommand = isShell ? sanitizeForIntent(command) : command; - const classifyText = isShell ? sanitizedCommand : haystack; + // A script the command runs is classified as if its body had been typed on the command line. + const scriptText = isShell && scripts.length ? scripts.map((sc) => sanitizeForIntent(sc.body)).join('\n') : ''; + const commandIntent = isShell ? sanitizeForIntent(command) : command; + const classifyText = isShell ? commandIntent : haystack; let destructive = args.destructive === true; if (!destructive && !isFileWrite) { @@ -337,12 +359,13 @@ export function enrichArgs( // `rm -rf` is destructive only when a target is a real system/absolute path (or unresolvable) — a // scratch/tmp/relative delete is routine agent work, not an irreversible world effect. const dangerousRm = RM_RF.test(classifyText) && !rmTargetsAllSafe(classifyText, workdir); - destructive = otherDestructive || dangerousRm; + const scriptDestructive = !!scriptText && SCRIPT_DESTRUCTIVE.some((re) => re.test(scriptText)); + destructive = otherDestructive || dangerousRm || scriptDestructive; } let risky = args.risky === true || destructive; if (!risky && !isFileWrite) { - if (isShell) risky = RISKY_SHELL.test(sanitizedCommand); + if (isShell) risky = RISKY_SHELL.test(commandIntent); else if (capability.startsWith('connector')) risky = !!tool && MUTATION_TOOL.test(tool); } @@ -451,6 +474,7 @@ export function enrichArgs( else if (injectionUncertain) facts.injectionUncertain = true; if (hostFacts) Object.assign(facts, hostFacts); if (outsideWorkdir !== undefined) facts.outsideWorkdir = outsideWorkdir; + if (isShell && scripts.length) facts.scriptsInspected = scripts.map((sc) => sc.path); if (writeTargets) facts.writeTargets = writeTargets; if (amountUsd !== undefined) facts.amountUsd = amountUsd; if (deleteCount !== undefined) facts.deleteCount = deleteCount; @@ -467,7 +491,15 @@ export function enrichArgs( // For a shell command, match the SANITIZED text (same payload-stripping as the built-in scan) so a // custom `prodBuild`/`serverReboot` rule can't be tripped by a PR body / commit message that merely // MENTIONS the trigger — only by an executed command. - const patternHaystack = isShell ? `${tool}\n${sanitizedCommand}` : `${tool}\n${haystack}`; + // + // Script bodies are matched LINE BY LINE, not as one blob. Operator patterns are free-form, and the + // multi-condition idiom `(?=[\s\S]*A)(?=[\s\S]*B)` is unanchored, so the engine retries it from every + // start position — quadratic in the text length. On a 30 KB script that is ~0.3 s per pattern, spent + // synchronously inside the gate (measured on globex, 2026-09-29). Per line it's negligible; the cost is + // that a pattern needing two conditions on DIFFERENT lines of a script won't fire (the command line + // itself is still matched whole, as before). + const patternHaystack = isShell ? `${tool}\n${commandIntent}` : `${tool}\n${haystack}`; + const scriptLines = scriptText ? scriptText.split('\n').filter((l) => l.trim()) : []; for (const p of patterns) { if (!p || typeof p.pattern !== 'string' || typeof p.fact !== 'string' || !p.fact) continue; const scope = p.scope ?? 'any'; @@ -484,7 +516,7 @@ export function enrichArgs( } // Match the TOOL NAME too (a connector's `STRIPE_REFUND` / `delete_site` is the action itself), not // just the command + input values. Harmless for shell, where `tool` is 'Bash'. - if (re.test(patternHaystack)) facts[p.fact] = true; + if (re.test(patternHaystack) || scriptLines.some((l) => re.test(l))) facts[p.fact] = true; } return facts; diff --git a/src/governance/script-bodies.ts b/src/governance/script-bodies.ts new file mode 100644 index 00000000..7b26b08f --- /dev/null +++ b/src/governance/script-bodies.ts @@ -0,0 +1,167 @@ +/** + * SCRIPT BODIES — let the gate see inside a script an agent RUNS, not just the line that runs it. + * + * The enricher classifies a shell call by its command line. That is blind to the most ordinary thing an + * agent does with a multi-step change: write it to a file, then run the file. On instawp (2026-09-23 and + * 09-26) billing-ops wrote `fsNNNN-execute-stripe-teardown.sh` — detach the card, cancel the subscription, + * delete the Stripe customer — and ran `CONFIRM=EXECUTE bash fsNNNN-execute-stripe-teardown.sh`. The gate + * saw `bash `, classified it green, and two live customer accounts lost their card / Stripe + * customer with no human in the loop. The same `php sdel.php customers/…` typed directly would have + * matched the tenant's guardrail; laundering it through a file made every pattern moot. + * + * So: find the script operands a command EXECUTES (`bash x.sh`, `sh x`, `./x.sh`, `php x.php`, + * `python3 x.py`, `source x`, `. x`, `bash -c "…"`), read each one (server-side, bounded), and hand the + * bodies to `enrichArgs`, which classifies them exactly like typed commands. Nested scripts (a script + * that runs another) are followed a couple of levels deep. + * + * Only FRESH files are read — changed during this run or in the last 24 h (`freshSince`). Laundering is + * write-then-run; a long-lived multi-purpose tool (`freescout-manager.php`, `qa-fast.sh`) carries every + * subcommand's text, including its own usage line, so scanning it on each `view`/`ssh` call denied reads + * (10 FreeScout reads in one replayed week on globex, because the tool's help text says `… reply`). + * A tool that changes is fresh again, so editing a trusted tool to hide an action doesn't escape either. + * + * Deliberately conservative in what it reads and fail-open in what it cannot: a file that is missing, + * binary, huge, or named through an unresolved `$VAR` is simply not inspected — the command line is still + * classified as before, so this can only ADD facts, never remove one. It is not a sandbox: a sufficiently + * determined agent can still hide intent (download-and-run, base64, `eval`). The hard stop for money and + * other irreversible effects is least privilege — a read-only credential — not text matching. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface ScriptBody { + /** Absolute path of the script that was read. */ + path: string; + /** Its text, with comment-only lines removed (see `stripComments`). */ + body: string; +} + +/** Shells + interpreters whose first non-flag operand is a script FILE they execute. */ +const RUNNERS = /^(bash|sh|zsh|ksh|dash|python[0-9.]*|node|ruby|perl|php|source|\.)$/; +/** Flags that consume the NEXT token as their value (so it isn't mistaken for the script). */ +const FLAG_WITH_VALUE = /^(-d|-e|-o|-O|-W|-X|-r|--rcfile|--init-file|--require|-I)$/; +/** Leading wrappers that don't change what runs. */ +const WRAPPERS = /^(sudo|env|nohup|time|exec|command|nice|timeout|stdbuf)$/; + +export const SCRIPT_MAX_BYTES = 128 * 1024; +/** Total across every script one command pulls in — bounds the gate's synchronous work. */ +export const SCRIPT_MAX_TOTAL_BYTES = 384 * 1024; +export const SCRIPT_MAX_FILES = 8; +export const SCRIPT_MAX_DEPTH = 3; +/** A script counts as fresh when modified within this window, or since the run started if earlier. */ +export const SCRIPT_FRESH_MS = 24 * 60 * 60 * 1000; + +/** The mtime cutoff for a run that started at `sessionStartMs`: the earlier of that and now − 24 h. */ +export function freshSince(sessionStartMs: number | undefined, nowMs = Date.now()): number { + const window = nowMs - SCRIPT_FRESH_MS; + return sessionStartMs && sessionStartMs < window ? sessionStartMs : window; +} + +/** Split a shell word list, honouring simple single/double quotes (no expansion). */ +function words(segment: string): string[] { + const out: string[] = []; + const re = /'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g; + for (const m of segment.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3]); + return out; +} + +/** + * The script paths a shell command executes, each with the directory it resolves against (tracking a + * preceding `cd` in the same command). Pure. `cwd` is the directory the command starts in. + */ +export function executedScripts(command: string, cwd: string): { file: string; cwd: string }[] { + const found: { file: string; cwd: string }[] = []; + let dir = cwd; + // Segments: split on newlines and the shell's list/pipe operators. Good enough for intent — a quoted + // `;` inside an argument only ever produces an extra harmless segment. + for (const raw of command.split(/\n|&&|\|\||;|\|/)) { + const toks = words(raw.trim()); + let i = 0; + while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]) || WRAPPERS.test(toks[i]))) { + // `timeout 60 bash x` / `nice -n 5 bash x` — skip the wrapper's own numeric / flag operands too. + i++; + while (i < toks.length && (/^-/.test(toks[i]) || /^\d+[smhd]?$/.test(toks[i]))) i++; + } + const cmd = toks[i]; + if (!cmd) continue; + if (cmd === 'cd') { + const target = toks[i + 1]; + if (target && !target.includes('$') && !target.startsWith('~') && target !== '-') dir = path.resolve(dir, target); + continue; + } + const base = cmd.slice(cmd.lastIndexOf('/') + 1); + if (RUNNERS.test(base)) { + for (let j = i + 1; j < toks.length; j++) { + const t = toks[j]; + if (/^-[a-z]*c$/.test(t)) { + // `bash -c "…"` / `bash -lc "…"`: the next word is a COMMAND string — follow it, not a file. + if (toks[j + 1] !== undefined) found.push(...executedScripts(toks.slice(j + 1).join(' '), dir)); + break; + } + if (t === '-m') break; // `python -m pkg` runs a module, not a file we can see + // Syntax-check / lint only (`php -l`, `bash -n`, `node --check`): the file is parsed, never run. + // A linted controller full of `->paymentMethods->detach(` is source code, not an action. + if (t === '-l' || t === '-n' || t === '--check' || t === '--syntax-check') break; + if (FLAG_WITH_VALUE.test(t)) { j++; continue; } + if (t.startsWith('-')) continue; + if (t.startsWith('<')) break; // `bash < x` / heredoc — stdin, not a file operand + found.push({ file: t, cwd: dir }); + break; + } + } else if (cmd.includes('/') && !cmd.includes('$')) { + // `./x.sh`, `bin/run`, `/abs/path/tool` — the file itself is executed. + found.push({ file: cmd, cwd: dir }); + } + } + return found.filter((f) => !f.file.includes('$') && !f.file.includes('`') && !f.file.startsWith('~')); +} + +/** + * Drop comment-only lines. A teardown script documents what it does ("# WRITES TO STRIPE", "# never + * DROP TABLE here") and a comment is not an executed command — scanning it would turn prose into a + * `destructive` fact and deny a harmless script. + */ +export function stripComments(body: string): string { + return body + .split('\n') + .filter((l) => !/^\s*(#|\/\/)/.test(l)) + .join('\n'); +} + +/** + * Read the scripts a command executes, following nested script calls up to `SCRIPT_MAX_DEPTH`. Files last + * modified before `modifiedSince` (epoch ms; see `freshSince`) are skipped. Bounded + * by `SCRIPT_MAX_FILES` and `SCRIPT_MAX_BYTES` each; binary (NUL-bearing), missing, unreadable and + * non-regular files are skipped. Never throws — the gate must not fail on an unreadable file. + */ +export function readExecutedScripts(command: string, cwd: string | undefined, modifiedSince = 0): ScriptBody[] { + if (!command || !cwd) return []; + const out: ScriptBody[] = []; + const seen = new Set(); + let total = 0; + const visit = (cmd: string, dir: string, depth: number): void => { + if (depth > SCRIPT_MAX_DEPTH) return; + for (const { file, cwd: base } of executedScripts(cmd, dir)) { + if (out.length >= SCRIPT_MAX_FILES) return; + const abs = path.resolve(base, file); + if (seen.has(abs)) continue; + seen.add(abs); + let text: string; + try { + const st = fs.statSync(abs); + if (!st.isFile() || st.size > SCRIPT_MAX_BYTES || total + st.size > SCRIPT_MAX_TOTAL_BYTES) continue; + if (st.mtimeMs < modifiedSince) continue; // a stable tool, not something this run just wrote + text = fs.readFileSync(abs, 'utf8'); + } catch { + continue; + } + if (text.includes('\0')) continue; + total += text.length; + const body = stripComments(text); + out.push({ path: abs, body }); + visit(body, path.dirname(abs), depth + 1); + } + }; + visit(command, cwd, 1); + return out; +} diff --git a/src/terminal.ts b/src/terminal.ts index aad6f83d..fd19a9f0 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -23,7 +23,8 @@ import { listConnectedAccounts } from './connectors/composio'; import { activeToolkits, resolveIdentities } from './connectors/composio-identity'; import { exclusionFor } from './connectors/composio-claims'; import { isCodingRuntime, runtimeSupports, CODING_RUNTIMES, CodingRuntimeId, ActionAttempt, AgentManifest, ApprovalLevel, AuditEvent, Decision, Member, RiskClass, Role, RunContext, RuntimeTuning, TaskRun, TaskStatus, TaskWorkers, TaskTimelineEntry, TaskDiscussionSummary, canApprove, resolveRuntimeTuning, riskClassForLevel } from './types'; -import { enrichArgs, autoClearsApproval, redactSecrets } from './governance/enricher'; +import { enrichArgs, autoClearsApproval, redactSecrets, commandText } from './governance/enricher'; +import { readExecutedScripts, freshSince, type ScriptBody } from './governance/script-bodies'; import { isolateClaudeConfig } from './edge/config-isolation'; import { resolveCapability } from './capabilities/normalize'; import { unwrapComposioEnvelope } from './capabilities/composio-envelope'; @@ -5740,6 +5741,19 @@ export class TerminalManager { return { ok: true }; } + /** The script files a shell call EXECUTES (`bash x.sh`, `./x.sh`), read so the enricher classifies + * their bodies too — an action written to a file and then run is otherwise invisible to the gate + * (script-bodies.ts). Resolved from the agent's folder, where every session starts; only scripts + * changed during this run or in the last 24 h are read. */ + private scriptsRun(sessionId: string, capability: string, args: Record, agentDir: string | undefined): ScriptBody[] { + if (capability !== 'shell.exec' || !agentDir) return []; + const input = (args.input && typeof args.input === 'object' ? args.input : args) as Record; + const command = commandText(args.command) || commandText(input.command); + if (!command) return []; + const started = this.db.prepare('SELECT created_at FROM term_sessions WHERE id = ?').get<{ created_at: number }>(sessionId)?.created_at; + return readExecutedScripts(command, agentDir, freshSince(started)); + } + /** The gate. Same policy brain as the console — allow flows, ask → inbox approval (auto-cleared for * an attended approver), never → deny. Args are enriched into facts first (the single classifier). */ gate(sessionId: string, agent: string, capability: string, rawArgs: Record, reasoning: string, subagent?: { type?: string; id?: string }): GateResult { @@ -5775,7 +5789,8 @@ export class TerminalManager { rawArgs = envelope.args; capability = envelope.capability; } - const args = enrichArgs(capability, rawArgs, this.emailOrgDomains(), this.os.agents.get(agent)?.dir, this.os.settings.enrichPatterns(), hostGrants); + const agentDir = this.os.agents.get(agent)?.dir; + const args = enrichArgs(capability, rawArgs, this.emailOrgDomains(), agentDir, this.os.settings.enrichPatterns(), hostGrants, this.scriptsRun(sessionId, capability, rawArgs, agentDir)); // An outbound email is its own governed capability: reclassify so the policy gates it by recipient // (internal → green, external → yellow) instead of the generic connector-mutation tier. if (args.emailSend === true) { @@ -6085,7 +6100,8 @@ export class TerminalManager { // classifies the same canonical capability the live gate will. const unwrapped = unwrapComposioEnvelope(capability, args, this.emailOrgDomains()); if (unwrapped) { args = unwrapped.args; capability = unwrapped.capability; } - const enriched = enrichArgs(capability, args, this.emailOrgDomains(), this.os.agents.get(agent)?.dir, this.os.settings.enrichPatterns()); + const agentDir = this.os.agents.get(agent)?.dir; + const enriched = enrichArgs(capability, args, this.emailOrgDomains(), agentDir, this.os.settings.enrichPatterns(), undefined, this.scriptsRun(sessionId, capability, args, agentDir)); const cap = enriched.emailSend === true ? 'email.send' : resolveCapability(capability, typeof enriched.tool === 'string' ? enriched.tool : undefined);