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
28 changes: 14 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.26'

Expand Down Expand Up @@ -52,7 +52,7 @@ jobs:
CLAUDECODE: ""

- name: Upload coverage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage
path: coverage.out
Expand All @@ -62,15 +62,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.26'

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.12.2

Expand All @@ -84,10 +84,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Run govulncheck
uses: golang/govulncheck-action@v1
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
with:
go-version-input: '1.26'
go-package: ./...
Expand All @@ -97,10 +97,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.26'

Expand All @@ -110,7 +110,7 @@ jobs:
go build -o vxd-improve ./cmd/vxd-improve

- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vxd-linux
path: vxd
Expand All @@ -122,17 +122,17 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0

- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.26'

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
version: '~> v2'
args: release --clean
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ dashboard:
| `vxd metrics` | Success rates, timing, escalations, SLA breaches per requirement |
| `vxd estimate "req"` | Cost estimation with `--quick`, `--json`, `--rate` |
| `vxd report <req-id>` | Client delivery report (`--html`, `--internal`) |
| `vxd preflight` | Run 15 pre-flight checks before dispatch |
| `vxd preflight` | Run 16 pre-flight checks before dispatch |
| `vxd approve <story-id>` | Approve a story PR for merge (`--all <req-id>` for batch) |
| `vxd approve-plan` | Approve story plan before dispatch |
| `vxd reject-plan` | Reject a plan with feedback |
Expand Down Expand Up @@ -451,7 +451,7 @@ A self-upskilling security agent embedded in vxd's core so every build is review
- **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.**
- **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 scanners installed (gosec, govulncheck, gitleaks, semgrep); the `security_scanners` preflight check (`CheckSecurityScanners`, WARNING tier) reports any that go missing, with install hints from `security.InstallHint`. **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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ vhs docs/demo.tape
| `vxd dashboard status` | Show whether the always-on dashboard daemon is running (PID, port, URL). |
| `vxd dashboard stop` | SIGTERM the always-on dashboard daemon and remove its pidfile (idempotent). |
| `vxd watch [req-id]` | Terminal-friendly always-on status: tails events for one requirement (defaults to the newest in the current repo) until terminal status or Ctrl+C. |
| `vxd preflight` | Run pre-flight environment checks (15 checks, 3 severity tiers) |
| `vxd preflight` | Run pre-flight environment checks (16 checks, 3 severity tiers) |
| `vxd estimate <requirement>` | Estimate cost (`--quick`, `--json`, `--rate`, `--save`) |
| `vxd report <req-id>` | Generate client delivery report (`--html`, `--internal`, `--output`) |
| `vxd metrics [--req ID]` | Show pipeline performance metrics with agent activity stats |
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package agent

import (
"bytes"
"text/template"
"text/template" // nosemgrep: go.lang.security.audit.xss.import-text-template.import-text-template -- renders agent prompts (plain text), never HTML
)

// TemplateContext holds all data available to prompt templates.
Expand Down
2 changes: 1 addition & 1 deletion internal/autoresearch/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func autoCommit(worktree, branch string) {
}

func runIn(dir string, args ...string) (string, error) {
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command(args[0], args[1:]...) // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command -- args are fixed git/tooling argv built by this package, not user input
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
Expand Down
7 changes: 4 additions & 3 deletions internal/autoresearch/sampler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"encoding/binary"
"log"
"math"
"math/rand"
"math/rand" // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used -- statistical sampling only; seeded from crypto/rand
"sync"
)

Expand Down Expand Up @@ -52,7 +52,7 @@ func NewBayesSampler(classes []ExperimentClass, priorAlpha, priorBeta float64) *
classes: append([]ExperimentClass(nil), classes...),
priorAlpha: priorAlpha,
priorBeta: priorBeta,
rng: rand.New(rand.NewSource(secureSeed())),
rng: rand.New(rand.NewSource(secureSeed())), // #nosec G404 -- Thompson sampling needs statistical, not cryptographic, randomness; the seed itself comes from crypto/rand (secureSeed)
}
}

Expand All @@ -73,14 +73,15 @@ func secureSeed() int64 {
log.Printf("[autoresearch] CRITICAL: crypto/rand.Read failed: %v — falling back to deterministic seed; Thompson sampling will be predictable for this process", err)
return 1
}
// #nosec G115 -- b is 8 random bytes; the uint64→int64 wraparound is harmless because only the bit pattern matters for a seed.
return int64(binary.LittleEndian.Uint64(b[:]))
}

// SetSeed makes Thompson sampling deterministic for tests.
func (s *BayesSampler) SetSeed(seed int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.rng = rand.New(rand.NewSource(seed))
s.rng = rand.New(rand.NewSource(seed)) // #nosec G404 -- deterministic test seeding by design
}

// Classes returns the class set this sampler covers.
Expand Down
1 change: 1 addition & 0 deletions internal/cli/autoresearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ func lookPath(name string) (string, error) {
continue
}
full := filepath.Join(dir, name)
// #nosec G703 -- PATH lookup mirrors exec.LookPath; dirs come from the operator's own environment
if fi, err := os.Stat(full); err == nil && !fi.IsDir() {
return full, nil
}
Expand Down
98 changes: 98 additions & 0 deletions internal/cli/dashboard_daemon_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cli

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

func TestDashboardStatus_NotRunning(t *testing.T) {
pidfile := filepath.Join(t.TempDir(), "dashboard.pid") // never created
cmd := newDashboardStatusCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"--pidfile", pidfile})
if err := cmd.Execute(); err != nil {
t.Fatalf("status must not error when daemon absent: %v", err)
}
if !strings.Contains(out.String(), "not running") {
t.Errorf("expected 'not running', got:\n%s", out.String())
}
if !strings.Contains(out.String(), pidfile) {
t.Errorf("status should print the pidfile path it checked:\n%s", out.String())
}
}

func TestDashboardStop_NoPidfile(t *testing.T) {
pidfile := filepath.Join(t.TempDir(), "dashboard.pid")
cmd := newDashboardStopCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"--pidfile", pidfile})
if err := cmd.Execute(); err != nil {
t.Fatalf("stop must be idempotent with no pidfile: %v", err)
}
if !strings.Contains(out.String(), "not running") {
t.Errorf("expected 'not running' note, got:\n%s", out.String())
}
}

func TestDashboardStop_MalformedPidfile(t *testing.T) {
pidfile := filepath.Join(t.TempDir(), "dashboard.pid")
if err := os.WriteFile(pidfile, []byte("not-a-pid"), 0o600); err != nil {
t.Fatal(err)
}
cmd := newDashboardStopCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"--pidfile", pidfile})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "malformed pidfile") {
t.Fatalf("expected malformed-pidfile error, got %v", err)
}
}

func TestDashboardStop_StalePid(t *testing.T) {
pidfile := filepath.Join(t.TempDir(), "dashboard.pid")
// PID far above pid_max on macOS/Linux defaults — signal gets ESRCH.
if err := os.WriteFile(pidfile, []byte("99999999"), 0o600); err != nil {
t.Fatal(err)
}
cmd := newDashboardStopCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"--pidfile", pidfile})
if err := cmd.Execute(); err != nil {
t.Fatalf("stop on stale pid must be idempotent: %v", err)
}
if _, err := os.Stat(pidfile); !os.IsNotExist(err) {
t.Error("stale pidfile must be removed")
}
}

func TestDefaultDashboardPidfile_UnderHome(t *testing.T) {
got := defaultDashboardPidfile()
if !strings.HasSuffix(got, filepath.Join(".vxd", "dashboard.pid")) &&
!strings.HasSuffix(got, "vxd-dashboard.pid") {
t.Errorf("unexpected pidfile location %q", got)
}
}

func TestPidStarted_BestEffort(t *testing.T) {
// On hosts without /proc (macOS) this returns zero time and nil error; on
// Linux it returns a real time. Either way it must never return an error.
ts, err := pidStarted(os.Getpid())
if err != nil {
t.Fatalf("pidStarted must be best-effort, got error: %v", err)
}
_ = ts
}

func TestRunWatch_UnknownRequirement(t *testing.T) {
cmd := newWatchCmd()
driveWithVxdYaml(t, cmd, "does-not-exist")
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "get requirement") {
t.Fatalf("expected get-requirement error for unknown req, got %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/cli/req.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The requirement text can be provided as:
func forkReqDaemon(self, reqID, logPath string, extraArgs []string) *exec.Cmd {
// Build the child argv: vxd resume <reqID> [extraArgs...]
argv := append([]string{"resume", reqID}, extraArgs...)
cmd := exec.Command(self, argv...)
cmd := exec.Command(self, argv...) // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command -- re-execs vxd's own binary (os.Executable) to self-daemonize

// Detach from the current process group (platform-specific: Setsid on
// Unix, CREATE_NEW_PROCESS_GROUP on Windows).
Expand Down
Loading
Loading