Skip to content

Commit f7eeca5

Browse files
authored
damage-control: class-C narrow mitigations (rows 20/22/24-27) (#1148)
* damage-control: class-C narrow mitigations (rows 20/22/24-27) - inline interpreter writing to a run-time-computed path while naming the control plane asks (unverifiable) - existing aliases reached through a literal `cd <dir> &&` are resolved - non-bash hooks get the SIGALRM scan deadline; every hook gets a watchdog thread that blocks before the harness timeout - `agentwire doctor` "Residuals you own" (informational) + wiki table of closed / fail-closed / user-owned - rows 20 (brace cap) and in-command `ln` verified already closed; pinned Built by dotdev.dev * damage-control: scope the opaque-inline-write ask to the sink's own path expression (#1148 review) Program text only from -c/-e or an interpreter heredoc; the control word must sit in the sink's path expression; memory/wiki/artifacts exempt; reads never fire; receiver/getattr/non-literal-mode/heredoc-program sinks now covered. Audit replay: 6853 commands, 1 new ask. Docs/test claims corrected. Built by dotdev.dev * damage-control: close the memory/wiki '..' bypass, add sinks, mask string data (#1148 review 2) The agent-owned exemption no longer applies to an expression that can climb out of it. Adds File.open/new, io/codecs/os.open, createWriteStream, openSync, fopen, php -r, archive extraction sinks. Calls quoted as data are not calls. Docs: replay count and residuals corrected. Built by dotdev.dev * damage-control: make the memory/wiki exemption all-or-nothing (#1148 review 3) Exempt only ONE static path literal (optionally in expanduser/Path) that lies inside the agent-owned dir on a whole segment; any non-literal piece, or a chdir in a program naming the control plane, means no exemption. Replaces the '..' spelling denylist. Built by dotdev.dev
1 parent 404f916 commit f7eeca5

10 files changed

Lines changed: 2603 additions & 112 deletions

File tree

‎agentwire/doctor_cli.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,9 +673,21 @@ def _render_damage_control_section() -> int:
673673
else:
674674
print(" [ok] Built-in unattended grants all resolve to live rules")
675675

676+
_render_residuals_you_own()
676677
return issues
677678

678679

680+
def _render_residuals_you_own() -> None:
681+
"""Informational, never scored: what the text hook cannot decide and the control that closes each (#1065)."""
682+
from .safety.residuals import USER_OWNED_RESIDUALS
683+
684+
print(" [..] Residuals you own — a text hook cannot decide these; the control is yours to set:")
685+
for residual, why, control in USER_OWNED_RESIDUALS:
686+
print(f" - {residual}")
687+
print(f" why: {why}")
688+
print(f" close it: {control}")
689+
690+
679691
def _render_voice_loop_section(config, ctx) -> int:
680692
"""Print the voice-loop (push-to-talk) preflight. Returns failures found.
681693

‎agentwire/hooks/damage-control/bash-tool-damage-control.py‎

Lines changed: 368 additions & 17 deletions
Large diffs are not rendered by default.

‎agentwire/hooks/damage-control/edit-tool-damage-control.py‎

Lines changed: 368 additions & 17 deletions
Large diffs are not rendered by default.

‎agentwire/hooks/damage-control/mcp-tool-damage-control.py‎

Lines changed: 368 additions & 17 deletions
Large diffs are not rendered by default.

‎agentwire/hooks/damage-control/read-tool-damage-control.py‎

Lines changed: 368 additions & 17 deletions
Large diffs are not rendered by default.

‎agentwire/hooks/damage-control/write-tool-damage-control.py‎

Lines changed: 368 additions & 17 deletions
Large diffs are not rendered by default.

‎agentwire/safety/_core.py‎

Lines changed: 367 additions & 16 deletions
Large diffs are not rendered by default.

‎agentwire/safety/residuals.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""What the text hook cannot decide, and who closes it (#1065).
2+
3+
The single list behind ``agentwire doctor``'s "Residuals you own" section and
4+
the closed / fail-closed / user-owned table in ``docs/wiki/internals/damage-control.md``.
5+
A PreToolUse hook reads the command TEXT before it runs. Anything whose path or
6+
verb comes into existence at run time, or whose content lives in a file the text
7+
only names, is outside what text can decide; tightening rules only moves the
8+
bypass to the next spelling. The honest claim is "every statically decidable
9+
shape closed, the rest fails closed or is yours to enforce at the OS".
10+
11+
Informational only: never scored, never an issue. agentwire does not set these
12+
controls for you.
13+
"""
14+
15+
from typing import Tuple
16+
17+
# (residual, why the text hook cannot decide it, the control that closes it)
18+
USER_OWNED_RESIDUALS: Tuple[Tuple[str, str, str], ...] = (
19+
(
20+
"SQL fed from a file (psql -f, mysql < f, sqlite3 db < f)",
21+
"the statements are in the file, not in the command",
22+
"give agent sessions a read-only database role; keep the write credential out of their environment",
23+
),
24+
(
25+
"make / just / task recipes and package scripts (make clean, just nuke, npm run x)",
26+
"the recipe text lives in a Makefile / justfile, edited by anyone",
27+
"run agent sessions under a sandbox profile (sandbox-exec, container, separate uid) scoped to the project",
28+
),
29+
(
30+
"check-then-use race (TOCTOU): a path the hook validated is swapped before the command uses it",
31+
"a PreToolUse hook sees the text once, before execution; it cannot hold the filesystem still",
32+
"set immutable flags on the control files yourself: chflags uchg (macOS) / chattr +i (Linux) on "
33+
"~/.agentwire/damagecontrol.yml, ~/.claude/settings.json, the installed hooks (chflags nouchg to edit)",
34+
),
35+
(
36+
"run-time-assembled paths and verbs beyond the shapes that fail closed "
37+
"(decoded payloads, chr() chains, $(...) output, aliases defined in an earlier turn)",
38+
"the string does not exist until the program runs",
39+
"the same immutable flags on the crown-jewel files, or a sandbox profile that denies writes to them",
40+
),
41+
)

‎docs/wiki/internals/damage-control.md‎

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,9 @@ re-scanned as a command, so the verb still decides (reads stay allowed):
352352
unresolvable-`cd` ask is evaluated against the control-plane allowlist, so a
353353
default `/tmp/*` allowance cannot waive it. The `patch`/`git apply` read-only
354354
flags excuse only the command they sit in, and the verb is matched by basename.
355-
Not covered: a symlink alias reached through a `cd` directory the hook's own
356-
cwd cannot see (`cd <work> && touch dirlink/…`), and a bare `cd`.
355+
An existing alias reached through a literal `cd <dir> &&` is resolved against
356+
that directory too (`_cwd_realpath_variants`: `cd /tmp/d && echo x > lnk`).
357+
Not covered: a bare `cd`, or a `cd` into a directory the scanner cannot read.
357358
- **A `cd` the scanner cannot resolve** (`cd -`, `popd`, `cd $x` with `x` not a
358359
literal assignment) followed by a distinctive protected NAME that the rest of
359360
the command would write (`cd - && touch damagecontrol.yml`) is an ASK that
@@ -388,9 +389,51 @@ re-scanned as a command, so the verb still decides (reads stay allowed):
388389
fail-closed for any inline program naming the path, an inline *read* of it is
389390
refused the same way it is unwrapped.
390391

392+
**Run-time-assembled paths fail closed on SHAPE (#1065).** The program text is
393+
taken ONLY from an inline interpreter's `-c`/`-e` argument (`php -r`;
394+
`python/node/perl/ruby/lua`) or a heredoc fed to one — never the rest of the
395+
command line. A write/exec sink whose path expression is not one plain literal
396+
AND itself contains a control-plane word (`.agentwire`, `.claude`,
397+
`damagecontrol`, `settings.json`) asks (`core.control-plane-target`,
398+
unverifiable — bypass does not demote it). Sinks: `open` / `io.open` /
399+
`codecs.open` / `os.open` / `File.open` / `File.new` / `fs.openSync` / `fopen` in a
400+
write (or non-literal) mode, a Perl `open(F, ">…")`, `File.write`,
401+
`writeFile*` / `appendFile*` / `createWriteStream`, `file_put_contents`,
402+
`os.system` / `subprocess.*` / `popen` / `exec*` / `spawn*`, `unlink` / `remove` /
403+
`rmtree` / `chmod`, `shutil.copy*` / `move` / `rename` / `os.replace`,
404+
archive `extractall` / `extract` / `unpack_archive`, and the receiver of
405+
`Path(...).write_text` / `write_bytes` / `touch` / `unlink` / `open(mode)`.
406+
`getattr` / `__import__` / `eval` / `exec` in a program that names the control
407+
plane asks too (the sink is hidden). Calls quoted as data (a fixture, a
408+
message) are not calls.
409+
410+
Not fired: a control word in a comment, a printed string, written data or argv;
411+
read-only use; a literal path (the verb rules decide it). The agent-owned
412+
`.claude/projects/*/memory` and `.agentwire/wiki|artifacts` are exempt
413+
**all-or-nothing**: only when the path expression is ONE static string literal
414+
(optionally the sole argument of `expanduser` / `Path`) whose normalized form lies
415+
inside that directory on a whole path segment (`.agentwire/wiki-evil` is not
416+
wiki). Any other piece — a variable segment, `os.path.join`, `+`, `os.pardir`,
417+
a call, a `chdir` earlier in a program that names the control plane — means the
418+
scan cannot say where the path lands, so there is no exemption and it asks. This
419+
is a shape, not a spelling denylist: `'.\x2e'`, `'.'*2`, `chr(46)*2` and a
420+
`dd='..'` variable all fall out the same way. The price: a routine writer that
421+
builds a memory path from a variable segment asks; write the full path as one
422+
literal (the Write tool is unaffected). Declared residuals: a symlink INSIDE
423+
`memory`/`wiki` that points at the control plane; a control word split across a
424+
variable assigned elsewhere in the program
425+
(`d = Path.home()/'.claude'; (d/'x').write_text(..)`).
426+
427+
Audit replay (7.0 k distinct logged commands): exactly one new ask on the current
428+
code — a scratch-dir script writing `S+"/ks/damagecontrol.yml"`, the shape itself,
429+
by design; no routine memory/wiki writer exists among them (memory is written with the
430+
Write tool). Earlier revisions asked on three: that one, a test-fixture author whose
431+
string data held sink calls, and a probe writer; string-literal masking removed
432+
the data-as-code ones.
433+
391434
Declared residuals (not statically knowable): paths assembled at runtime
392-
(`join`, `chr`, `format`, decoding, `d=$(…); … $d/damagecontrol.yml`, a
393-
component that is only `$var`), stdin text produced by anything but
435+
beyond that shape (`format`, decoding, `d=$(…); … $d/damagecontrol.yml`, a
436+
component that is only `$var`, a program that names none of the four words), stdin text produced by anything but
394437
`echo`/`printf`/a heredoc (`cat file | ssh host`, `bash -c "$(cat x)"` names no
395438
path until it runs), text that a stdin reader receives through a stage the
396439
scanner does not model (`… | tee x | ssh host`, `… | mosh host`,
@@ -408,7 +451,7 @@ name asks instead — above), `make -C`-style directory options of other tools
408451
(`make -C <dir> clean` runs a Makefile nobody can read), a symlink or hardlink
409452
alias created AND written through by the same command from a computed name, a
410453
derived-command budget exhausted by a pathological command, and the TOCTOU
411-
window. Directory-extraction spellings `/.`, `//`, `/sub/..`,
454+
window (see [Closed, fail-closed, user-owned](#closed-fail-closed-user-owned-1065)). Directory-extraction spellings `/.`, `//`, `/sub/..`,
412455
`--one-top-level=` and `bsdtar` are normalized. Pins:
413456
`tests/unit/test_control_plane_writers.py` (must-block per verb *and* must-allow
414457
siblings).
@@ -1638,6 +1681,34 @@ silent loop with no screen to show it on.
16381681
16391682
---
16401683
1684+
## Closed, fail-closed, user-owned (#1065)
1685+
1686+
The single honest statement of what the text hook decides. A PreToolUse hook reads
1687+
the command text before it runs, so anything whose path or verb exists only at run
1688+
time, or whose content is in a file the text merely names, is not decidable by
1689+
tightening rules (the lesson of #913/#915/#917/#921: it moves the bypass to the
1690+
next spelling). The claim is **not** "zero residuals": it is *every statically
1691+
decidable shape closed, the rest fails closed or is yours to enforce at the OS*.
1692+
1693+
| Residual | State | What decides it |
1694+
|---|---|---|
1695+
| Literal / quoted / escaped / `$HOME` / `~` spellings of a protected path; `ln` / `link` onto one | **Closed** (block) | path ladders, masked subcommands |
1696+
| Existing symlink / hardlink / directory alias of a protected file (pre-existing); a relative word after `cd <dir> &&` is new | **Closed** (block) | realpath + inode match at hook time (`_realpath_variants`, `_cwd_realpath_variants`) |
1697+
| Brace expansion past the 256-variant cap (pre-existing) | **Fail-closed** (block; ask `core.control-plane-target` when only that check sees it, e.g. a protected path before the braces) | `brace_variants` returns None; path patterns treat it as a hit |
1698+
| Inline interpreter writing to a computed path while naming the control plane | **Fail-closed** (ask, unverifiable) | `_opaque_inline_write` |
1699+
| Unresolvable `cd` then a bare protected name; `patch` / `git apply` in a protected dir | **Fail-closed** (ask) | `unverifiable_target_verdict` |
1700+
| Scan too large / too slow; hook outliving its timeout | **Fail-closed** (ask / block) | size gate, SIGALRM deadline, watchdog |
1701+
| SQL fed from a file (`psql -f`, `mysql < f`) | **User-owned** | read-only database role for agent sessions |
1702+
| `make` / `just` / package-script recipe text | **User-owned** | sandbox profile / container / separate uid |
1703+
| Check-then-use race (TOCTOU) | **User-owned** | `chflags uchg` (macOS) / `chattr +i` (Linux) on the control files |
1704+
| Run-time-assembled paths or verbs beyond the fail-closed shapes (decoded payloads, `chr()` chains, `$(…)` output, earlier-turn aliases) | **User-owned** | the same immutable flags, or a sandbox profile denying writes to them |
1705+
1706+
`agentwire doctor` prints the user-owned rows under "Residuals you own"
1707+
(informational, never scored; the list is `agentwire/safety/residuals.py`).
1708+
agentwire does not set these controls: the immutable flag stops agentwire's own
1709+
edits to the file too (`chflags nouchg` to change it), which is a decision for
1710+
the person who owns the machine.
1711+
16411712
## Troubleshooting
16421713
16431714
### Hook Not Blocking Expected Command
@@ -1870,12 +1941,22 @@ against main over that slice found no decision differences). Known allow, same
18701941
as main: a writer AND the path both inside ONE quoted string (`ln`, `curl -o`)
18711942
is data in a quote, not a command.
18721943

1873-
The read hook has no deadline but is linear in the path length (a 20 MB path
1874-
takes ~4 s); edit/write scan only the path.
1875-
1876-
Declared residuals: the clock budget needs `SIGALRM` on the main thread (every
1877-
hook process; not a portal worker thread or Windows, where only the size gate
1878-
applies).
1944+
The read hook is linear in the path length (a 20 MB path takes ~4 s); edit/write
1945+
scan only the path. All five hooks are bounded anyway (#1065): the edit/write/read/mcp
1946+
entrypoints run `main` under the same 10 s `SIGALRM` scan deadline
1947+
(`run_hook_guarded(main, deadline_seconds=…)`; over it = block exit 2), and EVERY
1948+
hook starts a 20 s watchdog thread that exits the process with a block, so a
1949+
hook can never outlive the 30 s harness timeout into "no decision".
1950+
1951+
Why two: the signal is the only thing that interrupts a pattern stuck inside the
1952+
regex engine (`re` checks for signals; a thread waiting on a timer needs the GIL,
1953+
which a long C regex never yields) — but it needs POSIX and the main thread.
1954+
The watchdog needs neither, so it covers Windows and any wait that releases the
1955+
GIL (a hung `stat`/`realpath` on a network mount). **Declared residual:** a
1956+
runaway regex on a platform without `SIGALRM` (Windows) outlives the watchdog
1957+
too; the size gate is the only bound there. `check_command` called in-process
1958+
from a portal worker thread keeps the old shape (size gate only; the watchdog
1959+
exits the process and so is hook-only).
18791960

18801961
### A command inside a compound body is judged like the same command at top level (#1112)
18811962

0 commit comments

Comments
 (0)