diff --git a/examples/pkgxray-guard/README.md b/examples/pkgxray-guard/README.md new file mode 100644 index 0000000..b788234 --- /dev/null +++ b/examples/pkgxray-guard/README.md @@ -0,0 +1,205 @@ +# 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) + - 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, 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. + +## 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 +# 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`. | +| `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. | + +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 +different version is always re-scanned. + +### 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 | + +**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 +`REVIEW` under `balanced` blocks there too. + +## Layout + +``` +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 +│ ├── 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 +`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 + +# 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 + +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/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/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/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/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 ] diff --git a/examples/pkgxray-guard/helpers.go b/examples/pkgxray-guard/helpers.go new file mode 100644 index 0000000..e018b0b --- /dev/null +++ b/examples/pkgxray-guard/helpers.go @@ -0,0 +1,47 @@ +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) { + // #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 { + 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/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 new file mode 100644 index 0000000..0e57770 --- /dev/null +++ b/examples/pkgxray-guard/main.go @@ -0,0 +1,238 @@ +// 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" + "strconv" + "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 auditManifestEdit(cfg, ctx.FilePath, toFileEdits(ctx.Edits)) + }) + + hookshot.RunCommand() +} + +type config struct { + 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 + guardWorkers int // max packages guarded concurrently per command +} + +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) + } + guard := pkgxrayguard.Guard{ + Bin: bin, + Timeout: 60 * time.Second, + 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 + // 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", + guardWorkers: workers, + } +} + +// 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 := pkgxrayguard.CheckAll(ctx, cfg.checker, specs, cfg.guardWorkers) + + switch pkgxrayguard.DecideAll(cfg.policy, results) { + 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, ", ") +} + +// 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 := pkgxrayguard.CheckAll(ctx, cfg.checker, specs, cfg.guardWorkers) + 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: + 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/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) + } +} 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 new file mode 100644 index 0000000..181e274 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/guard.go @@ -0,0 +1,245 @@ +package pkgxrayguard + +import ( + "context" + "encoding/json" + "errors" + "os" + "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"] + CacheURL string // PKGXRAY_CACHE_URL forwarded to the CLI so registry/GitHub fetches collapse across runs (optional) +} + +// 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 { + Summary string `json:"summary"` + Findings []struct { + Severity string `json:"severity"` + Category string `json:"category"` + Rationale string `json:"rationale"` + File string `json:"file"` + } `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 { + // 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" + } + timeout := g.Timeout + if timeout == 0 { + timeout = 60 * time.Second + } + 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 + // 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 + 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 +} + +// 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": + 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 + } + line := "[" + f.Category + "] " + clip(reason, 160) + if f.File != "" { + line += " (" + clip(f.File, 80) + ")" + } + reasons = append(reasons, line) + 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..9ea7118 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/guard_test.go @@ -0,0 +1,221 @@ +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 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") + } + 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 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"},` + + `{"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 (lib/.telemetry.js)" { + 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) + } +} + +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 new file mode 100644 index 0000000..d2a2406 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/parse.go @@ -0,0 +1,273 @@ +// 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" +) + +// 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" (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 +// 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) { + continue + } + kind, ok := classifySpec(tok) + if !ok { + continue + } + specs = append(specs, InstallSpec{Ref: toRef(tok, kind), Manager: manager, Raw: tok, Kind: kind}) + } + 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 { + kind, ok := classifySpec(tok) + if !ok { + return nil + } + // npx/bunx/pnpm-dlx execute the fetched package immediately. + return []InstallSpec{{Ref: toRef(tok, kind), Manager: "npx", Raw: tok, Kind: kind, Immediate: true}} +} + +// 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 + } + 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 KindVCS, true + } + return KindRegistry, true +} + +// 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, kind SpecKind) string { + if kind == KindVCS { + return tok + } + 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..f101963 --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/parse_test.go @@ -0,0 +1,104 @@ +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"}}, + + // 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}, + {"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 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"} + 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..cc1469c --- /dev/null +++ b/examples/pkgxray-guard/pkgxrayguard/policy.go @@ -0,0 +1,98 @@ +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 + } +} + +// 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 { + 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 + } +}