Skip to content

Commit d807646

Browse files
committed
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
1 parent 23efdbe commit d807646

10 files changed

Lines changed: 1077 additions & 116 deletions

File tree

‎agentwire/doctor_cli.py‎

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

643+
_render_residuals_you_own()
643644
return issues
644645

645646

647+
def _render_residuals_you_own() -> None:
648+
"""Informational, never scored: what the text hook cannot decide and the control that closes each (#1065)."""
649+
from .safety.residuals import USER_OWNED_RESIDUALS
650+
651+
print(" [..] Residuals you own — a text hook cannot decide these; the control is yours to set:")
652+
for residual, why, control in USER_OWNED_RESIDUALS:
653+
print(f" - {residual}")
654+
print(f" why: {why}")
655+
print(f" close it: {control}")
656+
657+
646658
def _render_voice_loop_section(config, ctx) -> int:
647659
"""Print the voice-loop (push-to-talk) preflight. Returns failures found.
648660

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

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def log_escape(*args, **kwargs): pass
3434

3535

3636
# === BEGIN AGENTWIRE HOOK STAMP (generated — do not edit) ===
37-
AGENTWIRE_HOOK_STAMP = {"core_sha256": "33f11ebb4e4472aedb37164dcb4c5d1010c5472bc516028d9aaa2c12070bdebc", "generated_at": "2026-10-19T00:29:08Z"}
37+
AGENTWIRE_HOOK_STAMP = {"core_sha256": "6053126c348d26b19caf090c3db81414833fcb056f8b1181debb0c96a0a6e614", "generated_at": "2026-10-19T11:24:11Z"}
3838
# === END AGENTWIRE HOOK STAMP ===
3939
# === BEGIN GENERATED FROM agentwire/safety/_core.py ===
4040
"""
@@ -4973,28 +4973,52 @@ def _realpath_variants(command: str) -> List[str]:
49734973
"""
49744974
out: List[str] = []
49754975
seen = set()
4976-
home = os.path.expanduser("~")
49774976
for m in itertools.islice(_PATH_WORD.finditer(command), _REALPATH_WORD_CAP):
49784977
word = m.group(0)
49794978
if word in seen or len(word) < 2:
49804979
continue
49814980
seen.add(word)
4982-
path = os.path.expanduser(word)
4983-
try:
4984-
if "/" not in path and not os.path.lexists(path):
4985-
continue
4986-
real = os.path.realpath(path)
4987-
target = real if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real) else None
4988-
if target is None and os.path.isfile(path) and os.stat(path).st_nlink > 1:
4989-
st = os.stat(path)
4990-
target = next((p for p, ps in _protected_inodes(home) if os.path.samestat(st, ps)), None)
4991-
except (OSError, ValueError):
4992-
continue
4993-
if target and target != path:
4981+
target = _alias_target(os.path.expanduser(word))
4982+
if target and target != os.path.expanduser(word):
49944983
out.append(command.replace(word, _quote_word(target)))
49954984
return out
49964985

49974986

4987+
def _alias_target(path: str) -> Optional[str]:
4988+
"""Where ``path`` really lands, when that is a protected file or directory it is only an alias of; else None."""
4989+
try:
4990+
if "/" not in path and not os.path.lexists(path):
4991+
return None
4992+
real = os.path.realpath(path)
4993+
if real != posixpath.normpath(os.path.abspath(path)) and _under_protected(real):
4994+
return real
4995+
if os.path.isfile(path) and os.stat(path).st_nlink > 1:
4996+
st = os.stat(path)
4997+
return next((p for p, ps in _protected_inodes(os.path.expanduser("~")) if os.path.samestat(st, ps)), None)
4998+
except (OSError, ValueError):
4999+
pass
5000+
return None
5001+
5002+
5003+
def _cwd_realpath_variants(command: str) -> List[str]:
5004+
"""``_realpath_variants`` for a relative word after a literal ``cd <dir> &&`` (#1065).
5005+
5006+
``cd /tmp/d && echo x > lnk`` names no path until the cwd is applied, and
5007+
``_cwd_variants`` only rewrites words that are lexically protected. The
5008+
aliased word is resolved against the same cwd and judged as what it is.
5009+
"""
5010+
out: List[str] = []
5011+
for cwd, rest in _cwd_scopes(command)[0]:
5012+
for m in itertools.islice(_PATH_WORD.finditer(rest), _REALPATH_WORD_CAP):
5013+
word = m.group(0)
5014+
if word.startswith(("/", "~", "-")) or len(word) < 2:
5015+
continue
5016+
target = _alias_target(posixpath.normpath(posixpath.join(cwd, word)))
5017+
if target:
5018+
out.append(rest.replace(word, _quote_word(target)))
5019+
return out
5020+
5021+
49985022
def _cwd_variants(command: str) -> List[str]:
49995023
"""Relative operands resolved against a literal ``cd <dir> &&`` / ``git -C <dir>`` (#1101).
50005024
@@ -5046,6 +5070,57 @@ def _named_control_plane_files() -> List[Tuple[str, "frozenset[str]"]]:
50465070
return [(d, frozenset(names)) for d, names in sorted(by_dir.items())]
50475071

50485072

5073+
_INLINE_INTERPRETER_RE = _rx(r'\b' + _INTERPRETERS + r'\b[^\n|;&]*?\s-[A-Za-z]*[ceE]\b')
5074+
# Words the program text uses for a control-plane directory or file, even when the
5075+
# path itself is assembled at run time (``'damage'+'control.yml'``, ``join(h, '.claude')``).
5076+
_CONTROL_PLANE_WORDS = (".agentwire", ".claude", "damagecontrol", "settings.json")
5077+
# A call that writes, deletes, or runs something, whatever it is handed…
5078+
_INLINE_SINK_RE = _rx(
5079+
r'\b(?:system|popen|subprocess|Popen|check_call|check_output|exec\w*|spawn\w*|write\w*'
5080+
r'|append\w*|unlink|rmtree|rename|replace|copy\w*|move|chmod|remove)\b'
5081+
)
5082+
# …and ``open``, which only writes in a write mode.
5083+
_INLINE_OPEN_RE = _rx(r'\b(?:open|Path|File|IO)\s*[.(]')
5084+
_INLINE_WRITE_MODE_RE = _rx(r'''['"](?:[wax][bt+]*|r\+[bt]*|\s*\+?>{1,2}[^'"]*)['"]''')
5085+
# The first argument of a sink that is not one plain string literal: a run-time path.
5086+
_INLINE_NONLITERAL_ARG_RE = _rx(
5087+
r'''\b(?:open|Path|write\w*|append\w*|system|popen|unlink|remove|rmtree|rename|replace|copy\w*|move'''
5088+
r'''|chmod|exec\w*|spawn\w*|run|call|check_call|check_output|Popen|join)\s*\(\s*'''
5089+
r'''(?!(['"])[^'"]*\1\s*[,)])'''
5090+
)
5091+
5092+
5093+
def _opaque_inline_write(command: str) -> Optional[str]:
5094+
"""Why an inline interpreter program needs a human: it writes to a path it computes, and names the control plane (#1065).
5095+
5096+
``python3 -c "open(os.path.join(h, '.agentwire', 'damage'+'control.yml'), 'w')"``
5097+
assembles its target at run time — no text match can read it, and no spelling
5098+
list ever will. The unknowable SHAPE is what is refused: an inline program
5099+
(``-c`` / ``-e``) whose call sinks take a non-literal first argument AND that
5100+
mentions a control-plane directory or file name. A literal path is the verb
5101+
rules' business; a program that never names the control plane, or only reads
5102+
(``open(p)`` with no write mode), is untouched.
5103+
"""
5104+
if "<<" in command: # a heredoc body is data (prose, a script): only an inline ``-c`` argument is the program
5105+
command = _hide_heredoc_bodies(command, keep_programs=False)
5106+
m = _INLINE_INTERPRETER_RE.search(command)
5107+
if not m:
5108+
return None
5109+
program = command[m.end():]
5110+
folded = program.casefold()
5111+
if not any(w in folded for w in _CONTROL_PLANE_WORDS):
5112+
return None
5113+
sink = _INLINE_SINK_RE.search(program) or (
5114+
_INLINE_OPEN_RE.search(program) and _INLINE_WRITE_MODE_RE.search(program)
5115+
)
5116+
if sink and _INLINE_NONLITERAL_ARG_RE.search(program):
5117+
return (
5118+
"an inline interpreter program writes to a path it computes at run time "
5119+
"and names the control plane"
5120+
)
5121+
return None
5122+
5123+
50495124
def unverifiable_target_verdict(
50505125
command: str, allowed_paths: List[Dict[str, Any]], depth: int = 0
50515126
) -> Optional[str]:
@@ -5068,6 +5143,9 @@ def unverifiable_target_verdict(
50685143
return reason
50695144
if "{" in command and mentions_control_plane(command) and brace_variants(canonicalize_command(command)) is None:
50705145
return "a brace expansion past the cap cannot be enumerated"
5146+
opaque = _opaque_inline_write(command)
5147+
if opaque:
5148+
return opaque
50715149
known, unknown = _cwd_scopes(command)
50725150
for cwd, rest in known:
50735151
if cwd in _protected_dirs() or cwd == os.path.expanduser("~"):
@@ -5132,6 +5210,7 @@ def _derived_commands(command: str) -> List[str]:
51325210
+ _glob_variants(visible)
51335211
+ _cwd_variants(visible)
51345212
+ _realpath_variants(visible)
5213+
+ _cwd_realpath_variants(visible)
51355214
)
51365215
return list(dict.fromkeys(derived))[:_RESCAN_FANOUT]
51375216

@@ -8477,11 +8556,41 @@ def hard_exit(code: int) -> None:
84778556
os._exit(code)
84788557

84798558

8480-
def run_hook_guarded(main: Any) -> None:
8481-
"""Run a hook ``main``; any unexpected exception becomes a blocking exit 2."""
8559+
HOOK_WATCHDOG_SECONDS = 20 # past every scan deadline, well inside HOOK_TIMEOUT_SECONDS
8560+
8561+
8562+
def _watchdog_fire() -> None:
8563+
"""The hook outlived its budget: block. Runs on the watchdog thread, so it exits the PROCESS."""
8564+
hard_exit(_block_exit(f"the hook exceeded its {HOOK_WATCHDOG_SECONDS}s budget"))
8565+
8566+
8567+
def run_hook_guarded(main: Any, deadline_seconds: Optional[float] = None) -> None:
8568+
"""Run a hook ``main``; any unexpected exception becomes a blocking exit 2.
8569+
8570+
The harness runs a hook that outlives its timeout as NO DECISION, so a hook
8571+
must answer inside it. Two bounds, independent of each other:
8572+
8573+
* ``deadline_seconds`` — the SIGALRM scan deadline (:func:`scan_deadline`)
8574+
around all of ``main``, for the hooks that do not carry their own per-scan
8575+
deadline (edit/write/read/mcp; the bash hook's ``check_command`` does).
8576+
The signal reaches a pattern stuck inside the regex engine, which checks
8577+
for signals; it needs POSIX and the main thread.
8578+
* a watchdog thread that exits the process with a block at
8579+
``HOOK_WATCHDOG_SECONDS``. It needs neither SIGALRM nor the main thread, so
8580+
it covers Windows and any wait that releases the GIL (a hung ``stat`` /
8581+
``realpath`` on a network mount). It cannot fire while one C call holds the
8582+
GIL for good (a runaway regex) — that is what the SIGALRM bound is for.
8583+
"""
8584+
watchdog = threading.Timer(HOOK_WATCHDOG_SECONDS, _watchdog_fire)
8585+
watchdog.daemon = True
8586+
watchdog.start()
84828587
code = 0
84838588
try:
8484-
main()
8589+
if deadline_seconds is None:
8590+
main()
8591+
else:
8592+
with scan_deadline(deadline_seconds):
8593+
main()
84858594
except SystemExit as e:
84868595
code = e.code if isinstance(e.code, int) else (0 if e.code is None else 2)
84878596
except HookInputError as e:
@@ -8492,6 +8601,7 @@ def run_hook_guarded(main: Any) -> None:
84928601
code = _block_exit("input nested too deeply to analyze safely")
84938602
except BaseException as e: # KeyboardInterrupt included: never exit 1
84948603
code = _block_exit(f"the hook failed ({type(e).__name__}: {e})")
8604+
watchdog.cancel()
84958605
hard_exit(code)
84968606

84978607

0 commit comments

Comments
 (0)