Skip to content
Merged
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
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ Tier 4: Pause (human intervention required)
- `STORY_SPLIT` — tech lead decomposed into child stories
- `STORY_SLA_BREACHED` — story exceeded per-complexity duration limit (configurable via `sla.max_minutes_per_complexity`)
- `REQ_BLOCKED` — completion gate could not get the composed mainline green after its auto-fix budget; requirement status → `blocked` instead of `completed` (resume with `--godmode` after addressing `.vxd-fix-gaps.md`)
- `STORY_SECURITY_PASSED` / `STORY_SECURITY_FAILED` — per-story security gate result; a FAILED gate pauses the requirement (human decision) rather than escalating
- `SECURITY_SCAN_COMPLETED` — a standalone `vxd security scan` finished (findings count, max severity)
- `SECURITY_RULE_LEARNED` — the security agent added a new vulnerability class to the knowledge base from a confirmed finding (self-upskilling)

### Event Sourcing
- **Source of truth**: `events.jsonl` (append-only, fsync'd)
Expand Down Expand Up @@ -128,6 +131,11 @@ qa:
path: coverage.html
disable_completion_gate: false # default false = gate ON (verify composed mainline before REQ_COMPLETED)
completion_fix_cycles: 2 # auto-fix attempts vs a red mainline before REQ_BLOCKED (0→2, negative→hard gate)
security:
disable_gate: false # default false = per-story security gate ON
gate_severity: critical # build-pausing threshold (critical|high|medium|low); critical = pause only on secrets/confirmed injection
auto_learn: true # grow the knowledge base from confirmed high+ findings
kb_path: "" # default <state_dir>/security/knowledge.json
billing:
default_rate: 150.0
currency: USD
Expand Down Expand Up @@ -187,6 +195,8 @@ dashboard:
| `vxd opportunity sources` | Show discovered sources pending approval |
| `vxd opportunity approve-source <url>` | Approve a discovered source for active scraping |
| `vxd learn [path]` | Run repo analysis (`--force`, `--pass 1\|2\|3`, `--json`) |
| `vxd security scan [path]` | Run the security agent on a repo (scanners + optional `--llm` review); `--json`, `--min <severity>` for CI exit code; auto-grows the knowledge base |
| `vxd security kb` | Show the security knowledge base — version, baseline + learned rules (`--json`) |
| `vxd backup` | Create tar.gz archive of project state (`--output DIR`) |
| `vxd gc` | Garbage-collect branches + expired logs |
| `vxd improve log` | Browse improvement changelog (`--disposition`, `--category`, `--since`, `--errors`) |
Expand Down Expand Up @@ -431,6 +441,18 @@ Closes the long-standing caveat: **vxd reported `REQ_COMPLETED` on code that did
- **Config:** `qa.disable_completion_gate` (default false = ON), `qa.completion_fix_cycles` (0→2, negative→hard gate; `completionFixCycles` in resume.go pins the mapping).
- **Tests:** `completion_gate_test.go` (green-first→no-fix, red→green→auto-fix once, stays-red→block after maxCycles, nil-client→hard-gate, writes gaps file, `emitRequirementOutcome`→`blocked` status against real stores via injectable `verify`/`pull` seams), `projection_test.go::TestProject_ReqBlocked`, `resume_helpers_test.go::TestCompletionFixCycles`, `resume_wiring_test.go::TestResume_WiresCompletionGate`. **NXD port pending.**

### Security agent (internal/security + engine/security_gate.go, 2026-06-26)
A self-upskilling security agent embedded in vxd's core so every build is reviewed for vulnerabilities and every future build inherits what past ones taught it.
- **`internal/security/`** — the agent's brains, LLM-free and fully unit-tested:
- `knowledge.go` `KnowledgeBase`: a versioned, JSON-persisted rule set seeded with the **OWASP Top 10 (2021)** + high-value CWEs (798 secrets, 22 path traversal, 79 XSS), each with detection + remediation guidance. `Add` is immutable, version-bumping, dedup-by-ID; `Covers(id)` matches a rule ID **or** its CWE (so an OWASP-indexed class isn't re-learned); `Checklist(langs)` renders markdown for prompts. This is the upskilling store at `<state_dir>/security/knowledge.json`.
- `scanners.go` orchestrates real SAST/secret/dep tools — **gosec, govulncheck, gitleaks, semgrep, npm audit** — with language-aware applicability + PATH detection (graceful degrade; a missing tool is *listed as skipped*, never silently dropped). Pure parsers per tool turn real output into `Finding`s — no hallucinated vulns. `RunScanners` is the orchestration entrypoint.
- `languages.go` manifest+extension language detection; `report.go` severity tally + markdown; `severity.go`/`finding.go` ranking + dedup.
- **`engine/security_gate.go`** `SecurityGate` — two entry points: `ScanRepo` (standalone whole-repo, `vxd security scan`) and `ReviewStory` (per-story pre-merge, wired in `monitor_post_execution.go` after QA, before merge). Combines deterministic scanners with an LLM threat-model review (whole-repo prose review, or inline-diff review for stories) against the KB checklist. A finding ≥ `security.gate_severity` (default high) **pauses** the requirement (human decision) rather than escalating — security needs judgment, not a tier-burning retry. A scanner failure never blocks merge.
- **Self-upskilling:** confirmed high+ findings whose vuln CLASS (CWE → OWASP category → tool rule) isn't already `Covers`ed are added as `learned` rules, persisted, and announced via `SECURITY_RULE_LEARNED`. The grown KB is what the gate applies on the next build — and what `vxd security kb` shows.
- **Forward-embedded in core:** the planner's ENGINEERING STANDARDS block now spells out the OWASP Top 10 so every planned story is *designed* secure; the per-story gate enforces the *live* KB at merge; `resume.go` wires both (`TestResume_WiresSecurityGate`). Skipped in dry-run and when `security.disable_gate`.
- **Config:** `security.disable_gate` (default false=ON), `security.gate_severity`, `security.auto_learn` (default true), `security.kb_path`. **Events:** `STORY_SECURITY_PASSED/FAILED`, `SECURITY_SCAN_COMPLETED`, `SECURITY_RULE_LEARNED` (all in the projection switch; `TestProject_AllDeclaredEventsHandled` guards exhaustiveness).
- **Tests:** `internal/security/*_test.go` (16: KB roundtrip/immutability/lang-filter/checklist/Covers, scanner applicability, all 5 parsers, report) + `engine/security_gate_test.go` (7: scan aggregation+event, block-on-critical, pass-below-threshold, self-upskill on new class, no-relearn known class, LLM-findings parse). **Host scanner install + NXD port pending.**

### Model ID Compatibility
- **Use undated aliases, not dated snapshots.** Current defaults: `claude-opus-4-8` (tech_lead), `claude-sonnet-4-6` (senior/qa/manager), `claude-haiku-4-5` (cheapest). All three are verified working on the Claude CLI subscription tier.
- **Default execution tiers are all-Anthropic (2026-06-24 fix).** `DefaultConfig` previously set junior/intermediate/supervisor to `{google, gemma-4-27b-it}` — a model that 404s on the Google AI API (it does not exist on `v1beta`). Every low-complexity story spawned a gemini agent that died in ~10s producing no code, then limped forward by escalating to senior. Defaults are now `{anthropic, claude-haiku-4-5}` so a fresh install works with only the Claude CLI configured (no Google AI key/quota). `TestDefaultConfig_NoInvalidJuniorModel` pins this. **A model 404 in the agent runtime surfaces as "agent produced no code changes," NOT as a model error — if a whole tier silently produces nothing, validate the model ID with `gemini -m <id> -p OK` / `claude --model <id> -p OK` first.**
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ Run `vxd init` to generate `vxd.yaml` with sensible defaults, then customize:
| `runtimes` | Map of named CLI runtime definitions — command, args, supported models, and idle/permission detection patterns | Includes built-in entries for `claude-code`, `codex`, `gemini`, `swe-agent`; each supports optional `runner: docker\|ssh` |
| `billing` | Hourly consulting rate, currency, Fibonacci-to-hours range mapping, and LLM cost accounting mode | `default_rate: 150.0`, `currency: USD`, `llm_costs.mode: subscription` |
| `qa` | Declarative success criteria evaluated after each story (output_contains, file_exists, file_contains, exit_code_zero, etc.); `disable_pre_merge_verify` (turn off the per-story pre-merge build/test gate); and the requirement-completion gate — `disable_completion_gate` (turn off) + `completion_fix_cycles` (auto-fix attempts against a red composed mainline before blocking; `0`→default 2, negative→hard gate). The completion gate verifies the merged mainline and emits `REQ_BLOCKED` instead of `REQ_COMPLETED` when it cannot make the build/tests green. | No criteria by default; standard lint/build/test always run; `disable_pre_merge_verify: false`, `disable_completion_gate: false`, `completion_fix_cycles: 2` |
| `security` | Security agent: per-story pre-merge security gate (scanners + LLM threat-model review against a growable OWASP/CWE knowledge base). `disable_gate` (turn the gate off), `gate_severity` (build-pausing block threshold — default `critical` so only leaked secrets/confirmed injection pause a build; tighten to `high` for stricter gating), `auto_learn` (grow the KB from confirmed findings), `kb_path` (KB location). Standalone audits via `vxd security scan` report high/medium too. | `disable_gate: false`, `gate_severity: critical`, `auto_learn: true`, `kb_path: <state_dir>/security/knowledge.json` |
| `sla` | Per-Fibonacci-point maximum story duration in minutes; `auto_escalate` promotes breached stories to the next tier | `1pt→60m`, `2pt→120m`, `3pt→240m`, `5pt→480m`, `8pt→960m`, `13pt→1920m`; `auto_escalate: false` |
| `secrets` | Secrets provider: `env` (default, reads from environment) or `vault` (HashiCorp Vault KV v2) | `provider: env`; Vault settings: `vault_mount: secret`, `vault_path: vxd` |
| `notify` | Outbound Slack webhook URL and per-event triggers (`notify_on_sla`, `notify_on_complete`) | Disabled by default (empty `slack_webhook_url`) |
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/tzone85/vortex-dispatch/internal/repolearn"
"github.com/tzone85/vortex-dispatch/internal/runtime"
"github.com/tzone85/vortex-dispatch/internal/scratchboard"
"github.com/tzone85/vortex-dispatch/internal/security"
"github.com/tzone85/vortex-dispatch/internal/state"
"github.com/tzone85/vortex-dispatch/internal/tmux"
)
Expand Down Expand Up @@ -514,6 +515,21 @@ func runResume(cmd *cobra.Command, args []string) error {
log.Printf("[resume] completion gate enabled (auto-fix cycles=%d)", fixCycles)
}

// Enable the per-story security gate: after QA and before merge, run the
// security agent (scanners + LLM threat-model review against the growable
// knowledge base) on each story and pause the requirement when a finding
// meets the gate severity. Skipped in dry-run and when disabled via
// security.disable_gate.
if !dryRun && !s.Config.Security.DisableGate {
gateSev := security.ParseSeverity(s.Config.Security.GateSeverity)
senior := s.Config.Models.Senior
monitor.SetSecurityGate(engine.NewSecurityGate(
llmClient, senior.Model, senior.MaxTokens, securityKBPath(s.Config),
gateSev, s.Config.Security.AutoLearn, s.Events, s.Proj,
))
log.Printf("[resume] security gate enabled (block at %s+, auto-learn=%v)", gateSev, s.Config.Security.AutoLearn)
}

rc := &engine.RunContext{
ReqID: reqID,
PlannedStories: plannedStories,
Expand Down
17 changes: 17 additions & 0 deletions internal/cli/resume_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,20 @@ func TestResume_WiresCompletionGate(t *testing.T) {
}
}
}

// TestResume_WiresSecurityGate guards the per-story security gate against the
// dead-wire class: the gate scans + reviews each story before merge, but only if
// runResume constructs and attaches it.
func TestResume_WiresSecurityGate(t *testing.T) {
src, err := os.ReadFile("resume.go")
if err != nil {
t.Fatalf("read resume.go: %v", err)
}
code := string(src)

for _, want := range []string{"NewSecurityGate(", "SetSecurityGate("} {
if !strings.Contains(code, want) {
t.Errorf("resume.go must wire the security gate: missing %q", want)
}
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func init() {
rootCmd.AddCommand(newRejectCmd())
rootCmd.AddCommand(newRetryCmd())
rootCmd.AddCommand(newLearnCmd())
rootCmd.AddCommand(newSecurityCmd())
rootCmd.AddCommand(newBackupCmd())
rootCmd.AddCommand(newImproveCmd())
rootCmd.AddCommand(newAutoresearchCmd())
Expand Down
203 changes: 203 additions & 0 deletions internal/cli/security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package cli

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
"github.com/tzone85/vortex-dispatch/internal/config"
"github.com/tzone85/vortex-dispatch/internal/engine"
"github.com/tzone85/vortex-dispatch/internal/llm"
"github.com/tzone85/vortex-dispatch/internal/security"
"github.com/tzone85/vortex-dispatch/internal/state"
)

func newSecurityCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "security",
Short: "Security agent: scan repositories and inspect the knowledge base",
Long: `The security agent combines deterministic scanners (gosec, govulncheck,
gitleaks, semgrep, npm audit) with an optional LLM threat-model review driven by
a growable knowledge base (OWASP Top 10 + CWE baseline that learns new
vulnerability classes from confirmed findings).`,
SilenceUsage: true,
}
cmd.AddCommand(newSecurityScanCmd())
cmd.AddCommand(newSecurityKBCmd())
return cmd
}

func newSecurityScanCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "scan [repo-path]",
Short: "Run a security scan on a repository",
Long: `Scans a repository with every applicable, installed scanner and (optionally)
an LLM threat-model review. Findings are reported by severity; applicable
scanners that are not installed are listed so coverage gaps are never silent.

Exit code is non-zero when a finding meets or exceeds --min (default: high),
so the command is CI-friendly.`,
Args: cobra.MaximumNArgs(1),
RunE: runSecurityScan,
}
cmd.Flags().Bool("json", false, "Output the report as JSON")
cmd.Flags().Bool("llm", false, "Add an LLM threat-model review (requires a configured model; reads source files)")
cmd.Flags().String("min", "high", "Severity that makes the command exit non-zero: critical|high|medium|low")
cmd.SilenceUsage = true
return cmd
}

func runSecurityScan(cmd *cobra.Command, args []string) error {
jsonOut, _ := cmd.Flags().GetBool("json")
useLLM, _ := cmd.Flags().GetBool("llm")
minStr, _ := cmd.Flags().GetString("min")

repoPath, err := resolveScanPath(args)
if err != nil {
return err
}

cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := loadConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}

kbPath := securityKBPath(cfg)

// Event/projection stores so scans are auditable. Use an in-memory
// projection (scan results are informational; the event log is the record).
es, err := state.NewFileStore(filepath.Join(expandHome(cfg.Workspace.StateDir), "events.jsonl"))
if err != nil {
return fmt.Errorf("open event store: %w", err)
}
defer func() { _ = es.Close() }()
ps, err := state.NewSQLiteStore(":memory:")
if err != nil {
return fmt.Errorf("open projection store: %w", err)
}
defer func() { _ = ps.Close() }()

// LLM review is opt-in: it needs file access, so it runs godmode (skip
// permission prompts) to stay non-interactive. Default is scanners-only
// (nil client ⇒ the gate runs deterministic scanners only).
var llmClient llm.Client
model := cfg.Models.Senior.Model
maxTokens := cfg.Models.Senior.MaxTokens
if useLLM {
built, buildErr := buildLLMClient(cfg.Models.Senior.Provider, nil, true)
if buildErr != nil {
fmt.Fprintf(cmd.OutOrStdout(), "warning: LLM review unavailable (%v) — running scanners only\n", buildErr)
} else {
llmClient = built
}
}

gate := engine.NewSecurityGate(
llmClient, model, maxTokens, kbPath,
security.ParseSeverity(cfg.Security.GateSeverity),
cfg.Security.AutoLearn, es, ps,
)

report, err := gate.ScanRepo(context.Background(), repoPath)
if err != nil {
return fmt.Errorf("scan: %w", err)
}

if jsonOut {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return err
}
} else {
fmt.Fprintln(cmd.OutOrStdout(), report.FormatMarkdown())
}

// CI-friendly exit code.
min := security.ParseSeverity(minStr)
if report.HasAtLeast(min) {
return fmt.Errorf("security scan found %s+ findings", min)
}
return nil
}

func newSecurityKBCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "kb",
Short: "Show the security knowledge base (version, rules, learned classes)",
Args: cobra.NoArgs,
RunE: runSecurityKB,
}
cmd.Flags().Bool("json", false, "Output the knowledge base as JSON")
cmd.SilenceUsage = true
return cmd
}

func runSecurityKB(cmd *cobra.Command, args []string) error {
cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := loadConfig(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
kb, err := security.LoadKnowledgeBase(securityKBPath(cfg))
if err != nil {
return fmt.Errorf("load knowledge base: %w", err)
}
jsonOut, _ := cmd.Flags().GetBool("json")
if jsonOut {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(kb)
}
out := cmd.OutOrStdout()
baseline, learned := 0, 0
for _, r := range kb.Rules {
if r.Source == security.RuleLearned {
learned++
} else {
baseline++
}
}
fmt.Fprintf(out, "Security knowledge base v%d — %d rules (%d baseline, %d learned)\n\n",
kb.Version, len(kb.Rules), baseline, learned)
for _, r := range kb.Rules {
marker := " "
if r.Source == security.RuleLearned {
marker = "+"
}
fmt.Fprintf(out, " %s [%s] %s — %s\n", marker, r.ID, r.Title, r.Severity)
}
return nil
}

// resolveScanPath resolves the target repo to an absolute path (cwd default).
func resolveScanPath(args []string) (string, error) {
p := ""
if len(args) > 0 {
p = args[0]
} else {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
p = cwd
}
abs, err := filepath.Abs(p)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
return abs, nil
}

// securityKBPath resolves where the knowledge base persists: the configured path
// or <state_dir>/security/knowledge.json.
func securityKBPath(cfg config.Config) string {
if cfg.Security.KBPath != "" {
return expandHome(cfg.Security.KBPath)
}
return filepath.Join(expandHome(cfg.Workspace.StateDir), "security", "knowledge.json")
}
Loading
Loading