Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions examples/pkgxray-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# pkgxray × hookshot — guard installs before they run

A [hookshot](https://github.com/CorridorSecurity/hookshot) hook binary that runs
[pkgxray](https://github.com/adamsjack711-ux/pkgxray) supply-chain triage on any
package an AI coding agent tries to install — **before a single line of it runs**
— and denies the command on a `BLOCK` verdict, with pkgxray's cited evidence
handed back to the agent.

hookshot supplies the cross-agent hook surface (Claude Code, Cursor, Windsurf
Cascade, Factory Droid, OpenAI Codex); pkgxray supplies the detection engine
(OSV vuln pre-check, sandboxed quarantine, static heuristics, prompt-injection
and obfuscation detection, GitHub provenance cross-check). This directory is the
glue.

```
agent runs: npm install left-pad evil-pkg@1.2.3
OnBeforeExecution (hookshot)
│ parse install targets
pkgxray guard npm:evil-pkg@1.2.3 --format json
│ SAFE / REVIEW / BLOCK (+ cited findings)
BLOCK → DenyExecution("pkgxray blocked …: credential-access …")
```

## What it does

- **`OnBeforeExecution`** — parses the agent's shell command for package
installs and runs `pkgxray guard` on each one:
- `npm|pnpm|yarn|bun install|i|add <pkg…>` (incl. `yarn global add`)
- `npx` / `bunx` / `pnpm dlx` / `bun x` runners
- `claude mcp add <name> -- <launcher>` (audits the launcher's package)
- Local paths (`./x`, `file:`), VCS URLs, and bare `npm ci`/`npm install`
are skipped — registry triage doesn't apply to them.
- **`OnAfterFileEdit`** *(opt-in)* — when the agent edits `package.json` or a
lockfile, runs `pkgxray audit` on it and feeds the verdict back as agent
context (or a block on Claude for a `BLOCK`).

The worst verdict across a multi-package command wins.

## Install

```bash
# 1. Build the hook binary (from inside the hookshot fork).
cd examples/pkgxray-guard
go build -o pkgxray-guard .

# 2. Make sure pkgxray is on PATH (or point PKGXRAY_BIN at it).
npm install -g pkgxray # or: export PKGXRAY_BIN=/path/to/pkgxray

# 3. Wire it into your agent(s). Either use hookshot's installer…
hookshot install --binary ./pkgxray-guard
# …or copy a config from ./configs/ into your agent's settings and set the
# absolute path to the built binary (see configs/claude-settings.json etc.).
```

> This example is part of the hookshot root module (no separate `go.mod`), so it
> builds and tests with the rest of the repo via `go build ./...` — no `go get`
> or `replace` needed.

## Configuration

All via environment variables (the hook reads them at startup):

| Variable | Default | Meaning |
|---|---|---|
| `PKGXRAY_BIN` | `pkgxray` | Path to the pkgxray CLI. |
| `PKGXRAY_HOOK_POLICY` | `balanced` | `strict` \| `balanced` \| `permissive` (see below). |
| `PKGXRAY_HOOK_DISABLE` | — | `1` bypasses all checks (fail-open kill switch). |
| `PKGXRAY_HOOK_AUDIT_LOCKFILES` | — | `1` enables the `OnAfterFileEdit` lockfile audit. |
| `PKGXRAY_GUARD_ARGS` | — | Extra flags passed to `pkgxray guard`, e.g. `--no-github-diff`. |

### Policies

| Verdict | `strict` | `balanced` (default) | `permissive` |
|---|---|---|---|
| `BLOCK` | deny | deny | deny |
| `REVIEW` | deny | **ask** | allow |
| `UNKNOWN` (pkgxray failed to run) | deny | deny | allow |
| `SAFE` | allow | allow | allow |

`balanced` never fails open on a broken pkgxray: if the CLI is missing or
errors, the verdict is `UNKNOWN` and the install is denied. On OpenAI Codex,
hookshot rewrites an `ask` decision to a deny (Codex has no approval prompt), so
`REVIEW` under `balanced` blocks there too.

## Layout

```
examples/pkgxray-guard/
├── main.go hookshot handler registration + env config
├── helpers.go lockfile detection + pkgxray CLI runner
├── pkgxrayguard/ pure, stdlib-only, unit-tested core
│ ├── parse.go shell command → []InstallSpec
│ ├── guard.go run `pkgxray guard`, map verdict + reasons
│ ├── policy.go verdict × policy → allow/ask/deny
│ └── *_test.go table tests + fake-pkgxray exec tests (offline)
└── configs/ ready-to-edit hook configs per agent
```

The `pkgxrayguard` package has no third-party dependencies, so
`go test ./pkgxrayguard/...` runs without the hookshot module or a network.

## Try it

```bash
go test ./pkgxrayguard/...

# Simulate a Claude PreToolUse event (deny path depends on the real package):
echo '{"tool_name":"Bash","tool_input":{"command":"npm install left-pad"}}' \
| ./pkgxray-guard claude-pre-tool-use
```

## CI

This example is part of the hookshot root module, so the repo's
[`.github/workflows/go.yml`](../../.github/workflows/go.yml) already builds and
tests it via `go build ./...` / `go test ./...` on every push and PR — including
the offline `pkgxrayguard` table tests.

To gate a *consuming* repo's dependencies on pkgxray in CI, use the reusable
audit workflow published in the pkgxray repo:

```yaml
jobs:
supply-chain:
uses: adamsjack711-ux/pkgxray/.github/workflows/pkgxray-audit.yml@main
with:
fail-on: block # or "review" to also fail on REVIEW verdicts
```

## Notes & limits

- Only registry installs are triaged. Local/VCS installs are out of scope for
pre-install registry analysis and are allowed through.
- Command parsing is conservative: unusual shapes (deeply nested subshells,
variable-expanded package names) may not be recognized. Unrecognized → allowed
rather than wrongly blocked. Treat the hook as defense-in-depth, not a
complete sandbox.
- `pkgxray guard` reaches the network (registry/OSV/GitHub). Budget ~1s/package;
tune with `PKGXRAY_GUARD_ARGS` (e.g. `--no-github-diff --no-github`).
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" }]
}
}
43 changes: 43 additions & 0 deletions examples/pkgxray-guard/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"errors"
"os/exec"
"path/filepath"
"strings"
)

// dependencyManifests are the files whose edits warrant a re-audit.
var dependencyManifests = map[string]bool{
"package.json": true,
"package-lock.json": true,
"yarn.lock": true,
"pnpm-lock.yaml": true,
"npm-shrinkwrap.json": true,
}

func isDependencyManifest(filePath string) bool {
return dependencyManifests[filepath.Base(filePath)]
}

// runCLI runs the pkgxray CLI and returns its combined output and exit code.
func runCLI(bin string, args ...string) (string, int) {
cmd := exec.Command(bin, args...)
out, err := cmd.CombinedOutput()
if err == nil {
return string(out), 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return string(out), exitErr.ExitCode()
}
return err.Error(), -1
}

func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
157 changes: 157 additions & 0 deletions examples/pkgxray-guard/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Command pkgxray-guard is a hookshot hook binary that audits packages with
// pkgxray before an AI coding agent installs them.
//
// It registers two unified hookshot handlers so it works across Claude Code,
// Cursor, Windsurf Cascade, Factory Droid, and OpenAI Codex:
//
// - OnBeforeExecution: parses the agent's shell command for package installs
// (npm/pnpm/yarn/bun add|install, npx/bunx/pnpm-dlx runners, and
// `claude mcp add … -- <launcher>`), runs `pkgxray guard` on each package,
// and denies the command on a BLOCK verdict — carrying pkgxray's cited
// evidence back to the agent as the deny reason.
// - OnAfterFileEdit: when the agent edits package.json or a lockfile, runs
// `pkgxray audit` on it and feeds the verdict back as context (opt-in).
//
// Configuration (environment variables):
//
// PKGXRAY_BIN path to the pkgxray CLI (default "pkgxray")
// PKGXRAY_HOOK_POLICY strict | balanced | permissive (default "balanced")
// PKGXRAY_HOOK_DISABLE set to "1" to bypass all checks (fail-open)
// PKGXRAY_HOOK_AUDIT_LOCKFILES set to "1" to enable the OnAfterFileEdit audit
// PKGXRAY_GUARD_ARGS extra space-separated flags for `pkgxray guard`
//
// Build: go build -o pkgxray-guard .
// Install: hookshot install --binary ./pkgxray-guard
package main

import (
"context"
"os"
"path/filepath"
"strings"
"time"

"github.com/CorridorSecurity/hookshot"
"github.com/CorridorSecurity/hookshot/examples/pkgxray-guard/pkgxrayguard"
)

func main() {
cfg := loadConfig()

hookshot.OnBeforeExecution(func(ctx hookshot.ExecutionContext) hookshot.ExecutionDecision {
if cfg.disabled || ctx.Type != hookshot.ExecutionShell {
return hookshot.AllowExecution()
}
specs := pkgxrayguard.ParseInstalls(ctx.Command)
if len(specs) == 0 {
return hookshot.AllowExecution()
}
return decideInstalls(cfg, specs)
})

hookshot.OnAfterFileEdit(func(ctx hookshot.FileEditContext) hookshot.FileEditDecision {
if cfg.disabled || !cfg.auditLockfiles || !isDependencyManifest(ctx.FilePath) {
return hookshot.FileEditOK()
}
return auditManifest(cfg, ctx.FilePath)
})

hookshot.RunCommand()
}

type config struct {
guard pkgxrayguard.Guard
policy pkgxrayguard.Policy
disabled bool
auditLockfiles bool
}

func loadConfig() config {
bin := os.Getenv("PKGXRAY_BIN")
if bin == "" {
bin = "pkgxray"
}
var extra []string
if raw := strings.TrimSpace(os.Getenv("PKGXRAY_GUARD_ARGS")); raw != "" {
extra = strings.Fields(raw)
}
return config{
guard: pkgxrayguard.Guard{Bin: bin, Timeout: 60 * time.Second, ExtraArgs: extra},
policy: pkgxrayguard.ParsePolicy(os.Getenv("PKGXRAY_HOOK_POLICY")),
disabled: os.Getenv("PKGXRAY_HOOK_DISABLE") == "1",
auditLockfiles: os.Getenv("PKGXRAY_HOOK_AUDIT_LOCKFILES") == "1",
}
}

// decideInstalls audits every package the command would install and folds the
// per-package results into one hook decision (worst verdict wins).
func decideInstalls(cfg config, specs []pkgxrayguard.InstallSpec) hookshot.ExecutionDecision {
ctx := context.Background()
results := make([]pkgxrayguard.Result, 0, len(specs))
for _, spec := range specs {
results = append(results, cfg.guard.Check(ctx, spec))
}

worst := pkgxrayguard.Worst(results)
switch pkgxrayguard.Decide(cfg.policy, worst) {
case pkgxrayguard.Deny:
return hookshot.DenyExecution(denyMessage(results))
case pkgxrayguard.Ask:
return hookshot.AskExecution(denyMessage(results))
default:
return hookshot.AllowExecutionWithReason("pkgxray: no blocking supply-chain risk in " + joinRefs(specs))
}
}

// denyMessage renders the offending packages and pkgxray's cited evidence so
// the agent (and user) see why the install was stopped.
func denyMessage(results []pkgxrayguard.Result) string {
var b strings.Builder
b.WriteString("pkgxray blocked this install:")
for _, r := range results {
switch r.Verdict {
case pkgxrayguard.Block, pkgxrayguard.Review, pkgxrayguard.Unknown:
b.WriteString("\n • ")
b.WriteString(r.Spec.Ref)
b.WriteString(" → ")
b.WriteString(string(r.Verdict))
if r.Summary != "" {
b.WriteString(" (" + r.Summary + ")")
}
if r.Err != nil {
b.WriteString(" [pkgxray error: " + r.Err.Error() + "]")
}
for _, reason := range r.Reasons {
b.WriteString("\n - ")
b.WriteString(reason)
}
}
}
b.WriteString("\nRe-run `pkgxray guard <ref>` for the full report, or set PKGXRAY_HOOK_POLICY=permissive to override.")
return b.String()
}

func joinRefs(specs []pkgxrayguard.InstallSpec) string {
refs := make([]string, len(specs))
for i, s := range specs {
refs[i] = s.Ref
}
return strings.Join(refs, ", ")
}

// auditManifest runs `pkgxray audit <lockfile>` and reports the verdict back to
// the agent. Post-edit hooks can't undo the write, so a BLOCK becomes agent
// feedback (FileEditBlock, honored by Claude); anything else is added context.
func auditManifest(cfg config, filePath string) hookshot.FileEditDecision {
bin := cfg.guard.Bin
out, code := runCLI(bin, "audit", filePath)
summary := firstLine(out)
switch code {
case 2:
return hookshot.FileEditBlock("pkgxray flagged a dependency in " + filepath.Base(filePath) + ": " + summary)
case 3:
return hookshot.FileEditAddContext("pkgxray: review recommended for " + filepath.Base(filePath) + ": " + summary)
default:
return hookshot.FileEditOK()
}
}
Loading