fix(#829): guard prefixed runtime variable launches
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
# Prefixes may be nested in either order (for example `exec env A=1`).
|
||||||
|
while index < len(command):
|
||||||
|
if command[index] in {"command", "exec", "nohup"}:
|
||||||
index += 1
|
index += 1
|
||||||
if index < len(command) and command[index] == "env":
|
continue
|
||||||
|
if command[index] == "env":
|
||||||
index += 1
|
index += 1
|
||||||
while index < len(command) and (
|
while index < len(command) and (
|
||||||
command[index].startswith("-") or assignment.match(command[index])
|
command[index].startswith("-") or assignment.match(command[index])
|
||||||
):
|
):
|
||||||
index += 1
|
index += 1
|
||||||
while index < len(command) and command[index] in {"command", "exec", "nohup"}:
|
continue
|
||||||
index += 1
|
break
|
||||||
if index < len(command) and Path(command[index]).name in {"claude", "pi"}:
|
if index < len(command):
|
||||||
return True
|
resolved.append(command[index])
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_variable_reference(token: str, runtime_variables: set[str]) -> bool:
|
||||||
|
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 False
|
||||||
|
return (match.group(1) or match.group(2)) in runtime_variables
|
||||||
|
|
||||||
|
|
||||||
def is_direct_line(path: Path, line: str) -> bool:
|
def is_shell_direct_invocation(
|
||||||
return is_shell_direct_invocation(path, line) or any(
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"), [])
|
||||||
|
|||||||
Reference in New Issue
Block a user