fix(mosaic): harden literal Claude recovery gate mapping

This commit is contained in:
wjarvis mos-comms
2026-07-19 20:42:05 -05:00
parent 65e2bd71cf
commit b6deed04c7
5 changed files with 56 additions and 32 deletions

View File

@@ -16,14 +16,13 @@ mutator remains behind the verified lease gate.
- **Claude:** invoke only this direct command shape (no shell composition):
```bash
python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" begin \
--construction "$MOSAIC_CONTEXT_REFRESH_CONSTRUCTION" \
--compaction-epoch "$MOSAIC_COMPACTION_EPOCH" \
--request-epoch "$MOSAIC_REQUEST_EPOCH"
python3 /home/hermes/.config/mosaic/tools/lease-broker/recover-context.py begin --construction /absolute/path/to/mosaic-context-refresh-construction.json --compaction-epoch 0 --request-epoch 0
```
Claude's all-tools gate maps this exact recovery executable and validated argument shape to
`mosaic_context_recover`; ordinary `Bash` remains gated.
This is a literal argv template: replace the construction path with a literal absolute path and
replace each epoch with literal decimal digits. Do not use variables, quoting, globs, redirects,
shell operators, substitutions, or line continuations. Claude's all-tools gate maps only this
fully literal recovery shape to `mosaic_context_recover`; ordinary `Bash` remains gated.
- **Pi:** call the registered `mosaic_context_recover` tool with `phase: "begin"`,
`construction`, `compactionEpoch`, and `requestEpoch`. It is the exact broker-exempt tool name;
@@ -41,7 +40,7 @@ mutator remains behind the verified lease gate.
`after_provider_response`).
Then invoke completion with the same adapter form: Claude runs
`python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" complete`; Pi calls
`python3 /home/hermes/.config/mosaic/tools/lease-broker/recover-context.py complete`; Pi calls
`mosaic_context_recover` with `phase: "complete"`. Completion supplies no receipt or challenge
argument. The broker observes the exact latest assistant entry, commits evidence, consumes its own
fresh challenge, and promotes VERIFIED last. If observation is absent, malformed, stale, or

View File

@@ -8,7 +8,7 @@ import json
import os
import socket
import sys
import shlex
import re
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import BinaryIO, Final
@@ -23,6 +23,8 @@ from lease_generation import read_runtime_generation
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
RECOVERY_TOOL: Final = "mosaic_context_recover"
_LITERAL_ABSOLUTE_PATH: Final = re.compile(r"/[A-Za-z0-9._/-]+\Z")
_SHELL_ACTIVE: Final = frozenset("$`~*?[]{}<>;|&" + '"' + "'" + "\\" + "\n\r\t")
def deny(code: str) -> int:
@@ -51,11 +53,13 @@ def read_tool_name(stream: BinaryIO | None = None) -> str:
def recovery_invocation_name(request: dict[str, object], recovery_command: Path | None) -> str:
"""Map only a direct Claude Bash invocation of the recovery executable.
"""Map only a byte-literal Claude recovery argv to ``RECOVERY_TOOL``.
The mapping is adapter-configured and remains broker-authenticated through
the ordinary authorization request. It never blesses Bash generally, shell
composition, a different executable, or unsupported recovery arguments.
Claude's Bash tool evaluates its raw command with a real shell. Therefore
the gate never attempts a second shell parser: any quote, expansion,
redirection, operator, glob, newline, or non-space whitespace is refused
before tokenizing. The remaining plain-space split is an exact argv proof,
not a best-effort interpretation of shell syntax.
"""
tool_name = request["tool_name"]
@@ -65,27 +69,33 @@ def recovery_invocation_name(request: dict[str, object], recovery_command: Path
if not isinstance(tool_input, dict) or set(tool_input) != {"command"}:
return str(tool_name)
command = tool_input.get("command")
if not isinstance(command, str) or not command:
if (
not isinstance(command, str)
or not command
or any(character in _SHELL_ACTIVE for character in command)
or command.startswith(" ")
or command.endswith(" ")
or " " in command
):
return str(tool_name)
try:
argv = shlex.split(command, posix=True)
except ValueError:
argv = command.split(" ")
if " ".join(argv) != command or len(argv) < 3 or argv[0] != "python3":
return str(tool_name)
if len(argv) < 3 or argv[0] != "python3":
return str(tool_name)
try:
if Path(argv[1]).resolve(strict=False) != recovery_command.resolve(strict=False):
return str(tool_name)
except OSError:
if argv[1] != str(recovery_command):
return str(tool_name)
phase = argv[2]
if phase == "complete" and len(argv) == 3:
return RECOVERY_TOOL
if phase != "begin" or len(argv) != 9:
return str(tool_name)
expected_flags = {"--construction", "--compaction-epoch", "--request-epoch"}
supplied_flags = set(argv[3::2])
if supplied_flags != expected_flags or any(not value for value in argv[4::2]):
if argv[3::2] != ["--construction", "--compaction-epoch", "--request-epoch"]:
return str(tool_name)
construction, compaction_epoch, request_epoch = argv[4::2]
if (
_LITERAL_ABSOLUTE_PATH.fullmatch(construction) is None
or not compaction_epoch.isdecimal()
or not request_epoch.isdecimal()
):
return str(tool_name)
return RECOVERY_TOOL

View File

@@ -102,7 +102,10 @@ class ClaudeRecoveryGateAdversarialTest(unittest.TestCase):
self.daemon.stdout.close()
self.temporary.cleanup()
def gate(self, command: str) -> subprocess.CompletedProcess[str]:
def gate(
self, command: str, recovery_command: Path | str | None = None
) -> subprocess.CompletedProcess[str]:
configured_recovery = self.recovery_path if recovery_command is None else recovery_command
return subprocess.run(
[
sys.executable,
@@ -113,7 +116,7 @@ class ClaudeRecoveryGateAdversarialTest(unittest.TestCase):
"--runtime",
"claude",
"--recovery-command",
str(self.recovery_path),
str(configured_recovery),
],
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
text=True,
@@ -153,9 +156,18 @@ class ClaudeRecoveryGateAdversarialTest(unittest.TestCase):
def test_canonical_literal_and_shipped_skill_invocation_are_ungated(self) -> None:
self.assertEqual(self.gate(self.canonical).returncode, 0)
self.assertIn(
"python3 /home/hermes/.config/mosaic/tools/lease-broker/recover-context.py begin",
SKILL.read_text(encoding="utf-8"),
shipped = (
"python3 /home/hermes/.config/mosaic/tools/lease-broker/recover-context.py begin "
"--construction /absolute/path/to/mosaic-context-refresh-construction.json "
"--compaction-epoch 0 --request-epoch 0"
)
self.assertIn(shipped, SKILL.read_text(encoding="utf-8"))
self.assertEqual(
self.gate(
shipped,
"/home/hermes/.config/mosaic/tools/lease-broker/recover-context.py",
).returncode,
0,
)
def test_every_shell_active_vector_in_every_argv_position_falls_through_to_bash_deny(self) -> None: