From d77b5296918ba3c68306be010a551ef565ef3ebc Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Wed, 1 Jul 2026 20:45:57 -0700 Subject: [PATCH 1/7] examples: add pkgxray-guard supply-chain hook (#1) Guards AI-agent package installs with pkgxray before they run. Registers OnBeforeExecution (parse npm/pnpm/yarn/bun installs + npx/bunx/pnpm-dlx runners + `claude mcp add -- `, run `pkgxray guard` per package, deny on BLOCK with cited evidence) and an opt-in OnAfterFileEdit lockfile audit. Policy layer (strict/balanced/permissive) fails closed on a broken pkgxray under the default balanced policy. Folded into the root module (no separate go.mod) so `go build/test ./...` covers it, matching the multi-hook example. Core lives in a stdlib-only pkgxrayguard package with offline table + fake-pkgxray exec tests. Co-authored-by: Claude Opus 4.8 (1M context) --- examples/pkgxray-guard/README.md | 142 ++++++++++ .../configs/claude-settings.json | 10 + .../pkgxray-guard/configs/codex-hooks.json | 7 + .../pkgxray-guard/configs/cursor-hooks.json | 7 + examples/pkgxray-guard/helpers.go | 43 +++ examples/pkgxray-guard/main.go | 157 +++++++++++ examples/pkgxray-guard/pkgxrayguard/guard.go | 173 ++++++++++++ .../pkgxray-guard/pkgxrayguard/guard_test.go | 131 +++++++++ examples/pkgxray-guard/pkgxrayguard/parse.go | 248 ++++++++++++++++++ .../pkgxray-guard/pkgxrayguard/parse_test.go | 69 +++++ examples/pkgxray-guard/pkgxrayguard/policy.go | 61 +++++ 11 files changed, 1048 insertions(+) create mode 100644 examples/pkgxray-guard/README.md create mode 100644 examples/pkgxray-guard/configs/claude-settings.json create mode 100644 examples/pkgxray-guard/configs/codex-hooks.json create mode 100644 examples/pkgxray-guard/configs/cursor-hooks.json create mode 100644 examples/pkgxray-guard/helpers.go create mode 100644 examples/pkgxray-guard/main.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/guard.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/guard_test.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/parse.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/parse_test.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/policy.go diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md new file mode 100644 index 0000000..f94611f --- /dev/null +++ b/examples/pkgxray-guard/README.md @@ -0,0 +1,142 @@ +# pkgxray × hookshot — guard installs before they run + +A [hookshot](https://github.com/CorridorSecurity/hookshot) hook binary that runs +[pkgxray](https://github.com/adamsjack711-ux/pkgxray) supply-chain triage on any +package an AI coding agent tries to install — **before a single line of it runs** +— and denies the command on a `BLOCK` verdict, with pkgxray's cited evidence +handed back to the agent. + +hookshot supplies the cross-agent hook surface (Claude Code, Cursor, Windsurf +Cascade, Factory Droid, OpenAI Codex); pkgxray supplies the detection engine +(OSV vuln pre-check, sandboxed quarantine, static heuristics, prompt-injection +and obfuscation detection, GitHub provenance cross-check). This directory is the +glue. + +``` +agent runs: npm install left-pad evil-pkg@1.2.3 + │ + OnBeforeExecution (hookshot) + │ parse install targets + ▼ + pkgxray guard npm:evil-pkg@1.2.3 --format json + │ SAFE / REVIEW / BLOCK (+ cited findings) + ▼ + BLOCK → DenyExecution("pkgxray blocked …: credential-access …") +``` + +## What it does + +- **`OnBeforeExecution`** — parses the agent's shell command for package + installs and runs `pkgxray guard` on each one: + - `npm|pnpm|yarn|bun install|i|add ` (incl. `yarn global add`) + - `npx` / `bunx` / `pnpm dlx` / `bun x` runners + - `claude mcp add -- ` (audits the launcher's package) + - Local paths (`./x`, `file:`), VCS URLs, and bare `npm ci`/`npm install` + are skipped — registry triage doesn't apply to them. +- **`OnAfterFileEdit`** *(opt-in)* — when the agent edits `package.json` or a + lockfile, runs `pkgxray audit` on it and feeds the verdict back as agent + context (or a block on Claude for a `BLOCK`). + +The worst verdict across a multi-package command wins. + +## Install + +```bash +# 1. Build the hook binary (from inside the hookshot fork). +cd examples/pkgxray-guard +go build -o pkgxray-guard . + +# 2. Make sure pkgxray is on PATH (or point PKGXRAY_BIN at it). +npm install -g pkgxray # or: export PKGXRAY_BIN=/path/to/pkgxray + +# 3. Wire it into your agent(s). Either use hookshot's installer… +hookshot install --binary ./pkgxray-guard +# …or copy a config from ./configs/ into your agent's settings and set the +# absolute path to the built binary (see configs/claude-settings.json etc.). +``` + +> This example is part of the hookshot root module (no separate `go.mod`), so it +> builds and tests with the rest of the repo via `go build ./...` — no `go get` +> or `replace` needed. + +## Configuration + +All via environment variables (the hook reads them at startup): + +| Variable | Default | Meaning | +|---|---|---| +| `PKGXRAY_BIN` | `pkgxray` | Path to the pkgxray CLI. | +| `PKGXRAY_HOOK_POLICY` | `balanced` | `strict` \| `balanced` \| `permissive` (see below). | +| `PKGXRAY_HOOK_DISABLE` | — | `1` bypasses all checks (fail-open kill switch). | +| `PKGXRAY_HOOK_AUDIT_LOCKFILES` | — | `1` enables the `OnAfterFileEdit` lockfile audit. | +| `PKGXRAY_GUARD_ARGS` | — | Extra flags passed to `pkgxray guard`, e.g. `--no-github-diff`. | + +### Policies + +| Verdict | `strict` | `balanced` (default) | `permissive` | +|---|---|---|---| +| `BLOCK` | deny | deny | deny | +| `REVIEW` | deny | **ask** | allow | +| `UNKNOWN` (pkgxray failed to run) | deny | deny | allow | +| `SAFE` | allow | allow | allow | + +`balanced` never fails open on a broken pkgxray: if the CLI is missing or +errors, the verdict is `UNKNOWN` and the install is denied. On OpenAI Codex, +hookshot rewrites an `ask` decision to a deny (Codex has no approval prompt), so +`REVIEW` under `balanced` blocks there too. + +## Layout + +``` +examples/pkgxray-guard/ +├── main.go hookshot handler registration + env config +├── helpers.go lockfile detection + pkgxray CLI runner +├── pkgxrayguard/ pure, stdlib-only, unit-tested core +│ ├── parse.go shell command → []InstallSpec +│ ├── guard.go run `pkgxray guard`, map verdict + reasons +│ ├── policy.go verdict × policy → allow/ask/deny +│ └── *_test.go table tests + fake-pkgxray exec tests (offline) +└── configs/ ready-to-edit hook configs per agent +``` + +The `pkgxrayguard` package has no third-party dependencies, so +`go test ./pkgxrayguard/...` runs without the hookshot module or a network. + +## Try it + +```bash +go test ./pkgxrayguard/... + +# Simulate a Claude PreToolUse event (deny path depends on the real package): +echo '{"tool_name":"Bash","tool_input":{"command":"npm install left-pad"}}' \ + | ./pkgxray-guard claude-pre-tool-use +``` + +## CI + +This example is part of the hookshot root module, so the repo's +[`.github/workflows/go.yml`](../../.github/workflows/go.yml) already builds and +tests it via `go build ./...` / `go test ./...` on every push and PR — including +the offline `pkgxrayguard` table tests. + +To gate a *consuming* repo's dependencies on pkgxray in CI, use the reusable +audit workflow published in the pkgxray repo: + +```yaml +jobs: + supply-chain: + uses: adamsjack711-ux/pkgxray/.github/workflows/pkgxray-audit.yml@main + with: + fail-on: block # or "review" to also fail on REVIEW verdicts +``` + +## Notes & limits + +- Only registry installs are triaged. Local/VCS installs are out of scope for + pre-install registry analysis and are allowed through. +- Command parsing is conservative: unusual shapes (deeply nested subshells, + variable-expanded package names) may not be recognized. Unrecognized → allowed + rather than wrongly blocked. Treat the hook as defense-in-depth, not a + complete sandbox. +- `pkgxray guard` reaches the network (registry/OSV/GitHub). Budget ~1s/package; + tune with `PKGXRAY_GUARD_ARGS` (e.g. `--no-github-diff --no-github`). diff --git a/examples/pkgxray-guard/configs/claude-settings.json b/examples/pkgxray-guard/configs/claude-settings.json new file mode 100644 index 0000000..7dc0bf7 --- /dev/null +++ b/examples/pkgxray-guard/configs/claude-settings.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "/absolute/path/to/pkgxray-guard claude-pre-tool-use" }] } + ], + "PostToolUse": [ + { "matcher": "Edit|Write|Bash", "hooks": [{ "type": "command", "command": "/absolute/path/to/pkgxray-guard claude-after-file-edit" }] } + ] + } +} diff --git a/examples/pkgxray-guard/configs/codex-hooks.json b/examples/pkgxray-guard/configs/codex-hooks.json new file mode 100644 index 0000000..4afba53 --- /dev/null +++ b/examples/pkgxray-guard/configs/codex-hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Bash|mcp__.*", "hooks": [{ "type": "command", "command": "/absolute/path/to/pkgxray-guard codex-pre-tool-use" }] } + ] + } +} diff --git a/examples/pkgxray-guard/configs/cursor-hooks.json b/examples/pkgxray-guard/configs/cursor-hooks.json new file mode 100644 index 0000000..494b802 --- /dev/null +++ b/examples/pkgxray-guard/configs/cursor-hooks.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "hooks": { + "beforeShellExecution": [{ "command": "/absolute/path/to/pkgxray-guard cursor-before-shell" }], + "afterFileEdit": [{ "command": "/absolute/path/to/pkgxray-guard cursor-after-file-edit" }] + } +} diff --git a/examples/pkgxray-guard/helpers.go b/examples/pkgxray-guard/helpers.go new file mode 100644 index 0000000..146be3d --- /dev/null +++ b/examples/pkgxray-guard/helpers.go @@ -0,0 +1,43 @@ +package main + +import ( + "errors" + "os/exec" + "path/filepath" + "strings" +) + +// dependencyManifests are the files whose edits warrant a re-audit. +var dependencyManifests = map[string]bool{ + "package.json": true, + "package-lock.json": true, + "yarn.lock": true, + "pnpm-lock.yaml": true, + "npm-shrinkwrap.json": true, +} + +func isDependencyManifest(filePath string) bool { + return dependencyManifests[filepath.Base(filePath)] +} + +// runCLI runs the pkgxray CLI and returns its combined output and exit code. +func runCLI(bin string, args ...string) (string, int) { + cmd := exec.Command(bin, args...) + out, err := cmd.CombinedOutput() + if err == nil { + return string(out), 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return string(out), exitErr.ExitCode() + } + return err.Error(), -1 +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return strings.TrimSpace(s[:i]) + } + return s +} diff --git a/examples/pkgxray-guard/main.go b/examples/pkgxray-guard/main.go new file mode 100644 index 0000000..a8d2b39 --- /dev/null +++ b/examples/pkgxray-guard/main.go @@ -0,0 +1,157 @@ +// Command pkgxray-guard is a hookshot hook binary that audits packages with +// pkgxray before an AI coding agent installs them. +// +// It registers two unified hookshot handlers so it works across Claude Code, +// Cursor, Windsurf Cascade, Factory Droid, and OpenAI Codex: +// +// - OnBeforeExecution: parses the agent's shell command for package installs +// (npm/pnpm/yarn/bun add|install, npx/bunx/pnpm-dlx runners, and +// `claude mcp add … -- `), runs `pkgxray guard` on each package, +// and denies the command on a BLOCK verdict — carrying pkgxray's cited +// evidence back to the agent as the deny reason. +// - OnAfterFileEdit: when the agent edits package.json or a lockfile, runs +// `pkgxray audit` on it and feeds the verdict back as context (opt-in). +// +// Configuration (environment variables): +// +// PKGXRAY_BIN path to the pkgxray CLI (default "pkgxray") +// PKGXRAY_HOOK_POLICY strict | balanced | permissive (default "balanced") +// PKGXRAY_HOOK_DISABLE set to "1" to bypass all checks (fail-open) +// PKGXRAY_HOOK_AUDIT_LOCKFILES set to "1" to enable the OnAfterFileEdit audit +// PKGXRAY_GUARD_ARGS extra space-separated flags for `pkgxray guard` +// +// Build: go build -o pkgxray-guard . +// Install: hookshot install --binary ./pkgxray-guard +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + "github.com/CorridorSecurity/hookshot" + "github.com/CorridorSecurity/hookshot/examples/pkgxray-guard/pkgxrayguard" +) + +func main() { + cfg := loadConfig() + + hookshot.OnBeforeExecution(func(ctx hookshot.ExecutionContext) hookshot.ExecutionDecision { + if cfg.disabled || ctx.Type != hookshot.ExecutionShell { + return hookshot.AllowExecution() + } + specs := pkgxrayguard.ParseInstalls(ctx.Command) + if len(specs) == 0 { + return hookshot.AllowExecution() + } + return decideInstalls(cfg, specs) + }) + + hookshot.OnAfterFileEdit(func(ctx hookshot.FileEditContext) hookshot.FileEditDecision { + if cfg.disabled || !cfg.auditLockfiles || !isDependencyManifest(ctx.FilePath) { + return hookshot.FileEditOK() + } + return auditManifest(cfg, ctx.FilePath) + }) + + hookshot.RunCommand() +} + +type config struct { + guard pkgxrayguard.Guard + policy pkgxrayguard.Policy + disabled bool + auditLockfiles bool +} + +func loadConfig() config { + bin := os.Getenv("PKGXRAY_BIN") + if bin == "" { + bin = "pkgxray" + } + var extra []string + if raw := strings.TrimSpace(os.Getenv("PKGXRAY_GUARD_ARGS")); raw != "" { + extra = strings.Fields(raw) + } + return config{ + guard: pkgxrayguard.Guard{Bin: bin, Timeout: 60 * time.Second, ExtraArgs: extra}, + policy: pkgxrayguard.ParsePolicy(os.Getenv("PKGXRAY_HOOK_POLICY")), + disabled: os.Getenv("PKGXRAY_HOOK_DISABLE") == "1", + auditLockfiles: os.Getenv("PKGXRAY_HOOK_AUDIT_LOCKFILES") == "1", + } +} + +// decideInstalls audits every package the command would install and folds the +// per-package results into one hook decision (worst verdict wins). +func decideInstalls(cfg config, specs []pkgxrayguard.InstallSpec) hookshot.ExecutionDecision { + ctx := context.Background() + results := make([]pkgxrayguard.Result, 0, len(specs)) + for _, spec := range specs { + results = append(results, cfg.guard.Check(ctx, spec)) + } + + worst := pkgxrayguard.Worst(results) + switch pkgxrayguard.Decide(cfg.policy, worst) { + case pkgxrayguard.Deny: + return hookshot.DenyExecution(denyMessage(results)) + case pkgxrayguard.Ask: + return hookshot.AskExecution(denyMessage(results)) + default: + return hookshot.AllowExecutionWithReason("pkgxray: no blocking supply-chain risk in " + joinRefs(specs)) + } +} + +// denyMessage renders the offending packages and pkgxray's cited evidence so +// the agent (and user) see why the install was stopped. +func denyMessage(results []pkgxrayguard.Result) string { + var b strings.Builder + b.WriteString("pkgxray blocked this install:") + for _, r := range results { + switch r.Verdict { + case pkgxrayguard.Block, pkgxrayguard.Review, pkgxrayguard.Unknown: + b.WriteString("\n • ") + b.WriteString(r.Spec.Ref) + b.WriteString(" → ") + b.WriteString(string(r.Verdict)) + if r.Summary != "" { + b.WriteString(" (" + r.Summary + ")") + } + if r.Err != nil { + b.WriteString(" [pkgxray error: " + r.Err.Error() + "]") + } + for _, reason := range r.Reasons { + b.WriteString("\n - ") + b.WriteString(reason) + } + } + } + b.WriteString("\nRe-run `pkgxray guard ` for the full report, or set PKGXRAY_HOOK_POLICY=permissive to override.") + return b.String() +} + +func joinRefs(specs []pkgxrayguard.InstallSpec) string { + refs := make([]string, len(specs)) + for i, s := range specs { + refs[i] = s.Ref + } + return strings.Join(refs, ", ") +} + +// auditManifest runs `pkgxray audit ` and reports the verdict back to +// the agent. Post-edit hooks can't undo the write, so a BLOCK becomes agent +// feedback (FileEditBlock, honored by Claude); anything else is added context. +func auditManifest(cfg config, filePath string) hookshot.FileEditDecision { + bin := cfg.guard.Bin + out, code := runCLI(bin, "audit", filePath) + summary := firstLine(out) + switch code { + case 2: + return hookshot.FileEditBlock("pkgxray flagged a dependency in " + filepath.Base(filePath) + ": " + summary) + case 3: + return hookshot.FileEditAddContext("pkgxray: review recommended for " + filepath.Base(filePath) + ": " + summary) + default: + return hookshot.FileEditOK() + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/guard.go b/examples/pkgxray-guard/pkgxrayguard/guard.go new file mode 100644 index 0000000..3187d3e --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/guard.go @@ -0,0 +1,173 @@ +package pkgxrayguard + +import ( + "context" + "encoding/json" + "errors" + "os/exec" + "strings" + "time" +) + +// Verdict is pkgxray's decision for a single package. +type Verdict string + +const ( + Safe Verdict = "safe" // pkgxray decision "allow"/"safe", exit 0 + Review Verdict = "review" // exit 3 — a human should look + Block Verdict = "block" // exit 2 — high-severity supply-chain risk + Unknown Verdict = "unknown" // pkgxray could not run / produced no verdict +) + +// Result is the outcome of auditing one InstallSpec. +type Result struct { + Spec InstallSpec + Verdict Verdict + Summary string // pkgxray's one-line verdict summary + Reasons []string // top high/medium findings, "[category] rationale" + Err error // set when pkgxray could not be run or parsed +} + +// Guard runs the pkgxray CLI to triage packages. +type Guard struct { + Bin string // pkgxray executable (default "pkgxray") + Timeout time.Duration // per-package timeout (default 60s) + ExtraArgs []string // extra guard flags, e.g. ["--no-github-diff"] +} + +// pkgxray guard --format json output (subset we consume). +type guardJSON struct { + Decision string `json:"decision"` // allow | review | block + Report struct { + Summary string `json:"summary"` + Findings []struct { + Severity string `json:"severity"` + Category string `json:"category"` + Rationale string `json:"rationale"` + } `json:"findings"` + } `json:"report"` +} + +// Check audits one package with `pkgxray guard --format json`. It derives +// the verdict from the JSON decision, falling back to the process exit code +// (2=block, 3=review, 0=safe) so a truncated/unparsable payload still fails in +// the correct direction. Any execution error yields Verdict=Unknown with Err +// set, leaving the fail-open/closed choice to the policy layer. +func (g Guard) Check(ctx context.Context, spec InstallSpec) Result { + bin := g.Bin + if bin == "" { + bin = "pkgxray" + } + timeout := g.Timeout + if timeout == 0 { + timeout = 60 * time.Second + } + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + args := append([]string{"guard", spec.Ref, "--format", "json"}, g.ExtraArgs...) + cmd := exec.CommandContext(cctx, bin, args...) + stdout, runErr := cmd.Output() + + exitCode := 0 + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + } else if runErr != nil { + // Binary missing, timeout, etc. — no verdict at all. + return Result{Spec: spec, Verdict: Unknown, Err: runErr} + } + + res := Result{Spec: spec} + var parsed guardJSON + if err := json.Unmarshal(stdout, &parsed); err == nil { + res.Verdict = verdictFromDecision(parsed.Decision) + res.Summary = strings.TrimSpace(parsed.Report.Summary) + res.Reasons = topReasons(parsed) + } + if res.Verdict == "" || res.Verdict == Unknown { + res.Verdict = verdictFromExit(exitCode) + } + if res.Verdict == Unknown && res.Err == nil { + res.Err = errors.New("pkgxray produced no verdict") + } + return res +} + +func verdictFromDecision(decision string) Verdict { + switch strings.ToLower(strings.TrimSpace(decision)) { + case "block": + return Block + case "review": + return Review + case "allow", "safe": + return Safe + default: + return Unknown + } +} + +func verdictFromExit(code int) Verdict { + switch code { + case 0: + return Safe + case 2: + return Block + case 3: + return Review + default: + return Unknown + } +} + +func topReasons(p guardJSON) []string { + var reasons []string + for _, f := range p.Report.Findings { + sev := strings.ToLower(f.Severity) + if sev != "high" && sev != "medium" { + continue + } + reason := f.Rationale + if reason == "" { + reason = f.Category + } + reasons = append(reasons, "["+f.Category+"] "+clip(reason, 160)) + if len(reasons) == 3 { + break + } + } + return reasons +} + +func clip(s string, n int) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + +// severity ranks verdicts so the worst one across a multi-package command wins. +func severity(v Verdict) int { + switch v { + case Block: + return 3 + case Unknown: + return 2 + case Review: + return 1 + default: // Safe + return 0 + } +} + +// Worst returns the highest-severity verdict among results. +func Worst(results []Result) Verdict { + worst := Safe + for _, r := range results { + if severity(r.Verdict) > severity(worst) { + worst = r.Verdict + } + } + return worst +} diff --git a/examples/pkgxray-guard/pkgxrayguard/guard_test.go b/examples/pkgxray-guard/pkgxrayguard/guard_test.go new file mode 100644 index 0000000..b4c1d7d --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/guard_test.go @@ -0,0 +1,131 @@ +package pkgxrayguard + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestDecide(t *testing.T) { + cases := []struct { + policy Policy + v Verdict + want Action + }{ + {Strict, Block, Deny}, + {Strict, Review, Deny}, + {Strict, Unknown, Deny}, + {Strict, Safe, Allow}, + {Balanced, Block, Deny}, + {Balanced, Review, Ask}, + {Balanced, Unknown, Deny}, + {Balanced, Safe, Allow}, + {Permissive, Block, Deny}, + {Permissive, Review, Allow}, + {Permissive, Unknown, Allow}, + {Permissive, Safe, Allow}, + } + for _, tc := range cases { + if got := Decide(tc.policy, tc.v); got != tc.want { + t.Errorf("Decide(%s, %s) = %s, want %s", tc.policy, tc.v, got, tc.want) + } + } +} + +func TestParsePolicyDefault(t *testing.T) { + if ParsePolicy("nonsense") != Balanced { + t.Fatal("unknown policy should default to Balanced") + } + if ParsePolicy("STRICT") != Strict { + t.Fatal("policy parse should be case-insensitive") + } +} + +func TestWorst(t *testing.T) { + results := []Result{{Verdict: Safe}, {Verdict: Review}, {Verdict: Block}, {Verdict: Safe}} + if got := Worst(results); got != Block { + t.Fatalf("Worst = %s, want block", got) + } + if got := Worst([]Result{{Verdict: Safe}, {Verdict: Review}}); got != Review { + t.Fatalf("Worst = %s, want review", got) + } +} + +// fakePkgxray writes a shell script that mimics `pkgxray guard … --format json`: +// it prints the given JSON to stdout and exits with the given code. +func fakePkgxray(t *testing.T, jsonOut string, exitCode int) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake shell script not supported on windows") + } + dir := t.TempDir() + p := filepath.Join(dir, "pkgxray") + script := "#!/bin/sh\ncat <<'EOF'\n" + jsonOut + "\nEOF\nexit " + itoa(exitCode) + "\n" + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + if neg { + b = append([]byte{'-'}, b...) + } + return string(b) +} + +func TestCheckBlock(t *testing.T) { + out := `{"decision":"block","report":{"summary":"1 high-severity finding","findings":[` + + `{"severity":"high","category":"credential-access","rationale":"reads ~/.aws/credentials near a network sink"},` + + `{"severity":"info","category":"noise","rationale":"ignore me"}]}}` + g := Guard{Bin: fakePkgxray(t, out, 2)} + res := g.Check(context.Background(), InstallSpec{Ref: "npm:evil"}) + if res.Verdict != Block { + t.Fatalf("verdict = %s, want block", res.Verdict) + } + if len(res.Reasons) != 1 || res.Reasons[0] != "[credential-access] reads ~/.aws/credentials near a network sink" { + t.Fatalf("reasons = %v", res.Reasons) + } + if res.Summary != "1 high-severity finding" { + t.Fatalf("summary = %q", res.Summary) + } +} + +func TestCheckSafe(t *testing.T) { + g := Guard{Bin: fakePkgxray(t, `{"decision":"allow","report":{"summary":"no risk","findings":[]}}`, 0)} + res := g.Check(context.Background(), InstallSpec{Ref: "npm:lodash"}) + if res.Verdict != Safe { + t.Fatalf("verdict = %s, want safe", res.Verdict) + } +} + +func TestCheckExitCodeFallback(t *testing.T) { + // Unparseable stdout but exit code 3 → review via exit-code fallback. + g := Guard{Bin: fakePkgxray(t, "not json", 3)} + res := g.Check(context.Background(), InstallSpec{Ref: "npm:x"}) + if res.Verdict != Review { + t.Fatalf("verdict = %s, want review", res.Verdict) + } +} + +func TestCheckMissingBinary(t *testing.T) { + g := Guard{Bin: "/nonexistent/pkgxray-binary-xyz"} + res := g.Check(context.Background(), InstallSpec{Ref: "npm:x"}) + if res.Verdict != Unknown || res.Err == nil { + t.Fatalf("verdict = %s err = %v, want unknown + error", res.Verdict, res.Err) + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/parse.go b/examples/pkgxray-guard/pkgxrayguard/parse.go new file mode 100644 index 0000000..84e3ee3 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/parse.go @@ -0,0 +1,248 @@ +// Package pkgxrayguard turns an AI agent's shell command into a set of package +// references and asks pkgxray whether each is safe to install. +// +// It has no third-party dependencies (stdlib only) so it can be unit-tested +// without the hookshot module or a network connection. The hookshot wiring +// lives in the parent main package. +package pkgxrayguard + +import ( + "path" + "strings" +) + +// InstallSpec is a single package an agent is about to install/run, expressed +// as a pkgxray reference. +type InstallSpec struct { + Ref string // pkgxray reference, e.g. "npm:express@4.18.0" + Manager string // "npm" | "pnpm" | "yarn" | "bun" | "npx" + Raw string // the original token, for messages +} + +// ParseInstalls extracts the packages a shell command would fetch from a +// registry, across npm/pnpm/yarn/bun installs, npx/bunx/pnpm-dlx runners, and +// `claude mcp add … -- ` forms. It is deliberately conservative: +// unrecognized shapes yield nothing rather than a wrong reference, and local +// paths / VCS URLs are skipped because pre-install registry triage does not +// apply to them. +func ParseInstalls(command string) []InstallSpec { + var out []InstallSpec + for _, seg := range splitSegments(command) { + out = append(out, parseSegment(seg)...) + } + return dedupe(out) +} + +// splitSegments breaks a command line into independently-executed pieces on +// newlines and the shell operators && || ; and |. +func splitSegments(command string) []string { + fields := replaceAll(command, []string{"\n", "&&", "||", ";", "|"}, "\x00") + var segs []string + for _, s := range strings.Split(fields, "\x00") { + if s = strings.TrimSpace(s); s != "" { + segs = append(segs, s) + } + } + return segs +} + +func replaceAll(s string, olds []string, new string) string { + for _, o := range olds { + s = strings.ReplaceAll(s, o, new) + } + return s +} + +func parseSegment(seg string) []InstallSpec { + // `claude mcp add -- ` (and similar wrappers): the real + // package lives in the launcher command after the `--` separator. + if i := indexToken(seg, "--"); i >= 0 { + rhs := strings.Join(tokenize(seg)[i+1:], " ") + if rhs != "" { + if specs := parseSegment(rhs); len(specs) > 0 { + return specs + } + } + } + + toks := tokenize(seg) + if len(toks) == 0 { + return nil + } + bin := path.Base(toks[0]) + + args := toks[1:] + switch bin { + case "npm", "pnpm", "bun", "yarn": + // `pnpm dlx`, `yarn dlx`, `bun x` are runner forms, not installs. + if len(args) > 0 && (args[0] == "dlx" || (bin == "bun" && args[0] == "x")) { + return parseRunner(bin, args[1:]) + } + return parseInstaller(bin, args) + case "npx", "bunx", "pnpx": + return parseRunner(bin, args) + } + return nil +} + +// installSubcommands are the verbs that add named packages from a registry. +var installSubcommands = map[string]bool{ + "install": true, "i": true, "add": true, "in": true, +} + +func parseInstaller(bin string, args []string) []InstallSpec { + // Skip a leading "global" (yarn global add / bun global add). + if len(args) > 0 && args[0] == "global" { + args = args[1:] + } + if len(args) == 0 || !installSubcommands[args[0]] { + return nil + } + manager := bin + if bin == "bunx" || bin == "pnpx" { + manager = "npx" + } + + var specs []InstallSpec + for _, tok := range args[1:] { + if isFlag(tok) || !isRegistrySpec(tok) { + continue + } + specs = append(specs, InstallSpec{Ref: toRef(tok), Manager: manager, Raw: tok}) + } + return specs +} + +func parseRunner(bin string, args []string) []InstallSpec { + // pnpm's runner is `pnpm dlx `. + if bin == "pnpx" || bin == "pnpm" { + if len(args) > 0 && args[0] == "dlx" { + args = args[1:] + } + } + for i := 0; i < len(args); i++ { + tok := args[i] + // -p/--package explicitly names the package to fetch. + if tok == "-p" || tok == "--package" { + if i+1 < len(args) { + return runnerSpec(args[i+1]) + } + continue + } + if v, ok := flagValue(tok, "--package"); ok { + return runnerSpec(v) + } + if isFlag(tok) { + continue + } + // First bare token is the package npx resolves and runs. + return runnerSpec(tok) + } + return nil +} + +func runnerSpec(tok string) []InstallSpec { + if !isRegistrySpec(tok) { + return nil + } + return []InstallSpec{{Ref: toRef(tok), Manager: "npx", Raw: tok}} +} + +// isRegistrySpec reports whether a token is a registry package (not a local +// path, a VCS/HTTP URL, or a bare "." / ".."). +func isRegistrySpec(tok string) bool { + if tok == "" || tok == "." || tok == ".." { + return false + } + if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") || strings.HasPrefix(tok, "/") || strings.HasPrefix(tok, "~") { + return false + } + if strings.HasPrefix(tok, "file:") || strings.HasPrefix(tok, "link:") || strings.HasPrefix(tok, "workspace:") { + return false + } + if strings.Contains(tok, "://") || strings.HasPrefix(tok, "git+") || strings.HasPrefix(tok, "git@") { + return false + } + return true +} + +// toRef normalizes a package token into a pkgxray reference. Already-qualified +// references (npm:, github:) pass through; everything else is treated as an npm +// package name (optionally with an @version or scope). +func toRef(tok string) string { + if strings.HasPrefix(tok, "npm:") || strings.HasPrefix(tok, "github:") { + return tok + } + return "npm:" + tok +} + +func isFlag(tok string) bool { return strings.HasPrefix(tok, "-") } + +// flagValue parses --name=value forms; returns (value, true) on a match. +func flagValue(tok, name string) (string, bool) { + prefix := name + "=" + if strings.HasPrefix(tok, prefix) { + return strings.TrimPrefix(tok, prefix), true + } + return "", false +} + +// tokenize splits a segment on whitespace while honoring single/double quotes +// so a quoted spec stays intact. Quotes are stripped from the result. +func tokenize(seg string) []string { + var toks []string + var cur strings.Builder + var quote rune + inTok := false + flush := func() { + if inTok { + toks = append(toks, cur.String()) + cur.Reset() + inTok = false + } + } + for _, r := range seg { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + inTok = true + case r == '\'' || r == '"': + quote = r + inTok = true + case r == ' ' || r == '\t': + flush() + default: + cur.WriteRune(r) + inTok = true + } + } + flush() + return toks +} + +// indexToken returns the index of the first token exactly equal to want. +func indexToken(seg, want string) int { + for i, t := range tokenize(seg) { + if t == want { + return i + } + } + return -1 +} + +func dedupe(specs []InstallSpec) []InstallSpec { + seen := make(map[string]bool, len(specs)) + var out []InstallSpec + for _, s := range specs { + if seen[s.Ref] { + continue + } + seen[s.Ref] = true + out = append(out, s) + } + return out +} diff --git a/examples/pkgxray-guard/pkgxrayguard/parse_test.go b/examples/pkgxray-guard/pkgxrayguard/parse_test.go new file mode 100644 index 0000000..5fccca5 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/parse_test.go @@ -0,0 +1,69 @@ +package pkgxrayguard + +import ( + "reflect" + "testing" +) + +func refs(specs []InstallSpec) []string { + out := make([]string, 0, len(specs)) + for _, s := range specs { + out = append(out, s.Ref) + } + return out +} + +func TestParseInstalls(t *testing.T) { + cases := []struct { + name string + cmd string + want []string + }{ + {"npm install one", "npm install express", []string{"npm:express"}}, + {"npm i short", "npm i react@18.2.0", []string{"npm:react@18.2.0"}}, + {"npm install many + flag", "npm install --save-dev jest lodash", []string{"npm:jest", "npm:lodash"}}, + {"scoped package", "npm install @types/node", []string{"npm:@types/node"}}, + {"scoped with version", "pnpm add @scope/pkg@1.2.3", []string{"npm:@scope/pkg@1.2.3"}}, + {"yarn add", "yarn add left-pad", []string{"npm:left-pad"}}, + {"yarn global add", "yarn global add typescript", []string{"npm:typescript"}}, + {"bun add", "bun add zod", []string{"npm:zod"}}, + {"npx runner", "npx create-react-app my-app", []string{"npm:create-react-app"}}, + {"npx -y flag", "npx -y cowsay hello", []string{"npm:cowsay"}}, + {"npx --package", "npx --package=typescript tsc", []string{"npm:typescript"}}, + {"npx -p value", "npx -p esbuild esbuild --version", []string{"npm:esbuild"}}, + {"pnpm dlx", "pnpm dlx prettier --write .", []string{"npm:prettier"}}, + {"chained &&", "npm ci && npm install evil-pkg", []string{"npm:evil-pkg"}}, + {"claude mcp add launcher", "claude mcp add weather -- npx -y @acme/weather-mcp", []string{"npm:@acme/weather-mcp"}}, + {"quoted spec", "npm install \"lodash@4.17.21\"", []string{"npm:lodash@4.17.21"}}, + + // Non-installs and non-registry targets → nothing. + {"bare npm install", "npm install", nil}, + {"npm ci", "npm ci", nil}, + {"npm run build", "npm run build", nil}, + {"local path", "npm install ./local-tarball.tgz", nil}, + {"file protocol", "npm install file:../sibling", nil}, + {"git url", "npm install git+https://github.com/x/y.git", nil}, + {"unrelated command", "rm -rf node_modules", nil}, + {"echo", "echo npm install nope", nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := refs(ParseInstalls(tc.cmd)) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ParseInstalls(%q) = %v, want %v", tc.cmd, got, tc.want) + } + }) + } +} + +func TestParseInstallsDedupes(t *testing.T) { + got := refs(ParseInstalls("npm install express && npm install express@4")) + want := []string{"npm:express", "npm:express@4"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/policy.go b/examples/pkgxray-guard/pkgxrayguard/policy.go new file mode 100644 index 0000000..d93fb17 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/policy.go @@ -0,0 +1,61 @@ +package pkgxrayguard + +import "strings" + +// Policy decides how a verdict maps to a hook action. +type Policy string + +const ( + // Strict denies BLOCK, REVIEW, and UNKNOWN (fail-closed on any doubt). + Strict Policy = "strict" + // Balanced denies BLOCK, asks for confirmation on REVIEW, and denies + // UNKNOWN so a broken pkgxray never silently fails open. This is the default. + Balanced Policy = "balanced" + // Permissive denies only BLOCK; REVIEW and UNKNOWN are allowed through. + Permissive Policy = "permissive" +) + +// Action is what the hook should tell the agent to do. +type Action string + +const ( + Allow Action = "allow" + Ask Action = "ask" + Deny Action = "deny" +) + +// ParsePolicy resolves a policy name (case-insensitive), defaulting to Balanced. +func ParsePolicy(s string) Policy { + switch strings.ToLower(strings.TrimSpace(s)) { + case "strict": + return Strict + case "permissive": + return Permissive + default: + return Balanced + } +} + +// Decide maps a verdict to an action under the given policy. +func Decide(p Policy, v Verdict) Action { + switch v { + case Block: + return Deny + case Review: + switch p { + case Strict: + return Deny + case Permissive: + return Allow + default: + return Ask + } + case Unknown: + if p == Permissive { + return Allow + } + return Deny + default: // Safe + return Allow + } +} From 2cc378ee29e7b20331a2ad5b5674df19d13987d6 Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Wed, 1 Jul 2026 20:49:31 -0700 Subject: [PATCH 2/7] ci: trigger Go workflow for pkgxray-guard example Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/pkgxray-guard/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md index f94611f..3675acc 100644 --- a/examples/pkgxray-guard/README.md +++ b/examples/pkgxray-guard/README.md @@ -140,3 +140,5 @@ jobs: complete sandbox. - `pkgxray guard` reaches the network (registry/OSV/GitHub). Budget ~1s/package; tune with `PKGXRAY_GUARD_ARGS` (e.g. `--no-github-diff --no-github`). + + From 137a54e31ce02347436c5570891ee31190cba68c Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Thu, 2 Jul 2026 06:36:19 -0700 Subject: [PATCH 3/7] Harden the pkgxray-guard install-gate (git-URL, fail-mode, cache, edit-diff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up hardening for the pkgxray × hookshot install guard. Keeps main a thin shim; all logic stays in the stdlib-only pkgxrayguard package. - Parser: git+https/git@/tarball-URL install specs were silently dropped (an `npm i git+https://…` sailed through). They now classify as an unvettable KindVCS spec that Guard short-circuits to REVIEW — surfaced, not allowed. Local file:/link:/workspace: paths are still skipped. - Fail-mode is shape-aware: npx/bunx/pnpm-dlx/bun x run code the instant they resolve. DecideResult escalates an immediate-exec spec whose verdict is UNKNOWN (pkgxray errored) or REVIEW to ASK even under `permissive`, so it never fails open; persistent installs still follow the policy table. DecideAll folds per-package results into the strongest action. - Deny/ask messages now include the finding's file path ([category] reason (file)). - MemoGuard caches verdicts per exact ref@version for the process lifetime (~1.3–1.5s cold each); never caches UNKNOWN/errored results or across versions. PKGXRAY_CACHE_URL is forwarded to the CLI on cmd.Env. - OnAfterFileEdit diffs the edit hunks: package.json deep-guards only the newly added/changed deps via ManifestAddedSpecs (reusing the memo); lockfiles keep the full `pkgxray audit`, which honors the sibling .pkgxray.lock memory. Falls back to a full audit when no added dep is extracted, so it's never less safe. Builds/vets/tests green in-tree (`go build/vet/test ./examples/pkgxray-guard/...`); 66 offline pkgxrayguard subtests. --- examples/pkgxray-guard/README.md | 33 +++++- examples/pkgxray-guard/main.go | 102 ++++++++++++++-- examples/pkgxray-guard/pkgxrayguard/cache.go | 55 +++++++++ .../pkgxray-guard/pkgxrayguard/cache_test.go | 73 ++++++++++++ examples/pkgxray-guard/pkgxrayguard/guard.go | 27 ++++- .../pkgxray-guard/pkgxrayguard/guard_test.go | 69 ++++++++++- .../pkgxray-guard/pkgxrayguard/manifest.go | 112 ++++++++++++++++++ .../pkgxrayguard/manifest_test.go | 110 +++++++++++++++++ examples/pkgxray-guard/pkgxrayguard/parse.go | 59 ++++++--- .../pkgxray-guard/pkgxrayguard/parse_test.go | 39 +++++- examples/pkgxray-guard/pkgxrayguard/policy.go | 37 ++++++ 11 files changed, 678 insertions(+), 38 deletions(-) create mode 100644 examples/pkgxray-guard/pkgxrayguard/cache.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/cache_test.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/manifest.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/manifest_test.go diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md index 3675acc..0deecb8 100644 --- a/examples/pkgxray-guard/README.md +++ b/examples/pkgxray-guard/README.md @@ -31,11 +31,22 @@ agent runs: npm install left-pad evil-pkg@1.2.3 - `npm|pnpm|yarn|bun install|i|add ` (incl. `yarn global add`) - `npx` / `bunx` / `pnpm dlx` / `bun x` runners - `claude mcp add -- ` (audits the launcher's package) - - Local paths (`./x`, `file:`), VCS URLs, and bare `npm ci`/`npm install` - are skipped — registry triage doesn't apply to them. + - Git / tarball / HTTP URL specs (`git+https://…`, `git@…`, `https://…​.tgz`) + can't be resolved by registry triage, so they're surfaced as **review-worthy** + (never silently allowed). + - Local paths (`./x`, `file:`, `link:`, `workspace:`) and bare `npm ci` / + `npm install` are skipped — that code is already local/visible, or there's + no per-package ref to triage. - **`OnAfterFileEdit`** *(opt-in)* — when the agent edits `package.json` or a - lockfile, runs `pkgxray audit` on it and feeds the verdict back as agent - context (or a block on Claude for a `BLOCK`). + lockfile, checks it and feeds the verdict back as agent context (or a block on + Claude for a `BLOCK`). It diffs the edit hunks so it doesn't re-triage the + whole tree every time: + - `package.json` — deep-guards **only the newly added/changed deps** (reusing + the session cache); a formatting/script-only edit triages nothing. Falls + back to a full-file audit if no added dep can be extracted, so it's never + less safe than a blanket scan. + - lockfiles — full-file `pkgxray audit`, which honors the sibling + `.pkgxray.lock` allow/block memory so already-approved deps don't re-prompt. The worst verdict across a multi-package command wins. @@ -70,6 +81,13 @@ All via environment variables (the hook reads them at startup): | `PKGXRAY_HOOK_DISABLE` | — | `1` bypasses all checks (fail-open kill switch). | | `PKGXRAY_HOOK_AUDIT_LOCKFILES` | — | `1` enables the `OnAfterFileEdit` lockfile audit. | | `PKGXRAY_GUARD_ARGS` | — | Extra flags passed to `pkgxray guard`, e.g. `--no-github-diff`. | +| `PKGXRAY_CACHE_URL` | — | Forwarded to pkgxray so registry/GitHub fetches route through a shared cache server across runs. | + +The hook memoizes verdicts per exact `ref@version` for the lifetime of its +process (one agent session): re-installing the same package reuses the first +verdict instead of re-scanning (~1.3–1.5s cold each). An `UNKNOWN`/errored +result is never cached, so a transient failure can't pin a wrong answer; a +different version is always re-scanned. ### Policies @@ -80,6 +98,13 @@ All via environment variables (the hook reads them at startup): | `UNKNOWN` (pkgxray failed to run) | deny | deny | allow | | `SAFE` | allow | allow | allow | +**Execute-immediately fail-mode.** `npx` / `bunx` / `pnpm dlx` / `bun x` run +package code the instant it resolves, with no persistent install to inspect +afterwards. So even under `permissive`, an immediate-exec spec whose verdict is +`UNKNOWN` (pkgxray errored) or `REVIEW` (e.g. an unvettable VCS/URL) is escalated +to **ask** rather than allowed — it never fails open. A *persistent* install +(`npm i …`) still follows the table above. + `balanced` never fails open on a broken pkgxray: if the CLI is missing or errors, the verdict is `UNKNOWN` and the install is denied. On OpenAI Codex, hookshot rewrites an `ask` decision to a deny (Codex has no approval prompt), so diff --git a/examples/pkgxray-guard/main.go b/examples/pkgxray-guard/main.go index a8d2b39..b2f8638 100644 --- a/examples/pkgxray-guard/main.go +++ b/examples/pkgxray-guard/main.go @@ -53,14 +53,15 @@ func main() { if cfg.disabled || !cfg.auditLockfiles || !isDependencyManifest(ctx.FilePath) { return hookshot.FileEditOK() } - return auditManifest(cfg, ctx.FilePath) + return auditManifestEdit(cfg, ctx.FilePath, toFileEdits(ctx.Edits)) }) hookshot.RunCommand() } type config struct { - guard pkgxrayguard.Guard + guard pkgxrayguard.Guard // raw guard (used for lockfile audits) + checker pkgxrayguard.Checker // session-memoized wrapper for install checks policy pkgxrayguard.Policy disabled bool auditLockfiles bool @@ -75,8 +76,17 @@ func loadConfig() config { if raw := strings.TrimSpace(os.Getenv("PKGXRAY_GUARD_ARGS")); raw != "" { extra = strings.Fields(raw) } + guard := pkgxrayguard.Guard{ + Bin: bin, + Timeout: 60 * time.Second, + ExtraArgs: extra, + CacheURL: strings.TrimSpace(os.Getenv("PKGXRAY_CACHE_URL")), + } return config{ - guard: pkgxrayguard.Guard{Bin: bin, Timeout: 60 * time.Second, ExtraArgs: extra}, + guard: guard, + // One memo per process = one agent session; repeat installs of the same + // ref@version reuse the first verdict instead of re-scanning. + checker: pkgxrayguard.NewMemoGuard(guard), policy: pkgxrayguard.ParsePolicy(os.Getenv("PKGXRAY_HOOK_POLICY")), disabled: os.Getenv("PKGXRAY_HOOK_DISABLE") == "1", auditLockfiles: os.Getenv("PKGXRAY_HOOK_AUDIT_LOCKFILES") == "1", @@ -89,11 +99,10 @@ func decideInstalls(cfg config, specs []pkgxrayguard.InstallSpec) hookshot.Execu ctx := context.Background() results := make([]pkgxrayguard.Result, 0, len(specs)) for _, spec := range specs { - results = append(results, cfg.guard.Check(ctx, spec)) + results = append(results, cfg.checker.Check(ctx, spec)) } - worst := pkgxrayguard.Worst(results) - switch pkgxrayguard.Decide(cfg.policy, worst) { + switch pkgxrayguard.DecideAll(cfg.policy, results) { case pkgxrayguard.Deny: return hookshot.DenyExecution(denyMessage(results)) case pkgxrayguard.Ask: @@ -139,12 +148,81 @@ func joinRefs(specs []pkgxrayguard.InstallSpec) string { return strings.Join(refs, ", ") } -// auditManifest runs `pkgxray audit ` and reports the verdict back to -// the agent. Post-edit hooks can't undo the write, so a BLOCK becomes agent -// feedback (FileEditBlock, honored by Claude); anything else is added context. -func auditManifest(cfg config, filePath string) hookshot.FileEditDecision { - bin := cfg.guard.Bin - out, code := runCLI(bin, "audit", filePath) +// toFileEdits converts hookshot's edit hunks into the guard package's stdlib-only +// mirror type so the diff logic stays importable without the hookshot module. +func toFileEdits(edits []hookshot.FileEdit) []pkgxrayguard.FileEdit { + out := make([]pkgxrayguard.FileEdit, len(edits)) + for i, e := range edits { + out[i] = pkgxrayguard.FileEdit{OldString: e.OldString, NewString: e.NewString} + } + return out +} + +// auditManifestEdit reacts to a dependency-manifest edit. For package.json it +// diffs the edit hunks and deep-guards only the newly added/changed deps +// (reusing the session memo), so an edit doesn't re-triage the whole tree — and +// so a bare formatting/script change triages nothing. When no added dep can be +// extracted it falls back to a full-file audit, so it is never less safe than a +// blanket audit. Lockfiles always take the full-file `pkgxray audit` path, which +// honors the sibling .pkgxray.lock allow/block memory. +func auditManifestEdit(cfg config, filePath string, edits []pkgxrayguard.FileEdit) hookshot.FileEditDecision { + if filepath.Base(filePath) == "package.json" { + if specs := pkgxrayguard.ManifestAddedSpecs(edits); len(specs) > 0 { + return decideManifestSpecs(cfg, filePath, specs) + } + // No dependency add/change detected — but fall through to a full audit + // rather than skipping, so an unusually-formatted addition can't slip by. + } + return auditManifestFile(cfg, filePath) +} + +// decideManifestSpecs guards the added deps and maps the worst decision onto a +// file-edit outcome. A post-edit hook can't undo the write, so a Deny becomes +// FileEditBlock (honored by Claude) and an Ask becomes added context. +func decideManifestSpecs(cfg config, filePath string, specs []pkgxrayguard.InstallSpec) hookshot.FileEditDecision { + ctx := context.Background() + results := make([]pkgxrayguard.Result, 0, len(specs)) + for _, spec := range specs { + results = append(results, cfg.checker.Check(ctx, spec)) + } + base := filepath.Base(filePath) + switch pkgxrayguard.DecideAll(cfg.policy, results) { + case pkgxrayguard.Deny: + return hookshot.FileEditBlock("pkgxray flagged a dependency added to " + base + ":" + evidenceLines(results)) + case pkgxrayguard.Ask: + return hookshot.FileEditAddContext("pkgxray: review recommended for a dependency added to " + base + ":" + evidenceLines(results)) + default: + return hookshot.FileEditOK() + } +} + +// evidenceLines renders the non-safe results as indented bullet lines, reusing +// the same ref → verdict → findings shape as the install-command deny message. +func evidenceLines(results []pkgxrayguard.Result) string { + var b strings.Builder + for _, r := range results { + switch r.Verdict { + case pkgxrayguard.Block, pkgxrayguard.Review, pkgxrayguard.Unknown: + b.WriteString("\n • ") + b.WriteString(r.Spec.Ref) + b.WriteString(" → ") + b.WriteString(string(r.Verdict)) + if r.Summary != "" { + b.WriteString(" (" + r.Summary + ")") + } + for _, reason := range r.Reasons { + b.WriteString("\n - ") + b.WriteString(reason) + } + } + } + return b.String() +} + +// auditManifestFile runs `pkgxray audit ` on the whole manifest and reports +// the verdict back to the agent. +func auditManifestFile(cfg config, filePath string) hookshot.FileEditDecision { + out, code := runCLI(cfg.guard.Bin, "audit", filePath) summary := firstLine(out) switch code { case 2: diff --git a/examples/pkgxray-guard/pkgxrayguard/cache.go b/examples/pkgxray-guard/pkgxrayguard/cache.go new file mode 100644 index 0000000..d5b583b --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/cache.go @@ -0,0 +1,55 @@ +package pkgxrayguard + +import ( + "context" + "sync" +) + +// Checker is the pkgxray-triage capability MemoGuard wraps. Guard satisfies it; +// tests inject a fake. +type Checker interface { + Check(ctx context.Context, spec InstallSpec) Result +} + +// MemoGuard memoizes verdicts for the lifetime of the hook process (one agent +// session). Re-installing the same package is common within a session and each +// underlying guard call is ~1.3–1.5s cold (mostly network), so caching keeps +// the agent's critical path fast — the main reason a user would otherwise +// disable the hook. +// +// Two correctness rules: +// - The key is spec.Ref, which already carries the @version, so a different +// version is a different key and never reuses another version's verdict. +// - An Unknown / errored result is never cached, so a transient pkgxray +// failure doesn't pin a wrong answer for the rest of the session. +type MemoGuard struct { + inner Checker + mu sync.Mutex + cache map[string]Result +} + +// NewMemoGuard wraps a Checker with a session cache. +func NewMemoGuard(inner Checker) *MemoGuard { + return &MemoGuard{inner: inner, cache: make(map[string]Result)} +} + +// Check returns a cached verdict for spec.Ref when one exists, otherwise runs +// the wrapped Checker and caches a real verdict. +func (m *MemoGuard) Check(ctx context.Context, spec InstallSpec) Result { + m.mu.Lock() + if r, ok := m.cache[spec.Ref]; ok { + m.mu.Unlock() + return r + } + m.mu.Unlock() + + r := m.inner.Check(ctx, spec) + + if r.Verdict == Unknown || r.Err != nil { + return r // never memoize a non-verdict / transient failure + } + m.mu.Lock() + m.cache[spec.Ref] = r + m.mu.Unlock() + return r +} diff --git a/examples/pkgxray-guard/pkgxrayguard/cache_test.go b/examples/pkgxray-guard/pkgxrayguard/cache_test.go new file mode 100644 index 0000000..5cf11dd --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/cache_test.go @@ -0,0 +1,73 @@ +package pkgxrayguard + +import ( + "context" + "testing" +) + +// fakeChecker records how many times Check ran per ref and returns a scripted +// verdict. +type fakeChecker struct { + calls map[string]int + verdict Verdict + err error +} + +func (f *fakeChecker) Check(_ context.Context, spec InstallSpec) Result { + if f.calls == nil { + f.calls = map[string]int{} + } + f.calls[spec.Ref]++ + return Result{Spec: spec, Verdict: f.verdict, Err: f.err} +} + +func TestMemoGuardCachesSameRef(t *testing.T) { + f := &fakeChecker{verdict: Safe} + m := NewMemoGuard(f) + spec := InstallSpec{Ref: "npm:express@4.18.0"} + + m.Check(context.Background(), spec) + m.Check(context.Background(), spec) + + if f.calls[spec.Ref] != 1 { + t.Fatalf("underlying Check ran %d times, want 1 (second call should hit cache)", f.calls[spec.Ref]) + } +} + +func TestMemoGuardDoesNotCacheAcrossVersions(t *testing.T) { + f := &fakeChecker{verdict: Safe} + m := NewMemoGuard(f) + + m.Check(context.Background(), InstallSpec{Ref: "npm:express@4.18.0"}) + m.Check(context.Background(), InstallSpec{Ref: "npm:express@5.0.0"}) + + if f.calls["npm:express@4.18.0"] != 1 || f.calls["npm:express@5.0.0"] != 1 { + t.Fatalf("each version should be scanned once, got %v", f.calls) + } +} + +func TestMemoGuardDoesNotCacheUnknown(t *testing.T) { + f := &fakeChecker{verdict: Unknown} + m := NewMemoGuard(f) + spec := InstallSpec{Ref: "npm:flaky"} + + m.Check(context.Background(), spec) + m.Check(context.Background(), spec) + + if f.calls[spec.Ref] != 2 { + t.Fatalf("Unknown verdict must not be cached; Check ran %d times, want 2", f.calls[spec.Ref]) + } +} + +func TestMemoGuardDoesNotCacheErrors(t *testing.T) { + f := &fakeChecker{verdict: Review, err: context.DeadlineExceeded} + m := NewMemoGuard(f) + spec := InstallSpec{Ref: "npm:slow"} + + m.Check(context.Background(), spec) + m.Check(context.Background(), spec) + + if f.calls[spec.Ref] != 2 { + t.Fatalf("errored result must not be cached; Check ran %d times, want 2", f.calls[spec.Ref]) + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/guard.go b/examples/pkgxray-guard/pkgxrayguard/guard.go index 3187d3e..19f640c 100644 --- a/examples/pkgxray-guard/pkgxrayguard/guard.go +++ b/examples/pkgxray-guard/pkgxrayguard/guard.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "os" "os/exec" "strings" "time" @@ -33,6 +34,7 @@ type Guard struct { Bin string // pkgxray executable (default "pkgxray") Timeout time.Duration // per-package timeout (default 60s) ExtraArgs []string // extra guard flags, e.g. ["--no-github-diff"] + CacheURL string // PKGXRAY_CACHE_URL forwarded to the CLI so registry/GitHub fetches collapse across runs (optional) } // pkgxray guard --format json output (subset we consume). @@ -44,6 +46,7 @@ type guardJSON struct { Severity string `json:"severity"` Category string `json:"category"` Rationale string `json:"rationale"` + File string `json:"file"` } `json:"findings"` } `json:"report"` } @@ -54,6 +57,17 @@ type guardJSON struct { // the correct direction. Any execution error yields Verdict=Unknown with Err // set, leaving the fail-open/closed choice to the policy layer. func (g Guard) Check(ctx context.Context, spec InstallSpec) Result { + // A git/tarball/HTTP URL can't be resolved by pre-install registry triage — + // pkgxray has no registry ref to fetch. Surface it as review-worthy rather + // than shelling out (which would just error) or silently allowing it. + if spec.Kind == KindVCS { + return Result{ + Spec: spec, + Verdict: Review, + Summary: "unvettable git/URL install spec — pkgxray cannot triage an arbitrary VCS or remote tarball; review the source manually", + } + } + bin := g.Bin if bin == "" { bin = "pkgxray" @@ -67,6 +81,13 @@ func (g Guard) Check(ctx context.Context, spec InstallSpec) Result { args := append([]string{"guard", spec.Ref, "--format", "json"}, g.ExtraArgs...) cmd := exec.CommandContext(cctx, bin, args...) + // pkgxray reads PKGXRAY_CACHE_URL from its environment (github.js routes + // upstream fetches through the cache server). A child inherits our env, but + // forward it explicitly so it still reaches the CLI if the host ever + // sanitizes the hook's child environment. + if g.CacheURL != "" { + cmd.Env = append(os.Environ(), "PKGXRAY_CACHE_URL="+g.CacheURL) + } stdout, runErr := cmd.Output() exitCode := 0 @@ -131,7 +152,11 @@ func topReasons(p guardJSON) []string { if reason == "" { reason = f.Category } - reasons = append(reasons, "["+f.Category+"] "+clip(reason, 160)) + line := "[" + f.Category + "] " + clip(reason, 160) + if f.File != "" { + line += " (" + clip(f.File, 80) + ")" + } + reasons = append(reasons, line) if len(reasons) == 3 { break } diff --git a/examples/pkgxray-guard/pkgxrayguard/guard_test.go b/examples/pkgxray-guard/pkgxrayguard/guard_test.go index b4c1d7d..43d2075 100644 --- a/examples/pkgxray-guard/pkgxrayguard/guard_test.go +++ b/examples/pkgxray-guard/pkgxrayguard/guard_test.go @@ -34,6 +34,55 @@ func TestDecide(t *testing.T) { } } +func TestDecideResultImmediateFailMode(t *testing.T) { + immediate := InstallSpec{Ref: "npm:x", Immediate: true} + persistent := InstallSpec{Ref: "npm:x", Immediate: false} + cases := []struct { + name string + policy Policy + result Result + want Action + }{ + // Permissive would normally allow Unknown/Review — but not for an + // execute-immediately spec, which must at least ask. + {"permissive immediate unknown → ask", Permissive, Result{Spec: immediate, Verdict: Unknown}, Ask}, + {"permissive immediate review → ask", Permissive, Result{Spec: immediate, Verdict: Review}, Ask}, + {"permissive persistent unknown → allow", Permissive, Result{Spec: persistent, Verdict: Unknown}, Allow}, + {"permissive persistent review → allow", Permissive, Result{Spec: persistent, Verdict: Review}, Allow}, + {"permissive immediate safe → allow", Permissive, Result{Spec: immediate, Verdict: Safe}, Allow}, + // Balanced already denies Unknown / asks on Review; the hardening is a no-op there. + {"balanced immediate unknown → deny", Balanced, Result{Spec: immediate, Verdict: Unknown}, Deny}, + {"balanced immediate review → ask", Balanced, Result{Spec: immediate, Verdict: Review}, Ask}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DecideResult(tc.policy, tc.result); got != tc.want { + t.Errorf("DecideResult(%s) = %s, want %s", tc.policy, got, tc.want) + } + }) + } +} + +func TestDecideAllStrongestWins(t *testing.T) { + results := []Result{ + {Spec: InstallSpec{Ref: "npm:a"}, Verdict: Safe}, + {Spec: InstallSpec{Ref: "npm:b"}, Verdict: Review}, // balanced → ask + {Spec: InstallSpec{Ref: "npm:c"}, Verdict: Block}, // → deny (strongest) + } + if got := DecideAll(Balanced, results); got != Deny { + t.Fatalf("DecideAll = %s, want deny", got) + } + // Worst raw verdict is Review, but an immediate-exec Unknown forces Ask + // under permissive even though a persistent Unknown would allow. + mixed := []Result{ + {Spec: InstallSpec{Ref: "npm:a"}, Verdict: Safe}, + {Spec: InstallSpec{Ref: "npm:b", Immediate: true}, Verdict: Unknown}, + } + if got := DecideAll(Permissive, mixed); got != Ask { + t.Fatalf("DecideAll = %s, want ask", got) + } +} + func TestParsePolicyDefault(t *testing.T) { if ParsePolicy("nonsense") != Balanced { t.Fatal("unknown policy should default to Balanced") @@ -90,14 +139,14 @@ func itoa(n int) string { func TestCheckBlock(t *testing.T) { out := `{"decision":"block","report":{"summary":"1 high-severity finding","findings":[` + - `{"severity":"high","category":"credential-access","rationale":"reads ~/.aws/credentials near a network sink"},` + + `{"severity":"high","category":"credential-access","rationale":"reads ~/.aws/credentials near a network sink","file":"lib/.telemetry.js"},` + `{"severity":"info","category":"noise","rationale":"ignore me"}]}}` g := Guard{Bin: fakePkgxray(t, out, 2)} res := g.Check(context.Background(), InstallSpec{Ref: "npm:evil"}) if res.Verdict != Block { t.Fatalf("verdict = %s, want block", res.Verdict) } - if len(res.Reasons) != 1 || res.Reasons[0] != "[credential-access] reads ~/.aws/credentials near a network sink" { + if len(res.Reasons) != 1 || res.Reasons[0] != "[credential-access] reads ~/.aws/credentials near a network sink (lib/.telemetry.js)" { t.Fatalf("reasons = %v", res.Reasons) } if res.Summary != "1 high-severity finding" { @@ -129,3 +178,19 @@ func TestCheckMissingBinary(t *testing.T) { t.Fatalf("verdict = %s err = %v, want unknown + error", res.Verdict, res.Err) } } + +func TestCheckVCSSpecIsReviewWithoutRunning(t *testing.T) { + // A VCS spec must be reviewed without ever invoking the binary — point Bin + // at a script that would (wrongly) report "allow" if it were called. + g := Guard{Bin: fakePkgxray(t, `{"decision":"allow","report":{"summary":"","findings":[]}}`, 0)} + res := g.Check(context.Background(), InstallSpec{Ref: "git+https://github.com/x/y.git", Kind: KindVCS}) + if res.Verdict != Review { + t.Fatalf("verdict = %s, want review", res.Verdict) + } + if res.Err != nil { + t.Fatalf("unexpected err = %v", res.Err) + } + if res.Summary == "" { + t.Fatal("expected an explanatory summary for the unvettable spec") + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/manifest.go b/examples/pkgxray-guard/pkgxrayguard/manifest.go new file mode 100644 index 0000000..7dc4963 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/manifest.go @@ -0,0 +1,112 @@ +package pkgxrayguard + +import ( + "regexp" + "strings" +) + +// FileEdit mirrors hookshot.FileEdit (a single OldString→NewString replacement) +// so this stdlib-only package can diff manifest edits without importing the +// hookshot module. main.go passes ctx.Edits straight through. +type FileEdit struct { + OldString string + NewString string +} + +// manifestEntryRE matches a JSON "key": "value" string pair, e.g. a +// package.json dependency line `"express": "^4.18.0"`. +var manifestEntryRE = regexp.MustCompile(`"(@?[A-Za-z0-9][\w.-]*(?:/[\w.-]+)?)"\s*:\s*"([^"]*)"`) + +// exactVersionRE matches a pinned semver (no range operator), so we only attach +// a version to the ref when pkgxray can resolve it verbatim. +var exactVersionRE = regexp.MustCompile(`^\d+\.\d+\.\d+[\w.+-]*$`) + +// nonDepKeys are package.json string fields (and common engines/runtime keys) +// whose values can look like a version but are not packages to triage. +var nonDepKeys = map[string]bool{ + "version": true, "name": true, "description": true, "license": true, + "main": true, "module": true, "types": true, "typings": true, + "homepage": true, "author": true, "type": true, "packageManager": true, + "engines": true, "os": true, "cpu": true, "private": true, + "node": true, "npm": true, "yarn": true, "pnpm": true, "vscode": true, +} + +// ManifestAddedSpecs diffs a set of package.json edit hunks and returns the +// dependency specs that were added or had their version changed. Unchanged +// entries (present identically in the old text) are ignored, so an edit that +// only reformats or bumps an unrelated field re-triages nothing. Git/URL deps +// are classified as review-worthy VCS specs (same as install commands); local +// file:/link:/workspace: deps are skipped. Extraction is best-effort — callers +// should fall back to a full audit when it returns nothing. +func ManifestAddedSpecs(edits []FileEdit) []InstallSpec { + var out []InstallSpec + seen := make(map[string]bool) + for _, e := range edits { + old := parseManifestDeps(e.OldString) + for name, val := range parseManifestDeps(e.NewString) { + if old[name] == val { + continue // unchanged dependency + } + spec, ok := manifestSpec(name, val) + if !ok || seen[spec.Ref] { + continue + } + seen[spec.Ref] = true + out = append(out, spec) + } + } + return out +} + +// parseManifestDeps extracts dependency-looking "name": "spec" pairs from a +// package.json fragment, keyed by package name. Entries whose key is a known +// non-dependency field or whose value doesn't look like a version/spec are +// dropped, so `"description": "a tool"` or `"version": "1.0.0"` aren't mistaken +// for packages. +func parseManifestDeps(text string) map[string]string { + deps := make(map[string]string) + for _, m := range manifestEntryRE.FindAllStringSubmatch(text, -1) { + key, val := m[1], m[2] + if nonDepKeys[key] || !looksLikeDepSpec(val) { + continue + } + deps[key] = val + } + return deps +} + +func manifestSpec(name, val string) (InstallSpec, bool) { + kind, ok := classifySpec(val) + if !ok { + return InstallSpec{}, false // local file:/link:/workspace: dep — nothing to fetch + } + if kind == KindVCS { + return InstallSpec{Ref: val, Kind: KindVCS, Raw: name + "@" + val}, true + } + ref := "npm:" + name + if v := strings.TrimPrefix(val, "="); exactVersionRE.MatchString(v) { + ref += "@" + v // pin only when the spec is an exact version + } + return InstallSpec{Ref: ref, Kind: KindRegistry, Raw: name + "@" + val}, true +} + +// looksLikeDepSpec reports whether a JSON value resembles a dependency spec (a +// version, range, dist-tag, or git/URL/protocol) rather than free text. +func looksLikeDepSpec(v string) bool { + if v == "" { + return false + } + switch v[0] { + case '^', '~', '>', '<', '=', '*', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + return true + } + if v == "latest" || v == "next" { + return true + } + for _, p := range []string{"npm:", "file:", "link:", "workspace:", "git", "http", "github:"} { + if strings.HasPrefix(v, p) { + return true + } + } + return false +} diff --git a/examples/pkgxray-guard/pkgxrayguard/manifest_test.go b/examples/pkgxray-guard/pkgxrayguard/manifest_test.go new file mode 100644 index 0000000..4149fd5 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/manifest_test.go @@ -0,0 +1,110 @@ +package pkgxrayguard + +import ( + "reflect" + "sort" + "testing" +) + +func specRefs(specs []InstallSpec) []string { + out := make([]string, 0, len(specs)) + for _, s := range specs { + out = append(out, s.Ref) + } + sort.Strings(out) + return out +} + +func TestManifestAddedSpecs(t *testing.T) { + cases := []struct { + name string + edit FileEdit + want []string + }{ + { + "added registry dep with exact version", + FileEdit{ + OldString: ` "dependencies": {`, + NewString: ` "dependencies": {` + "\n" + ` "left-pad": "1.3.0",`, + }, + []string{"npm:left-pad@1.3.0"}, + }, + { + "added dep with a range keeps no version", + FileEdit{OldString: ``, NewString: ` "express": "^4.18.0"`}, + []string{"npm:express"}, + }, + { + "scoped dep", + FileEdit{OldString: ``, NewString: ` "@types/node": "^20.1.0"`}, + []string{"npm:@types/node"}, + }, + { + "git dep is a review-worthy vcs spec, not npm:name", + FileEdit{OldString: ``, NewString: ` "evil": "git+https://github.com/x/y.git"`}, + []string{"git+https://github.com/x/y.git"}, + }, + { + "version bump counts as changed", + FileEdit{ + OldString: ` "lodash": "4.17.20"`, + NewString: ` "lodash": "4.17.21"`, + }, + []string{"npm:lodash@4.17.21"}, + }, + { + "unchanged dep in the hunk is ignored", + FileEdit{ + OldString: ` "lodash": "4.17.21",` + "\n" + ` "react": "18.2.0"`, + NewString: ` "lodash": "4.17.21",` + "\n" + ` "react": "18.2.0",` + "\n" + ` "zod": "3.22.0"`, + }, + []string{"npm:zod@3.22.0"}, + }, + { + "non-dependency fields are not treated as packages", + FileEdit{ + OldString: ``, + NewString: ` "name": "my-app",` + "\n" + ` "version": "1.0.0",` + "\n" + ` "description": "a tool",` + "\n" + ` "license": "MIT",` + "\n" + ` "main": "index.js"`, + }, + nil, + }, + { + "local file: dep is skipped", + FileEdit{OldString: ``, NewString: ` "sibling": "file:../sibling"`}, + nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := specRefs(ManifestAddedSpecs([]FileEdit{tc.edit})) + want := tc.want + sort.Strings(want) + if len(got) == 0 && len(want) == 0 { + return + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ManifestAddedSpecs = %v, want %v", got, want) + } + }) + } +} + +func TestManifestAddedSpecsKindAndImmediate(t *testing.T) { + specs := ManifestAddedSpecs([]FileEdit{{NewString: `"evil": "git+https://github.com/x/y.git", "ok": "1.2.3"`}}) + byRef := map[string]InstallSpec{} + for _, s := range specs { + byRef[s.Ref] = s + } + if byRef["git+https://github.com/x/y.git"].Kind != KindVCS { + t.Errorf("git dep should be KindVCS, got %q", byRef["git+https://github.com/x/y.git"].Kind) + } + if byRef["npm:ok@1.2.3"].Kind != KindRegistry { + t.Errorf("registry dep should be KindRegistry, got %q", byRef["npm:ok@1.2.3"].Kind) + } + // Manifest deps are persistent installs, never execute-immediately. + for _, s := range specs { + if s.Immediate { + t.Errorf("%s should not be Immediate", s.Ref) + } + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/parse.go b/examples/pkgxray-guard/pkgxrayguard/parse.go index 84e3ee3..d2a2406 100644 --- a/examples/pkgxray-guard/pkgxrayguard/parse.go +++ b/examples/pkgxray-guard/pkgxrayguard/parse.go @@ -11,12 +11,24 @@ import ( "strings" ) +// SpecKind distinguishes a registry package (pkgxray can triage it) from an +// unvettable remote spec (an arbitrary git/tarball/HTTP URL, which pre-install +// registry triage cannot resolve). +type SpecKind string + +const ( + KindRegistry SpecKind = "registry" + KindVCS SpecKind = "vcs" // git+/git@/tarball/HTTP URL — unvettable, review-worthy +) + // InstallSpec is a single package an agent is about to install/run, expressed // as a pkgxray reference. type InstallSpec struct { - Ref string // pkgxray reference, e.g. "npm:express@4.18.0" - Manager string // "npm" | "pnpm" | "yarn" | "bun" | "npx" - Raw string // the original token, for messages + Ref string // pkgxray reference, e.g. "npm:express@4.18.0" (or the raw URL for a VCS spec) + Manager string // "npm" | "pnpm" | "yarn" | "bun" | "npx" + Raw string // the original token, for messages + Kind SpecKind // registry | vcs + Immediate bool // true for npx/bunx/pnpm-dlx/bun x — runs package code without a persistent install } // ParseInstalls extracts the packages a shell command would fetch from a @@ -105,10 +117,14 @@ func parseInstaller(bin string, args []string) []InstallSpec { var specs []InstallSpec for _, tok := range args[1:] { - if isFlag(tok) || !isRegistrySpec(tok) { + if isFlag(tok) { continue } - specs = append(specs, InstallSpec{Ref: toRef(tok), Manager: manager, Raw: tok}) + kind, ok := classifySpec(tok) + if !ok { + continue + } + specs = append(specs, InstallSpec{Ref: toRef(tok, kind), Manager: manager, Raw: tok, Kind: kind}) } return specs } @@ -142,34 +158,43 @@ func parseRunner(bin string, args []string) []InstallSpec { } func runnerSpec(tok string) []InstallSpec { - if !isRegistrySpec(tok) { + kind, ok := classifySpec(tok) + if !ok { return nil } - return []InstallSpec{{Ref: toRef(tok), Manager: "npx", Raw: tok}} + // npx/bunx/pnpm-dlx execute the fetched package immediately. + return []InstallSpec{{Ref: toRef(tok, kind), Manager: "npx", Raw: tok, Kind: kind, Immediate: true}} } -// isRegistrySpec reports whether a token is a registry package (not a local -// path, a VCS/HTTP URL, or a bare "." / ".."). -func isRegistrySpec(tok string) bool { +// classifySpec buckets an install token. Local paths and workspace/link specs +// point at already-visible code (nothing to gate) and are skipped. Git/tarball/ +// HTTP URLs can't be resolved by pre-install registry triage, so they surface +// as an unvettable KindVCS spec (review-worthy) rather than being silently +// allowed. Everything else is a registry package. +func classifySpec(tok string) (SpecKind, bool) { if tok == "" || tok == "." || tok == ".." { - return false + return "", false } if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") || strings.HasPrefix(tok, "/") || strings.HasPrefix(tok, "~") { - return false + return "", false } if strings.HasPrefix(tok, "file:") || strings.HasPrefix(tok, "link:") || strings.HasPrefix(tok, "workspace:") { - return false + return "", false } if strings.Contains(tok, "://") || strings.HasPrefix(tok, "git+") || strings.HasPrefix(tok, "git@") { - return false + return KindVCS, true } - return true + return KindRegistry, true } -// toRef normalizes a package token into a pkgxray reference. Already-qualified +// toRef normalizes a package token into a pkgxray reference. A VCS/URL spec is +// carried verbatim (there is no registry ref to build). Already-qualified // references (npm:, github:) pass through; everything else is treated as an npm // package name (optionally with an @version or scope). -func toRef(tok string) string { +func toRef(tok string, kind SpecKind) string { + if kind == KindVCS { + return tok + } if strings.HasPrefix(tok, "npm:") || strings.HasPrefix(tok, "github:") { return tok } diff --git a/examples/pkgxray-guard/pkgxrayguard/parse_test.go b/examples/pkgxray-guard/pkgxrayguard/parse_test.go index 5fccca5..f101963 100644 --- a/examples/pkgxray-guard/pkgxrayguard/parse_test.go +++ b/examples/pkgxray-guard/pkgxrayguard/parse_test.go @@ -36,13 +36,19 @@ func TestParseInstalls(t *testing.T) { {"claude mcp add launcher", "claude mcp add weather -- npx -y @acme/weather-mcp", []string{"npm:@acme/weather-mcp"}}, {"quoted spec", "npm install \"lodash@4.17.21\"", []string{"npm:lodash@4.17.21"}}, - // Non-installs and non-registry targets → nothing. + // Git/tarball/URL specs are unvettable by registry triage but must be + // surfaced (as review-worthy) rather than silently dropped. + {"git+https spec", "npm install git+https://github.com/x/y.git", []string{"git+https://github.com/x/y.git"}}, + {"git@ ssh spec", "npm i git@github.com:x/y.git", []string{"git@github.com:x/y.git"}}, + {"remote tarball url", "npm i https://example.com/pkg.tgz", []string{"https://example.com/pkg.tgz"}}, + {"npx of a git url", "npx git+https://github.com/x/y.git", []string{"git+https://github.com/x/y.git"}}, + + // Non-installs and local targets → nothing (already-visible code). {"bare npm install", "npm install", nil}, {"npm ci", "npm ci", nil}, {"npm run build", "npm run build", nil}, {"local path", "npm install ./local-tarball.tgz", nil}, {"file protocol", "npm install file:../sibling", nil}, - {"git url", "npm install git+https://github.com/x/y.git", nil}, {"unrelated command", "rm -rf node_modules", nil}, {"echo", "echo npm install nope", nil}, } @@ -60,6 +66,35 @@ func TestParseInstalls(t *testing.T) { } } +func TestParseInstallsKindAndImmediate(t *testing.T) { + cases := []struct { + name string + cmd string + wantKind SpecKind + wantImmediat bool + }{ + {"registry install is not immediate", "npm install express", KindRegistry, false}, + {"registry runner is immediate", "npx create-react-app app", KindRegistry, true}, + {"pnpm dlx is immediate", "pnpm dlx prettier", KindRegistry, true}, + {"git install is vcs, not immediate", "npm i git+https://github.com/x/y.git", KindVCS, false}, + {"git runner is vcs and immediate", "npx git+https://github.com/x/y.git", KindVCS, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + specs := ParseInstalls(tc.cmd) + if len(specs) != 1 { + t.Fatalf("ParseInstalls(%q) = %d specs, want 1", tc.cmd, len(specs)) + } + if specs[0].Kind != tc.wantKind { + t.Errorf("Kind = %q, want %q", specs[0].Kind, tc.wantKind) + } + if specs[0].Immediate != tc.wantImmediat { + t.Errorf("Immediate = %v, want %v", specs[0].Immediate, tc.wantImmediat) + } + }) + } +} + func TestParseInstallsDedupes(t *testing.T) { got := refs(ParseInstalls("npm install express && npm install express@4")) want := []string{"npm:express", "npm:express@4"} diff --git a/examples/pkgxray-guard/pkgxrayguard/policy.go b/examples/pkgxray-guard/pkgxrayguard/policy.go index d93fb17..cc1469c 100644 --- a/examples/pkgxray-guard/pkgxrayguard/policy.go +++ b/examples/pkgxray-guard/pkgxrayguard/policy.go @@ -36,6 +36,43 @@ func ParsePolicy(s string) Policy { } } +// DecideResult maps one audited result to an action. It applies the base +// policy, then hardens the fail-mode for execute-immediately specs (npx/bunx/ +// pnpm-dlx): those run package code the instant they resolve, with no +// persistent install to inspect afterwards. When we hold no real verdict for +// such a spec — pkgxray errored (Unknown) or the spec is an unvettable VCS/URL +// (Review) — never fail open. Ask at minimum, even under Permissive. +func DecideResult(p Policy, r Result) Action { + base := Decide(p, r.Verdict) + if r.Spec.Immediate && base == Allow && (r.Verdict == Unknown || r.Verdict == Review) { + return Ask + } + return base +} + +// DecideAll folds per-package results into one command decision: the strongest +// action wins (Deny > Ask > Allow). +func DecideAll(p Policy, results []Result) Action { + strongest := Allow + for _, r := range results { + if actionRank(DecideResult(p, r)) > actionRank(strongest) { + strongest = DecideResult(p, r) + } + } + return strongest +} + +func actionRank(a Action) int { + switch a { + case Deny: + return 2 + case Ask: + return 1 + default: // Allow + return 0 + } +} + // Decide maps a verdict to an action under the given policy. func Decide(p Policy, v Verdict) Action { switch v { From 681ed2b3cd2866b21915e73d353683fe2d3397dd Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Thu, 2 Jul 2026 10:55:45 -0700 Subject: [PATCH 4/7] pkgxray-guard: document the CLI contract + add Cascade/Droid configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two integration-completeness follow-ups: - Document the pkgxray CLI contract the hook depends on (top-level `decision`, exit codes 2/3/0, `report.findings[].file`) and the minimum tested version (pkgxray >= 0.15.0), on the guardJSON type and in a README "Requirements" section. pkgxray has no `--version` flag, so there's no runtime probe; the hook instead degrades safely (missing/old/erroring pkgxray → UNKNOWN → denied under strict/balanced, never a false allow). - Add ready-to-edit host configs for the two agents main.go already advertised but shipped no config for: Factory Droid (~/.factory/settings.json) and Windsurf Cascade (~/.codeium/windsurf/hooks.json). Values match hookshot's own installer (droid-pre-tool-use / droid-after-file-edit; cascade-pre-run-command / cascade-post-write-code). README layout now lists all five configs and the cache.go/manifest.go core files. --- examples/pkgxray-guard/README.md | 23 +++++++++++++++++++ .../pkgxray-guard/configs/cascade-hooks.json | 6 +++++ .../pkgxray-guard/configs/droid-settings.json | 10 ++++++++ examples/pkgxray-guard/pkgxrayguard/guard.go | 16 ++++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 examples/pkgxray-guard/configs/cascade-hooks.json create mode 100644 examples/pkgxray-guard/configs/droid-settings.json diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md index 0deecb8..aff51dc 100644 --- a/examples/pkgxray-guard/README.md +++ b/examples/pkgxray-guard/README.md @@ -50,6 +50,22 @@ agent runs: npm install left-pad evil-pkg@1.2.3 The worst verdict across a multi-package command wins. +## Requirements + +The hook shells out to the **pkgxray CLI** and depends on this contract: + +- `pkgxray guard --format json` emitting a top-level `decision` + (`allow`/`review`/`block`) and, for finding locations, + `report.findings[].file`; +- exit codes `2`=block, `3`=review, `0`=safe (used as the fallback when the JSON + can't be parsed). + +This is stable as of **pkgxray ≥ 0.15.0** — keep the CLI reasonably current. +pkgxray has no `--version` flag today, so the hook can't probe the version at +runtime; instead it **degrades safely**: a missing `file` just omits the path, +and a missing/old/erroring pkgxray yields `UNKNOWN`, which is denied under +`strict`/`balanced` (never a false allow). Set `PKGXRAY_HOOK_POLICY` accordingly. + ## Install ```bash @@ -120,8 +136,15 @@ examples/pkgxray-guard/ │ ├── parse.go shell command → []InstallSpec │ ├── guard.go run `pkgxray guard`, map verdict + reasons │ ├── policy.go verdict × policy → allow/ask/deny +│ ├── cache.go per-session verdict memo (keyed by ref@version) +│ ├── manifest.go diff a package.json edit → added/changed deps │ └── *_test.go table tests + fake-pkgxray exec tests (offline) └── configs/ ready-to-edit hook configs per agent + ├── claude-settings.json Claude Code (~/.claude/settings.json) + ├── cursor-hooks.json Cursor (.cursor/hooks.json) + ├── codex-hooks.json OpenAI Codex (~/.codex/hooks.json) + ├── droid-settings.json Factory Droid (~/.factory/settings.json) + └── cascade-hooks.json Windsurf Cascade (~/.codeium/windsurf/hooks.json) ``` The `pkgxrayguard` package has no third-party dependencies, so diff --git a/examples/pkgxray-guard/configs/cascade-hooks.json b/examples/pkgxray-guard/configs/cascade-hooks.json new file mode 100644 index 0000000..1f32382 --- /dev/null +++ b/examples/pkgxray-guard/configs/cascade-hooks.json @@ -0,0 +1,6 @@ +{ + "hooks": { + "pre_run_command": [{ "command": "/absolute/path/to/pkgxray-guard cascade-pre-run-command" }], + "post_write_code": [{ "command": "/absolute/path/to/pkgxray-guard cascade-post-write-code" }] + } +} diff --git a/examples/pkgxray-guard/configs/droid-settings.json b/examples/pkgxray-guard/configs/droid-settings.json new file mode 100644 index 0000000..b187ce8 --- /dev/null +++ b/examples/pkgxray-guard/configs/droid-settings.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "*", "hooks": [{ "type": "command", "command": "/absolute/path/to/pkgxray-guard droid-pre-tool-use" }] } + ], + "PostToolUse": [ + { "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "/absolute/path/to/pkgxray-guard droid-after-file-edit" }] } + ] + } +} diff --git a/examples/pkgxray-guard/pkgxrayguard/guard.go b/examples/pkgxray-guard/pkgxrayguard/guard.go index 19f640c..a7fd95c 100644 --- a/examples/pkgxray-guard/pkgxrayguard/guard.go +++ b/examples/pkgxray-guard/pkgxrayguard/guard.go @@ -37,7 +37,21 @@ type Guard struct { CacheURL string // PKGXRAY_CACHE_URL forwarded to the CLI so registry/GitHub fetches collapse across runs (optional) } -// pkgxray guard --format json output (subset we consume). +// guardJSON is the subset of `pkgxray guard --format json` output this +// hook consumes — the CLI contract it depends on: +// +// - top-level `decision`: "allow" | "review" | "block" +// - process exit code 2=block, 3=review, 0=safe (the fallback when the JSON +// can't be parsed) +// - `report.summary` and `report.findings[].{severity,category,rationale,file}` +// (`file` drives the location shown in a deny/ask message) +// +// This shape is stable as of pkgxray >= 0.15.0. The hook degrades safely against +// drift: a missing `file` just omits the path, and if `decision` is absent it +// falls back to the exit code — so an older or erroring pkgxray fails toward +// UNKNOWN (denied under strict/balanced), never a false allow. pkgxray has no +// `--version` flag today, so there is no runtime version probe; keep the CLI +// current. See the README "Requirements" section. type guardJSON struct { Decision string `json:"decision"` // allow | review | block Report struct { From 5f9f9871c0719bc62300f02d12c5d10bfec2de2c Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Thu, 2 Jul 2026 12:44:37 -0700 Subject: [PATCH 5/7] pkgxray-guard: add end-to-end smoke test (real binary + real pkgxray) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests drive the pkgxrayguard package against a fake pkgxray. e2e-smoke.sh exercises the whole chain instead: it builds the hook binary and feeds it genuine Claude PreToolUse payloads on stdin, so the compiled binary and the real pkgxray CLI are actually integrated. Deterministic cases (git-URL install → deny/ask, non-install → allow, local path → skipped) run offline and are hard assertions. One live case runs `npm install left-pad` through the real pkgxray CLI and asserts a valid decision comes back (verdict not pinned — it depends on the registry). Skips the live case if pkgxray isn't on PATH. README "Try it" + layout updated. --- examples/pkgxray-guard/README.md | 6 +++ examples/pkgxray-guard/e2e-smoke.sh | 67 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100755 examples/pkgxray-guard/e2e-smoke.sh diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md index aff51dc..3c81631 100644 --- a/examples/pkgxray-guard/README.md +++ b/examples/pkgxray-guard/README.md @@ -132,6 +132,7 @@ hookshot rewrites an `ask` decision to a deny (Codex has no approval prompt), so examples/pkgxray-guard/ ├── main.go hookshot handler registration + env config ├── helpers.go lockfile detection + pkgxray CLI runner +├── e2e-smoke.sh real binary + real pkgxray end-to-end smoke test ├── pkgxrayguard/ pure, stdlib-only, unit-tested core │ ├── parse.go shell command → []InstallSpec │ ├── guard.go run `pkgxray guard`, map verdict + reasons @@ -158,6 +159,11 @@ go test ./pkgxrayguard/... # Simulate a Claude PreToolUse event (deny path depends on the real package): echo '{"tool_name":"Bash","tool_input":{"command":"npm install left-pad"}}' \ | ./pkgxray-guard claude-pre-tool-use + +# End-to-end smoke test: builds the binary and drives it through the real chain +# (deterministic deny/ask/allow cases + one live `pkgxray` call). Needs pkgxray +# on PATH for the live case; deterministic cases run offline. +./e2e-smoke.sh ``` ## CI diff --git a/examples/pkgxray-guard/e2e-smoke.sh b/examples/pkgxray-guard/e2e-smoke.sh new file mode 100755 index 0000000..4910cf0 --- /dev/null +++ b/examples/pkgxray-guard/e2e-smoke.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# e2e-smoke.sh — end-to-end smoke test for the pkgxray-guard hook. +# +# Exercises the REAL chain the unit tests can't: a genuine Claude Code +# PreToolUse payload on stdin → the compiled hook binary → (for registry +# installs) the real pkgxray CLI → an allow/ask/deny decision. The unit tests +# use a fake pkgxray; this proves the binary and the CLI actually integrate. +# +# Deterministic cases run offline (git-URL/non-install/local specs never call +# pkgxray) and are hard assertions. One live case runs `npm install ` +# through the real pkgxray CLI and checks that a valid decision comes back — the +# exact verdict depends on the registry, so it isn't pinned. +# +# Usage: ./e2e-smoke.sh +# Env: PKGXRAY_BIN the pkgxray CLI the hook shells out to (default: pkgxray) + +set -uo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +BIN="$(mktemp -d)/pkgxray-guard" +PASS=0 +FAIL=0 + +echo "building hook binary…" +( cd "$REPO_ROOT" && go build -o "$BIN" ./examples/pkgxray-guard ) || { echo "build failed"; exit 1; } + +# drive → prints the hook's Claude decision JSON +drive() { + printf '{"session_id":"e2e","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"%s"},"tool_use_id":"t1"}' "$2" \ + | PKGXRAY_HOOK_POLICY="$1" "$BIN" claude-pre-tool-use +} + +# assert +assert() { + local name="$1" want="$2" out + out="$(drive "$3" "$4")" + if printf '%s' "$out" | grep -q "\"permissionDecision\":\"$want\""; then + echo " PASS: $name → $want"; PASS=$((PASS + 1)) + else + echo " FAIL: $name (want $want)"; echo " got: $out"; FAIL=$((FAIL + 1)) + fi +} + +echo "=== deterministic cases (offline, no pkgxray call) ===" +assert "git-URL install under strict blocks" deny strict "npm install git+https://github.com/evil/pkg.git" +assert "git-URL install under balanced asks" ask balanced "npm install git+https://github.com/evil/pkg.git" +assert "non-install command passes" allow balanced "ls -la" +assert "local path install is skipped" allow balanced "npm install ./local-tarball.tgz" + +echo "=== live case (real pkgxray) ===" +CLI="${PKGXRAY_BIN:-pkgxray}" +if command -v "$CLI" >/dev/null 2>&1; then + out="$(drive balanced "npm install left-pad")" + if printf '%s' "$out" | grep -qE "\"permissionDecision\":\"(allow|ask|deny)\""; then + verdict="$(printf '%s' "$out" | grep -oE '"permissionDecision":"[a-z]+"' | head -1)" + echo " PASS: real pkgxray returned a decision ($verdict)"; PASS=$((PASS + 1)) + else + echo " FAIL: no valid decision from real pkgxray"; echo " got: $out"; FAIL=$((FAIL + 1)) + fi +else + echo " SKIP: '$CLI' not on PATH — deterministic cases still ran (set PKGXRAY_BIN to enable)" +fi + +echo "" +echo "e2e: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From e908a5d086599b1cab02396a6c4c10d28b35db36 Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Thu, 2 Jul 2026 13:01:13 -0700 Subject: [PATCH 6/7] pkgxray-guard: guard multi-package installs concurrently + load harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the pkgxray-side change: a command installing several packages guarded them sequentially (~N × per-package pkgxray latency; a 20-pkg install ≈ 10s). - pkgxrayguard.CheckAll: stdlib-only bounded fan-out (each Check writes its own result slot; MemoGuard mutex-guards its cache → concurrency-safe, go test -race clean). Used in decideInstalls + decideManifestSpecs. Cap via PKGXRAY_HOOK_CONCURRENCY (default 8). Results fold order-independently, so verdicts are unchanged. - load-test.py: throughput/latency harness (offline + fake-pkgxray at high concurrency; real pkgxray at low volume). Measured one 20-pkg command at 1607ms serial → 216ms (default 8) → 143ms (16). Measured with go 1.26; builds/tests green in-tree. --- examples/pkgxray-guard/README.md | 9 +- examples/pkgxray-guard/load-test.py | 97 +++++++++++++++++++ examples/pkgxray-guard/main.go | 19 ++-- examples/pkgxray-guard/pkgxrayguard/batch.go | 47 +++++++++ .../pkgxray-guard/pkgxrayguard/batch_test.go | 76 +++++++++++++++ 5 files changed, 239 insertions(+), 9 deletions(-) create mode 100755 examples/pkgxray-guard/load-test.py create mode 100644 examples/pkgxray-guard/pkgxrayguard/batch.go create mode 100644 examples/pkgxray-guard/pkgxrayguard/batch_test.go diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md index 3c81631..b788234 100644 --- a/examples/pkgxray-guard/README.md +++ b/examples/pkgxray-guard/README.md @@ -98,8 +98,15 @@ All via environment variables (the hook reads them at startup): | `PKGXRAY_HOOK_AUDIT_LOCKFILES` | — | `1` enables the `OnAfterFileEdit` lockfile audit. | | `PKGXRAY_GUARD_ARGS` | — | Extra flags passed to `pkgxray guard`, e.g. `--no-github-diff`. | | `PKGXRAY_CACHE_URL` | — | Forwarded to pkgxray so registry/GitHub fetches route through a shared cache server across runs. | +| `PKGXRAY_HOOK_CONCURRENCY` | `8` | Max packages guarded concurrently within one command. | -The hook memoizes verdicts per exact `ref@version` for the lifetime of its +When a single command installs several packages (`npm i a b c …`), they are +guarded **concurrently** (bounded by `PKGXRAY_HOOK_CONCURRENCY`), so the gate's +latency is roughly the slowest package rather than the sum — a 20-package +install drops from ~10s to ~1–2s. Lower the cap to be gentler on rate-limited +upstreams; raise it if pkgxray runs against a local cache server. + +The hook also memoizes verdicts per exact `ref@version` for the lifetime of its process (one agent session): re-installing the same package reuses the first verdict instead of re-scanning (~1.3–1.5s cold each). An `UNKNOWN`/errored result is never cached, so a transient failure can't pin a wrong answer; a diff --git a/examples/pkgxray-guard/load-test.py b/examples/pkgxray-guard/load-test.py new file mode 100755 index 0000000..ec2f211 --- /dev/null +++ b/examples/pkgxray-guard/load-test.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Load / throughput harness for the pkgxray-guard hook. + +Drives the compiled hook binary with real Claude PreToolUse payloads on stdin at +varying concurrency, and reports throughput + latency. High-concurrency benches +use offline paths (git-URL / non-install → no pkgxray call) or a fake pkgxray, so +this never hammers npm / OSV / GitHub. Real pkgxray is characterized at low +volume only (needs `pkgxray` on PATH; skipped otherwise). + +Usage: python3 load-test.py # run from anywhere in the hookshot repo +""" +import json, os, subprocess, sys, tempfile, time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor + +ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, + text=True).stdout.strip() or "." +TMP = tempfile.mkdtemp() +BIN = os.path.join(TMP, "pkgxray-guard") +FAKE = os.path.join(TMP, "fake-pkgxray") + +print("building hook binary…") +if subprocess.run(["go", "build", "-o", BIN, "./examples/pkgxray-guard"], cwd=ROOT).returncode: + sys.exit("build failed") +with open(FAKE, "w") as f: + f.write('#!/bin/sh\necho \'{"decision":"allow","report":{"summary":"ok (fake)","findings":[]}}\'\n') +os.chmod(FAKE, 0o755) + +def payload(cmd): + return json.dumps({"session_id": "lt", "cwd": "/tmp", "hook_event_name": "PreToolUse", + "tool_name": "Bash", "tool_input": {"command": cmd}, "tool_use_id": "t"}) + +def run_once(cmd, policy, pkgxray_bin=None): + env = dict(os.environ, PKGXRAY_HOOK_POLICY=policy) + if pkgxray_bin: + env["PKGXRAY_BIN"] = pkgxray_bin + t0 = time.perf_counter() + p = subprocess.run([BIN, "claude-pre-tool-use"], input=payload(cmd), + capture_output=True, text=True, env=env, timeout=120) + dt = time.perf_counter() - t0 + try: + return dt, json.loads(p.stdout)["hookSpecificOutput"]["permissionDecision"] + except Exception: + return dt, f"" + +def bench(name, n, conc, cmd, policy, pkgxray_bin=None, want=None): + lat, decs = [], [] + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=conc) as ex: + for dt, dec in ex.map(lambda _: run_once(cmd, policy, pkgxray_bin), range(n)): + lat.append(dt); decs.append(dec) + wall = time.perf_counter() - t0 + lat.sort() + pct = lambda q: lat[min(len(lat) - 1, int(q * len(lat)))] * 1000 + print(f"\n[{name}] n={n} concurrency={conc}") + print(f" throughput : {n / wall:8.1f} req/s (wall {wall:.2f}s)") + print(f" latency ms : p50={pct(.50):.1f} p95={pct(.95):.1f} max={lat[-1]*1000:.1f}") + if want: + ok = sum(1 for d in decs if d == want) + print(f" correctness: {ok}/{n} == {want}" + (" ✓" if ok == n else " ✗ MISMATCH")) + else: + print(f" decisions : {dict(Counter(decs))}") + +print("=" * 64 + "\nA. Offline hook-overhead ceiling (no pkgxray call)") +bench("git-URL install → deny (strict)", 300, 16, + "npm install git+https://github.com/evil/pkg.git", "strict", want="deny") +bench("non-install → allow", 300, 16, "ls -la", "balanced", want="allow") + +print("\n" + "=" * 64 + "\nB. Full pipeline with FAKE pkgxray (subprocess spawn, no network)") +bench("registry install, 1 pkg", 300, 16, "npm install left-pad", "balanced", + pkgxray_bin=FAKE, want="allow") + +# A slow fake (50 ms/call) makes the per-package cost dominate so the multi-package +# concurrency (PKGXRAY_HOOK_CONCURRENCY) shows cleanly. Measured on ONE invocation +# at a time (concurrency=1 outer) so the internal parallelism isn't masked by CPU +# saturation from many binaries × many workers. +SLOW = os.path.join(TMP, "slow-fake-pkgxray") +with open(SLOW, "w") as f: + f.write('#!/bin/sh\nsleep 0.05\necho \'{"decision":"allow","report":{"summary":"ok","findings":[]}}\'\n') +os.chmod(SLOW, 0o755) +many = "npm install " + " ".join(f"pkg{i}" for i in range(20)) +print("\n" + "=" * 64 + "\nC. Multi-package concurrency — ONE `npm i <20 pkgs>` (slow fake, 50 ms/pkg)") +for w in (1, 4, 8, 16): + os.environ["PKGXRAY_HOOK_CONCURRENCY"] = str(w) + dt, dec = run_once(many, "balanced", pkgxray_bin=SLOW) + tag = " (serial baseline)" if w == 1 else " (default)" if w == 8 else "" + print(f" PKGXRAY_HOOK_CONCURRENCY={w:<2}: {dt*1000:7.1f} ms → {dec}{tag}") +os.environ.pop("PKGXRAY_HOOK_CONCURRENCY", None) + +CLI = os.environ.get("PKGXRAY_BIN", "pkgxray") +if any(os.access(os.path.join(p, CLI), os.X_OK) for p in os.environ.get("PATH", "").split(":")): + print("\n" + "=" * 64 + "\nD. Real pkgxray — LOW volume (network-polite), cold vs warm") + for i in range(6): + dt, dec = run_once("npm install left-pad", "balanced") + print(f" call {i+1} ({'cold' if i == 0 else 'warm'}): {dt*1000:7.1f} ms → {dec}") +else: + print(f"\nC. skipped — '{CLI}' not on PATH") diff --git a/examples/pkgxray-guard/main.go b/examples/pkgxray-guard/main.go index b2f8638..0e57770 100644 --- a/examples/pkgxray-guard/main.go +++ b/examples/pkgxray-guard/main.go @@ -28,6 +28,7 @@ import ( "context" "os" "path/filepath" + "strconv" "strings" "time" @@ -65,6 +66,7 @@ type config struct { policy pkgxrayguard.Policy disabled bool auditLockfiles bool + guardWorkers int // max packages guarded concurrently per command } func loadConfig() config { @@ -82,6 +84,12 @@ func loadConfig() config { ExtraArgs: extra, CacheURL: strings.TrimSpace(os.Getenv("PKGXRAY_CACHE_URL")), } + workers := pkgxrayguard.DefaultGuardWorkers + if v := strings.TrimSpace(os.Getenv("PKGXRAY_HOOK_CONCURRENCY")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 1 { + workers = n + } + } return config{ guard: guard, // One memo per process = one agent session; repeat installs of the same @@ -90,6 +98,7 @@ func loadConfig() config { policy: pkgxrayguard.ParsePolicy(os.Getenv("PKGXRAY_HOOK_POLICY")), disabled: os.Getenv("PKGXRAY_HOOK_DISABLE") == "1", auditLockfiles: os.Getenv("PKGXRAY_HOOK_AUDIT_LOCKFILES") == "1", + guardWorkers: workers, } } @@ -97,10 +106,7 @@ func loadConfig() config { // per-package results into one hook decision (worst verdict wins). func decideInstalls(cfg config, specs []pkgxrayguard.InstallSpec) hookshot.ExecutionDecision { ctx := context.Background() - results := make([]pkgxrayguard.Result, 0, len(specs)) - for _, spec := range specs { - results = append(results, cfg.checker.Check(ctx, spec)) - } + results := pkgxrayguard.CheckAll(ctx, cfg.checker, specs, cfg.guardWorkers) switch pkgxrayguard.DecideAll(cfg.policy, results) { case pkgxrayguard.Deny: @@ -181,10 +187,7 @@ func auditManifestEdit(cfg config, filePath string, edits []pkgxrayguard.FileEdi // FileEditBlock (honored by Claude) and an Ask becomes added context. func decideManifestSpecs(cfg config, filePath string, specs []pkgxrayguard.InstallSpec) hookshot.FileEditDecision { ctx := context.Background() - results := make([]pkgxrayguard.Result, 0, len(specs)) - for _, spec := range specs { - results = append(results, cfg.checker.Check(ctx, spec)) - } + results := pkgxrayguard.CheckAll(ctx, cfg.checker, specs, cfg.guardWorkers) base := filepath.Base(filePath) switch pkgxrayguard.DecideAll(cfg.policy, results) { case pkgxrayguard.Deny: diff --git a/examples/pkgxray-guard/pkgxrayguard/batch.go b/examples/pkgxray-guard/pkgxrayguard/batch.go new file mode 100644 index 0000000..d0bb62a --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/batch.go @@ -0,0 +1,47 @@ +package pkgxrayguard + +import ( + "context" + "sync" +) + +// DefaultGuardWorkers bounds how many packages in a single command are guarded +// at once. Guarding is independent per package but each Check spawns a pkgxray +// process that hits the network, so this caps concurrent load rather than +// firing one process per package unbounded. +const DefaultGuardWorkers = 8 + +// CheckAll runs checker.Check on every spec with bounded concurrency and returns +// the results in the same order as specs. Because packages are guarded +// independently, this turns a multi-package command's latency from the sum of +// the per-package times into roughly max(per-package) × ceil(n/workers) — the +// difference between ~10s and ~1s for a 20-package install. +// +// checker must be safe for concurrent use (Guard is stateless; MemoGuard guards +// its cache with a mutex). workers < 1 is treated as 1. +func CheckAll(ctx context.Context, checker Checker, specs []InstallSpec, workers int) []Result { + results := make([]Result, len(specs)) + if len(specs) == 0 { + return results + } + if workers < 1 { + workers = 1 + } + if workers > len(specs) { + workers = len(specs) + } + + sem := make(chan struct{}, workers) + var wg sync.WaitGroup + for i := range specs { + wg.Add(1) + sem <- struct{}{} // block once `workers` are in flight + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + results[i] = checker.Check(ctx, specs[i]) // own index → no shared-slice race + }(i) + } + wg.Wait() + return results +} diff --git a/examples/pkgxray-guard/pkgxrayguard/batch_test.go b/examples/pkgxray-guard/pkgxrayguard/batch_test.go new file mode 100644 index 0000000..e56e9f2 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/batch_test.go @@ -0,0 +1,76 @@ +package pkgxrayguard + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" +) + +// checkerFunc adapts a function to the Checker interface for tests. +type checkerFunc func(context.Context, InstallSpec) Result + +func (f checkerFunc) Check(ctx context.Context, s InstallSpec) Result { return f(ctx, s) } + +func TestCheckAllRunsEverySpecInOrder(t *testing.T) { + fake := checkerFunc(func(_ context.Context, s InstallSpec) Result { + return Result{Spec: s, Verdict: Safe} + }) + specs := make([]InstallSpec, 12) + for i := range specs { + specs[i] = InstallSpec{Ref: fmt.Sprintf("npm:p%d", i)} + } + res := CheckAll(context.Background(), fake, specs, 4) + if len(res) != len(specs) { + t.Fatalf("got %d results, want %d", len(res), len(specs)) + } + for i, r := range res { + if r.Spec.Ref != specs[i].Ref { + t.Errorf("result[%d].Ref = %q, want %q (order not preserved)", i, r.Spec.Ref, specs[i].Ref) + } + } +} + +func TestCheckAllBoundsConcurrency(t *testing.T) { + var inflight, maxSeen int32 + fake := checkerFunc(func(_ context.Context, s InstallSpec) Result { + n := atomic.AddInt32(&inflight, 1) + for { // record the high-water mark + m := atomic.LoadInt32(&maxSeen) + if n <= m || atomic.CompareAndSwapInt32(&maxSeen, m, n) { + break + } + } + time.Sleep(5 * time.Millisecond) // hold the slot so overlap is observable + atomic.AddInt32(&inflight, -1) + return Result{Spec: s, Verdict: Safe} + }) + specs := make([]InstallSpec, 20) + for i := range specs { + specs[i] = InstallSpec{Ref: fmt.Sprintf("npm:p%d", i)} + } + workers := 4 + CheckAll(context.Background(), fake, specs, workers) + + if maxSeen > int32(workers) { + t.Errorf("observed %d concurrent checks, exceeds cap %d", maxSeen, workers) + } + if maxSeen < 2 { + t.Errorf("expected real parallelism, max concurrency was %d", maxSeen) + } +} + +func TestCheckAllEdgeCases(t *testing.T) { + fake := checkerFunc(func(_ context.Context, s InstallSpec) Result { + return Result{Spec: s, Verdict: Safe} + }) + if got := CheckAll(context.Background(), fake, nil, 4); len(got) != 0 { + t.Errorf("empty specs → %d results, want 0", len(got)) + } + // workers < 1 must not deadlock or panic — treated as sequential. + one := []InstallSpec{{Ref: "npm:solo"}} + if got := CheckAll(context.Background(), fake, one, 0); len(got) != 1 || got[0].Spec.Ref != "npm:solo" { + t.Errorf("workers=0 → %v, want one result for npm:solo", got) + } +} From 0e158880ddc4cdbe74382328325071c8e721488b Mon Sep 17 00:00:00 2001 From: Jack Adams-Lovell Date: Thu, 2 Jul 2026 14:17:52 -0700 Subject: [PATCH 7/7] pkgxray-guard: harden subprocess spawns (gosec G204) with ref validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two exec sites — guard.go (`pkgxray guard `) and helpers.go (`pkgxray audit `) — tripped gosec G204 ("subprocess launched with a variable"). Both are safe: exec.Command passes args as a []string argv (no shell), so a package ref / path can't inject a command (CWE-78 is unreachable), and `bin` is the operator-configured PKGXRAY_BIN, not attacker-controlled. Close the findings with genuine defense-in-depth rather than bare suppression: - guard.go: validateInstallRef() rejects an install ref that is empty, begins with '-' (argument injection into the pkgxray CLI), or carries control bytes — a sanitizer checkpoint on the untrusted-input → subprocess path. Registry refs the parser produces (name, @scope/name, name@version) always pass; a flag-like ref is surfaced as Review instead of being shelled out. - Both exec sites carry a `#nosec G204` annotation documenting why the variable target is safe (no shell, operator-set bin, validated argv). Verified: `gosec -include=G204 ./...` now reports 0 issues (2 nosec). Tests cover validateInstallRef (valid refs pass, flag-like/control/empty refs error) and that Check refuses a flag-like ref before exec. `go build`/`go vet`/ `go test ./...` all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/pkgxray-guard/helpers.go | 4 +++ examples/pkgxray-guard/pkgxrayguard/guard.go | 33 +++++++++++++++++++ .../pkgxray-guard/pkgxrayguard/guard_test.go | 25 ++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/examples/pkgxray-guard/helpers.go b/examples/pkgxray-guard/helpers.go index 146be3d..e018b0b 100644 --- a/examples/pkgxray-guard/helpers.go +++ b/examples/pkgxray-guard/helpers.go @@ -22,6 +22,10 @@ func isDependencyManifest(filePath string) bool { // runCLI runs the pkgxray CLI and returns its combined output and exit code. func runCLI(bin string, args ...string) (string, int) { + // #nosec G204 -- bin is the operator-configured pkgxray CLI (PKGXRAY_BIN or a + // PATH lookup of "pkgxray"); args are fixed subcommands plus a repo-local + // manifest path the hook resolved. No shell is involved (args are a []string + // argv), so the variable target cannot inject a command (CWE-78). cmd := exec.Command(bin, args...) out, err := cmd.CombinedOutput() if err == nil { diff --git a/examples/pkgxray-guard/pkgxrayguard/guard.go b/examples/pkgxray-guard/pkgxrayguard/guard.go index a7fd95c..181e274 100644 --- a/examples/pkgxray-guard/pkgxrayguard/guard.go +++ b/examples/pkgxray-guard/pkgxrayguard/guard.go @@ -93,7 +93,20 @@ func (g Guard) Check(ctx context.Context, spec InstallSpec) Result { cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + // Defense-in-depth before spawning the CLI. exec.CommandContext passes args + // as a []string argv — no shell is involved — so spec.Ref cannot inject a + // command (CWE-78 is not reachable here). We still reject a ref that could be + // misread as a CLI flag (argument injection) or carries control bytes, as a + // sanitizer checkpoint on the untrusted-input → subprocess path. + if err := validateInstallRef(spec.Ref); err != nil { + return Result{Spec: spec, Verdict: Review, Summary: "refusing to scan an install ref: " + err.Error()} + } + args := append([]string{"guard", spec.Ref, "--format", "json"}, g.ExtraArgs...) + // #nosec G204 -- bin is the operator-configured pkgxray CLI (PKGXRAY_BIN, or a + // PATH lookup of "pkgxray"), never attacker-controlled; args are a fixed argv + // with a validated ref and no shell, so the variable target cannot inject a + // command. Spawning the pkgxray CLI is this gate's sole purpose. cmd := exec.CommandContext(cctx, bin, args...) // pkgxray reads PKGXRAY_CACHE_URL from its environment (github.js routes // upstream fetches through the cache server). A child inherits our env, but @@ -129,6 +142,26 @@ func (g Guard) Check(ctx context.Context, spec InstallSpec) Result { return res } +// validateInstallRef guards the install reference before it is handed to the +// pkgxray CLI as an argv element. exec.* never invokes a shell, so this is +// defense-in-depth, not the primary control: it blocks a ref that could be read +// as a flag (argument injection) or that carries control bytes. Registry refs +// the parser produces (name, @scope/name, name@version) always pass. +func validateInstallRef(ref string) error { + if strings.TrimSpace(ref) == "" { + return errors.New("empty install ref") + } + if strings.HasPrefix(ref, "-") { + return errors.New("install ref must not begin with '-'") + } + for _, r := range ref { + if r < 0x20 || r == 0x7f { + return errors.New("install ref contains a control character") + } + } + return nil +} + func verdictFromDecision(decision string) Verdict { switch strings.ToLower(strings.TrimSpace(decision)) { case "block": diff --git a/examples/pkgxray-guard/pkgxrayguard/guard_test.go b/examples/pkgxray-guard/pkgxrayguard/guard_test.go index 43d2075..9ea7118 100644 --- a/examples/pkgxray-guard/pkgxrayguard/guard_test.go +++ b/examples/pkgxray-guard/pkgxrayguard/guard_test.go @@ -137,6 +137,31 @@ func itoa(n int) string { return string(b) } +func TestValidateInstallRef(t *testing.T) { + ok := []string{"lodash", "npm:lodash", "@scope/pkg", "@scope/pkg@1.2.3", "left-pad@1.3.0", "npm:some-package@1.2.3-rc.1"} + for _, ref := range ok { + if err := validateInstallRef(ref); err != nil { + t.Errorf("validateInstallRef(%q) = %v, want nil", ref, err) + } + } + bad := []string{"", " ", "-rf", "--format", "lodash\nrm", "a\x00b"} + for _, ref := range bad { + if err := validateInstallRef(ref); err == nil { + t.Errorf("validateInstallRef(%q) = nil, want error", ref) + } + } +} + +// A ref that could be read as a CLI flag is refused before any exec, surfaced +// as Review rather than shelled out. +func TestCheckRejectsFlagLikeRef(t *testing.T) { + g := Guard{Bin: fakePkgxray(t, `{"decision":"allow","report":{"summary":"","findings":[]}}`, 0)} + res := g.Check(context.Background(), InstallSpec{Ref: "--format"}) + if res.Verdict != Review { + t.Fatalf("Check(--format) verdict = %s, want review (refused before exec)", res.Verdict) + } +} + func TestCheckBlock(t *testing.T) { out := `{"decision":"block","report":{"summary":"1 high-severity finding","findings":[` + `{"severity":"high","category":"credential-access","rationale":"reads ~/.aws/credentials near a network sink","file":"lib/.telemetry.js"},` +