fix(#829): harden runtime launch guard against marker evasions
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:
@@ -54,7 +54,10 @@ function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter
|
||||
return {
|
||||
harnessPreflight: () => {},
|
||||
composePrompt: () => '# Composed Claude contract',
|
||||
execLeaseGated: adapterOverrides.execLeaseGated ?? ((args, env) => exec?.('claude', args, env)),
|
||||
execLeaseGated:
|
||||
adapterOverrides.execLeaseGated ??
|
||||
((args, env, dangerous) =>
|
||||
exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)),
|
||||
...adapterOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,7 +475,7 @@ export interface ClaudexHarnessAdapter {
|
||||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||||
composePrompt: () => string;
|
||||
/** Register the Claude parent with the broker, then exec with the same PID. */
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => void;
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) => void;
|
||||
}
|
||||
|
||||
export interface LaunchClaudexDeps {
|
||||
@@ -533,9 +533,8 @@ export async function launchClaudex(
|
||||
|
||||
log(buildClaudexBanner(resolvedModels));
|
||||
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
||||
adapter.execLeaseGated(cliArgs, env);
|
||||
const cliArgs = ['--append-system-prompt', prompt, ...args];
|
||||
adapter.execLeaseGated(cliArgs, env, yolo);
|
||||
} catch (err) {
|
||||
errorLog(
|
||||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -755,7 +755,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
printSettingsWarnings(settingsAudit);
|
||||
|
||||
const prompt = buildRuntimePrompt('claude');
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
const cliArgs: string[] = [];
|
||||
cliArgs.push('--append-system-prompt', prompt);
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
@@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execLeaseGatedRuntime('claude', cliArgs);
|
||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -818,13 +818,19 @@ function execLeaseGatedRuntime(
|
||||
runtime: 'claude' | 'pi',
|
||||
args: string[],
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
dangerous = false,
|
||||
): void {
|
||||
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||
execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], {
|
||||
...baseEnv,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||
});
|
||||
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
||||
execRuntime(
|
||||
'python3',
|
||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||
{
|
||||
...baseEnv,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
@@ -860,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||
checkSequentialThinking('claude');
|
||||
},
|
||||
composePrompt: () => buildRuntimePrompt('claude'),
|
||||
execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env),
|
||||
execLeaseGated: (cmdArgs, env, dangerous) =>
|
||||
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
||||
};
|
||||
void launchClaudex(args, yolo, adapter);
|
||||
}
|
||||
|
||||
@@ -546,8 +546,20 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
||||
harnessPreflight: () => {},
|
||||
composePrompt: () => '# composed Claude contract',
|
||||
// Claudex exposes only the shared register-before-exec boundary.
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) =>
|
||||
run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env),
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) =>
|
||||
run(
|
||||
'python3',
|
||||
[
|
||||
launcherPath,
|
||||
...(dangerous ? ['--dangerous'] : []),
|
||||
'--runtime',
|
||||
'claude',
|
||||
'--',
|
||||
'claude',
|
||||
...args,
|
||||
],
|
||||
env,
|
||||
),
|
||||
} as unknown as ClaudexHarnessAdapter;
|
||||
|
||||
await launchClaudex([], yolo, adapter, {
|
||||
|
||||
@@ -56,6 +56,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||
"command-substitution.sh": "output=$(claude -p prompt)\n",
|
||||
"eval.sh": "launcher='claude -p prompt'\neval \"$launcher\"\n",
|
||||
"variable-exec.sh": "launcher=claude\n\"$launcher\" -p prompt\n",
|
||||
"env-prefix.sh": "env SAFE=1 claude --help\n",
|
||||
"command-prefix.sh": "command pi --help\n",
|
||||
"nohup-prefix.sh": "nohup claude --help &\n",
|
||||
}
|
||||
for filename, source in cases.items():
|
||||
with self.subTest(filename=filename):
|
||||
@@ -73,6 +76,28 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||
with self.subTest(filename=filename):
|
||||
self.assertEqual(GUARD.scan_text(Path(filename), source), [])
|
||||
|
||||
def test_accepts_only_validated_multiline_typescript_wrapper_invocation(self) -> None:
|
||||
source = """execRuntime(
|
||||
'python3',
|
||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||
environment,
|
||||
);
|
||||
"""
|
||||
sites = GUARD.classify_text(Path("launch.ts"), source)
|
||||
self.assertEqual(len(sites), 1)
|
||||
self.assertEqual(sites[0].classification, "gated")
|
||||
|
||||
def test_marker_comments_strings_and_assignments_are_not_gated_sites(self) -> None:
|
||||
harmless_sources = {
|
||||
"comment.sh": "# launch-runtime.py --runtime claude --\n",
|
||||
"echo.sh": "echo 'launch-runtime.py --runtime claude --'\n",
|
||||
"assignment.sh": "marker='launch-runtime.py --runtime claude --'\n",
|
||||
"argument.sh": "printf '%s' 'launch-runtime.py --runtime claude --'\n",
|
||||
}
|
||||
for filename, source in harmless_sources.items():
|
||||
with self.subTest(filename=filename):
|
||||
self.assertEqual(GUARD.classify_text(Path(filename), source), [])
|
||||
|
||||
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"), [])
|
||||
@@ -122,7 +147,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||
root = Path(directory)
|
||||
source = root / "packages/example/launch.sh"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("exec claude -p prompt\n")
|
||||
source.write_text(
|
||||
'exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py\n'
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
|
||||
Reference in New Issue
Block a user