fix(#829): guard prefixed runtime variable launches
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
ms-lead-reviewer
2026-07-18 00:39:13 -05:00
parent 91a4a98370
commit 04cc065c2b
5 changed files with 108 additions and 23 deletions

View File

@@ -39,9 +39,17 @@ The executable submits the runtime's actual tool name to `authorize_tool`. Missi
Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic``execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers. Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic``execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers.
`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED. `check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED.
Both checks are load-bearing: command-position analysis covers consequential launches that do not use dangerous mode, while primitive ownership cannot be fooled by a comment or marker string for dangerous mode. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future bypass. Permanent regressions cover the original add-ungated effectiveness probe, comments, strings, assignments, heredocs, continuations, command chains, command substitution, `eval`, and variable execution; real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary.
### Parser stopping criterion
- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes.
- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease.
- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round.
All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator.
The live inventory is emitted by: The live inventory is emitted by:

View File

@@ -121,3 +121,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE
- Both independent gates converged on one guard-only completeness gap at round-4 head `1eb77c17f3147d4fa9944f77f1826243135b9cc0`; all round-4 primitive anchoring, command-position parsing, 14/14 inventory, broker ordering, and coverage remain locked-good. - Both independent gates converged on one guard-only completeness gap at round-4 head `1eb77c17f3147d4fa9944f77f1826243135b9cc0`; all round-4 primitive anchoring, command-position parsing, 14/14 inventory, broker ordering, and coverage remain locked-good.
- Reproduced exactly: a temporary production source containing `launcher=claude` followed by `exec "$launcher" -p x` exits 0 with `0 gated / 0 total`. The literal command resolver skips prefixes but cannot resolve a tracked variable; the variable resolver handles only bare/eval references and cannot skip prefixes. - Reproduced exactly: a temporary production source containing `launcher=claude` followed by `exec "$launcher" -p x` exits 0 with `0 gated / 0 total`. The literal command resolver skips prefixes but cannot resolve a tracked variable; the variable resolver handles only bare/eval references and cannot skip prefixes.
- Round-5 plan: add 10 permanent RED forms (quoted/unquoted `exec`, `command`, `nohup`, and `env` with assignment, each multiline and same-line), then unify shell command-position resolution so literal and tracked-variable terminal tokens traverse the same prefix parser. Retain an independent variable-reference backstop, the 14/14 inventory, and every round-4 regression. - Round-5 plan: add 10 permanent RED forms (quoted/unquoted `exec`, `command`, `nohup`, and `env` with assignment, each multiline and same-line), then unify shell command-position resolution so literal and tracked-variable terminal tokens traverse the same prefix parser. Retain an independent variable-reference backstop, the 14/14 inventory, and every round-4 regression.
- Mos stopping-criterion augment: command parsing is explicitly best-effort rather than a complete shell interpreter. Add B1 proving a parser-exotic alias launch with the raw dangerous flag is still RED by primitive anchoring, and B2 proving a parser-missed non-dangerous alias launch reaches the global `.*` hook and fails closed with `GATE_UNAVAILABLE` when no lease session exists. Document A (realistic parser matrix) + B (robust residual backstops); fresh reviewers supply criterion C (no new non-overlapping finding).
- RED commit `91a4a983`: all 10 prefix×variable cases failed as expected before the fix—quoted/unquoted `exec`, `command`, `nohup`, and `env A=1`, each in multiline and same-line assignment shapes.
- GREEN structural resolution: `shell_command_tokens()` now owns command-position prefix skipping for both literal and variable callers, including nested `exec`/`command`/`nohup`/`env` ordering. `runtime_variables` is threaded into `is_shell_direct_invocation()` and the same terminal-token resolver backs `executes_runtime_variable()`; exact `$v` and `${v}` references are resolved after `shlex` removes quoting. Same-line runtime assignment delimiters include shell operators.
- Residual backstops: B1 proves alias-indirected dangerous mode is classified `dangerous-primitive` even though the parser does not resolve the alias. B2 proves a non-dangerous alias residual remains parser-missed, then verifies the shipped global `.*` Claude hook and status-2 `GATE_UNAVAILABLE` denial for representative read, mutator, and custom/MCP tools without a lease session.
- Stopping-criterion evidence A+B is committed in tests and architecture docs; C remains the fresh exact-head terra/Opus determination. Repository inventory remains exactly **14 gated / 14 total**.
- Fresh round-5 coverage: permanent guard remains **97%** branch-aware aggregate (251 statements, 112 branches). Locked-good launcher and mutator-gate executables remain unchanged at their round-4 **100% / 100%** evidence.
- Fresh round-5 gates: real-socket acceptance **50/50**; persistence **10/10**; launcher/gate **14/14**; guard **12/12**; coord **19/19**; Mosaic **1385/1385**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green.

View File

@@ -79,7 +79,7 @@ DIRECT_PATTERNS: Final = (
re.compile(r"\beval\s+[\"'](?:claude|pi)(?:\s|[\"'])"), re.compile(r"\beval\s+[\"'](?:claude|pi)(?:\s|[\"'])"),
) )
RUNTIME_ASSIGNMENT: Final = re.compile( RUNTIME_ASSIGNMENT: Final = re.compile(
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*[\"']?(?:claude|pi)(?:\s|[\"']|$)" r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*[\"']?(?:claude|pi)(?:\s|[\"']|[;&|]|$)"
) )
TYPESCRIPT_WRAPPER_INVOCATION: Final = re.compile( TYPESCRIPT_WRAPPER_INVOCATION: Final = re.compile(
r"(?m)^\s*execRuntime\s*\(\s*[\"']python3[\"']\s*,\s*" r"(?m)^\s*execRuntime\s*\(\s*[\"']python3[\"']\s*,\s*"
@@ -226,44 +226,71 @@ def is_gated_line(path: Path, line: str) -> bool:
) )
def is_shell_direct_invocation(path: Path, line: str) -> bool: def shell_command_tokens(path: Path, line: str) -> list[str]:
if path.suffix.lower() not in SHELL_SUFFIXES: if path.suffix.lower() not in SHELL_SUFFIXES:
return False return []
assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
case_arm = re.match(r"^\s*[^;&()]+\)\s*", line) case_arm = re.match(r"^\s*[^;&()]+\)\s*", line)
command_source = line[case_arm.end() :] if case_arm is not None else line command_source = line[case_arm.end() :] if case_arm is not None else line
resolved: list[str] = []
for command in shell_commands(command_source): for command in shell_commands(command_source):
index = 0 index = 0
while index < len(command) and assignment.match(command[index]): while index < len(command) and assignment.match(command[index]):
index += 1 index += 1
while index < len(command) and command[index] in {"command", "exec", "nohup"}: # Resolve command position once for every literal and variable caller.
index += 1 # Prefixes may be nested in either order (for example `exec env A=1`).
if index < len(command) and command[index] == "env": while index < len(command):
index += 1 if command[index] in {"command", "exec", "nohup"}:
while index < len(command) and (
command[index].startswith("-") or assignment.match(command[index])
):
index += 1 index += 1
while index < len(command) and command[index] in {"command", "exec", "nohup"}: continue
if command[index] == "env":
index += 1 index += 1
if index < len(command) and Path(command[index]).name in {"claude", "pi"}: while index < len(command) and (
return True command[index].startswith("-") or assignment.match(command[index])
return False ):
index += 1
continue
break
if index < len(command):
resolved.append(command[index])
return resolved
def is_direct_line(path: Path, line: str) -> bool: def runtime_variable_reference(token: str, runtime_variables: set[str]) -> bool:
return is_shell_direct_invocation(path, line) or any( match = re.fullmatch(r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})", token)
if match is None:
return False
return (match.group(1) or match.group(2)) in runtime_variables
def is_shell_direct_invocation(
path: Path, line: str, runtime_variables: set[str]
) -> bool:
return any(
Path(token).name in {"claude", "pi"}
or runtime_variable_reference(token, runtime_variables)
for token in shell_command_tokens(path, line)
)
def is_direct_line(path: Path, line: str, runtime_variables: set[str]) -> bool:
return is_shell_direct_invocation(path, line, runtime_variables) or any(
pattern.search(line) for pattern in DIRECT_PATTERNS pattern.search(line) for pattern in DIRECT_PATTERNS
) )
def executes_runtime_variable(line: str, runtime_variables: set[str]) -> bool: def executes_runtime_variable(
path: Path, line: str, runtime_variables: set[str]
) -> bool:
if any(
runtime_variable_reference(token, runtime_variables)
for token in shell_command_tokens(path, line)
):
return True
for variable in runtime_variables: for variable in runtime_variables:
reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})" reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})"
if re.search(rf"\beval\s+[\"']?{reference}", line): if re.search(rf"\beval\s+[\"']?{reference}", line):
return True return True
if re.match(rf"^\s*[\"']?{reference}[\"']?(?:\s|$)", line):
return True
return False return False
@@ -293,8 +320,8 @@ def classify_text(path: Path, source: str) -> list[LaunchSite]:
if assignment is not None: if assignment is not None:
runtime_variables.add(assignment.group(1)) runtime_variables.add(assignment.group(1))
primitive_violation = DANGEROUS_PRIMITIVE in line and not is_choke_point(path) primitive_violation = DANGEROUS_PRIMITIVE in line and not is_choke_point(path)
line_is_direct = is_direct_line(path, line) or executes_runtime_variable( line_is_direct = is_direct_line(path, line, runtime_variables) or executes_runtime_variable(
line, runtime_variables path, line, runtime_variables
) )
line_is_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line) line_is_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line)

View File

@@ -24,6 +24,7 @@ interface BrokerPaths {
const frameworkRoot = new URL('../../framework/', import.meta.url).pathname; const frameworkRoot = new URL('../../framework/', import.meta.url).pathname;
const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py'); const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py');
const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py'); const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py');
const launchGuardPath = join(frameworkRoot, 'tools/lease-broker/check-runtime-launches.py');
const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py'); const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py');
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
@@ -282,6 +283,39 @@ describe('whole mutator-class lease gate', () => {
}); });
}); });
test('non-dangerous parser residual is denied by the global all-tools hook without a lease', async () => {
const root = await mkdtemp(join(tmpdir(), 'mosaic-parser-residual-'));
temporaryRoots.push(root);
const source = join(root, 'packages/probe/launch.sh');
await mkdir(join(root, 'packages/probe'), { recursive: true });
await writeFile(source, 'alias hidden_runtime=claude\nhidden_runtime -p x\n');
const parserResult = spawnSync('python3', [launchGuardPath, '--root', root, '--json'], {
encoding: 'utf8',
});
expect(parserResult.status).toBe(0);
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
};
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
expect(allToolsHook?.hooks[0]?.command).toContain('mutator-gate.py --runtime claude');
const environment = { ...process.env };
delete environment['MOSAIC_LEASE_SESSION_ID'];
delete environment['MOSAIC_LEASE_BROKER_SOCKET'];
for (const toolName of ['Bash', 'Read', 'mcp__provider__custom']) {
const gateResult = spawnSync('python3', [gatePath, '--runtime', 'claude'], {
input: `${JSON.stringify({ tool_name: toolName })}\n`,
encoding: 'utf8',
env: environment,
});
expect(gateResult.status, toolName).toBe(2);
expect(gateResult.stderr, toolName).toContain('GATE_UNAVAILABLE');
}
});
test('T-B raw and custom mutator tools are default-denied without shell parsing', async () => { test('T-B raw and custom mutator tools are default-denied without shell parsing', async () => {
const { socket } = await startBroker(); const { socket } = await startBroker();
const sessionId = await register(socket); const sessionId = await register(socket);

View File

@@ -114,6 +114,15 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
with self.subTest(filename=filename): with self.subTest(filename=filename):
self.assertEqual(GUARD.classify_text(Path(filename), source), []) self.assertEqual(GUARD.classify_text(Path(filename), source), [])
def test_dangerous_primitive_backstops_parser_exotic_alias_indirection(self) -> None:
source = (
"alias hidden_runtime=claude\n"
"hidden_runtime --dangerously-skip-permissions -p x\n"
)
sites = GUARD.scan_text(Path("alias-launch.sh"), source)
self.assertEqual(len(sites), 1)
self.assertEqual(sites[0].classification, "dangerous-primitive")
def test_dangerous_primitive_is_owned_only_by_the_choke_point(self) -> None: def test_dangerous_primitive_is_owned_only_by_the_choke_point(self) -> None:
primitive = "--dangerously-skip-permissions" primitive = "--dangerously-skip-permissions"
self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), []) self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), [])