From 5fe388f4790c52cb07d50bf04d22d0fd70e29625 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Fri, 24 Jul 2026 19:18:49 +0530 Subject: [PATCH] fix(governance): rm -rf inside the agent's own workdir (absolute path) not destructive (v0.264.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A post-deploy fleet scan surfaced a residual false positive: an agent cleaning a dir inside its OWN home by absolute path — e.g. `rm -rf /home//…/agents//work/client-app/broken` — was still flagged destructive, because the v0.260 path-safety only whitelisted /tmp + relative paths, not the agent's own workdir subtree. isSafeDeletePath now treats a strict SUBPATH of the workdir as safe (mirrors the outsideWorkdir fact used for file writes). Still denied: the workdir ROOT itself (wiping the whole agent home), a SIBLING agent's home, a `..` escape, and any unrelated absolute/system path. 4 new golden cases; 102/102 pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019zMSbnPEbqSVPGLasTKENp --- CHANGELOG.md | 10 +++++++ package-lock.json | 4 +-- package.json | 2 +- src/governance/enricher.ts | 15 +++++++--- test/governance/conformance.json | 48 ++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e197857..270d9cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ new version heading in the same commit. ## [Unreleased] +## [0.264.3] — 2026-07-24 +### Fixed +- **Enricher: `rm -rf` inside the agent's OWN workdir (via an absolute path) is no longer hard-denied.** + A post-deploy fleet scan surfaced a residual false positive: an agent cleaning up a dir inside its own + home by absolute path — `rm -rf /home//…/agents//work/client-app/broken` — was still `destructive`, + because the v0.260 path-safety only whitelisted `/tmp` + relative paths, not the agent's own workdir + subtree. `isSafeDeletePath` now treats a strict SUBPATH of the workdir as safe (mirrors the + `outsideWorkdir` fact used for file writes). The workdir ROOT itself, a SIBLING agent's home, a `..` + escape, and any unrelated absolute/system path all stay denied — verified. 4 new golden cases (102/102). + ## [0.264.2] — 2026-07-24 ### Changed - **Task room tabs are deep-linked in the URL.** The hash detail becomes `/` diff --git a/package-lock.json b/package-lock.json index a135571..a19fbbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.264.2", + "version": "0.264.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.264.2", + "version": "0.264.3", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 0d51610..ecb5b32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.264.2", + "version": "0.264.3", "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/src/governance/enricher.ts b/src/governance/enricher.ts index c07d56a..a72cddf 100644 --- a/src/governance/enricher.ts +++ b/src/governance/enricher.ts @@ -158,12 +158,19 @@ export function sanitizeForIntent(command: string): string { * escape. UNSAFE (keeps it destructive) = any other absolute path, `~`/`$HOME`, a `..` escape, or an * unresolved variable / command-substitution (`` marker) — we never green-light a delete we can't * see the target of. */ -function isSafeDeletePath(target: string): boolean { +function isSafeDeletePath(target: string, workdir?: string): boolean { const t = target.trim(); if (!t || t.includes('') || t.includes('$(') || t.includes('`')) return false; // unknown target if (t === '/' || t === '~' || t === '.' || t === '..' || t === '*') return false; if (/(^|\/)\.\.(\/|$)/.test(t)) return false; // escapes upward if (/^(\/tmp|\/private\/tmp|\/var\/folders)\//.test(t)) return true; // scratch roots + // A strict SUBPATH of the agent's own workdir is its sandbox — deleting there is its own work (like an + // in-folder file write; mirrors `outsideWorkdir`). Covers an absolute path into the agent's home, e.g. + // `rm -rf /home//.../agents//work/x`. The workdir ROOT itself stays gated (wiping the whole home). + if (workdir && t.startsWith('/')) { + const rel = path.relative(workdir, t); + if (rel && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)) return true; + } if (t.startsWith('/') || t.startsWith('~')) return false; // any other absolute / home path return true; // relative, no escape → inside the agent's cwd sandbox } @@ -172,7 +179,7 @@ function isSafeDeletePath(target: string): boolean { * `VAR=value` assignments made INLINE in the same command (`SCRATCH=/tmp/x … rm -rf "$SCRATCH"`) so the * common scratch-cleanup idiom is recognised. Returns false (→ stays destructive) on any unknown or * unsafe target. Best-effort + conservative: it can only DOWNGRADE an `rm -rf` that is provably safe. */ -function rmTargetsAllSafe(command: string): boolean { +function rmTargetsAllSafe(command: string, workdir?: string): boolean { const vars: Record = {}; for (const m of command.matchAll(/(?:^|[\s;&|(])([A-Za-z_][A-Za-z0-9_]*)=("[^"\n]*"|'[^'\n]*'|[^\s;&|)]+)/g)) { vars[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); @@ -188,7 +195,7 @@ function rmTargetsAllSafe(command: string): boolean { targets.push(expand(tok)); } } - return targets.length > 0 && targets.every(isSafeDeletePath); + return targets.length > 0 && targets.every((t) => isSafeDeletePath(t, workdir)); } const SECRET_RE = /\b(gh[posru]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})\b/g; @@ -240,7 +247,7 @@ export function enrichArgs( const otherDestructive = DESTRUCTIVE.some((re) => re.test(classifyText)) || (!!tool && DESTRUCTIVE_TOOL.test(tool)); // `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); + const dangerousRm = RM_RF.test(classifyText) && !rmTargetsAllSafe(classifyText, workdir); destructive = otherDestructive || dangerousRm; } diff --git a/test/governance/conformance.json b/test/governance/conformance.json index 9245ef1..b2a65b3 100644 --- a/test/governance/conformance.json +++ b/test/governance/conformance.json @@ -1258,6 +1258,54 @@ } }, "expect": "never" + }, + { + "name": "rm -rf a subpath of the agent workdir (absolute) is allowed", + "capability": "shell.exec", + "args": { + "tool": "Bash", + "input": { + "command": "rm -rf /home/ubuntu/agents/engineer/work/client-app/broken" + } + }, + "workdir": "/home/ubuntu/agents/engineer", + "expect": "allow" + }, + { + "name": "rm -rf the workdir ROOT is still denied", + "capability": "shell.exec", + "args": { + "tool": "Bash", + "input": { + "command": "rm -rf /home/ubuntu/agents/engineer" + } + }, + "workdir": "/home/ubuntu/agents/engineer", + "expect": "never" + }, + { + "name": "rm -rf a SIBLING agent home is still denied", + "capability": "shell.exec", + "args": { + "tool": "Bash", + "input": { + "command": "rm -rf /home/ubuntu/agents/qa/secrets" + } + }, + "workdir": "/home/ubuntu/agents/engineer", + "expect": "never" + }, + { + "name": "rm -rf .. escape from workdir still denied", + "capability": "shell.exec", + "args": { + "tool": "Bash", + "input": { + "command": "rm -rf /home/ubuntu/agents/engineer/../qa" + } + }, + "workdir": "/home/ubuntu/agents/engineer", + "expect": "never" } ], "context": [