Repository navigation
fix(O168): a script's arguments are not deletions (write-target false positives) #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f7d4e65
7c6e649
be8108f
452b0ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -599,6 +599,112 @@ function cdOnlyOperands(command: string, cwd: string): Set<string> { | |
| return cd; | ||
| } | ||
|
|
||
| /** Heads that run a script file: interpreters and shells (O168 exemption). */ | ||
| const SCRIPT_RUN_HEADS = new Set([ | ||
| 'node', | ||
| 'nodejs', | ||
| 'python', | ||
| 'python2', | ||
| 'python3', | ||
| 'ruby', | ||
| 'php', | ||
| 'perl', | ||
| 'lua', | ||
| 'bun', | ||
| 'deno', | ||
| 'sh', | ||
| 'bash', | ||
| 'zsh', | ||
| 'dash', | ||
| 'ksh', | ||
| 'ash', | ||
| ]); | ||
| /** Inline-code flags: with one of these the invocation is not a script form. */ | ||
| const INLINE_CODE_FLAGS = new Set(['-c', '-e', '-E', '-p', '-r', '--eval', '--print', 'eval']); | ||
| const DOT_SOURCE_HEADS = new Set(['.', 'source']); | ||
|
|
||
| function stripEdgeQuotes(w: string): string { | ||
| return w.replace(/^(['"])(.*)\1$/, '$2'); | ||
| } | ||
|
|
||
| function resolveScriptArgWord(raw: string, cwd: string, home: string): string | undefined { | ||
| const w = stripEdgeQuotes(raw); | ||
| if (!w || w.startsWith('-') || /^https?:\/\//i.test(w)) return undefined; | ||
| const expanded = w | ||
| .replace(/^\$\{PWD\}(?=\/|$)|^\$PWD(?=\/|$)/, () => cwd) | ||
| .replace(/^\$\{HOME\}(?=\/|$)|^\$HOME(?=\/|$)/, () => home) | ||
| .replace(/^\$\{USERPROFILE\}(?=\/|$)|^\$USERPROFILE(?=\/|$)/, () => home) | ||
| .replace(/^~/, () => home); | ||
| try { | ||
| return path.resolve(cwd, expanded); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * O168: arguments of an invoked script are not deletions. `python3 x.py | ||
| * $HOME`, `node x.js "$PWD"`, `. venv/bin/activate && python -V` are normal | ||
| * commands that an uncertain command (an interpreter can write anything) | ||
| * turned into "deletes home / the workspace root" hits on a mere mention. | ||
| * The exemption covers only the delete-ancestor rule, and — like the cd | ||
| * exemption — only while the path is named NOWHERE else in the command | ||
| * (`source ./evil.sh; ls <root>` still hits the root, O151). An argument | ||
| * that names a protected path directly (`python3 x.py $HOME/.ssh`, | ||
| * `.git/config`) still classifies as before, and the interpreter inline | ||
| * forms (`-c` / `-e`) keep the old behavior. Actual writes stay covered by | ||
| * layers 2–3 (script scan + post-run snapshot). | ||
| */ | ||
| function scriptArgumentMentions(forms: readonly string[], cwd: string, home: string): Set<string> { | ||
| const out = new Set<string>(); | ||
| const argTokens = new Set<string>(); | ||
| const add = (raw: string | undefined) => { | ||
| if (raw === undefined) return; | ||
| const r = resolveScriptArgWord(raw, cwd, home); | ||
| if (r) { | ||
| out.add(r); | ||
| const stripped = stripEdgeQuotes(raw); | ||
| argTokens.add(raw); | ||
| argTokens.add(stripped); | ||
| argTokens.add(stripped.replace(/[\\/]+$/, '')); | ||
| } | ||
| }; | ||
| for (const command of forms) { | ||
| for (const segment of command.split(/&&|\|\||[;|\n]/)) { | ||
| const words = segment.trim().split(/\s+/).filter(Boolean); | ||
| let i = 0; | ||
| while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(stripEdgeQuotes(words[i]!))) | ||
| i += 1; | ||
| if (i >= words.length) continue; | ||
| const head = path.basename(stripEdgeQuotes(words[i]!)).toLowerCase(); | ||
| const rest = words.slice(i + 1); | ||
| if (DOT_SOURCE_HEADS.has(head)) { | ||
| // The bare `.` itself resolves to cwd; the operand is the sourced file. | ||
| add('.'); | ||
| add(rest[0]); | ||
| continue; | ||
| } | ||
| if (!SCRIPT_RUN_HEADS.has(head) && !/^python3\.\d+$/.test(head)) continue; | ||
| if (rest.some((a) => INLINE_CODE_FLAGS.has(stripEdgeQuotes(a)))) continue; | ||
| const scriptIdx = rest.findIndex((a) => !stripEdgeQuotes(a).startsWith('-')); | ||
| if (scriptIdx < 0) continue; | ||
| for (const arg of rest.slice(scriptIdx + 1)) add(arg); | ||
| } | ||
| } | ||
| // Named anywhere else in any form → the exemption does not apply. | ||
| for (const command of forms) { | ||
| for (const word of command.split(/[\s'"`;&|()<>=]+/)) { | ||
| if (!word || argTokens.has(word)) continue; | ||
|
Comment on lines
+696
to
+697
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When another command repeats an exempt argument verbatim, Useful? React with 👍 / 👎. |
||
| const r = resolveScriptArgWord(word, cwd, home); | ||
| if (r) { | ||
| out.delete(r); | ||
| out.delete(r.replace(/[\\/]+$/, '')); | ||
| } | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** | ||
| * O151: commands that only READ the paths they are given. Naming the | ||
| * workspace root as their operand cannot delete the root's `.git`. | ||
|
|
@@ -1081,6 +1187,15 @@ export function findShellProtectedWrites( | |
| let rootReadOnlyMemo: boolean | undefined; | ||
| const rootReadOnly = () => (rootReadOnlyMemo ??= workspaceRootOnlyReadOnly(command, ctx)); | ||
| // Prefer absolute mentions from the shared detector; fall back to text regex. | ||
| // O168: a script's arguments (`python3 x.py $HOME`, `node x.js "$PWD"`, | ||
| // the `.` of `. venv/bin/activate`) are mentions, not deletions — the | ||
| // delete-ancestor rule is skipped for them (direct protected paths still | ||
| // classify). | ||
| const scriptArgs = scriptArgumentMentions( | ||
| winForm ? [winForm, command] : [command], | ||
| ctx.cwd, | ||
| homeOf(ctx) | ||
| ); | ||
| for (const m of mentions) { | ||
| // O103: a word still holding `$VAR` / `$(…)` is not a path we resolved; | ||
| // expanding it to '' used to misreport the workspace root as git internals. | ||
|
|
@@ -1092,9 +1207,10 @@ export function findShellProtectedWrites( | |
| // O151: the root named only as a read-only operand (`git -C <root> diff`, | ||
| // `ls -la <root>`) besides that single cd is not a delete of its .git. | ||
| const isReadOnlyRoot = !isCd && isWorkspaceRootPath(m, ctx) && rootReadOnly(); | ||
| const isScriptArg = scriptArgs.has(path.resolve(ctx.cwd, m)); | ||
| const hit = | ||
| classifyProtectedPath(m, ctx) ?? | ||
| (isCd || isReadOnlyRoot ? undefined : deletesProtectedAncestor(m, ctx)); | ||
| (isCd || isReadOnlyRoot || isScriptArg ? undefined : deletesProtectedAncestor(m, ctx)); | ||
| if (!hit || seen.has(hit.resolved)) continue; | ||
| seen.add(hit.resolved); | ||
| hits.push({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * O168: the write-target rules must not block normal commands. On main | ||
| * (O101/O151), an uncertain command (any interpreter run) put every absolute | ||
| * mention through the delete-ancestor rule, so `python3 x.py $HOME`, | ||
| * `node x.js "$PWD"`, `. venv/bin/activate && python -V` and `python3 x.py | ||
| * /tmp` were reported as deleting home / the workspace root — and a NON-git | ||
| * directory's $PWD was labeled "git repository internals". A script's | ||
| * arguments are mentions, not deletions: the ancestor rule now skips them. | ||
| * Directly protected arguments (`$HOME/.ssh`, `.git/config`), the interpreter | ||
| * inline forms, and real deletes (`rm -rf $HOME`, `mv $HOME /tmp`) are | ||
| * unchanged — actual writes stay covered by the script scan (layer 2) and the | ||
| * post-run snapshot (layer 3). | ||
| */ | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
|
|
||
| import { o152Scenario, IS_WIN } from './o152-harness.mjs'; | ||
| import { findShellProtectedWrites } from '../dist/safety/protected-paths.js'; | ||
|
|
||
| const WS = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'o168-'))); | ||
| // Home lives OUTSIDE the workspace, like a real one (an inside-workspace | ||
| // home makes the home anchor swallow the workspace-root anchor). | ||
| const HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'o168home-'))); | ||
| fs.mkdirSync(HOME, { recursive: true }); | ||
| fs.writeFileSync(path.join(WS, 'x.py'), 'import sys\nprint(sys.argv)\n'); | ||
| fs.writeFileSync(path.join(WS, 'x.js'), 'console.log(process.argv)\n'); | ||
| fs.writeFileSync(path.join(WS, 'x.sh'), 'echo "$@"\n'); | ||
| fs.mkdirSync(path.join(WS, 'venv', 'bin'), { recursive: true }); | ||
| fs.writeFileSync(path.join(WS, 'venv', 'bin', 'activate'), 'export VIRTUAL_ENV=x\n'); | ||
| const ctx = { | ||
| cwd: WS, | ||
| workspaceRoot: WS, | ||
| configDirs: [], | ||
| env: process.env, | ||
| home: HOME, | ||
| }; | ||
|
|
||
| const hitsOf = (cmd) => findShellProtectedWrites(cmd, ctx).map((h) => `${h.raw}:${h.category}`); | ||
|
|
||
| // ── policy: normal commands are no longer hits ────────────────────────────── | ||
|
|
||
| const GREEN = [ | ||
| 'python3 x.py $HOME', | ||
| 'python3 -u x.py $HOME', | ||
| 'python3 x.py /tmp', | ||
| 'node x.js "$PWD"', | ||
| 'node x.js $HOME', | ||
| 'sh x.sh $HOME', | ||
| 'bash x.sh "$PWD"', | ||
| '. venv/bin/activate && python -V', | ||
| 'source venv/bin/activate && python -V', | ||
| '. ./venv/bin/activate && python3 -V', | ||
| ]; | ||
|
|
||
| for (const cmd of GREEN) { | ||
| test(`policy GREEN: ${cmd}`, () => { | ||
| assert.deepEqual(hitsOf(cmd), [], cmd); | ||
| }); | ||
| } | ||
|
|
||
| // ── policy: protection unchanged where it matters ────────────────────────── | ||
|
|
||
| const RED = [ | ||
| ['python3 x.py $HOME/.ssh', 'ssh'], | ||
| ['python3 x.py .git/config', 'git'], | ||
| ['node x.js ~/.ssh/authorized_keys', 'ssh'], | ||
| ['rm -rf $HOME', 'ssh'], | ||
| ['mv $HOME /tmp', 'ssh'], | ||
| ['rm -rf .git', 'git'], | ||
| ['rm -rf "$PWD"', 'git'], | ||
| ]; | ||
|
|
||
| for (const [cmd, category] of RED) { | ||
| test(`policy RED: ${cmd} still hits (${category})`, () => { | ||
| const hits = hitsOf(cmd); | ||
| assert.ok(hits.length > 0, cmd); | ||
| assert.ok( | ||
| hits.some((h) => h.endsWith(`:${category}`)), | ||
| `${cmd} -> ${JSON.stringify(hits)}` | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| test('policy RED: interpreter inline form keeps the old behavior', () => { | ||
| // No script file: the argument is not a script argument, so the ancestor | ||
| // rule still applies (stricter only). | ||
| const hits = hitsOf('python3 -c "print(1)" $HOME'); | ||
| assert.ok(hits.length > 0, JSON.stringify(hits)); | ||
| }); | ||
|
|
||
| // ── process: the four commands run (exit 0) ──────────────────────────────── | ||
|
|
||
| const FINAL = 'All done, everything is finished.'; | ||
|
|
||
| async function runsClean(command) { | ||
| return o152Scenario({ | ||
| lang: 'en', | ||
| gitInit: false, | ||
| seed: { | ||
| 'x.py': 'import sys\nprint(sys.argv)\n', | ||
| 'x.js': 'console.log(process.argv)\n', | ||
| 'x.sh': 'echo "$@"\n', | ||
| 'venv/bin/activate': 'export VIRTUAL_ENV=x\n', | ||
| }, | ||
| steps: () => [{ name: 'exec', args: { command } }], | ||
| finalText: FINAL, | ||
| }); | ||
| } | ||
|
|
||
| const E2E_GREEN = [ | ||
| 'python3 x.py $HOME && node -e 1', | ||
| 'node x.js "$PWD" && node -e 1', | ||
| '. venv/bin/activate && python -V && node -e 1', | ||
| ]; | ||
|
|
||
| for (const command of E2E_GREEN) { | ||
| test( | ||
| `process GREEN: ${command} → exit 0`, | ||
| { skip: IS_WIN ? 'sh-form e2e; policy covered above' : false }, | ||
| async () => { | ||
| const r = await runsClean(command); | ||
| assert.equal(r.code, 0, `code=${r.code} last=${r.last}\n${(r.err ?? '').slice(-400)}`); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| test( | ||
| 'process RED control: python3 x.py $HOME/.ssh still blocks (exit 9)', | ||
| { skip: IS_WIN ? 'sh-form e2e; policy covered above' : false }, | ||
| async () => { | ||
| const r = await runsClean('python3 x.py $HOME/.ssh; node -e 1'); | ||
| assert.equal(r.code, 9, `code=${r.code} last=${r.last}`); | ||
| } | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a script prints its path argument into a mutating downstream command, the path is named only as a script argument but still becomes a deletion target. With
echo.jscontainingconsole.log(process.argv[2]),node echo.js /absolute/workspace | xargs rm -rfnow returns no protected-path hits; the parent commit blocks it. A local headless CLI reproduction deleted the workspace's README, and snapshot restoration did not recover it. The inspectedxargs --helpexplicitly says it runs COMMAND with “more arguments read from input.” Apply downstream-pipeline safety checks before granting this exemption, as the existing read-only-root exemption does.Useful? React with 👍 / 👎.