feat(detector): add --engine-timeout and --max-turns kill switches for runaway models - #918
Conversation
… switches Add two independent bounds so a runaway detection model can be killed without relying on the enclosing GitHub Actions job timeout: - `--engine-timeout` (default 5m, env `THREAT_DETECTION_ENGINE_TIMEOUT`): wall-clock timeout applied per attempt via `context.WithTimeout`. On expiry the subprocess is killed. A verdict written to the result sink just before the deadline still wins (race honored). All attempts timing out yields exit 2 with a new `engine_timeout` status reason, distinct from `engine_error` so daily statistics can separate runaway-model kills from other engine failures. - `--max-turns` (default 20, env `THREAT_DETECTION_MAX_TURNS`; also honors `GH_AW_MAX_TURNS`): exported as `GH_AW_MAX_TURNS` (gh-aw's universal contract read by all three engine harnesses) and additionally passed as `--max-turns` to the bare Claude CLI. The bare Copilot CLI has no equivalent flag; the detector logs a diagnostic on that path. Per TD-21a, `engine_timeout` is in the same "MAY fail the step" bucket as `engine_error`/`config_error`/`cancelled`, so `conclude` in warn mode still lets safe outputs proceed (soft workflow failure); strict mode blocks. Defaults sized so 2 attempts × 5m fits inside the smokes' 15m job budget with headroom. Spec: adds TD-21b covering both budgets and the verdict-race rule. README: documents the new flags and defaults. Tests: new coverage for timeout kills, verdict-vs-deadline race, `GH_AW_MAX_TURNS` propagation, disabled cap, and the `GH_AW_MAX_TURNS` env fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds per-attempt timeout and turn limits to prevent runaway threat-detection engines.
Changes:
- Adds
--engine-timeout,--max-turns, and environment propagation. - Introduces
engine_timeoutstatus handling and retry logic. - Adds documentation and tests for limits and deadline races.
Show a summary per file
| File | Description |
|---|---|
specs/threat-detection-spec.md |
Defines timeout and turn-cap requirements. |
README.md |
Documents the new flags. |
pkg/engine/engine.go |
Propagates turn limits to engines. |
pkg/engine/engine_test.go |
Tests Claude argument generation. |
cmd/threat-detect/main.go |
Implements timeout configuration and status handling. |
cmd/threat-detect/main_test.go |
Tests timeout and environment behavior. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
cmd/threat-detect/main.go:189
- Negative CLI values bypass both kill switches:
--engine-timeout=-1sskipscontext.WithTimeout, and--max-turns=-1is omitted bymaxTurnsEnv. This contradicts the documented contract that only0disables a cap and differs from the env parsing, which rejects negatives. Validate both resolved flag values after parsing and returnconfig_errorfor negatives.
flag.IntVar(&maxTurns, "max-turns", envMaxTurns(defaultMaxTurns),
"Maximum agentic tool-use turns per attempt; 0 disables the cap. Exported to the engine subprocess as GH_AW_MAX_TURNS and passed as --max-turns to engines whose CLI accepts it (env: THREAT_DETECTION_MAX_TURNS, GH_AW_MAX_TURNS)")
flag.DurationVar(&engineTimeout, "engine-timeout", envDuration("THREAT_DETECTION_ENGINE_TIMEOUT", defaultEngineTimeout),
"Wall-clock timeout per detection attempt (e.g. 5m, 300s); 0 disables. On expiry the engine subprocess is killed; if all attempts time out the run exits 2 with reason engine_timeout (env: THREAT_DETECTION_ENGINE_TIMEOUT)")
- Files reviewed: 6/6 changed files
- Comments generated: 5
- Review effort level: Balanced
| func maxTurnsEnv(maxTurns int) []string { | ||
| if maxTurns <= 0 { | ||
| return nil | ||
| } | ||
| return []string{fmt.Sprintf("%s=%d", MaxTurnsEnvVar, maxTurns)} |
| // errEngineTimeout marks a per-attempt wall-clock timeout expiring before the | ||
| // engine recorded a verdict. It is treated as an engine failure within | ||
| // analyzeWithRetries so the retry loop still applies, but propagates a distinct | ||
| // terminal reason (reasonEngineTimeout) so the daily statistics can separate | ||
| // runaway-model kills from other engine failures. | ||
| var errEngineTimeout = errors.New("engine timeout") |
| if i == attempts-1 { | ||
| return nil, fmt.Errorf("%w: detection engine did not record a verdict within %s across %d attempt(s)", | ||
| errEngineTimeout, formatTimeout(engineTimeout), attempts) |
| if engineTimeout > 0 { | ||
| attemptCtx, attemptCancel = context.WithTimeout(ctx, engineTimeout) |
| // defaultEngineTimeout bounds a single engine invocation. Two attempts at | ||
| // 5m each plus artifact prep and upload fits well inside the 15-minute | ||
| // smoke job budget. Zero (via --engine-timeout=0 or an unparseable env) | ||
| // disables the wall-clock cap. |
… group Address PR review feedback and rethink retry semantics: - Default `--retries` to `0`. A from-scratch retry rarely fixes anything the in-session iteration on the `threat_detection_result` tool wrapper does not already handle, and engine CLIs already retry transient provider errors internally. Keep the flag for callers who want it. - Make `--engine-timeout` terminal: on the first timeout the run exits `2` with `engine_timeout` immediately, no retry. A same-prompt retry of a runaway would just run away again and double the credit spend. - On timeout, kill the whole process group (`Setpgid` + `SIGKILL` to `-pid` via `cmd.Cancel`). The direct child on harness paths is `node`; the actual engine CLI is a grandchild, so the previous single-process kill left the runaway burning credits after the detector had moved on. - Reject negative `--retries`, `--max-turns`, `--engine-timeout` values with `config_error` so a negative flag cannot silently bypass the kill switch. - Scrub inherited `GH_AW_MAX_TURNS` from the engine subprocess env when the cap is disabled, so a caller who explicitly disables it cannot have it reimposed by an ambient parent-process variable. - Plumb `engine_timeout` through `conclude`'s reason map (aggregates into gh-aw's `agent_failure` category; the detector's own status line preserves the distinct reason for local debugging). - Fix reversed comment on `envDuration` invalid-value fallback. Spec (TD-21b) and README updated for the new defaults and rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
|
Follow-up commit 177d661 addresses all five review comments. Summary of responses: 1. 2. 3. Mixed-failure classification ( 4. Process-group kill ( 5. Reversed Also, per user feedback in the same review:
All tests pass ( |
…udget
Two related changes:
1. Bump `--max-turns` default from 20 to 50. The turn cap's real job is
catching tool-loop pathology (model stuck calling Read in a loop), not
being the primary credit bound — the wall-clock timeout is the primary
bound. At the old 20 the caps only met around 15s/turn: faster than that,
the turn cap fired at ~3.3 min (wasted budget); slower than that, time
fired at ~10 turns (turn cap was a no-op). 50 gives comfortable headroom
for legitimate wide exploration (e.g. a patch touching many files, where
Read alone can burn 20+ turns) while still bounding a truly runaway loop.
2. Tell the model its budget. Adds `PromptBudget` and a `{BUDGET}` placeholder
to the prompt template. The rendered block names the concrete wall-clock
and turn caps, explains that exhaustion triggers a hard process-group
SIGKILL with no retry and no partial verdict, and instructs the model to
call `threat_detection_result` with its best current assessment rather
than let the deadline fire silently. Custom `--prompt-template` overrides
that omit the placeholder still get the block appended — a model that
does not know its budget cannot avoid triggering the kill switch on
legitimate runs.
Spec: TD-21b updated for the new default; new TD-21c covers the budget
disclosure requirement. README updated too.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
Summary
Adds two per-attempt kill switches so a detection model that goes off the rails is stopped by the detector itself, not by the enclosing GitHub Actions job timeout.
Prior to this change the detector had no wall-clock cap, no turn cap, and no credit ceiling — only sink-driven early cancel (which shortens successful runs) and the retry loop (which only fires on malformed output). A runaway model could burn the entire 15-minute job budget on a single attempt.
New flags
--engine-timeout5mTHREAT_DETECTION_ENGINE_TIMEOUT0disables.--max-turns20THREAT_DETECTION_MAX_TURNS, falls back toGH_AW_MAX_TURNS0disables.Default sizing
5mper attempt ×retries+1(default 2 attempts) = 10 minutes worst case, comfortably inside the smokes'timeout-minutes: 15with headroom for artifact prep and result upload.20turns matches the existing gh-aw smoke workflow convention and is well above the ~5–15 turns a verdict-only detection run typically needs.Propagation
GH_AW_MAX_TURNS=<n>is exported to the engine subprocess — the universal gh-aw contract read by all three engine harnesses (Claude, Codex, Copilot).--max-turns Nis additionally passed to the bare Claude CLI (documented flag).--max-turnsisn't misled about which cap is actually active on that path.Error surfacing (soft failure)
New status reason
engine_timeout(exit2), distinct fromengine_errorso the daily statistics workflow can separate runaway-model kills from other engine failures.Per TD-21a,
engine_timeoutfalls in the same "MAY surface as step failure" bucket asengine_error,config_error,cancelled— so:concludein warn mode still lets safe outputs proceed (soft failure workflow-wide, matching how gh-aw treatsagent_failure).GH_AW_DETECTION_CONTINUE_ON_ERROR=false) blocks safe outputs, as intended.Verdict-vs-deadline race
If the engine writes a valid verdict to the result sink just before the deadline fires, the verdict wins over the timeout — the run reports
result_recordednormally. This prevents a healthy run near its budget from being reclassified as a runaway kill.Per-attempt, not aggregate
Each retry gets a fresh budget. A timeout on attempt 1 still allows the correction retry.
Docs
Tests
TestRunEmitsEngineTimeoutStatusWhenAllAttemptsExceedDeadline— hanging engine is killed on the per-attempt timeout, both attempts are killed, terminal reason isengine_timeout.TestRunHonorsVerdictWrittenBeforeDeadline— verdict written just before deadline still wins.TestRunExportsMaxTurnsToEngineEnv—GH_AW_MAX_TURNS=17reaches the engine subprocess.TestRunOmitsMaxTurnsEnvWhenDisabled—--max-turns=0does NOT export the env var (so a downstream harness's own default resolution isn't blocked).TestRunResolvesMaxTurnsFromGhAwEnv—GH_AW_MAX_TURNS=42in env is honored when no flag/detector env is set.claudeArgs/claudeHarnessArgstests extended for the newmaxTurnsparameter, including--max-turnspropagation to the bare Claude CLI.Validation
make fmt lint build test test-scriptsall clean.gosecfinding count unchanged frommain(36 pre-existing findings, none introduced).