Runner: deny background tool tasks (nohup + log polling instead); kill leftover workspace processes after each session
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 changed files +58 −0
modified
services/research-runner/src/policy.test.ts
+4 −0
@@ -58,6 +58,10 @@ describe("platform policy hook", () => { | |||||
| 58 | expect(isDeny(await call("Bash", { command })), command).toBe(true); | 58 | expect(isDeny(await call("Bash", { command })), command).toBe(true); | ||
| 59 | } | 59 | } | ||
| 60 | expect(isDeny(await call("Bash", { command: "ls", dangerouslyDisableSandbox: true }))).toBe(true); | 60 | expect(isDeny(await call("Bash", { command: "ls", dangerouslyDisableSandbox: true }))).toBe(true); | ||
| 61 | + expect(isDeny(await call("Bash", { command: "python3 solve.py", run_in_background: true }))).toBe(true); | ||||
| 62 | + expect(isDeny(await call("Bash", { command: "nohup python3 solve.py > results/solve.log 2>&1 &" }))).toBe( | ||||
| 63 | + false, | ||||
| 64 | + ); | ||||
| 61 | }); | 65 | }); | ||
| 62 | it("allows ordinary research commands", async () => { | 66 | it("allows ordinary research commands", async () => { | ||
| 63 | for (const command of [ | 67 | for (const command of [ | ||
modified
services/research-runner/src/policy.ts
+6 −0
@@ -191,6 +191,12 @@ export function createPolicyHook(opts: PolicyOptions): HookCallback { | |||||
| 191 | if (tool === "Bash" || tool === "PowerShell") { | 191 | if (tool === "Bash" || tool === "PowerShell") { | ||
| 192 | const cmd = String(ti.command ?? ""); | 192 | const cmd = String(ti.command ?? ""); | ||
| 193 | if (ti.dangerouslyDisableSandbox === true) return deny(input, "sandbox bypass requested", ti); | 193 | if (ti.dangerouslyDisableSandbox === true) return deny(input, "sandbox bypass requested", ti); | ||
| 194 | + if (ti.run_in_background === true) | ||||
| 195 | + return deny( | ||||
| 196 | + input, | ||||
| 197 | + "background tool tasks keep the stage open indefinitely; run long jobs with `nohup <cmd> > results/<name>.log 2>&1 &` (bounded with a timeout) and poll the log file instead", | ||||
| 198 | + { command: cmd.slice(0, 200) }, | ||||
| 199 | + ); | ||||
| 194 | for (const p of DENIED_COMMAND_PATTERNS) | 200 | for (const p of DENIED_COMMAND_PATTERNS) | ||
| 195 | if (p.re.test(cmd)) return deny(input, p.reason, { command: cmd.slice(0, 300) }); | 201 | if (p.re.test(cmd)) return deny(input, p.reason, { command: cmd.slice(0, 300) }); | ||
| 196 | if (cmd.length > 20_000) return deny(input, "command too long", {}); | 202 | if (cmd.length > 20_000) return deny(input, "command too long", {}); | ||
modified
services/research-runner/src/runner.ts
+48 −0
@@ -394,6 +394,7 @@ export class ResearchRunner { | |||||
| 394 | clearInterval(cancelPoll); | 394 | clearInterval(cancelPoll); | ||
| 395 | } | 395 | } | ||
| 396 | result.denied = denied.n; | 396 | result.denied = denied.n; | ||
| 397 | + await this.killLeftoverProcesses(spec.key); | ||||
| 397 | this.spent += result.costUsd; | 398 | this.spent += result.costUsd; | ||
| 398 | this.turns += result.turns; | 399 | this.turns += result.turns; | ||
| 399 | this.emit( | 400 | this.emit( | ||
@@ -406,6 +407,53 @@ export class ResearchRunner { | |||||
| 406 | return result; | 407 | return result; | ||
| 407 | } | 408 | } | ||
| 408 | 409 | ||||
| 410 | + /** Kills processes (owned by this user) whose command line references this workspace — leftover solvers, servers, watchers. */ | ||||
| 411 | + private async killLeftoverProcesses(stage: StageKey): Promise<void> { | ||||
| 412 | + try { | ||||
| 413 | + const out = await new Promise<string>((resolve) => { | ||||
| 414 | + const ps = spawn("/bin/ps", ["-axo", "pid=,command="], { stdio: ["ignore", "pipe", "ignore"] }); | ||||
| 415 | + let buf = ""; | ||||
| 416 | + ps.stdout.on("data", (d) => { | ||||
| 417 | + buf += d.toString(); | ||||
| 418 | + }); | ||||
| 419 | + ps.on("close", () => resolve(buf)); | ||||
| 420 | + ps.on("error", () => resolve("")); | ||||
| 421 | + }); | ||||
| 422 | + const mine = out | ||||
| 423 | + .split("\n") | ||||
| 424 | + .map((l) => l.trim()) | ||||
| 425 | + .filter((l) => l.includes(this.ws) && !/claude-agent-sdk|dist\/main\.js/.test(l)) | ||||
| 426 | + .map((l) => Number(l.split(/\s+/)[0])) | ||||
| 427 | + .filter((pid) => Number.isInteger(pid) && pid !== process.pid); | ||||
| 428 | + for (const pid of mine) { | ||||
| 429 | + try { | ||||
| 430 | + process.kill(pid, "SIGTERM"); | ||||
| 431 | + } catch { | ||||
| 432 | + /* gone */ | ||||
| 433 | + } | ||||
| 434 | + } | ||||
| 435 | + if (mine.length) { | ||||
| 436 | + await new Promise((r) => setTimeout(r, 3000)); | ||||
| 437 | + for (const pid of mine) { | ||||
| 438 | + try { | ||||
| 439 | + process.kill(pid, "SIGKILL"); | ||||
| 440 | + } catch { | ||||
| 441 | + /* gone */ | ||||
| 442 | + } | ||||
| 443 | + } | ||||
| 444 | + this.emit( | ||||
| 445 | + "process.cleanup", | ||||
| 446 | + `terminated ${mine.length} leftover process(es) after the ${stage} session`, | ||||
| 447 | + { pids: mine.slice(0, 20) }, | ||||
| 448 | + "warn", | ||||
| 449 | + stage, | ||||
| 450 | + ); | ||||
| 451 | + } | ||||
| 452 | + } catch { | ||||
| 453 | + /* best effort */ | ||||
| 454 | + } | ||||
| 455 | + } | ||||
| 456 | + | ||||
| 409 | private async stageContext(extra: Partial<StageContext> = {}): Promise<StageContext> { | 457 | private async stageContext(extra: Partial<StageContext> = {}): Promise<StageContext> { | ||
| 410 | return { | 458 | return { | ||
| 411 | filesPresent: await listFiles(this.ws), | 459 | filesPresent: await listFiles(this.ws), | ||
| 412 | 460 | ||||