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:
@@ -79,7 +79,7 @@ DIRECT_PATTERNS: Final = (
|
||||
re.compile(r"\beval\s+[\"'](?:claude|pi)(?:\s|[\"'])"),
|
||||
)
|
||||
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(
|
||||
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:
|
||||
return False
|
||||
return []
|
||||
assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
case_arm = re.match(r"^\s*[^;&()]+\)\s*", 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):
|
||||
index = 0
|
||||
while index < len(command) and assignment.match(command[index]):
|
||||
index += 1
|
||||
while index < len(command) and command[index] in {"command", "exec", "nohup"}:
|
||||
index += 1
|
||||
if index < len(command) and command[index] == "env":
|
||||
index += 1
|
||||
while index < len(command) and (
|
||||
command[index].startswith("-") or assignment.match(command[index])
|
||||
):
|
||||
# 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
|
||||
while index < len(command) and command[index] in {"command", "exec", "nohup"}:
|
||||
continue
|
||||
if command[index] == "env":
|
||||
index += 1
|
||||
if index < len(command) and Path(command[index]).name in {"claude", "pi"}:
|
||||
return True
|
||||
return False
|
||||
while index < len(command) and (
|
||||
command[index].startswith("-") or assignment.match(command[index])
|
||||
):
|
||||
index += 1
|
||||
continue
|
||||
break
|
||||
if index < len(command):
|
||||
resolved.append(command[index])
|
||||
return resolved
|
||||
|
||||
|
||||
def is_direct_line(path: Path, line: str) -> bool:
|
||||
return is_shell_direct_invocation(path, line) or any(
|
||||
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 (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
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})"
|
||||
if re.search(rf"\beval\s+[\"']?{reference}", line):
|
||||
return True
|
||||
if re.match(rf"^\s*[\"']?{reference}[\"']?(?:\s|$)", line):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -293,8 +320,8 @@ def classify_text(path: Path, source: str) -> list[LaunchSite]:
|
||||
if assignment is not None:
|
||||
runtime_variables.add(assignment.group(1))
|
||||
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, runtime_variables
|
||||
line_is_direct = is_direct_line(path, line, runtime_variables) or executes_runtime_variable(
|
||||
path, line, runtime_variables
|
||||
)
|
||||
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 daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.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 claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
||||
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 () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
|
||||
@@ -114,6 +114,15 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||
with self.subTest(filename=filename):
|
||||
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:
|
||||
primitive = "--dangerously-skip-permissions"
|
||||
self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), [])
|
||||
|
||||
Reference in New Issue
Block a user