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
706 changes: 684 additions & 22 deletions agentwire/hooks/damage-control/bash-tool-damage-control.py

Large diffs are not rendered by default.

706 changes: 684 additions & 22 deletions agentwire/hooks/damage-control/edit-tool-damage-control.py

Large diffs are not rendered by default.

706 changes: 684 additions & 22 deletions agentwire/hooks/damage-control/mcp-tool-damage-control.py

Large diffs are not rendered by default.

706 changes: 684 additions & 22 deletions agentwire/hooks/damage-control/read-tool-damage-control.py

Large diffs are not rendered by default.

706 changes: 684 additions & 22 deletions agentwire/hooks/damage-control/write-tool-damage-control.py

Large diffs are not rendered by default.

704 changes: 683 additions & 21 deletions agentwire/safety/_core.py

Large diffs are not rendered by default.

87 changes: 66 additions & 21 deletions docs/wiki/internals/damage-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,14 @@ it would unwrapped:
`bun`) behind `sudo`/`exec`/`nohup`/`busybox`/`env`/`command`/`--`/`-s`;
`sudo -s`/`-i` count only as sudo's *own* options (`sudo ls -i` does not).
With a real remote program (`ssh host cat`, `sudo tee`) stdin is data and is
left alone. Other interpreters (`lua`, `tclsh`, …) are not in the list, the
same stance the unwrapped inline-interpreter net takes.
left alone. The embedded-language interpreters (`lua`, `luajit`, `tclsh`,
`wish`, `R`, `Rscript`, `julia`, `groovy`, `osascript`, `expect`) are in the
inline-interpreter net too, but read stdin as a program only when handed none
of their own (`lua -e '…'` and `lua script.lua` take stdin as data).
`mosh` is `ssh` for this purpose. Text piped or heredoc'd to a *remote*
interpreter (`… | ssh host python3`, `… | mosh host lua`) is Python/Lua, not
shell: it is re-scanned as that interpreter's inline program
(`python3 -c '<text>'`), so the same nets and rules apply as when typed.
- **Heredoc bodies are data for the rewriting passes** (cd / `git -C`
resolution, glob, brace, redirect and path normalization), quoted delimiter or
not: a commit body that *describes* `cd <dir> && touch <file>` is not that
Expand Down Expand Up @@ -336,15 +342,12 @@ Declared residuals (not statically knowable): paths assembled at runtime
component that is only `$var`), stdin text produced by anything but
`echo`/`printf`/a heredoc (`cat file | ssh host`, `bash -c "$(cat x)"` names no
path until it runs), text that a stdin reader receives through a stage the
scanner does not model (`… | tee x | ssh host`, `… | mosh host`,
`… | ssh host xargs -0 sh`), interpreter source piped to a remote interpreter
(`echo "open('<path>','w')" | ssh host python3` — the text is re-scanned as
shell, not as python), a `patch` / `git apply` (including `git -C <dir> apply`)
whose target is named only inside the diff, `sftp get`, `ssh host lua` (and
other interpreters outside the list), a `cd` written inside interpreter source
(`perl -e 'system("cd <dir> && touch <file>")'`), a body piped into a
`sh -c '{}'` placeholder (`cat <<'EOF' | xargs -I{} sh -c '{}'` with the literal
path in the body), `cd <dir> && rsync -a <file> host:x` (the absolute spelling
scanner does not model (`… | tee x | ssh host`, `… | ssh host xargs -0 sh`), a
`patch` / `git apply` (including `git -C <dir> apply`)
whose target is named only inside the diff, a `cd` written inside interpreter source
(`perl -e 'system("cd <dir> && touch <file>")'`), an sftp batch read from a
file (`sftp -b file`; stdin batches' `get` / `!` lines ARE judged),
`cd <dir> && rsync -a <file> host:x` (the absolute spelling
is refused too — rsync's source is not distinguished from its destination), a
`<<$"EOF"` (not recognised as a heredoc, so a body line that is itself
`cd <dir> && touch <file>` blocks; #1106), a directory taken from `$(pwd)` after a `cd` (ask-tier, not
Expand Down Expand Up @@ -1226,18 +1229,15 @@ and known launchers.
allows).

Residuals, stated plainly. Wrongful asks we accept as the price of the
direction: an unknown launcher followed by the bare word (`parallel -j4 grep
eval ::: f`, `flock /tmp/l grep eval f`, `poetry run`, `docker run … grep eval`),
direction: an unknown launcher followed by the bare word (`chroot / grep eval f`),
a quoted `'>f'` used as a command name, and a shell invoked with a payload
that merely mentions the variable (`p=eval; sh -c 'echo $p'`). **Not detected:**
a data consumer that can itself run a command line, except `sed` and `git` above
(also: `read c < <(echo …)` — `read c <<< word` and `read c <<'EOF'; eval "$c"` ask via `ambiguous:eval`; `npm exec -c='…'`/`-c…`, `git difftool -x|--extcmd`, `git fetch
--upload-pack` (`git config <exec-key> <value>` set-form asks since #1133; `git rebase -x` and
(also: `read c < <(echo …)` — `read c <<< word` and `read c <<'EOF'; eval "$c"` ask via `ambiguous:eval`;
`git config <exec-key> <value>` set-form asks since #1133 and its value is rescanned (#1136); `git rebase -x` and
`GIT_SEQUENCE_EDITOR` ask via the git.rebase rule); any tool not in the
tables asks; Makefile/justfile recipe text from a plain file is undecidable)
(`awk system()`, `python -c os.system`, `perl system`, `vim -c '!…'`, `tar
--to-command`, `rsync -e` — refused **only when the payload names a control-plane
path** (a text match on the path, not a judgment of the exec shape); a payload guarded by verb rules alone still allows); a verb built from pieces (`x=ev; y=al; $x$y z`), an array element (`${cmds[0]}
tables asks; Makefile/justfile recipe text from a plain file is undecidable);
a verb built from pieces (`x=ev; y=al; $x$y z`), an array element (`${cmds[0]}
x`), `alias a=eval; a x`, `$1 x` outside a `-c` payload, `e${x}val`, a name
assigned in another command, program text from a producer other than
`echo`/`printf`/heredoc/here-string (`cat file | sh`, `curl … | sh`),
Expand Down Expand Up @@ -1843,8 +1843,53 @@ exact-shape pins for the stripper.
**Declared residuals:** a glob like `[a-z]*` in the same command as a pipe into a
shell can expand onto a control-plane name (`echo [a-z]*; echo hi | sh` blocks at
top level too; false-block direction, not wrapper-specific). A substitution in a
case pattern is ask-tier, never allowed. `<(rm -rf x)`, `sh <<< 'rm -rf /x'` and a
heredoc into bash allow for non-control-plane verbs on main too (pre-existing).
case pattern is ask-tier, never allowed.

### Hidden commands are judged as commands (#1136)

Text another program RUNS is a command in its own right, whatever carries it:
`<(…)` / `>(…)` bodies (the shell runs them whatever consumes the pipe), a
heredoc / here-string / `echo`-`printf` pipe into a shell (or into `xargs -I{}
sh -c '{}'`), and the value of an option or the program of an embedded language:

| carrier | hidden command |
|---|---|
| `sed` | the `e command` command (any address form), the replacement of `s/x/<cmd>/e` |
| `awk`/`gawk`/`mawk` | every string literal (and their concatenation) of a program that uses `system(`, `getline`, or a `|` pipe |
| `tar` | `--to-command`, `--checkpoint-action=exec=`, `-I`/`--use-compress-program`, `-F`/`--info-script`/`--new-volume-script`, `--rsh-command`, `--rmt-command` |
| `git` | `difftool -x/--extcmd`, `rebase -x/--exec`, `--upload-pack`/`--receive-pack`/`--exec` (fetch/pull/push/clone/ls-remote/archive), `clone -u`/`-c <exec-key>=`, `grep -O`, `filter-branch --*-filter`, `submodule foreach`, `bisect run`, set-form `config <exec-key> <value>` |
| `npm exec` / `npx` | `-c`/`--call` |
| `ssh`/`scp`/`sftp` | `-o ProxyCommand=`/`LocalCommand=`; scp/sftp `-S program` |
| `sh -c '…'`, `ssh host '…'` | a hidden command *inside* the payload |

`_hidden_commands` collects them; `_decide_command` judges each with the same
engine that judges it top-level and keeps the strictest verdict, and
`_derived_commands` feeds the same list to the protected-path scan. So a
payload's decision inside a carrier equals its decision alone — by
construction, with no per-rule twin. The outer escape hatch / kill switch ends
the walk (the hidden command is part of what the user waved through); an inner
one can never lower the outer verdict. For the general rules an interpreter's
stdin is NOT shell text (`python3 - <<< 'eval'` runs no eval); only the
protected-path scan keeps reading it as shell, as before.

`parallel … :::`, `flock LOCK cmd` / `flock LOCK -c 'cmd'` and `docker|podman|nerdctl
run|exec IMAGE cmd` (also `docker container exec`, `docker compose|docker-compose exec|run`, `kubectl exec POD -- cmd`; `--entrypoint` joins the command; a quoted `parallel 'cmd {}' :::` line is scanned whole) are hidden-command
carriers for the verb rules too: the launched command is judged as if typed
(`flock /tmp/l rm -rf /x` blocks like `rm -rf /x`). The eval check models the same
grammars, so only the eval word's wrongful ask is gone — `docker run alpine ls`
still asks, by the docker write-tier rule.
`tests/unit/test_damage_control_hidden_commands.py` is the differential.

**Declared residuals (undecidable statically, or text a program produces):**
`sed e` with no operand and `awk system($0)` run input data; `awk -f prog` /
`sed -f script` are files; `cat x.sh | sh` and other non-`echo` producers; an
interpreter's stdin on a LOCAL run (`echo 'os.execute(…)' | lua`, `lua <<< …`,
`… | python3`, `python3 - <<EOF`) is judged by the protected-path net only, not
the general verb rules (a prose literal in a heredoc script would otherwise
block, as `python3 -c` does); a remote interpreter's stdin IS judged.
Also declared: a here-string after a compound keyword on the SAME line
(`if true; then sh <<< 'rm -rf xx'; fi`, `for …; do bash <<< …; done`), and a
wrapper + `-c` + nested here-string (`sudo|env|nohup|timeout N bash -c 'sh <<< "rm -rf xx"'`).

### A `)` / `;` inside a word's data does not hide the verb (#1119)

Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/dc_msg_body_quoting_carriers.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
S=/private/tmp/claude-501/-Users-dotdev-projects-agentwire-dev/6b6b0069-d296-4be2-933c-5654b294a13c/scratchpad
agentwire msg send --to agentwire-dev-nodelete-1127-1128 --kind request "Reviewer-1135 final verdict was on the OLD head de2ce8f4; your ce17bc78 covers B1/B3/S1/S2/S3. Still open, fix in 20 minutes: (B2) wrongful UNVERIFIABLE ask (not bypass-demoted, blocks unattended) on benign scripts: 'cp a.txt \$D/b.txt; grep -n foo bar.json', 'for i in a b; do cp t/\$i.webp /tmp/\$i.webp; done; grep -n foo bar.json', 'mv \$a /tmp/b; ls' -> ask. Cause: a cp/mv with a \$var operand marks the op 'fed', then ANY search verb elsewhere with a relative root makes producer_may_emit true. cp/mv are not producers; a search verb in a different segment is not feeding the delete. Verify each is allow on your head, pin them (must-allow). Also kill the 3 surviving mutants with behavior tests: producer 'fd'; 'mv -t DIR' parsing; the scratch-root skip (_inside_scratch). Then push, gh pr checks, notify-parent." | tail -1
agentwire msg flush --force -s agentwire-dev-nodelete-1127-1128 | tail -1
agentwire msg send --to agentwire-dev-reviewer-1135 --kind note "Worker pushed ce17bc78 (merged main, fixes B1/B3/S1/S2/S3; B2 + 3 survivors pending in ~20 min). Re-verify ONLY the delta on ce17bc78 once the worker notifies or in 20 minutes, whichever first: your B1/B3/S1/S2/S3 repros, B2 repros, the 3 mutation survivors, corpus replay diffs. Final verdict within 25 minutes." | tail -1
agentwire msg flush --force -s agentwire-dev-reviewer-1135 | tail -1
agentwire msg send --to agentwire-dev-dc-batch3-execshapes --kind request "Reviewer verdict on #1138 (aabd0240): CHANGES REQUESTED, narrow. Fix within 25 minutes, merge origin/main first, regenerate, push, gh pr checks, notify-parent. (1) Launcher carriers wrongly ALLOW destructive payloads: 'flock /tmp/l rm -rf /x', 'flock /tmp/l -c \"rm -rf /x\"', 'flock -n /tmp/l git push --force origin main', 'nohup/sudo flock ... rm -rf' allow (default and unattended); the payload alone blocks. You added flock only to the eval-check table, not to command-position RULE scanning. Expose the launched command of flock (and docker/podman run|exec incl --entrypoint, parallel ':::') to the verb-rule walk / _hidden_commands, with the differential (decision inside the launcher == decision at top level). (2) Local interpreter stdin ('echo os.execute(...) | lua', 'lua <<< ...', 'echo ...os.system... | python3') is deliberately not rescanned in rules mode: confirm this is in the declared-residuals text, or close it if cheap. (3) 'docker run alpine ls' still asks: do not claim wrongful asks removed beyond the eval word. Mutation-check pins; fast set." | tail -1
agentwire msg flush --force -s agentwire-dev-dc-batch3-execshapes | tail -1
2 changes: 1 addition & 1 deletion tests/unit/test_control_plane_writers.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ def test_round1_review_shapes(cfg):
"echo 'touch @P' | sudo ls -i",
"echo 'touch @P' | sudo -u root ls -si",
"echo 'touch @P' | sudo cat",
"echo 'touch @P' | lua -e 'print(1)'",
"echo 'touch @P' | jq .",
"echo 'touch /tmp/x' | sudo -s",
"sudo -s <<EOF\nls /tmp\nEOF",
]
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/test_damage_control_eval_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,9 @@ def test_word_lists_and_quoted_values_are_data():
("cargo build --release", "allow"), ("pip list | grep requests", "allow"), ("docker ps -a", "allow"),
("curl -s https://example.com | jq .", "allow"), ("tar -czf a.tgz dir", "allow"), ("diff a b", "allow"),
# declared wrongful asks: an unknown launcher followed by the bare word cannot be told from a verb
(f"someunknowntool {W}", "ask"), (f"parallel -j4 grep {W} ::: f", "ask"), (f"flock /tmp/l grep {W} f", "ask"),
(f"someunknowntool {W}", "ask"),
# launchers with a modelled grammar (#14): the word after the lock file / before ``:::`` is the verb
(f"parallel -j4 grep {W} ::: f", "allow"), (f"flock /tmp/l grep {W} f", "allow"),
(f"foo --flag '{W}'", "ask"),
]

Expand Down
Loading
Loading