Skip to content
205 changes: 205 additions & 0 deletions examples/pkgxray-guard/README.md
Original file line number Diff line number Diff line change
@@ -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 <pkg…>` (incl. `yarn global add`)
- `npx` / `bunx` / `pnpm dlx` / `bun x` runners
- `claude mcp add <name> -- <launcher>` (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 <ref> --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`).

<!-- ci: exercise go.yml against examples/pkgxray-guard -->
6 changes: 6 additions & 0 deletions examples/pkgxray-guard/configs/cascade-hooks.json
Original file line number Diff line number Diff line change
@@ -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" }]
}
}
10 changes: 10 additions & 0 deletions examples/pkgxray-guard/configs/claude-settings.json
Original file line number Diff line number Diff line change
@@ -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" }] }
]
}
}
7 changes: 7 additions & 0 deletions examples/pkgxray-guard/configs/codex-hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|mcp__.*", "hooks": [{ "type": "command", "command": "/absolute/path/to/pkgxray-guard codex-pre-tool-use" }] }
]
}
}
7 changes: 7 additions & 0 deletions examples/pkgxray-guard/configs/cursor-hooks.json
Original file line number Diff line number Diff line change
@@ -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" }]
}
}
10 changes: 10 additions & 0 deletions examples/pkgxray-guard/configs/droid-settings.json
Original file line number Diff line number Diff line change
@@ -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" }] }
]
}
}
67 changes: 67 additions & 0 deletions examples/pkgxray-guard/e2e-smoke.sh
Original file line number Diff line number Diff line change
@@ -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 <pkg>`
# 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 <policy> <command> → 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 <name> <expected-decision> <policy> <command>
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 ]
47 changes: 47 additions & 0 deletions examples/pkgxray-guard/helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading