Skip to content

Commit c2afe7f

Browse files
davidslaterGitHub Ace
andauthored
feat(runlog): persist structured detection logs (#746)
Co-authored-by: GitHub Ace <githubnext@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
1 parent d16cb10 commit c2afe7f

7 files changed

Lines changed: 190 additions & 38 deletions

File tree

‎.github/workflows/detection-only.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ jobs:
230230
fi
231231
# shellcheck disable=SC1003,SC2016,SC2086
232232
awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw/threat-detection:/tmp/gh-aw/threat-detection:rw --log-level info --skip-pull \
233-
-- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && threat-detect --engine copilot --output /tmp/gh-aw/threat-detection/detection_result.json /tmp/gh-aw/threat-detection' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
233+
-- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true; set -- --engine copilot --output /tmp/gh-aw/threat-detection/detection_result.json; if threat-detect --help 2>&1 | grep -q -- "-log-file"; then set -- "$@" --log-file /tmp/gh-aw/threat-detection/detection-runlog.jsonl; else echo "::warning::Selected detector does not support --log-file; structured detection diagnostics will not be available."; fi; set -- "$@" /tmp/gh-aw/threat-detection; threat-detect "$@"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
234234
env:
235235
AWF_REFLECT_ENABLED: 1
236236
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
@@ -265,6 +265,7 @@ jobs:
265265
name: detection
266266
path: |
267267
/tmp/gh-aw/threat-detection/detection_result.json
268+
/tmp/gh-aw/threat-detection/detection-runlog.jsonl
268269
/tmp/gh-aw/threat-detection/detection.log
269270
if-no-files-found: ignore
270271
- name: Conclude threat detection

‎.github/workflows/replay-detection.yml‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,17 @@ jobs:
413413
set -euo pipefail
414414
result_path="${REPLAY_OUTPUT}/result.json"
415415
log_path="${REPLAY_OUTPUT}/replay.log"
416+
runlog_path="${REPLAY_OUTPUT}/detection-runlog.jsonl"
416417
if [ "$ENGINE" = "copilot" ] && [ -n "${GH_AW_COPILOT_TOKEN}" ]; then
417418
export GH_TOKEN="$GH_AW_COPILOT_TOKEN"
418419
export GITHUB_TOKEN="$GH_AW_COPILOT_TOKEN"
419420
fi
420421
args=(--engine "$ENGINE" --output "$result_path")
422+
if "$DETECTOR_BIN" --help 2>&1 | grep -q -- "-log-file"; then
423+
args+=(--log-file "$runlog_path")
424+
else
425+
echo "::warning::Selected detector does not support --log-file; structured replay diagnostics will not be available."
426+
fi
421427
if [ -n "$MODEL" ]; then
422428
args+=(--model "$MODEL")
423429
fi
@@ -462,8 +468,8 @@ jobs:
462468
exit "$run_status"
463469
fi
464470
465-
- name: Extract original detection result
466-
if: ${{ inputs.original_detection_artifact != '' }}
471+
- name: Extract original detection diagnostics
472+
if: ${{ always() && inputs.original_detection_artifact != '' }}
467473
run: |
468474
set -euo pipefail
469475
python3 - <<'PY'
@@ -472,7 +478,11 @@ jobs:
472478
import re
473479
474480
root = pathlib.Path(__import__('os').environ['REPLAY_DOWNLOADS']) / 'original-detection'
475-
output = pathlib.Path(__import__('os').environ['REPLAY_OUTPUT']) / 'original-result.json'
481+
output_root = pathlib.Path(__import__('os').environ['REPLAY_OUTPUT'])
482+
output = output_root / 'original-result.json'
483+
runlogs = list(root.rglob('detection-runlog.jsonl'))
484+
if runlogs:
485+
(output_root / 'original-detection-runlog.jsonl').write_bytes(runlogs[0].read_bytes())
476486
candidates = list(root.rglob('detection.log')) + list(root.rglob('*.log'))
477487
marker = re.compile(r'THREAT_DETECTION_RESULT:(\{.*\})')
478488
for path in candidates:
@@ -582,6 +592,8 @@ jobs:
582592
${{ runner.temp }}/gh-aw-replay/output/result.json
583593
${{ runner.temp }}/gh-aw-replay/output/comparison.json
584594
${{ runner.temp }}/gh-aw-replay/output/original-result.json
595+
${{ runner.temp }}/gh-aw-replay/output/detection-runlog.jsonl
596+
${{ runner.temp }}/gh-aw-replay/output/original-detection-runlog.jsonl
585597
${{ runner.temp }}/gh-aw-replay/output/replay.log
586598
${{ runner.temp }}/gh-aw-replay/output/awf-replay-config.json
587599
if-no-files-found: ignore

‎README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ threat-detect [flags] <artifacts-dir>
6262
- `--custom-prompt` — Additional detection instructions appended to the prompt. Overrides `CUSTOM_PROMPT`
6363
- `--custom-prompt-file` — Path to a file with additional detection instructions. Takes precedence over `--custom-prompt` and `CUSTOM_PROMPT`
6464
- `--output` — Path to write JSON result (defaults to stdout)
65-
- `--log-file` — Path to write structured JSONL run logs (one JSON object per line). Env: `THREAT_DETECTION_LOG_FILE`
65+
- `--log-file` — Path to write structured JSONL run logs (one JSON object per line). Env: `THREAT_DETECTION_LOG_FILE`; defaults to `detection-runlog.jsonl` beside `--output`
6666
- `--retries` — Retries for malformed detection outputs. Default: `1` (env: `THREAT_DETECTION_RETRIES`)
6767
- `--version` — Print version and exit
6868

@@ -115,10 +115,12 @@ engine/config failures surface as a step failure. See spec TD-21a.
115115

116116
#### JSONL run logs (`--log-file`)
117117

118-
Pass `--log-file <path>` (or set `THREAT_DETECTION_LOG_FILE`) to record a
119-
structured trace of the run as [JSON Lines](https://jsonlines.org/): one JSON
120-
object per line, created fresh (truncating any existing file) with `0600`
121-
permissions. Every record starts with `time` (RFC 3339), `level`
118+
Pass `--log-file <path>` (or set `THREAT_DETECTION_LOG_FILE`) to choose where to
119+
record a structured trace of the run. When `--output` is set without an explicit
120+
log path, the detector writes `detection-runlog.jsonl` in the output file's
121+
directory. The log uses [JSON Lines](https://jsonlines.org/): one JSON object per
122+
line, created fresh (truncating any existing file) with `0600` permissions. Every
123+
record starts with `time` (RFC 3339), `level`
122124
(`info`/`error`), and `event`, followed by event-specific fields. Emitted
123125
events include `run_start`, `artifacts_loaded`, `prompt_built`,
124126
`attempt_start`/`attempt_recorded`/`attempt_no_verdict`, `verdict`,
@@ -245,7 +247,7 @@ be available on the runner where the binary runs.
245247

246248
### Replay workflow
247249

248-
Maintainers can manually run **Replay Threat Detection** from the Actions tab to rerun detection against artifacts from a prior workflow run. Provide the source repository and run ID; the workflow downloads the `agent`, `activation`, optional experiment, and optional original `detection` artifacts, normalizes them into the CLI input contract above, runs `threat-detect`, and uploads a sanitized `replay-detection-<run_id>` artifact with the manifest, file inventory, logs, replay result, and original-result comparison when available.
250+
Maintainers can manually run **Replay Threat Detection** from the Actions tab to rerun detection against artifacts from a prior workflow run. Provide the source repository and run ID; the workflow downloads the `agent`, `activation`, optional experiment, and optional original `detection` artifacts, normalizes them into the CLI input contract above, runs `threat-detect`, and uploads a sanitized `replay-detection-<run_id>` artifact with the manifest, file inventory, free-form replay log, replay result, and original-result comparison. Detectors that support structured logging also produce `detection-runlog.jsonl`; when available, the source run's structured log is retained separately as `original-detection-runlog.jsonl`.
249251

250252
Replay uses the dispatching repository's `GITHUB_TOKEN`; no extra replay token is required. The selected source run must be accessible to that token.
251253

‎cmd/threat-detect/logfile_test.go‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,36 @@ func TestRunWritesJSONLLog(t *testing.T) {
9696
}
9797
}
9898

99+
func TestRunDefaultsLogBesideOutput(t *testing.T) {
100+
artifactsDir := t.TempDir()
101+
outputDir := t.TempDir()
102+
outputPath := filepath.Join(outputDir, "result.json")
103+
logPath := filepath.Join(outputDir, "detection-runlog.jsonl")
104+
copilotMarker := filepath.Join(t.TempDir(), "copilot-called")
105+
sinkJSON := `{"prompt_injection":false,"secret_leak":false,"malicious_patch":false,"reasons":[]}`
106+
fakeBinDir := writeFakeCopilotWithSink(t, copilotMarker, sinkJSON, 0)
107+
108+
code := runWithTestArgs(t, []string{
109+
"threat-detect",
110+
"-output", outputPath,
111+
artifactsDir,
112+
}, map[string]string{
113+
"PATH": fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"),
114+
"THREAT_DETECTION_LOG_FILE": "",
115+
})
116+
117+
if code != exitSafe {
118+
t.Fatalf("run() exit code = %d, want %d", code, exitSafe)
119+
}
120+
records := readJSONLRecords(t, logPath)
121+
if findRecord(records, "run_start") == nil {
122+
t.Fatalf("expected default log file to receive records: %#v", records)
123+
}
124+
if status := findRecord(records, "status"); status == nil || status["reason"] != reasonResultRecorded {
125+
t.Fatalf("expected result_recorded status, got %#v", status)
126+
}
127+
}
128+
99129
func TestRunUsesLogFileEnvVar(t *testing.T) {
100130
artifactsDir := t.TempDir()
101131
outputPath := filepath.Join(t.TempDir(), "result.json")
@@ -152,6 +182,62 @@ func TestRunRejectsLogFileCollidingWithOutput(t *testing.T) {
152182
}
153183
}
154184

185+
func TestRunRejectsDefaultLogCollidingThroughDanglingOutputSymlink(t *testing.T) {
186+
artifactsDir := t.TempDir()
187+
outputDir := t.TempDir()
188+
outputPath := filepath.Join(outputDir, "result.json")
189+
logPath := filepath.Join(outputDir, "detection-runlog.jsonl")
190+
if err := os.Symlink(filepath.Base(logPath), outputPath); err != nil {
191+
t.Fatalf("creating dangling output symlink: %v", err)
192+
}
193+
194+
code, stderr := runWithTestArgsCapture(t, []string{
195+
"threat-detect",
196+
"-output", outputPath,
197+
artifactsDir,
198+
}, map[string]string{"THREAT_DETECTION_LOG_FILE": ""})
199+
200+
if code != exitError {
201+
t.Fatalf("run() exit code = %d, want %d", code, exitError)
202+
}
203+
if !strings.Contains(stderr, "must not point to the same file") {
204+
t.Fatalf("stderr missing collision error, got:\n%s", stderr)
205+
}
206+
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
207+
t.Fatalf("expected no log file to be written, stat err = %v", err)
208+
}
209+
}
210+
211+
func TestRunRejectsCollisionWithParentTraversalAfterSymlink(t *testing.T) {
212+
artifactsDir := t.TempDir()
213+
root := t.TempDir()
214+
targetDir := filepath.Join(root, "target")
215+
childDir := filepath.Join(targetDir, "child")
216+
if err := os.MkdirAll(childDir, 0o755); err != nil {
217+
t.Fatalf("creating symlink target: %v", err)
218+
}
219+
linkPath := filepath.Join(root, "link")
220+
if err := os.Symlink(childDir, linkPath); err != nil {
221+
t.Fatalf("creating directory symlink: %v", err)
222+
}
223+
outputPath := linkPath + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "result.json"
224+
logPath := filepath.Join(targetDir, "result.json")
225+
226+
code, stderr := runWithTestArgsCapture(t, []string{
227+
"threat-detect",
228+
"-output", outputPath,
229+
"-log-file", logPath,
230+
artifactsDir,
231+
}, nil)
232+
233+
if code != exitError {
234+
t.Fatalf("run() exit code = %d, want %d", code, exitError)
235+
}
236+
if !strings.Contains(stderr, "must not point to the same file") {
237+
t.Fatalf("stderr missing collision error, got:\n%s", stderr)
238+
}
239+
}
240+
155241
func TestRunRejectsUnopenableLogFile(t *testing.T) {
156242
artifactsDir := t.TempDir()
157243
// Parent directory does not exist, so runlog.Open must fail.

‎cmd/threat-detect/main.go‎

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"os/signal"
2525
"path/filepath"
2626
"strconv"
27+
"strings"
2728

2829
"github.com/github/gh-aw-threat-detection/pkg/artifacts"
2930
"github.com/github/gh-aw-threat-detection/pkg/detector"
@@ -148,6 +149,13 @@ func run() (code int) {
148149
// standalone detector honors the model gh-aw configured for detection.
149150
model = engine.ResolveModel(engineID, model)
150151

152+
// File-based integrations expect diagnostics to survive beside the result.
153+
// An explicit flag or environment variable still takes precedence.
154+
if logFile == "" && outputJSON != "" {
155+
dir, _ := filepath.Split(outputJSON)
156+
logFile = dir + "detection-runlog.jsonl"
157+
}
158+
151159
// Reject a --log-file that collides with --output: they are opened and
152160
// truncated independently, so sharing an inode would interleave the JSONL
153161
// trace and the result JSON and corrupt both while still reporting success.
@@ -163,8 +171,8 @@ func run() (code int) {
163171
}
164172
}
165173

166-
// Open the JSONL run log if requested. A failure here is a config error:
167-
// the caller explicitly asked for logs and should learn they were not written.
174+
// Open the JSONL run log when configured or derived. A failure here is a
175+
// config error because file-based integrations require the diagnostic sink.
168176
if logFile != "" {
169177
l, err := runlog.Open(logFile)
170178
if err != nil {
@@ -430,21 +438,58 @@ func samePath(a, b string) (bool, error) {
430438
return false, nil
431439
}
432440

433-
// resolvePath returns an absolute, symlink-resolved path. When the target does
434-
// not yet exist, it resolves the deepest existing ancestor directory and rejoins
435-
// the remaining components so not-yet-created siblings still compare correctly.
441+
// resolvePath returns an absolute, symlink-resolved path. Components are
442+
// processed in filesystem order so ".." after a symlink applies to the symlink
443+
// target, matching how the operating system resolves the path.
436444
func resolvePath(p string) (string, error) {
437-
abs, err := filepath.Abs(p)
438-
if err != nil {
439-
return "", err
440-
}
441-
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
442-
return resolved, nil
443-
}
444-
dir, base := filepath.Split(abs)
445-
dir = filepath.Clean(dir)
446-
if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil {
447-
return filepath.Join(resolvedDir, base), nil
445+
if !filepath.IsAbs(p) {
446+
wd, err := os.Getwd()
447+
if err != nil {
448+
return "", err
449+
}
450+
p = wd + string(os.PathSeparator) + p
451+
}
452+
volume := filepath.VolumeName(p)
453+
resolved := volume + string(os.PathSeparator)
454+
pending := splitPathComponents(strings.TrimPrefix(p, volume))
455+
symlinks := 0
456+
457+
for len(pending) > 0 {
458+
component := pending[0]
459+
pending = pending[1:]
460+
switch component {
461+
case ".":
462+
continue
463+
case "..":
464+
resolved = filepath.Dir(resolved)
465+
continue
466+
}
467+
468+
candidate := filepath.Join(resolved, component)
469+
if info, err := os.Lstat(candidate); err == nil && info.Mode()&os.ModeSymlink != 0 {
470+
symlinks++
471+
if symlinks > 255 {
472+
return "", fmt.Errorf("resolving %q: too many symlinks", p)
473+
}
474+
target, err := os.Readlink(candidate)
475+
if err != nil {
476+
return "", fmt.Errorf("reading symlink %q: %w", candidate, err)
477+
}
478+
if filepath.IsAbs(target) {
479+
volume = filepath.VolumeName(target)
480+
resolved = volume + string(os.PathSeparator)
481+
target = strings.TrimPrefix(target, volume)
482+
}
483+
pending = append(splitPathComponents(target), pending...)
484+
continue
485+
}
486+
resolved = candidate
448487
}
449-
return filepath.Clean(abs), nil
488+
return filepath.Clean(resolved), nil
489+
}
490+
491+
func splitPathComponents(p string) []string {
492+
return strings.FieldsFunc(p, func(r rune) bool {
493+
return r <= 255 && os.IsPathSeparator(uint8(r))
494+
})
450495
}

‎specs/threat-detection-spec.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,15 @@ detector MUST emit a non-fatal `ERR_VALIDATION` warning and continue.
180180

181181
**TD-20a**: The detector MUST support writing a structured run log in JSON Lines
182182
(JSONL) format to a file via the `--log-file` flag (also configurable through the
183-
`THREAT_DETECTION_LOG_FILE` environment variable). When enabled, the detector MUST
184-
write one JSON object per line, each containing at least the `time`, `level`, and
185-
`event` keys, and MUST record a terminal `status` event whose `reason` and `exit`
186-
fields carry the same reason string and exit code as the stderr status line. The
187-
run log is an additive observability
188-
sink: it MUST NOT alter the result JSON contract (TD-08) or the exit codes (TD-21).
189-
A failure to open the log file MUST be treated as a configuration error.
183+
`THREAT_DETECTION_LOG_FILE` environment variable). When `--output` is set and no
184+
log path is explicitly configured, the detector MUST write the run log as
185+
`detection-runlog.jsonl` in the result file's directory. When enabled, the
186+
detector MUST write one JSON object per line, each containing at least the `time`,
187+
`level`, and `event` keys, and MUST record a terminal `status` event whose
188+
`reason` and `exit` fields carry the same reason string and exit code as the
189+
stderr status line. The run log is an additive observability sink: it MUST NOT
190+
alter the result JSON contract (TD-08) or the exit codes (TD-21). A failure to
191+
open the log file MUST be treated as a configuration error.
190192

191193
**TD-20b**: The detector MUST provide a `conclude` subcommand that reads a structured
192194
result file written by a prior detection run and emits the host-side job-output

‎specs/usage-spec.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,10 @@ engine CLI.
106106
verdict payload and MUST read it from the location it configured (stdout, or the
107107
`--output` path, or the `detection_result.json` sink used by `conclude`).
108108

109-
**U-09**: The host SHOULD enable the structured JSONL run log via `--log-file`
110-
(or `THREAT_DETECTION_LOG_FILE`) for observability (per TD-20a), and MUST NOT
109+
**U-09**: The host SHOULD retain the structured JSONL run log for observability
110+
(per TD-20a). The host MAY configure its path via `--log-file` (or
111+
`THREAT_DETECTION_LOG_FILE`); otherwise, when `--output` is set, the detector
112+
writes `detection-runlog.jsonl` in the result file's directory. The host MUST NOT
111113
point `--log-file` and `--output` at the same path.
112114

113115
### 3.1 Flags
@@ -122,7 +124,7 @@ versions.
122124
| `--model <name>` | Override the engine model (see U-13) |
123125
| `--prompt-template <path>` | Override the embedded default prompt |
124126
| `--output <path>` | Write the JSON result to a file instead of stdout |
125-
| `--log-file <path>` | Write structured JSONL run logs |
127+
| `--log-file <path>` | Write structured JSONL run logs (defaults beside `--output` to `detection-runlog.jsonl`) |
126128
| `--retries <n>` | Retries for malformed detection outputs (default `1`) |
127129
| `--version` | Print version and exit |
128130

@@ -259,7 +261,9 @@ require the newest stable release MUST re-pin explicitly.
259261
**U-27**: A host MAY re-run detection against artifacts from a prior run for
260262
diagnostics. When it does, it MUST normalize the downloaded artifacts into the
261263
input contract of Section 3 before invoking the detector, and SHOULD retain the
262-
detector's JSONL run log (per TD-20a) and structured result for comparison.
264+
detector's `detection-runlog.jsonl` run log (per TD-20a) and structured result
265+
for comparison. When the source detection artifact contains a run log, the replay
266+
host SHOULD retain it separately from the replay run log.
263267

264268
**U-28**: Replay MUST NOT require credentials beyond those already needed to read
265269
the source run's artifacts and to authenticate the selected engine.

0 commit comments

Comments
 (0)