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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ threat-detect [flags] <artifacts-dir>
```

**Flags:**
- `--engine` — AI engine to use (`copilot`, `claude`, `codex`). Default: `copilot`
- `--model` — Model override for the engine
- `--engine` — AI engine to use (`copilot`, `claude`, `codex`). Default: `copilot` (env: `THREAT_DETECTION_ENGINE`)
- `--model` — Model override for the engine; per-engine defaults: `THREAT_DETECTION_COPILOT_MODEL` / `THREAT_DETECTION_CLAUDE_MODEL` / `THREAT_DETECTION_CODEX_MODEL`
- `--prompt-template` — Path to custom prompt template
- `--output` — Path to write JSON result (defaults to stdout)
- `--retries` — Retries for malformed detection outputs. Default: `1` (env: `THREAT_DETECTION_RETRIES`)
Expand Down
11 changes: 9 additions & 2 deletions cmd/threat-detect/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ func run() (code int) {
flag.CommandLine.Init(os.Args[0], flag.ContinueOnError)
flag.CommandLine.SetOutput(os.Stderr)

flag.StringVar(&engineID, "engine", "", "AI engine to use (copilot, claude, codex)")
flag.StringVar(&model, "model", "", "Model to use for detection")
flag.StringVar(&engineID, "engine", envString("THREAT_DETECTION_ENGINE", ""), "AI engine to use (copilot, claude, codex) (env: THREAT_DETECTION_ENGINE)")
flag.StringVar(&model, "model", "", "Model override for the engine; per-engine defaults: env THREAT_DETECTION_COPILOT_MODEL / THREAT_DETECTION_CLAUDE_MODEL / THREAT_DETECTION_CODEX_MODEL")
flag.StringVar(&promptFile, "prompt-template", "", "Path to custom prompt template (defaults to built-in)")
flag.StringVar(&outputJSON, "output", "", "Path to write JSON result (defaults to stdout)")
flag.BoolVar(&version, "version", false, "Print version and exit")
Expand Down Expand Up @@ -266,3 +266,10 @@ func envInt(key string, fallback int) int {
}
return parsed
}

func envString(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
26 changes: 26 additions & 0 deletions cmd/threat-detect/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,32 @@ func TestRunEmitsEngineErrorStatusOnFailClosed(t *testing.T) {
}
}

func TestRunUsesEngineEnvVar(t *testing.T) {
// When --engine is omitted but THREAT_DETECTION_ENGINE=copilot, the
// copilot engine should still be selected (the env var sets the default).
artifactsDir := t.TempDir()
outputPath := filepath.Join(t.TempDir(), "result.json")
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_ENGINE": "copilot",
})

if code != exitSafe {
t.Fatalf("run() exit code = %d, want %d", code, exitSafe)
}
if _, err := os.Stat(copilotMarker); err != nil {
t.Fatalf("expected copilot to run (selected via THREAT_DETECTION_ENGINE): %v", err)
}
Comment on lines +207 to +215
}

func runWithTestArgs(t *testing.T, args []string, env map[string]string) int {
t.Helper()
code, _ := runWithTestArgsCapture(t, args, env)
Expand Down
20 changes: 20 additions & 0 deletions pkg/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,39 @@ type Engine interface {
Analyze(ctx context.Context, prompt string, opts AnalyzeOptions) (string, error)
}

// Environment variables for per-engine model configuration.
// Each can be overridden by the --model flag passed to New.
const (
EnvCopilotModel = "THREAT_DETECTION_COPILOT_MODEL"
EnvClaudeModel = "THREAT_DETECTION_CLAUDE_MODEL"
EnvCodexModel = "THREAT_DETECTION_CODEX_MODEL"
)

// New creates a new engine instance based on the engine ID.
// If engineID is empty, it defaults to "copilot".
// When model is empty, each engine falls back to its per-engine env var
// (THREAT_DETECTION_COPILOT_MODEL, THREAT_DETECTION_CLAUDE_MODEL, or
// THREAT_DETECTION_CODEX_MODEL).
func New(engineID, model string) (Engine, error) {
if engineID == "" {
engineID = "copilot"
}

switch strings.ToLower(engineID) {
case "copilot":
if model == "" {
model = os.Getenv(EnvCopilotModel)
}
return &copilotEngine{model: model}, nil
case "claude":
if model == "" {
model = os.Getenv(EnvClaudeModel)
}
return &claudeEngine{model: model}, nil
case "codex":
if model == "" {
model = os.Getenv(EnvCodexModel)
}
return &codexEngine{model: model}, nil
default:
return nil, fmt.Errorf("unsupported engine: %q (supported: copilot, claude, codex)", engineID)
Expand Down
80 changes: 80 additions & 0 deletions pkg/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,86 @@ func TestNew_CaseInsensitive(t *testing.T) {
}
}

func TestNew_ModelEnvVarFallback(t *testing.T) {
tests := []struct {
name string
engineID string
envKey string
envModel string
wantModel string
}{
{
name: "copilot env model used when no flag model",
engineID: "copilot",
envKey: EnvCopilotModel,
envModel: "gpt-5",
wantModel: "gpt-5",
},
{
name: "claude env model used when no flag model",
engineID: "claude",
envKey: EnvClaudeModel,
envModel: "claude-opus-4.5",
wantModel: "claude-opus-4.5",
},
{
name: "codex env model used when no flag model",
engineID: "codex",
envKey: EnvCodexModel,
envModel: "gpt-5-codex",
wantModel: "gpt-5-codex",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(tc.envKey, tc.envModel)
eng, err := New(tc.engineID, "")
if err != nil {
t.Fatalf("New() error = %v", err)
}
var gotModel string
switch e := eng.(type) {
case *copilotEngine:
gotModel = e.model
case *claudeEngine:
gotModel = e.model
case *codexEngine:
gotModel = e.model
default:
t.Fatalf("unexpected engine type %T", eng)
}
if gotModel != tc.wantModel {
t.Fatalf("engine.model = %q, want %q", gotModel, tc.wantModel)
}
})
}
}

func TestNew_FlagModelOverridesEnvVar(t *testing.T) {
t.Setenv(EnvCopilotModel, "env-model")
eng, err := New("copilot", "flag-model")
if err != nil {
t.Fatalf("New() error = %v", err)
}
got := eng.(*copilotEngine).model
if got != "flag-model" {
t.Fatalf("engine.model = %q, want %q (flag must override env)", got, "flag-model")
}
}

func TestNew_EnvVarNotLeakedToOtherEngine(t *testing.T) {
// THREAT_DETECTION_COPILOT_MODEL must not affect the claude engine.
t.Setenv(EnvCopilotModel, "copilot-specific-model")
eng, err := New("claude", "")
if err != nil {
t.Fatalf("New() error = %v", err)
}
got := eng.(*claudeEngine).model
if got != "" {
t.Fatalf("claude engine.model = %q, want empty (copilot env must not leak)", got)
}
}

func TestEngineCommandArgs(t *testing.T) {
t.Run("copilot", func(t *testing.T) {
t.Setenv("GITHUB_WORKSPACE", "/workspace/repo")
Expand Down
7 changes: 7 additions & 0 deletions specs/threat-detection-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ treats a missing verdict as a recoverable `parse_error` and proceeds.
| `WORKFLOW_NAME` | Name of the workflow being analyzed |
| `WORKFLOW_DESCRIPTION` | Description of the workflow |
| `CUSTOM_PROMPT` | Additional detection instructions |
| `THREAT_DETECTION_ENGINE` | Default engine when `--engine` is not specified (`copilot`, `claude`, `codex`) |
| `THREAT_DETECTION_COPILOT_MODEL` | Default model for the Copilot engine when `--model` is not specified |
| `THREAT_DETECTION_CLAUDE_MODEL` | Default model for the Claude engine when `--model` is not specified |
| `THREAT_DETECTION_CODEX_MODEL` | Default model for the Codex engine when `--model` is not specified |
| `THREAT_DETECTION_RETRIES` | Retries for malformed detection outputs (integer, default `1`) |

The `--engine` flag overrides `THREAT_DETECTION_ENGINE`. The `--model` flag overrides the per-engine model env var. Per-engine model env vars are engine-scoped: `THREAT_DETECTION_COPILOT_MODEL` is only consulted when the Copilot engine is selected, and so on.

**TD-23**: AI engine authentication variables MUST be treated as runtime-only configuration. They MUST NOT be required for parser, prompt building, unit test, or binary smoke test execution.

Expand Down
Loading