Skip to content

Commit 3f46dd0

Browse files
davidslaterGitHub Ace
andauthored
feat(cli): add explicit workflow-context flags and prompt diagnostics (#742)
* feat(cli): add explicit workflow-context flags and prompt diagnostics Add --workflow-name, --workflow-description, --custom-prompt, and --custom-prompt-file flags so the workflow context can be injected without relying on env-var plumbing that can be silently dropped (issue #688). Flags override the corresponding env vars; --custom-prompt-file takes precedence over --custom-prompt. Record the resolved workflow name/description, whether each was defaulted, and the applied custom-prompt source/bytes on the prompt_built run-log event and a single stderr diagnostic line, so a dropped custom prompt or missing workflow context is diagnosable. Export DefaultWorkflowName/DefaultWorkflowDescription from pkg/artifacts. Update README and spec (TD-22-flags). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * fix(cli): track workflow-context provenance via flag visitation Address PR review feedback: - Derive defaulted/applied state from whether a flag was explicitly provided (flag.CommandLine.Visit) and whether the env var was set, instead of comparing against the fallback text. An explicit flag now wins even when empty (e.g. --custom-prompt='' clears an env-supplied prompt), and a value equal to the default text is no longer misreported as defaulted. - Expand the stderr Prompt context line to include the resolved workflow_description and custom_prompt_applied, matching TD-22-flags. - Make promptcontext tests deterministic by clearing inherited WORKFLOW_NAME/WORKFLOW_DESCRIPTION/CUSTOM_PROMPT, and add cases for explicit-default-text and empty-flag-clears-env provenance. 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 e26d1ca commit 3f46dd0

5 files changed

Lines changed: 296 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ threat-detect [flags] <artifacts-dir>
5757
- `--engine` — AI engine to use (`copilot`, `claude`, `codex`). Default: `copilot`
5858
- `--model` — Model override for the engine. When unset, the detector resolves the model from `GH_AW_MODEL_DETECTION_{COPILOT,CLAUDE,CODEX}`, then the engine CLI's native model env var (`COPILOT_MODEL`, `ANTHROPIC_MODEL`)
5959
- `--prompt-template` — Path to custom prompt template
60+
- `--workflow-name` — Workflow name for the prompt. Overrides `WORKFLOW_NAME`
61+
- `--workflow-description` — Workflow description for the prompt. Overrides `WORKFLOW_DESCRIPTION`
62+
- `--custom-prompt` — Additional detection instructions appended to the prompt. Overrides `CUSTOM_PROMPT`
63+
- `--custom-prompt-file` — Path to a file with additional detection instructions. Takes precedence over `--custom-prompt` and `CUSTOM_PROMPT`
6064
- `--output` — Path to write JSON result (defaults to stdout)
6165
- `--log-file` — Path to write structured JSONL run logs (one JSON object per line). Env: `THREAT_DETECTION_LOG_FILE`
6266
- `--retries` — Retries for malformed detection outputs. Default: `1` (env: `THREAT_DETECTION_RETRIES`)
@@ -287,9 +291,9 @@ No additional secrets are required for unit tests, `make build`, `make test`, or
287291
| `COPILOT_GITHUB_TOKEN` | Running `--engine copilot` in an environment that needs explicit token-based Copilot authentication | Use a fine-grained PAT owned by a user account with **Account permissions → Copilot Requests: Read**. `GITHUB_TOKEN` is not sufficient for Copilot inference. |
288292
| `ANTHROPIC_API_KEY` | Running `--engine claude` with the Claude CLI | Not used by unit tests. |
289293
| `OPENAI_API_KEY` | Running `--engine codex` with the Codex CLI | Not used by unit tests. |
290-
| `WORKFLOW_NAME` | Optional local runs | Included in the generated prompt. |
291-
| `WORKFLOW_DESCRIPTION` | Optional local runs | Included in the generated prompt. |
292-
| `CUSTOM_PROMPT` | Optional local runs | Appended to the default detection prompt. |
294+
| `WORKFLOW_NAME` | Optional local runs | Included in the generated prompt. Overridable with `--workflow-name`. |
295+
| `WORKFLOW_DESCRIPTION` | Optional local runs | Included in the generated prompt. Overridable with `--workflow-description`. |
296+
| `CUSTOM_PROMPT` | Optional local runs | Appended to the default detection prompt. Overridable with `--custom-prompt` / `--custom-prompt-file`. |
293297

294298
## Development
295299

‎cmd/threat-detect/main.go‎

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,17 @@ func run() (code int) {
9999
}()
100100

101101
var (
102-
engineID string
103-
model string
104-
promptFile string
105-
outputJSON string
106-
logFile string
107-
version bool
108-
retries int
102+
engineID string
103+
model string
104+
promptFile string
105+
outputJSON string
106+
logFile string
107+
workflowName string
108+
workflowDescription string
109+
customPrompt string
110+
customPromptFile string
111+
version bool
112+
retries int
109113
)
110114

111115
// Parse flags with ContinueOnError so usage/flag errors return through the
@@ -118,6 +122,10 @@ func run() (code int) {
118122
flag.StringVar(&promptFile, "prompt-template", "", "Path to custom prompt template (defaults to built-in)")
119123
flag.StringVar(&outputJSON, "output", "", "Path to write JSON result (defaults to stdout)")
120124
flag.StringVar(&logFile, "log-file", os.Getenv("THREAT_DETECTION_LOG_FILE"), "Path to write JSONL run logs (env: THREAT_DETECTION_LOG_FILE)")
125+
flag.StringVar(&workflowName, "workflow-name", "", "Workflow name for the prompt (overrides WORKFLOW_NAME)")
126+
flag.StringVar(&workflowDescription, "workflow-description", "", "Workflow description for the prompt (overrides WORKFLOW_DESCRIPTION)")
127+
flag.StringVar(&customPrompt, "custom-prompt", "", "Additional detection instructions appended to the prompt (overrides CUSTOM_PROMPT)")
128+
flag.StringVar(&customPromptFile, "custom-prompt-file", "", "Path to a file with additional detection instructions (takes precedence over --custom-prompt and CUSTOM_PROMPT)")
121129
flag.BoolVar(&version, "version", false, "Print version and exit")
122130
flag.IntVar(&retries, "retries", envInt("THREAT_DETECTION_RETRIES", 1), "Retries for malformed detection outputs (env: THREAT_DETECTION_RETRIES)")
123131
if err := flag.CommandLine.Parse(os.Args[1:]); err != nil {
@@ -193,6 +201,58 @@ func run() (code int) {
193201
}
194202
logger.Info("artifacts_loaded", map[string]any{"artifacts_dir": artifactsDir})
195203

204+
// Resolve workflow-context overrides. Provenance is tracked from whether a
205+
// flag was explicitly provided (FlagSet.Visit) rather than from the value's
206+
// content, so an explicit flag wins even when empty and a value that merely
207+
// equals the built-in default text is not misreported as defaulted. This
208+
// gives gh-aw and local callers a plumbing-independent way to inject workflow
209+
// context so it cannot be silently dropped by an env-passthrough filter.
210+
providedFlags := map[string]bool{}
211+
flag.CommandLine.Visit(func(f *flag.Flag) { providedFlags[f.Name] = true })
212+
213+
// A value is "defaulted" only when neither the flag nor its environment
214+
// variable supplied it; equality with the fallback text is not sufficient.
215+
nameDefaulted := !providedFlags["workflow-name"] && os.Getenv("WORKFLOW_NAME") == ""
216+
descriptionDefaulted := !providedFlags["workflow-description"] && os.Getenv("WORKFLOW_DESCRIPTION") == ""
217+
if providedFlags["workflow-name"] {
218+
arts.WorkflowName = workflowName
219+
}
220+
if providedFlags["workflow-description"] {
221+
arts.WorkflowDescription = workflowDescription
222+
}
223+
224+
// Custom prompt precedence: --custom-prompt-file, then --custom-prompt, then
225+
// the CUSTOM_PROMPT environment variable already loaded into arts. Presence is
226+
// determined by explicit flag provision, so an explicit empty flag clears an
227+
// env-supplied prompt (flags win).
228+
customPromptSource := "none"
229+
if arts.CustomPrompt != "" {
230+
customPromptSource = "env"
231+
}
232+
if providedFlags["custom-prompt"] {
233+
arts.CustomPrompt = customPrompt
234+
customPromptSource = "flag"
235+
}
236+
if providedFlags["custom-prompt-file"] {
237+
data, err := os.ReadFile(customPromptFile)
238+
if err != nil {
239+
fmt.Fprintf(os.Stderr, "Error reading custom prompt file: %v\n", err)
240+
logger.Error("custom_prompt_read_failed", map[string]any{"path": customPromptFile, "error": err.Error()})
241+
reason = reasonConfigError
242+
return exitError
243+
}
244+
arts.CustomPrompt = string(data)
245+
customPromptSource = "file"
246+
}
247+
248+
// Surface the resolved workflow context on stderr so a dropped CUSTOM_PROMPT
249+
// or missing workflow name/description is diagnosable from the run log alone,
250+
// not silently absorbed into the prompt.
251+
fmt.Fprintf(os.Stderr,
252+
"Prompt context: workflow_name=%q (defaulted=%t) workflow_description=%q (defaulted=%t) custom_prompt_applied=%t custom_prompt_source=%s custom_prompt_bytes=%d\n",
253+
arts.WorkflowName, nameDefaulted, arts.WorkflowDescription, descriptionDefaulted,
254+
arts.CustomPrompt != "", customPromptSource, len(arts.CustomPrompt))
255+
196256
// Build the prompt
197257
promptTemplate := ""
198258
if promptFile != "" {
@@ -212,7 +272,16 @@ func run() (code int) {
212272
reason = reasonConfigError
213273
return exitError
214274
}
215-
logger.Info("prompt_built", map[string]any{"prompt_bytes": len(prompt)})
275+
logger.Info("prompt_built", map[string]any{
276+
"prompt_bytes": len(prompt),
277+
"workflow_name": arts.WorkflowName,
278+
"workflow_description": arts.WorkflowDescription,
279+
"workflow_name_defaulted": nameDefaulted,
280+
"workflow_description_defaulted": descriptionDefaulted,
281+
"custom_prompt_applied": arts.CustomPrompt != "",
282+
"custom_prompt_source": customPromptSource,
283+
"custom_prompt_bytes": len(arts.CustomPrompt),
284+
})
216285

217286
// Create engine
218287
eng, err := engine.New(engineID, model)
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// runDetectionForPromptContext runs a full detection pass against a fake copilot
11+
// that records a safe verdict, returning the parsed prompt_built log record and
12+
// the captured stderr. Extra args are appended before the artifacts dir.
13+
func runDetectionForPromptContext(t *testing.T, env map[string]string, extraArgs ...string) (map[string]any, string) {
14+
t.Helper()
15+
artifactsDir := t.TempDir()
16+
logPath := filepath.Join(t.TempDir(), "run.jsonl")
17+
marker := filepath.Join(t.TempDir(), "copilot-called")
18+
sinkJSON := `{"prompt_injection":false,"secret_leak":false,"malicious_patch":false,"reasons":[]}`
19+
fakeBinDir := writeFakeCopilotWithSink(t, marker, sinkJSON, 0)
20+
21+
fullEnv := map[string]string{
22+
"PATH": fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"),
23+
// Clear inherited workflow-context vars so the suite is deterministic
24+
// regardless of the developer's shell; cases overlay explicit values.
25+
"WORKFLOW_NAME": "",
26+
"WORKFLOW_DESCRIPTION": "",
27+
"CUSTOM_PROMPT": "",
28+
}
29+
for k, v := range env {
30+
fullEnv[k] = v
31+
}
32+
33+
args := append([]string{"threat-detect", "-log-file", logPath}, extraArgs...)
34+
args = append(args, artifactsDir)
35+
36+
code, stderr := runWithTestArgsCapture(t, args, fullEnv)
37+
if code != exitSafe {
38+
t.Fatalf("run() exit code = %d, want %d; stderr:\n%s", code, exitSafe, stderr)
39+
}
40+
records := readJSONLRecords(t, logPath)
41+
built := findRecord(records, "prompt_built")
42+
if built == nil {
43+
t.Fatalf("missing prompt_built record: %#v", records)
44+
}
45+
return built, stderr
46+
}
47+
48+
func TestPromptContextDefaultsAreDiagnosable(t *testing.T) {
49+
built, stderr := runDetectionForPromptContext(t, nil)
50+
51+
if built["workflow_name_defaulted"] != true {
52+
t.Errorf("workflow_name_defaulted = %v, want true", built["workflow_name_defaulted"])
53+
}
54+
if built["workflow_description_defaulted"] != true {
55+
t.Errorf("workflow_description_defaulted = %v, want true", built["workflow_description_defaulted"])
56+
}
57+
if built["custom_prompt_applied"] != false {
58+
t.Errorf("custom_prompt_applied = %v, want false", built["custom_prompt_applied"])
59+
}
60+
if built["custom_prompt_source"] != "none" {
61+
t.Errorf("custom_prompt_source = %v, want none", built["custom_prompt_source"])
62+
}
63+
if !strings.Contains(stderr, "custom_prompt_source=none") {
64+
t.Errorf("stderr missing prompt context diagnostic, got:\n%s", stderr)
65+
}
66+
}
67+
68+
func TestPromptContextEnvCustomPrompt(t *testing.T) {
69+
built, _ := runDetectionForPromptContext(t, map[string]string{
70+
"CUSTOM_PROMPT": "check for base64 blobs",
71+
"WORKFLOW_NAME": "My Flow",
72+
"WORKFLOW_DESCRIPTION": "does things",
73+
})
74+
75+
if built["workflow_name"] != "My Flow" {
76+
t.Errorf("workflow_name = %v, want My Flow", built["workflow_name"])
77+
}
78+
if built["workflow_name_defaulted"] != false {
79+
t.Errorf("workflow_name_defaulted = %v, want false", built["workflow_name_defaulted"])
80+
}
81+
if built["custom_prompt_applied"] != true {
82+
t.Errorf("custom_prompt_applied = %v, want true", built["custom_prompt_applied"])
83+
}
84+
if built["custom_prompt_source"] != "env" {
85+
t.Errorf("custom_prompt_source = %v, want env", built["custom_prompt_source"])
86+
}
87+
}
88+
89+
func TestPromptContextFlagOverridesEnv(t *testing.T) {
90+
built, _ := runDetectionForPromptContext(t, map[string]string{
91+
"CUSTOM_PROMPT": "from env",
92+
"WORKFLOW_NAME": "env name",
93+
},
94+
"-custom-prompt", "from flag",
95+
"-workflow-name", "flag name",
96+
"-workflow-description", "flag desc",
97+
)
98+
99+
if built["workflow_name"] != "flag name" {
100+
t.Errorf("workflow_name = %v, want flag name", built["workflow_name"])
101+
}
102+
if built["workflow_description"] != "flag desc" {
103+
t.Errorf("workflow_description = %v, want flag desc", built["workflow_description"])
104+
}
105+
if built["custom_prompt_source"] != "flag" {
106+
t.Errorf("custom_prompt_source = %v, want flag", built["custom_prompt_source"])
107+
}
108+
}
109+
110+
func TestPromptContextCustomPromptFileWins(t *testing.T) {
111+
promptFile := filepath.Join(t.TempDir(), "extra.md")
112+
if err := os.WriteFile(promptFile, []byte("from file"), 0o644); err != nil {
113+
t.Fatalf("writing custom prompt file: %v", err)
114+
}
115+
116+
built, _ := runDetectionForPromptContext(t, map[string]string{
117+
"CUSTOM_PROMPT": "from env",
118+
},
119+
"-custom-prompt", "from flag",
120+
"-custom-prompt-file", promptFile,
121+
)
122+
123+
if built["custom_prompt_source"] != "file" {
124+
t.Errorf("custom_prompt_source = %v, want file", built["custom_prompt_source"])
125+
}
126+
if bytes, ok := built["custom_prompt_bytes"].(float64); !ok || int(bytes) != len("from file") {
127+
t.Errorf("custom_prompt_bytes = %v, want %d", built["custom_prompt_bytes"], len("from file"))
128+
}
129+
}
130+
131+
func TestPromptContextExplicitDefaultTextIsNotDefaulted(t *testing.T) {
132+
// Supplying the fallback text explicitly must not be misreported as defaulted.
133+
built, stderr := runDetectionForPromptContext(t, nil,
134+
"-workflow-name", "Unnamed Workflow",
135+
"-workflow-description", "No description provided",
136+
)
137+
138+
if built["workflow_name_defaulted"] != false {
139+
t.Errorf("workflow_name_defaulted = %v, want false", built["workflow_name_defaulted"])
140+
}
141+
if built["workflow_description_defaulted"] != false {
142+
t.Errorf("workflow_description_defaulted = %v, want false", built["workflow_description_defaulted"])
143+
}
144+
if !strings.Contains(stderr, "workflow_description=\"No description provided\"") {
145+
t.Errorf("stderr missing resolved description, got:\n%s", stderr)
146+
}
147+
}
148+
149+
func TestPromptContextEmptyFlagClearsEnvCustomPrompt(t *testing.T) {
150+
// An explicit empty --custom-prompt must win over CUSTOM_PROMPT (flags win),
151+
// clearing the applied prompt while recording the flag as its provenance.
152+
built, _ := runDetectionForPromptContext(t, map[string]string{
153+
"CUSTOM_PROMPT": "from env",
154+
}, "-custom-prompt", "")
155+
156+
if built["custom_prompt_applied"] != false {
157+
t.Errorf("custom_prompt_applied = %v, want false", built["custom_prompt_applied"])
158+
}
159+
if built["custom_prompt_source"] != "flag" {
160+
t.Errorf("custom_prompt_source = %v, want flag", built["custom_prompt_source"])
161+
}
162+
if bytes, ok := built["custom_prompt_bytes"].(float64); !ok || int(bytes) != 0 {
163+
t.Errorf("custom_prompt_bytes = %v, want 0", built["custom_prompt_bytes"])
164+
}
165+
}
166+
167+
func TestPromptContextRejectsUnreadableCustomPromptFile(t *testing.T) {
168+
missing := filepath.Join(t.TempDir(), "does-not-exist.md")
169+
170+
code, stderr := runWithTestArgsCapture(t, []string{
171+
"threat-detect",
172+
"-custom-prompt-file", missing,
173+
t.TempDir(),
174+
}, nil)
175+
176+
if code != exitError {
177+
t.Fatalf("run() exit code = %d, want %d", code, exitError)
178+
}
179+
if !strings.Contains(stderr, "Error reading custom prompt file") {
180+
t.Fatalf("stderr missing custom prompt read error, got:\n%s", stderr)
181+
}
182+
if !strings.Contains(stderr, "reason=config_error") {
183+
t.Fatalf("stderr missing config_error status, got:\n%s", stderr)
184+
}
185+
}

‎pkg/artifacts/artifacts.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import (
99
"strings"
1010
)
1111

12+
// Default workflow-context values used when neither the corresponding flag nor
13+
// environment variable supplies one. They are exported so callers (e.g. the CLI)
14+
// can detect when a value was defaulted rather than provided, which is useful
15+
// for diagnosing dropped workflow-context plumbing.
16+
const (
17+
DefaultWorkflowName = "Unnamed Workflow"
18+
DefaultWorkflowDescription = "No description provided"
19+
)
20+
1221
// Artifacts holds the loaded artifact information for threat detection.
1322
type Artifacts struct {
1423
// Dir is the base artifacts directory path.
@@ -56,8 +65,8 @@ func Load(dir string) (*Artifacts, error) {
5665

5766
arts := &Artifacts{
5867
Dir: dir,
59-
WorkflowName: envOrDefault("WORKFLOW_NAME", "Unnamed Workflow"),
60-
WorkflowDescription: envOrDefault("WORKFLOW_DESCRIPTION", "No description provided"),
68+
WorkflowName: envOrDefault("WORKFLOW_NAME", DefaultWorkflowName),
69+
WorkflowDescription: envOrDefault("WORKFLOW_DESCRIPTION", DefaultWorkflowDescription),
6170
CustomPrompt: os.Getenv("CUSTOM_PROMPT"),
6271
}
6372

‎specs/threat-detection-spec.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,22 @@ treats a missing verdict as a recoverable `parse_error` and proceeds.
225225
| `WORKFLOW_DESCRIPTION` | Description of the workflow |
226226
| `CUSTOM_PROMPT` | Additional detection instructions |
227227

228+
**TD-22-flags**: The detector MUST also accept the workflow context via explicit
229+
flags so it cannot be silently dropped by environment-variable plumbing:
230+
`--workflow-name` overrides `WORKFLOW_NAME`, `--workflow-description` overrides
231+
`WORKFLOW_DESCRIPTION`, and `--custom-prompt` (or `--custom-prompt-file`, which
232+
reads the instructions from a file) overrides `CUSTOM_PROMPT`. When both a flag
233+
and its environment variable are set, the flag wins (even when empty, so an
234+
explicit empty `--custom-prompt` clears an env-supplied prompt);
235+
`--custom-prompt-file` takes precedence over `--custom-prompt`. A value is
236+
reported as defaulted only when neither its flag nor its environment variable was
237+
provided (not merely because it equals the fallback text). The detector MUST
238+
record the resolved workflow
239+
name and description, whether each fell back to its built-in default, and the
240+
source and byte length of any applied custom prompt (`flag`, `file`, `env`, or
241+
`none`) on the `prompt_built` run-log event and on a single stderr diagnostic
242+
line, so a dropped custom prompt or missing workflow context is diagnosable.
243+
228244
**TD-22a**: When the model is not set explicitly (via the `--model` flag or engine configuration), the detector MUST resolve the model for the selected engine from environment variables, in the following precedence:
229245

230246
1. the engine-specific detection model variable `GH_AW_MODEL_DETECTION_{COPILOT,CLAUDE,CODEX}`;

0 commit comments

Comments
 (0)