refactor(logging): remove the --log-file JSONL run log - #795
Conversation
The detector wrote a structured JSONL run log to a separate artifact, but almost every record duplicated something already on stderr/stdout: the degraded-input annotations, the config/engine/prompt-build errors, the verdict (= the result JSON), and the terminal status line. In `conclude` the duplication was total — every logger call sat next to a `c.info` twin. Drop the sink entirely (flag, THREAT_DETECTION_LOG_FILE, the derived `detection-runlog.jsonl` default, the path-collision checks, and the `pkg/runlog` package) and fold the genuinely log-only diagnostics onto stderr so the job log is the single place a run is diagnosed: - run configuration: version, engine, model, retry budget - the recursive artifact inventory (TD-17b), bounded and sanitized - prompt metadata: byte count and framework-scaffolding detection - the per-attempt trace and whether each recorded a verdict - the engine subprocess argv (stderr previously had only the arg count) - each degraded-input finding's field and required-input classification, which the "::warning::" text alone cannot convey in warn mode Untrusted values stay confined to one physical line and listings stay bounded, so neither model-authored text nor a hostile filename can forge a workflow command or flood the job log. 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
Removes JSONL run logging and consolidates detector diagnostics into standard output/error and captured job logs.
Changes:
- Removes
--log-file, runlog plumbing, package, and tests. - Adds bounded stderr diagnostics for artifacts, prompts, attempts, and engine invocation.
- Updates workflows, specifications, and documentation.
Show a summary per file
| File | Description |
|---|---|
specs/usage-spec.md |
Updates host logging requirements. |
specs/threat-detection-spec.md |
Defines stderr-only diagnostics. |
README.md |
Documents the new logging model. |
pkg/runlog/runlog.go |
Removes the JSONL logger. |
pkg/runlog/runlog_test.go |
Removes logger tests. |
pkg/engine/log_test.go |
Tests stderr invocation diagnostics. |
pkg/engine/engine.go |
Removes logger plumbing and reports argv. |
pkg/artifacts/artifacts.go |
Updates warning documentation. |
cmd/threat-detect/promptcontext_test.go |
Validates prompt context via stderr. |
cmd/threat-detect/main.go |
Replaces runlog events with stderr output. |
cmd/threat-detect/main_test.go |
Tests stderr artifact inventory. |
cmd/threat-detect/logfile_test.go |
Removes JSONL integration tests. |
cmd/threat-detect/conclude.go |
Removes conclusion runlog support. |
cmd/threat-detect/conclude_test.go |
Removes conclusion runlog tests. |
CLAUDE.md |
Removes obsolete runlog guidance. |
.github/workflows/replay-detection.yml |
Removes replay runlog handling. |
.github/workflows/detection-only.yml |
Stops producing/uploading JSONL logs. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Suppressed comments (1)
README.md:136
framework_scaffolding_markersis populated only fromknownScaffoldingMarkers(<safe-output-tools>,<github-context>,<agent-context>, and<safe-outputs-config>); the outer<system>tag is never returned. Use an emitted marker (or(none)) in the example.
[threat-detect] prompt built: prompt_bytes=9241 framework_scaffolding_detected=true framework_scaffolding_markers=<system>
- Files reviewed: 17/17 changed files
- Comments generated: 9
- Review effort level: Balanced
| fmt.Fprintf(os.Stderr, "[threat-detect] run start: version=%s engine=%s model=%s retries=%d\n", | ||
| detector.Version, engine.Canonical(engineID), sanitizeLogValue(modelDesc), retries) |
| @@ -569,22 +564,21 @@ func logEngineInvoke(logger *runlog.Logger, engineID, name string, args []string | |||
| // Emit to stderr so the message appears in the GitHub Actions job log even | |||
| // when the engine subprocess produces no output of its own. | |||
| fmt.Fprintf(engineInvokeStderr, "[threat-detect] engine invoke: engine=%s model=%s command=%s args=%d\n", engineID, modelDesc, command, len(args)) | |||
| the terminal `THREAT_DETECTION_STATUS:` line (TD-21a). Untrusted values echoed | ||
| into these diagnostics — model-authored text, artifact paths, and configuration | ||
| values — MUST be escaped so that each is confined to a single physical output | ||
| line and cannot emit a host workflow command, and listings MUST be bounded so a |
| remain advisory warnings in both modes. Each finding MUST identify the artifact | ||
| it concerns and whether it is a required input. The detector MUST apply this same mode selection everywhere it |
| runlogs = list(root.rglob('detection-runlog.jsonl')) | ||
| if runlogs: | ||
| (output_root / 'original-detection-runlog.jsonl').write_bytes(runlogs[0].read_bytes()) | ||
| candidates = list(root.rglob('detection.log')) + list(root.rglob('*.log')) |
| } | ||
|
|
||
| func warnDegradedPromptAnalysis(analysis *detector.PromptAnalysis, logger *runlog.Logger) { | ||
| func warnDegradedPromptAnalysis(analysis *detector.PromptAnalysis) { |
| shown := arts.Inventory | ||
| if len(shown) > maxInventoryEntries { | ||
| shown = shown[:maxInventoryEntries] | ||
| } | ||
| fmt.Fprintf(os.Stderr, "[threat-detect] artifact inventory (%d entries):\n", len(arts.Inventory)) | ||
| for _, entry := range shown { | ||
| fmt.Fprintf(os.Stderr, "[threat-detect] %s bytes=%d kind=%s consumed=%t\n", | ||
| sanitizeLogValue(entry.Path), entry.Size, sanitizeLogValue(entry.Kind), entry.Consumed) |
| artifact inventory defined by TD-17b is surfaced on standard error (TD-20a) only, | ||
| and the conclusion verdict through the `conclude` diagnostics (TD-20d). The | ||
| rendered prompt itself MUST NOT be surfaced: the detector reports only its | ||
| metadata (byte count, resolved workflow name/description, custom-prompt | ||
| provenance, scaffolding detection). |
| [threat-detect] run start: version=1.2.3 engine=copilot model=(none; using engine default) retries=1 | ||
| [threat-detect] artifacts loaded: dir=/tmp/gh-aw/threat-detection prompt_bytes=4096 agent_output_bytes=812 patch_files=1 all_primary_inputs_missing=false | ||
| [threat-detect] artifact inventory (3 entries): | ||
| [threat-detect] aw-prompts/prompt.txt bytes=4096 kind=prompt consumed=true |
Review follow-ups on the run-log removal. Two values reached a diagnostic line unescaped: - The engine ID is echoed on the run-start line before engine.New validates it, and Canonical only lowercases, so an arbitrary --engine value flowed through verbatim. Sanitize it (and the version, for consistency) as the model already was. - The engine-invoke line composes its command description from nodeCommand(), which reads GH_AW_NODE_BIN, and emitted it raw. Quote it with %q as argv and the model already are. Both are now covered by tests that fail without the fix. Three spec/doc claims were stale or wrong: - TD-18b still required a `prompt_analysis_degraded` run-log event and TD-22-flags still required workflow context on `prompt_built`; both reference a sink TD-20a now forbids. Reduce each to its job-log requirement. - TD-20a's escaping rule read as covering forwarded engine output, which runCLIEnvWithSink tees verbatim by design. Scope the requirement to detector-authored diagnostics and state that forwarded engine output is a separate, non-attested stream; mirror this in the README. - The README trace showed `kind=prompt` and a `<system>` scaffolding marker; artifactKind emits `file` and `<system>` is never a marker. Restore two guarantees the deleted logfile_test.go was the sole cover for: the TD-18b ERR_VALIDATION warning, and the TD-20a inventory bound, omission label, and path escaping. Finally, retain the source run's detection.log as `original-detection.log` in the replay artifact, which usage-spec U-27 requires and the runlog removal had dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
|
Thanks — reviewed all nine. Eight were correct and are fixed in c87c0ca; one I've scoped rather than "fixed", with reasoning below. Injection vectors (both real, both fixed)
Both have tests that fail without the fix (I checked by reverting each). Stale spec references (both mine, both fixed)TD-18b and TD-22-flags both still required run-log events ( Engine output streaming — scoped, not fixedYou're right that So the defect is in my spec wording, not the code. My TD-20a said "untrusted values echoed into these diagnostics", intending the enumerated detector-composed lines, but it reads as covering everything on stderr. I've scoped it explicitly and stated the passthrough is a separate, non-attested stream, and mirrored that in the README rather than letting the README keep overclaiming. Framing/sanitizing forwarded engine output is a real question, but it's a behavior change to the engine layer that deserves its own PR — happy to open an issue if you'd like. Lost coverage (both real, both restored)You're right that deleting And the inventory bound was genuinely untested — I'd added Replay U-27Correct catch — I loosened U-27's wording to "captured log" but removed the copy step, leaving the requirement unmet. The step now writes the selected source log to README exampleBoth wrong, as flagged:
|
What
Removes
--log-fileand the separate JSONL run-log artifact. Everything the detector reports now goes to stderr/stdout, so the job log is the single place a run is diagnosed.Why
The JSONL log was largely a re-encoding of output that already existed:
conclude— 100% redundant. Everyc.logger.Info(...)sat next to ac.info(...)printing the same data to stdout.artifact_degraded(already::warning::/::error::),artifacts_all_primary_inputs_missing,artifacts_required_inputs_degraded,custom_prompt_read_failed,prompt_analysis_degraded,engine_create_failed,detection_failed,verdict(= the result JSON), andstatus(= theTHREAT_DETECTION_STATUS:line).Only five things were genuinely log-only, and those moved to stderr rather than being dropped.
Removed
--log-fileflag,THREAT_DETECTION_LOG_FILE, the deriveddetection-runlog.jsonldefault, and the associated path-collision checks (in bothrunandconclude)pkg/runlogpackageAnalyzeOptions.Loggerplumbing throughpkg/engine--log-fileplumbing and runlog artifact uploads indetection-only.ymlandreplay-detection.ymlMoved to stderr
One subtlety worth calling out: the
::warning::annotation text alone cannot conveyrequired_input, because in warn mode both required and advisory findings render as::warning::. TD-18c therefore needed its own explicit line.Untrusted values (model-authored text, artifact paths, config values) are sanitized to a single physical line, and the inventory listing is bounded at 200 entries, so neither a hostile filename nor model output can forge a workflow command or flood the job log.
Contract
Unchanged: the result JSON (
prompt_injection,secret_leak,malicious_patch,reasons), the exit codes, and theTHREAT_DETECTION_STATUS:line. Hosts that captured detection output (2>&1 | tee detection.log) keep the full trace; the only loss is the machine-parseable JSONL encoding, which had no consumer.Docs
Spec is the source of truth, so it moved first: TD-18c, TD-20a (now forbids a separate log artifact and enumerates the required stderr reporting), TD-20c, TD-20d; U-09, U-10, U-18a, U-27 in the usage spec; plus README and CLAUDE.md.
Validation
make fmt-check,lint,build,test,security-govulncheckall pass. Verified end-to-end against a stub engine on both the safe path and the degraded-input/fail-closed path, plusconclude.Two pre-existing failures are unrelated to this change:
make golint— golangci-lint built with Go 1.25 vs. the repo's 1.26 targetmake gosec— fails identically on a clean tree (37 findings) and is not run in CI; this change net-reduces findings 37 → 36