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 + } +}