Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pkg/detector/ Core detection logic
pkg/engine/ AI engine abstraction
├── engine.go copilot/claude/codex CLI adapters; Copilot uses runCLIWithPromptFile, Claude uses runCLI with stdin, Codex passes prompts via codexArgs/runCLIEnv
└── tool.go threat_detection_result wrapper provisioning + result-sink watcher
pkg/runlog/ Structured JSONL run-log writer (--log-file); nil-safe no-op logger
specs/ Normative spec (threat-detection-spec.md)
scripts/ create-threat-detection-sibling-workflows.py (regenerates *-container.lock.yml)
skills/ Repo-relevant agent skills (console-rendering, error-messages)
Expand Down Expand Up @@ -80,6 +81,7 @@ threat-detect [flags] <artifacts-dir>
- `--model <name>` — model override forwarded to the engine
- `--prompt-template <path>` — override the embedded default
- `--output <path>` — write JSON result (defaults to stdout)
- `--log-file <path>` — write structured JSONL run logs; env: `THREAT_DETECTION_LOG_FILE`
- `--retries` (default `1`) — retries for malformed detection outputs; env: `THREAT_DETECTION_RETRIES`

**Exit codes** (defined in `cmd/threat-detect/main.go`):
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ threat-detect [flags] <artifacts-dir>
- `--model` — Model override for the engine
- `--prompt-template` — Path to custom prompt template
- `--output` — Path to write JSON result (defaults to stdout)
- `--log-file` — Path to write structured JSONL run logs (one JSON object per line). Env: `THREAT_DETECTION_LOG_FILE`
- `--retries` — Retries for malformed detection outputs. Default: `1` (env: `THREAT_DETECTION_RETRIES`)
- `--version` — Print version and exit

Expand Down Expand Up @@ -108,6 +109,27 @@ the step, so warn-mode workflows proceed exactly as they do under `gh-aw`'s nati
engine (which treats a missing verdict as a recoverable `parse_error`). Only genuine
engine/config failures surface as a step failure. See spec TD-21a.

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

Pass `--log-file <path>` (or set `THREAT_DETECTION_LOG_FILE`) to record a
structured trace of the run as [JSON Lines](https://jsonlines.org/): one JSON
object per line, created fresh (truncating any existing file) with `0600`
permissions. Every record starts with `time` (RFC 3339), `level`
(`info`/`error`), and `event`, followed by event-specific fields. Emitted
events include `run_start`, `artifacts_loaded`, `prompt_built`,
`attempt_start`/`attempt_recorded`/`attempt_no_verdict`, `verdict`,
`detection_failed`, and a terminal `status` record carrying the same `reason`
and `exit` code as the stderr status line. The verdict JSON contract
(`--output`) is unchanged; the log file is an additive observability sink.
`--log-file` and `--output` must not resolve to the same file — a collision is
rejected as a configuration error to avoid corrupting both outputs.

```jsonl
{"time":"2026-07-14T18:00:00Z","level":"info","event":"run_start","engine":"copilot","model":"","retries":1,"version":"1.2.3"}
{"time":"2026-07-14T18:00:03Z","level":"info","event":"verdict","has_threats":false,"malicious_patch":false,"prompt_injection":false,"reasons":[],"secret_leak":false}
{"time":"2026-07-14T18:00:03Z","level":"info","event":"status","exit":0,"reason":"result_recorded"}
```

#### Concluding a run (`conclude`)

In `gh-aw`-compiled workflows the detector runs inside the AWF sandbox, where the
Expand Down
175 changes: 175 additions & 0 deletions cmd/threat-detect/logfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

// readJSONLRecords parses a JSONL file into a slice of decoded records.
func readJSONLRecords(t *testing.T, path string) []map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading log file: %v", err)
}
var records []map[string]any
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("log line is not valid JSON (%q): %v", line, err)
}
records = append(records, rec)
}
return records
}

func findRecord(records []map[string]any, event string) map[string]any {
for _, rec := range records {
if rec["event"] == event {
return rec
}
}
return nil
}

func TestRunWritesJSONLLog(t *testing.T) {
artifactsDir := t.TempDir()
outputPath := filepath.Join(t.TempDir(), "result.json")
logPath := filepath.Join(t.TempDir(), "run.jsonl")
copilotMarker := filepath.Join(t.TempDir(), "copilot-called")
sinkJSON := `{"prompt_injection":true,"secret_leak":false,"malicious_patch":false,"reasons":["agentic detection"]}`
fakeBinDir := writeFakeCopilotWithSink(t, copilotMarker, sinkJSON, 0)

code := runWithTestArgs(t, []string{
"threat-detect",
"-output", outputPath,
"-log-file", logPath,
artifactsDir,
}, map[string]string{
"PATH": fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"),
})

if code != exitThreat {
t.Fatalf("run() exit code = %d, want %d", code, exitThreat)
}

records := readJSONLRecords(t, logPath)

// The primary audit record must reflect the engine that actually runs:
// an omitted --engine resolves to copilot, not "".
start := findRecord(records, "run_start")
if start == nil {
t.Fatalf("missing run_start record: %#v", records)
}
if start["engine"] != "copilot" {
t.Errorf("run_start engine = %v, want copilot", start["engine"])
}

verdict := findRecord(records, "verdict")
if verdict == nil {
t.Fatalf("missing verdict record: %#v", records)
}
if verdict["prompt_injection"] != true {
t.Errorf("verdict prompt_injection = %v, want true", verdict["prompt_injection"])
}
if verdict["has_threats"] != true {
t.Errorf("verdict has_threats = %v, want true", verdict["has_threats"])
}

// The terminal status record must mirror the stderr status line: reason +
// exit, using the JSON number 1 for a detected threat.
status := findRecord(records, "status")
if status == nil {
t.Fatalf("missing status record: %#v", records)
}
if status["reason"] != reasonResultRecorded {
t.Errorf("status reason = %v, want %s", status["reason"], reasonResultRecorded)
}
if exit, ok := status["exit"].(float64); !ok || int(exit) != exitThreat {
t.Errorf("status exit = %v, want %d", status["exit"], exitThreat)
}
}

func TestRunUsesLogFileEnvVar(t *testing.T) {
artifactsDir := t.TempDir()
outputPath := filepath.Join(t.TempDir(), "result.json")
logPath := filepath.Join(t.TempDir(), "run.jsonl")
copilotMarker := filepath.Join(t.TempDir(), "copilot-called")
sinkJSON := `{"prompt_injection":false,"secret_leak":false,"malicious_patch":false,"reasons":[]}`
fakeBinDir := writeFakeCopilotWithSink(t, copilotMarker, sinkJSON, 0)

code := runWithTestArgs(t, []string{
"threat-detect",
"-output", outputPath,
artifactsDir,
}, map[string]string{
"PATH": fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"THREAT_DETECTION_LOG_FILE": logPath,
})

if code != exitSafe {
t.Fatalf("run() exit code = %d, want %d", code, exitSafe)
}
records := readJSONLRecords(t, logPath)
if findRecord(records, "run_start") == nil {
t.Fatalf("expected env-configured log file to receive records: %#v", records)
}
status := findRecord(records, "status")
if status == nil || status["reason"] != reasonResultRecorded {
t.Fatalf("expected result_recorded status, got %#v", status)
}
}

func TestRunRejectsLogFileCollidingWithOutput(t *testing.T) {
artifactsDir := t.TempDir()
shared := filepath.Join(t.TempDir(), "same.json")

code, stderr := runWithTestArgsCapture(t, []string{
"threat-detect",
"-output", shared,
"-log-file", shared,
artifactsDir,
}, nil)

if code != exitError {
t.Fatalf("run() exit code = %d, want %d", code, exitError)
}
if !strings.Contains(stderr, "must not point to the same file") {
t.Fatalf("stderr missing collision error, got:\n%s", stderr)
}
if !strings.Contains(stderr, "reason=config_error") {
t.Fatalf("stderr missing config_error status, got:\n%s", stderr)
}
// Neither file should have been created by the aborted run.
if _, err := os.Stat(shared); !os.IsNotExist(err) {
t.Fatalf("expected no file to be written, stat err = %v", err)
}
}

func TestRunRejectsUnopenableLogFile(t *testing.T) {
artifactsDir := t.TempDir()
// Parent directory does not exist, so runlog.Open must fail.
logPath := filepath.Join(t.TempDir(), "missing-dir", "run.jsonl")

code, stderr := runWithTestArgsCapture(t, []string{
"threat-detect",
"-log-file", logPath,
artifactsDir,
}, nil)

if code != exitError {
t.Fatalf("run() exit code = %d, want %d", code, exitError)
}
if !strings.Contains(stderr, "Error opening log file") {
t.Fatalf("stderr missing open error, got:\n%s", stderr)
}
if !strings.Contains(stderr, "reason=config_error") {
t.Fatalf("stderr missing config_error status, got:\n%s", stderr)
}
}
Loading
Loading