Skip to content

Commit e2115f9

Browse files
davidslaterGitHub Ace
andauthored
feat(detector): require forensic, locatable reasons for detections (#852)
* feat(detector): require forensic, locatable reasons for detections Reasons are the only artifact a maintainer sees in the Actions log, so they must be enough to find and remove the root cause (or judge a false positive) without access to the artifacts. - Add a "Reason Requirements (Forensic Detail)" section to the detection prompt: one reason per distinct finding, tagged by category, with LOCATION, EVIDENCE (verbatim quote), ORIGIN, WHY and REMEDIATION. - Prompt injection reasons must quote the triggering passage verbatim with line numbers and name the untrusted region and actor it came from. - Secret-leak reasons must never reproduce the credential: mask it with type prefix and length, and instead supply provenance (type, artifact, source variable/step, and the sink it was headed to) plus the evasion technique used. - Malicious-patch reasons must name the patch, target file and hunk, the added lines, and dependency name/version/registry host plus the concrete indicator that fired. - Raise the per-reason bound from 1000 to 2000 characters so evidence fits, and render multi-line reasons across real log lines with a gutter prefix so quoted evidence stays copy-pasteable without letting an untrusted line emit a workflow command. - Update spec (TD-10d, TD-10b, TD-20d) and README to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * fix(detector): transport reasons out of band of the shell Review flagged that the new verbatim-evidence requirement pushes attacker-authored text onto a shell command line: the engine invokes threat_detection_result through Bash, so evidence containing $(...), backticks, or quotes passed via --reason is expanded or executed before the tool sees it. Prompt-level quoting guidance is not a boundary. - Add --reasons-file to report-result, reading a JSON array of reason strings via detector.ReadReasonsFile. The model writes the file with its file-editing tool, so evidence never reaches a command line; a malformed file is a correctable parse error, not an executed command. Entries are bounded by the same validateRawResult rules as --reason. - Rewrite the prompt's Response Format to mandate the file transport for anything quoting artifact content, explain why, and restrict --reason to short self-authored text. Update the self-correction instruction. - Extract resultToolScript so the wrapper's use of double-quoted "$@" (no re-splitting or globbing) is documented and tested. - Add TD-10e requiring a non-shell transport and forbidding prompt-only quoting guidance as the sole protection. - Tests: end-to-end invocation through /bin/sh and the provisioned wrapper with hostile evidence ($(...), backticks, ;, |, globs, quotes, newlines) asserting no canary file is created and the reason survives byte-for-byte; wrapper argument-forwarding test; reasons-file round-trip and malformed-input cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * fix(detector): close two gaps found in re-review Legacy workflow-command marker reached the job log (cmd/threat-detect): the Actions runner accepts "##[command]data" in addition to "::command::", and locates it with an unanchored IndexOf (ActionCommand.TryParse) rather than the TrimStart+StartsWith used for "::" (TryParseV2). A marker anywhere inside a line is therefore live, so the reason gutter — which only guarantees a line never *starts* with "::" — could not neutralize it. Reachable commands include add-mask (redacts arbitrary log text) and stop-commands (suppresses every later command, including this program's own threat annotation, letting attacker-authored evidence hide the very finding it caused). Escape "##[" in sanitizeLogValue, covering all four untrusted echo paths (reasons, filenames, detection-log lines, the annotation message). Verified the new tests fail without the fix. Reasons transport was unreachable on Claude (pkg/engine): the prompt requires reasons to be written to a file with a file-writing tool, but claudeArgs granted only Bash when the result sink is provisioned — so on Claude the model's only options were a heredoc or a --reason argument, i.e. exactly the shell-expansion surface the transport removes, leaving prompt wording as the sole protection (contra TD-10e). Grant Write and Edit alongside Bash; this adds no capability, since Bash can already write files. Add a test pinning the grant to the transport. Also provision THREAT_DETECTION_REASONS_FILE next to the result sink (a directory every engine can reach — Copilot gets it via --add-dir) instead of naming a hardcoded /tmp path the engine may be refused, and delete any reasons file left by a previous attempt so a retry cannot report stale reasons. Spec TD-10e/TD-20d and README updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> --------- Co-authored-by: GitHub Ace <githubnext@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
1 parent 09b2818 commit e2115f9

14 files changed

Lines changed: 827 additions & 53 deletions

File tree

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,32 @@ infrastructure error.
7878

7979
On the agentic CLI engine path (`copilot`, `claude`, `codex`), the detector
8080
provisions a `threat_detection_result` command on the model's `PATH` and sets
81-
`THREAT_DETECTION_RESULT_FILE` to a private sink file before each engine
82-
invocation. The model reports its verdict by running the command exactly once:
81+
`THREAT_DETECTION_RESULT_FILE` to a private sink file, plus
82+
`THREAT_DETECTION_REASONS_FILE` to the path the model writes its reasons to
83+
(alongside the sink, in a directory every engine can reach), before each engine
84+
invocation. Any reasons file left by a previous attempt is removed at
85+
provisioning time so a retry cannot report stale reasons. The model reports its
86+
verdict by running the command exactly once:
8387

8488
```bash
85-
threat_detection_result --prompt-injection <true|false> --secret-leak <true|false> --malicious-patch <true|false> --reason "..."
89+
threat_detection_result --prompt-injection <true|false> --secret-leak <true|false> --malicious-patch <true|false> --reasons-file <path>
8690
```
8791

8892
The three boolean flags accept both the space-separated form shown above and the
89-
`--prompt-injection=true` form. The command validates the input synchronously: on bad input it prints
93+
`--prompt-injection=true` form.
94+
95+
Reasons are transported through a **file**, not the command line. Reasons quote
96+
attacker-authored artifact content verbatim, and the model runs this command
97+
through a shell, so evidence containing `$(...)`, backticks, or quotes passed as
98+
a `--reason` argument would be expanded or executed by the shell before the tool
99+
received it. `--reasons-file` points at a file — written by the model with its
100+
file-editing tool — containing a JSON array of reason strings, which is parsed
101+
by the tool itself. A malformed file is a correctable parse error rather than an
102+
executed command. A repeatable `--reason "<text>"` flag remains for short,
103+
model-authored text that quotes nothing; both sources are validated against the
104+
same bounds and concatenated in order.
105+
106+
The command validates the input synchronously: on bad input it prints
90107
`THREAT_DETECTION_RESULT_ERROR:` and exits non-zero without recording anything,
91108
so the model can correct it in-session; on valid input it atomically records the
92109
canonical JSON verdict to the sink (first valid write wins, idempotent) and
@@ -342,7 +359,7 @@ explicitly treated as untrusted runtime data.
342359
The three booleans are fully constrained by the schema: all are required, no
343360
other fields are accepted, and a result that adds, omits, or mistypes a field is
344361
rejected. `reasons` is model-authored free text and is bounded as well — at most
345-
20 entries, each non-blank and at most 1000 characters — and the whole result
362+
20 entries, each non-blank and at most 2000 characters — and the whole result
346363
file is capped at 1 MiB before it is parsed. The same bounds apply when the model
347364
reports a result and when the file is read back, so a recorded result can never
348365
fail validation later. A rejected report is returned to the model as a

cmd/threat-detect/conclude.go

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,18 @@ const (
5151
maxListedFiles = 200
5252
maxEchoedLogLines = 50
5353
maxEchoedLineRunes = 500
54+
maxReasonLogLines = 40
5455
diagnosticLogMaxSize = 8 << 20 // 8 MiB: read no more of the detection log
5556
)
5657

58+
// reasonContinuationGutter prefixes every physical line of a multi-line reason
59+
// after the first. Reasons are untrusted text and a line of theirs could start
60+
// with "::", which the Actions runner would interpret as a workflow command
61+
// (leading whitespace is not protection — the runner trims it). The gutter puts
62+
// a non-"::" character first on every continuation line, so the line stays a
63+
// plain log line while remaining readable and copy-pasteable.
64+
const reasonContinuationGutter = " | "
65+
5766
// bannerRule is the horizontal rule framing the conclude banners. It mirrors
5867
// gh-aw's inline conclusion step so both paths read identically in job logs.
5968
const bannerRule = "════════════════════════════════════════════════════════"
@@ -239,7 +248,7 @@ func (c *concluder) conclude(resultFile string) int {
239248
for _, reason := range result.Reasons {
240249
safe = append(safe, sanitizeLogValue(truncateRunes(reason, maxEchoedLineRunes)))
241250
}
242-
message += "\nReasons: " + strings.Join(safe, "; ")
251+
message += "\nReasons (full detail in the verdict block above): " + strings.Join(safe, "; ")
243252
}
244253
return c.fail(result, detector.ReasonThreatDetected, message)
245254
}
@@ -273,13 +282,44 @@ func (c *concluder) reportVerdict(result *detector.Result) {
273282
if len(result.Reasons) > 0 {
274283
c.info(fmt.Sprintf(" reasons (%d):", len(result.Reasons)))
275284
for i, reason := range result.Reasons {
276-
c.info(fmt.Sprintf(" [%d] %s", i+1, sanitizeLogValue(reason)))
285+
lines := reasonLogLines(reason)
286+
c.info(fmt.Sprintf(" [%d] %s", i+1, lines[0]))
287+
for _, line := range lines[1:] {
288+
c.info(reasonContinuationGutter + line)
289+
}
277290
}
278291
} else {
279292
c.info(" reasons : (none)")
280293
}
281294
}
282295

296+
// reasonLogLines renders an untrusted, model-authored reason as the physical
297+
// log lines to print. Reasons carry forensic detail — verbatim quotes of the
298+
// triggering content, file and line references — which is only usable if it
299+
// keeps its line structure, so embedded newlines become real lines rather than
300+
// escaped "\n" runs. Each returned line is individually sanitized (so no line
301+
// can carry a control character or start a line of its own) and bounded, and
302+
// the line count is capped so one pathological reason cannot flood the job log.
303+
// The result always has at least one element.
304+
func reasonLogLines(reason string) []string {
305+
normalized := strings.ReplaceAll(reason, "\r\n", "\n")
306+
normalized = strings.ReplaceAll(normalized, "\r", "\n")
307+
raw := strings.Split(normalized, "\n")
308+
truncated := false
309+
if len(raw) > maxReasonLogLines {
310+
raw = raw[:maxReasonLogLines]
311+
truncated = true
312+
}
313+
lines := make([]string, 0, len(raw)+1)
314+
for _, line := range raw {
315+
lines = append(lines, sanitizeLogValue(truncateRunes(line, maxEchoedLineRunes)))
316+
}
317+
if truncated {
318+
lines = append(lines, "… (reason truncated)")
319+
}
320+
return lines
321+
}
322+
283323
// listDirectory prints a recursive listing of dir so a missing or unusable
284324
// result file can be diagnosed from the job log alone.
285325
func (c *concluder) listDirectory(dir string) {
@@ -426,15 +466,36 @@ func readBounded(path string, limit int64) ([]byte, int64, error) {
426466
return data, totalBytes, nil
427467
}
428468

469+
// legacyCommandMarker is the runner's legacy workflow-command marker,
470+
// "##[command]data". The Actions runner honors it in addition to the "::" form,
471+
// and — unlike "::", which it accepts only at the start of a line (after
472+
// trimming leading whitespace) — it locates this marker with an unanchored
473+
// IndexOf. A legacy marker anywhere inside a log line is therefore a live
474+
// command, so no line prefix, gutter, or indentation can render it inert: the
475+
// value itself must be broken up. Reachable commands include add-mask (which
476+
// redacts arbitrary text from the log) and stop-commands (which suppresses
477+
// every later command, including this program's own threat annotation).
478+
const legacyCommandMarker = "##["
479+
480+
// legacyCommandMarkerEscaped is the inert rendering of legacyCommandMarker. It
481+
// keeps the sequence readable and greppable while breaking the runner's match.
482+
const legacyCommandMarkerEscaped = `##\[`
483+
429484
// sanitizeLogValue renders an untrusted string (a model-authored reason, an
430-
// artifact filename, or a detection-log line) so it cannot break out of its
431-
// single physical log line. Embedded newlines would otherwise let the value
432-
// emit a line of its own beginning with "::", which the Actions runner would
433-
// interpret as a workflow command. Control characters are escaped rather than
434-
// dropped so the original content stays visible and diagnosable.
485+
// artifact filename, or a detection-log line) so it cannot act as a workflow
486+
// command. Two things are neutralized:
487+
//
488+
// - Control characters are escaped rather than dropped, so the original
489+
// content stays visible and diagnosable. This confines the value to one
490+
// physical line, since an embedded newline would otherwise let it emit a
491+
// line of its own beginning with "::" — which the runner would interpret.
492+
// (Reasons are rendered across real lines by reasonLogLines, which calls
493+
// this per line and prefixes continuations so none can start with "::".)
494+
// - The legacy "##[" marker is escaped wherever it appears, because the
495+
// runner matches it mid-line and line-position defenses cannot reach it.
435496
func sanitizeLogValue(s string) string {
436497
if !strings.ContainsFunc(s, unicode.IsControl) {
437-
return s
498+
return strings.ReplaceAll(s, legacyCommandMarker, legacyCommandMarkerEscaped)
438499
}
439500
var b strings.Builder
440501
b.Grow(len(s))
@@ -452,7 +513,9 @@ func sanitizeLogValue(s string) string {
452513
b.WriteRune(r)
453514
}
454515
}
455-
return b.String()
516+
// The control-character escapes above emit only backslash sequences, so they
517+
// can neither create nor hide a "##[" marker; escaping it afterwards is safe.
518+
return strings.ReplaceAll(b.String(), legacyCommandMarker, legacyCommandMarkerEscaped)
456519
}
457520

458521
// truncateRunes shortens s to at most max runes, appending an ellipsis marker

cmd/threat-detect/conclude_test.go

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,10 +390,10 @@ func TestConcludeThreatMessageEscaped(t *testing.T) {
390390
if !strings.Contains(got, "::error::") {
391391
t.Fatalf("expected ::error:: command, got: %q", got)
392392
}
393-
if strings.Contains(got, "\nReasons:") {
393+
if strings.Contains(got, "\nReasons (full detail") {
394394
t.Fatalf("newline in message must be escaped, got: %q", got)
395395
}
396-
if !strings.Contains(got, "%0AReasons:") {
396+
if !strings.Contains(got, "%0AReasons (full detail") {
397397
t.Fatalf("expected escaped newline before Reasons, got: %q", got)
398398
}
399399
if !strings.Contains(got, errCodeValidation) {
@@ -845,6 +845,12 @@ func TestSanitizeLogValue(t *testing.T) {
845845
{"carriage return is escaped", "text\r::error::boom", `text\r::error::boom`},
846846
{"tab is escaped", "a\tb", `a\tb`},
847847
{"other control chars are escaped", "a\x00b\x1bc", `a\x00b\x1bc`},
848+
// The runner matches the legacy "##[cmd]" marker anywhere in a line, so
849+
// it must be broken up in the value regardless of line position.
850+
{"legacy marker is escaped", "evidence ##[stop-commands]tok", `evidence ##\[stop-commands]tok`},
851+
{"legacy marker escaped without control chars", "##[add-mask]word", `##\[add-mask]word`},
852+
{"every legacy marker occurrence is escaped", "##[error]a ##[add-mask]b", `##\[error]a ##\[add-mask]b`},
853+
{"legacy marker escaped alongside control chars", "x\n##[error]y", `x\n##\[error]y`},
848854
}
849855
for _, tt := range tests {
850856
t.Run(tt.name, func(t *testing.T) {
@@ -883,8 +889,111 @@ func TestConcludeReasonCannotInjectWorkflowCommand(t *testing.T) {
883889
t.Errorf("unexpected workflow command emitted: %q", line)
884890
}
885891
}
886-
if !strings.Contains(stdout.String(), `[1] benign looking\n::add-mask::injected`) {
887-
t.Errorf("escaped reason not rendered on a single line:\n%s", stdout.String())
892+
// The reason keeps its line structure (so quoted evidence stays readable),
893+
// but the continuation line carries the gutter so it cannot start a command.
894+
if !strings.Contains(stdout.String(), " [1] benign looking\n") {
895+
t.Errorf("first reason line not rendered:\n%s", stdout.String())
896+
}
897+
if !strings.Contains(stdout.String(), reasonContinuationGutter+"::add-mask::injected\n") {
898+
t.Errorf("continuation line not rendered with gutter:\n%s", stdout.String())
899+
}
900+
}
901+
902+
// TestReasonLogLines verifies multi-line reasons keep their line structure,
903+
// stay individually sanitized and bounded, and are capped in line count.
904+
func TestReasonLogLines(t *testing.T) {
905+
got := reasonLogLines("LOCATION: prompt.txt:42\r\nEVIDENCE: ignore\tprior\rrules")
906+
want := []string{"LOCATION: prompt.txt:42", `EVIDENCE: ignore\tprior`, "rules"}
907+
if len(got) != len(want) {
908+
t.Fatalf("reasonLogLines lines = %#v, want %#v", got, want)
909+
}
910+
for i := range want {
911+
if got[i] != want[i] {
912+
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
913+
}
914+
}
915+
916+
long := strings.Repeat("x", maxEchoedLineRunes+50)
917+
if lines := reasonLogLines(long); !strings.HasSuffix(lines[0], "… (truncated)") {
918+
t.Errorf("over-long line not truncated: %q", lines[0])
919+
}
920+
921+
many := reasonLogLines(strings.Repeat("a\n", maxReasonLogLines+10))
922+
if len(many) != maxReasonLogLines+1 {
923+
t.Fatalf("line count = %d, want %d", len(many), maxReasonLogLines+1)
924+
}
925+
if many[len(many)-1] != "… (reason truncated)" {
926+
t.Errorf("missing truncation marker, got %q", many[len(many)-1])
927+
}
928+
}
929+
930+
// TestConcludeReasonCannotEmitLegacyWorkflowCommand verifies a reason cannot
931+
// smuggle the runner's legacy "##[command]" marker into the job log. The runner
932+
// matches that marker mid-line, so the line gutter cannot neutralize it; if it
933+
// survived, attacker-authored evidence could emit ##[stop-commands] and
934+
// suppress this program's own threat annotation, or ##[add-mask] and redact the
935+
// log a maintainer is meant to read.
936+
func TestConcludeReasonCannotEmitLegacyWorkflowCommand(t *testing.T) {
937+
dir := t.TempDir()
938+
resultFile := filepath.Join(dir, "detection_result.json")
939+
verdict := `{"prompt_injection":true,"secret_leak":false,"malicious_patch":false,` +
940+
`"reasons":["EVIDENCE:\nharmless ##[stop-commands]pwn3d then ##[add-mask]detected"]}`
941+
if err := os.WriteFile(resultFile, []byte(verdict), 0o600); err != nil {
942+
t.Fatalf("WriteFile error = %v", err)
943+
}
944+
945+
var stdout bytes.Buffer
946+
c := &concluder{
947+
runDetection: "true",
948+
githubOutput: filepath.Join(dir, "out"),
949+
githubEnv: filepath.Join(dir, "env"),
950+
stdout: &stdout,
951+
}
952+
if code := c.run(resultFile); code != concludeExitFail {
953+
t.Fatalf("exit code = %d, want %d", code, concludeExitFail)
954+
}
955+
got := stdout.String()
956+
if strings.Contains(got, "##[") {
957+
t.Fatalf("live legacy workflow-command marker reached the log:\n%s", got)
958+
}
959+
// The evidence must still be present and readable, just inert.
960+
if !strings.Contains(got, `##\[stop-commands]pwn3d`) || !strings.Contains(got, `##\[add-mask]detected`) {
961+
t.Fatalf("escaped evidence not rendered:\n%s", got)
962+
}
963+
// The threat annotation must still be emitted (it is what stop-commands
964+
// would have suppressed).
965+
if !strings.Contains(got, "::error::") {
966+
t.Fatalf("expected ::error:: annotation, got:\n%s", got)
967+
}
968+
}
969+
970+
// TestConcludeDiagnosticsCannotEmitLegacyWorkflowCommand verifies the other
971+
// untrusted echo paths — artifact filenames and detection-log lines — are
972+
// neutralized the same way.
973+
func TestConcludeDiagnosticsCannotEmitLegacyWorkflowCommand(t *testing.T) {
974+
dir := t.TempDir()
975+
logPath := filepath.Join(dir, "detection.log")
976+
logLine := "THREAT_DETECTION_STATUS: reason=engine_error exit=2 ##[add-mask]x\n"
977+
if err := os.WriteFile(logPath, []byte(logLine), 0o600); err != nil {
978+
t.Fatalf("WriteFile error = %v", err)
979+
}
980+
if err := os.WriteFile(filepath.Join(dir, "evil##[error]name.txt"), []byte("x"), 0o600); err != nil {
981+
t.Fatalf("WriteFile error = %v", err)
982+
}
983+
984+
var stdout bytes.Buffer
985+
c := &concluder{
986+
runDetection: "true",
987+
warnMode: true,
988+
githubOutput: filepath.Join(dir, "out"),
989+
githubEnv: filepath.Join(dir, "env"),
990+
detectionLog: logPath,
991+
stdout: &stdout,
992+
}
993+
c.run(filepath.Join(dir, "missing_result.json"))
994+
995+
if strings.Contains(stdout.String(), "##[") {
996+
t.Fatalf("live legacy workflow-command marker reached the log:\n%s", stdout.String())
888997
}
889998
}
890999

cmd/threat-detect/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const (
3838

3939
detectionCorrectionPrefix = "Your previous response did not record a verdict"
4040
detectionCorrectionMessage = "The threat_detection_result command was not run, or it reported an error and exited before a verdict was recorded."
41-
detectionCorrectionInstruction = "Run the threat_detection_result command exactly once with --prompt-injection, --secret-leak, and --malicious-patch each set to true or false, plus a --reason for every threat set to true."
41+
detectionCorrectionInstruction = "Run the threat_detection_result command exactly once with --prompt-injection, --secret-leak, and --malicious-patch each set to true or false. When any of them is true, use your file-writing tool to write your reasons as a JSON array of strings to the path in $THREAT_DETECTION_REASONS_FILE and pass it with --reasons-file; do not paste quoted artifact content onto the command line."
4242
promptAnalysisValidationCode = "ERR_VALIDATION"
4343

4444
// maxInventoryEntries bounds the artifact inventory printed to stderr so a

cmd/threat-detect/report.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ func runReport(args []string) int {
4343
secretLeak bool
4444
maliciousPatch bool
4545
reasons stringSliceFlag
46+
reasonsFile string
4647
resultFile string
4748
)
4849
fs.BoolVar(&promptInjection, "prompt-injection", false, "Whether a prompt injection threat was detected (required)")
4950
fs.BoolVar(&secretLeak, "secret-leak", false, "Whether a secret leak threat was detected (required)")
5051
fs.BoolVar(&maliciousPatch, "malicious-patch", false, "Whether a malicious patch threat was detected (required)")
51-
fs.Var(&reasons, "reason", "Reason explaining a detected threat (repeatable)")
52+
fs.Var(&reasons, "reason", "Reason explaining a detected threat (repeatable; use --reasons-file for text quoting artifact content)")
53+
fs.StringVar(&reasonsFile, "reasons-file", "", "Path to a file containing a JSON array of reason strings; the shell-free way to report reasons that quote artifact content")
5254
fs.StringVar(&resultFile, "result-file", os.Getenv("THREAT_DETECTION_RESULT_FILE"), "Path to the result sink file (defaults to env THREAT_DETECTION_RESULT_FILE)")
5355

5456
if err := fs.Parse(normalizeBoolFlagArgs(args)); err != nil {
@@ -71,15 +73,25 @@ func runReport(args []string) int {
7173
return reportExitConfig
7274
}
7375

76+
// Reasons from the file transport are appended after any --reason flags, so
77+
// the recorded order matches the order the model supplied them.
7478
reasonsSlice := []string(reasons)
79+
if reasonsFile != "" {
80+
fileReasons, err := detector.ReadReasonsFile(reasonsFile)
81+
if err != nil {
82+
reportError(detector.TruncateCorrectionMessage(err.Error()))
83+
return reportExitInvalid
84+
}
85+
reasonsSlice = append(reasonsSlice, fileReasons...)
86+
}
7587
if msg := detector.ValidateReportFields(promptInjection, secretLeak, maliciousPatch, toAnySlice(reasonsSlice)); msg != "" {
7688
reportError(msg)
7789
return reportExitInvalid
7890
}
7991

8092
// Require at least one reason when any threat is reported.
8193
if (promptInjection || secretLeak || maliciousPatch) && len(reasonsSlice) == 0 {
82-
reportError("at least one --reason is required when any threat is true")
94+
reportError("at least one reason is required when any threat is true; supply --reasons-file (preferred) or --reason")
8395
return reportExitInvalid
8496
}
8597

0 commit comments

Comments
 (0)