npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vinctor-claude-code-hook

v0.6.0

Published

Claude Code PreToolUse hook routing tool calls through Vinctor runtime authorization.

Readme

Vinctor Claude Code Hook

Status: Boundary Preview

CI

A Claude Code PreToolUse hook that routes tool calls through runtime authorization before execution.

Set up: To wire Claude Code to Vinctor end to end, follow the Set up Claude Code guide.

Why This Exists

AI agents are no longer just generating text. Across agent systems, they are increasingly executing tools — running shell commands, editing files, querying databases, calling third-party APIs, triggering deployments, and reaching into systems that hold sensitive data. The surface area of "what an agent can touch" is expanding faster than the surface area of "what an agent has been explicitly authorized to do."

Static credentials and prompt-level safety guidelines do not cover this gap. An agent's allowed scope depends on the task, the operator, the target resource, and the moment. It changes during a session, can be revoked mid-run, and must be evaluated against operator-defined policy — not against the agent's own reasoning.

A runtime authorization boundary — one that decides, per tool call, whether a specific agent may perform a specific action on a specific resource right now — narrows that gap by making selected tool calls subject to an authorization decision before execution. This repository implements that boundary for Claude Code.

What This Repository Contains

This repo holds the runtime boundary that selected Claude Code tool calls pass through before they execute. It is one piece of the broader Vinctor runtime authorization infrastructure. The authorization service itself — grant model, policy evaluation, audit log, revocation — lives separately.

The hook does two things, in this order:

  1. Asks the authorization service for a decision. When Claude Code is about to invoke a configured tool (e.g. a Bash command), Claude Code invokes the configured hook for the PreToolUse event, the hook maps the tool input to an (action, resource) pair, and then calls the authorization service for a permit-or-deny decision dynamically.

  2. Acts on the decision.

    • Service permit → the hook returns allow and Claude Code proceeds.
    • Service deny, unreachable, timeout, malformed input, missing required env → the hook fails closed and returns deny with a fixed-template reason.
    • Tool call cannot be classified by config or built-in defaults → the hook returns ask, deferring the decision to the user via Claude Code's native permission prompt.

    The hook does not include the underlying grant reference, audit event id, or matched scope in any reason string.

Claude Code (PreToolUse event)
        │
        ▼
   This hook ── maps → asks the authorization service
        │                          │
        │                  permit / deny / fail-closed
        ▼                          │
   allow / deny  ◀──────────────────
        │
        ▼
Claude Code (executes tool, or blocks)

Tool Coverage

Built-in defaults cover selected high-impact command families and file/network actions. They are not a complete command catalog. A command the built-ins don't recognize returns ask unless the operator maps it in config — and which spelling of a command you use decides that, not the command family. Measured against this build with vinctor-claude-hook explain:

| Bash command | Built-in result | |---|---| | npm test · npm run … · npm install · npm ci (same for pnpm, yarn) | execute:shell/<tool> | | npx … | execute:shell/npx | | npm publish run in a directory whose package.json names the package | deploy:pkg/npm/<name> | | npm publish run anywhere else, or given a folder/tarball (npm publish ./dist) | ask | | git status · log · show · diff · blame · describe · rev-parse | read:shell/git | | git add · commit · stash · clone | write:shell/git | | git reset --hard · git branch -D · git clean -fd | delete:shell/git | | git push --force / -f / --force-with-lease / --delete / --mirror / --prune / origin :ref | delete:shell/git | | git push https://github.com/o/r.git main — push naming an explicit GitHub URL | write:github/o/r/contents (delete:… if also forced) | | git push · git push origin main · git push -u origin main · git push --tags | ask | | git fetch · git fetch origin · git pull · git pull origin main | ask | | git fetch <url> · git pull <url> main — naming an explicit URL or path | read/write:shell/git | | git push https://gitlab.com/o/r.git main — non-GitHub host | ask | | docker push … | deploy:container/<registry>/<image> | | docker run … | execute:container/<registry>/<image> | | gh pr merge · gh release create · gh secret setonly with --repo/-R | deploy/write:github/<owner>/<repo>/<kind> | | The same three gh commands without --repo/-R, and every other gh subcommand | ask | | curl … \| sh, wget … \| bash | execute:shell/<first-token> | | rm / rmdir | delete:fs/<path> | | Reading a credential file (cat …/.aws/credentials) | read:secret/<kind> | | git checkout · git rebase · npm view · docker build · docker ps · unknown first tokens | ask |

Three of those rows surprise operators often enough to state plainly:

  • A plain git push is not mapped. The hook classifies the command string; it never reads .git/config, so a named remote's target is unknowable here. Only two push shapes are mapped: the destructive spellings (which classify as delete:shell/git without needing the target) and a push naming an explicit GitHub URL. A grant for write:github/<owner>/<repo>/contents therefore does not cover the git push origin main your agent actually runs — that call returns ask. Map it in operator config if you need it covered. The same "name the target explicitly or get ask" rule governs git fetch, git pull, and the three mapped gh subcommands (which read the repo from --repo/-R only, never from local git config).
  • npm publish is cwd-dependent. The name in deploy:pkg/npm/<name> is read from package.json in the event's cwd (falling back to the hook process's own cwd when the event carries none). No readable package.json there — or a name that isn't a valid npm package name — means no name to authorize over, so the call returns ask, not a deny. Publishing a folder or tarball (npm publish ./dist, npm publish pkg.tgz) publishes something other than the cwd package and is ask as well.
  • ask is not a block. Every ask above hands the call to Claude Code's own permission prompt. Unmapped is uncovered, not denied — an operator who approves the prompt runs the command with no Vinctor decision behind it.

Use vinctor-claude-hook explain <event> to see exactly how any single call maps, or vinctor-claude-hook list-defaults to print the full built-in catalog (pattern rules, Bash classifier families, and the recognized MCP tools per server).

The Runtime column is measured against Claude Code 2.1.169 — whether that build actually sends a PreToolUse event to the hook for the tool (see the coverage matrix). Cells not exercised by that run are marked unmeasured; do not read them as covered.

| Tool | Runtime (2.1.169) | Coverage | Default behavior | |---|---|---|---| | Bash | measured: observed | high-impact subset | classifier-aware for a selected subset of git / npm·pnpm·yarn·npx / docker / gh / rm·rmdir, plus pipe-to-shell (… | sh) and pattern defaults (secrets, protected files, release, infra, exfiltration). Resources follow the Vinctor Action Taxonomy canon (shell/git, pkg/npm/<name>, container/<registry>/<image>, github/<owner>/<repo>/<kind>, fs/<path>). Everything else → ask. | | Read / Write / Edit | measured: observed | secret + protected paths | pattern defaults (secrets, protected files). Reading and writing .env, SSH keys, and cloud-credential files is in-boundary (read/write:secret/<kind>). Ordinary files → ask. | | MultiEdit | measured: not present in 2.1.169 | n/a | the matcher token is harmless, but this build emits no MultiEdit events (the model falls back to Edit). | | WebFetch | measured: observed | full | every fetch is mapped to send:net/internal/<host> or send:net/external/<host>. Operator config can override per-host. | | WebSearch | measured: observed | matcher only | no built-in mapping; operator config required, otherwise ask | | mcp__<server>__<tool> | unmeasured (no MCP server in probe) | built-in classifiers for filesystem, github, slack; other servers: matcher only | These three servers' common tools are mapped out of the box. Tools they don't recognize, and all other MCP servers, require operator config — otherwise ask. |

What counts as net/internal. WebFetch host classification treats these as internal: localhost, *.local, *.internal (the ICANN reserved private-use TLD), private/loopback/link-local IPv4 (10., 172.16–31., 192.168., 127., 169.254.), the unspecified/wildcard binds (0.0.0.0, 0.0.0.0/8, ::), the limited broadcast (255.255.255.255), IPv4 (224.0.0.0/4) and IPv6 (ff00::/8) multicast, and IPv6 loopback / ULA (fc00::/7) / link-local (fe80::/10). IPv4 addresses embedded in IPv6 (::ffff:127.0.0.1, ::169.254.169.254) are decoded and classified as the IPv4 address they name, and a trailing DNS root dot is canonicalised away (localhost. is localhost). Everything else is net/external. Operator config can override per-host.

Limitation: classification is by NAME, not by resolved address. A hostname is classified from the string alone — the hook never resolves it. A public name that resolves (or is re-bound) to a loopback, private, link-local, or metadata address is still classified net/external, so a grant for send:net/external/* authorizes it. This is inherent to a PreToolUse boundary: the hook does not own the socket, so any address it resolved could differ from the one the runtime finally connects to (DNS rebinding), and resolving here would give false assurance rather than protection. Do not rely on net/external as an SSRF boundary against attacker-chosen hostnames. Where that guarantee is required, enforce at the resource side, where the connection is actually made. Literal-IP spellings — including the alternate encodings above — are fully classified.

Opt-in strict hostname policy (VINCTOR_NET_HOSTNAME_POLICY). Default name — unchanged behaviour, and the behaviour for an unset, empty, or unrecognised value, so a typo can never silently turn enforcement on or off. Set it to exactly strict to withhold the universal net/<scope>/<host> built-in for the one case this hook cannot account for: a host that is not a literal IP and classifies external — a public NAME whose resolved destination the hook never sees. Such a call is denied (hostname_not_literal), not deferred. Literal IP addresses in every spelling keep their mapping, because classification fully accounts for them; so do reserved internal names (localhost, *.local, *.internal, RFC 6761 .localhost), because they already classify internal and an external-only grant never covered them. An operator config rule naming a host still authorizes it under strict, since operator config is consulted before the built-in.

What strict does NOT do. It does not resolve DNS and it does not stop DNS rebinding. For a hostname an operator has explicitly allowed by rule, the destination is still whatever the resolver returns at connect time, which this hook never observes. strict narrows which names get a built-in mapping; it does not make net/external an SSRF boundary. That guarantee belongs at the resource side, where the connection is actually made.

Bash is classified as one complete command. Unsupported chaining, grouping, substitution, and redirection operators stop classification before any operator rule or first-token classifier can match — and the whole command then returns ask, which is not a deny. Measured: npm publish && curl … | sh maps to nothing at all — not to the deploy:pkg/npm/<name> its first segment would earn on its own, and not to the execute:shell/curl its second would. Neither segment is enforced and the decision goes to Claude Code's permission prompt. One deliberate compound form is supported: piping into a recognized shell (curl … | sh) maps to execute:shell/<first-token>. Static shell quotes and escapes are resolved for destructive git push classification for anchored git commands, the explicit !/env/command wrappers, and trusted executable paths. Assignment prefixes, repository/config overrides, arbitrary executable paths, nested reinterpreters, external helpers, and dynamic or unresolved argv are left unmapped. Non-destructive Git fetch, pull, and push commands must name an explicit standard URL or filesystem path; default and named remotes can bind executable helpers through repository config. Ref deletion, mirror, and prune pushes retain delete. This boundary is detailed in the coverage matrix §3.

v0.3.0 ships built-in classifiers for the filesystem, github, and slack MCP servers. Coverage tracks each server's documented tool set (for slack, the reference @modelcontextprotocol/server-slack slack_* tools and the Zencoder-fork conversations_* / channels_* tools, matched on those servers' field names). Tool names or input fields outside a server's recognized set fall to ask; add an operator config rule to route them. Built-in MCP coverage is not exhaustive and is not "any tool on these servers."

Compound operations are authorized on every effect they cause (PKA-145). An mcp__filesystem__move_file causes three: the destination gains state (write), and the source is disclosed at a new location (read) and removed (delete). The hook makes one /v1/enforce call per distinct pair and allows the call only if all of them permit; the first refusal is a deny. It used to send the destination write alone, so a destination-write grant moved a protected file out of its subtree with no read and no delete on the source. Single-effect operations are unchanged and still make exactly one call.

This holds on the operator-config path too: a rule may ADD a charge; it may never SUBTRACT an effect. Its pair becomes the primary one, and the classifier's entire required set is required alongside it — so a rule naming move_file still requires the source read, the source delete AND the destination write. A rule cannot override a refusal either: when the classifier declines to map a multi-path call ("never move an ambiguous target"), config cannot turn that into a single-pair permit. Under observe, where nothing is blocked and every effect actually happens, each one is recorded as its own audit event.

Surface coverage caveat

Enforcement applies wherever Claude Code runs the agent loop on your machine and loads your local settings.json PreToolUse hook. Per the Claude Code docs, settings.json hooks are shared between the CLI and the VS Code extension, and Remote Control keeps the session running locally — so this hook is in the path on the CLI, the VS Code / JetBrains IDE surfaces, the Desktop app, and Remote Control sessions. Only the CLI is empirically measured (against 2.1.169); the other local surfaces are doc-reasoned and unverified here. Two surfaces are explicitly different:

  • Jupyter cell execution via the IDE MCP (mcp__ide__executeCode) — the PreToolUse hook does fire (the docs list it "as seen by hooks", so this hook can deny a cell), but VS Code adds a separate Quick Pick confirmation that the docs state is "separate from PreToolUse hooks." A hook allow therefore does not by itself run the cell, and the hook neither sees nor controls that Quick Pick. Not verified here.
  • Claude Code on the web (cloud-hosted) — runs in Anthropic-managed cloud, which does not load your local settings.json. This hook is not in the path there; treat web/cloud as not covered.

Details and doc citations are in the coverage matrix §4b Surface coverage.

What This Is Not

Vinctor authorizes configured, mediated tool calls routed through an adapter boundary. Unwrapped tool paths remain outside Vinctor's boundary. Vinctor does not provide OS/process/account isolation, sandboxing, raw tool interception, provider credential control, or rollback of already-started work.

This repository is not an official Claude Code plugin. It is a Claude Code PreToolUse hook boundary for the Vinctor authorization service.

Before You Start

You need:

  • Node.js 20+ and npm — to install (or build) the hook CLI.
  • A running Vinctor authorization service endpoint — the hook only classifies a tool call and asks the service; it does not run the service. Run one locally with pip install vinctor-core && vinctor local start --db ./vinctor-local.db (Python 3.11+), or evaluate the hook offline without one — both are in Getting a grant, and evaluating offline.
  • A valid agent grant — the agent API key (aak_…) and grant reference (grt_…) issued for your agent by that service. Live allow / deny decisions require these; vinctor local start prints a set.

Install (Boundary Preview)

From npm (recommended):

npm install -g vinctor-claude-code-hook
vinctor-claude-hook --version

Or from source (contributors):

git clone https://github.com/vinctor-ai/vinctor-claude-code-hook.git
cd vinctor-claude-code-hook
npm install
npm run build          # CLI at dist/src/cli.js

Wire the CLI into Claude Code's settings.json hooks.PreToolUse:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Read|Write|Edit|MultiEdit|WebFetch|WebSearch|mcp__.*",
        "hooks": [{ "type": "command", "command": "vinctor-claude-hook" }]
      }
    ]
  }
}

If the harness session doesn't inherit your shell PATH, use the absolute bin path instead (command -v vinctor-claude-hook), or the built <absolute path>/dist/src/cli.js for a source install.

Required env in the Claude Code session:

  • VINCTOR_ENDPOINT — base URL of the Vinctor authorization service
  • VINCTOR_AGENT_KEY — agent API key (aak_…)
  • VINCTOR_GRANT_REF — opaque grant reference (grt_…)

Optional:

  • VINCTOR_BOUNDARY_ID — boundary id from the local Vinctor service. It is required by fresh vinctor-core 0.6.0 databases; upgraded databases retain their previous mandate default. The hook sends it as X-Vinctor-Boundary-Id.

  • VINCTOR_ENFORCEMENT_MODEenforce (default), simulate, or observe. Observe mode always allows the tool call and sends only classification, plus the mapped action/resource when available, to POST /v1/observe. It never sends raw tool input and does not require VINCTOR_GRANT_REF. Observation delivery is best-effort: a missing or unavailable service does not block execution and therefore creates an audit gap that operators must monitor. Delivery failures emit a fixed diagnostic to stderr without tool input or credentials.

  • simulate sends mapped calls to POST /v1/simulate and always allows the tool call, including a recorded would_deny. Unmapped calls continue through /v1/observe so mapping gaps remain visible. Missing configuration or service failure also allows and emits a fixed stderr diagnostic for the resulting audit gap.

    A service answer that claims success but carries no usable audit_event_id is treated as no answer. On the enforce path that is a fail-closed deny. In observe and simulate it is logged to stderr and the tool call proceeds — by design, since those modes exist not to block. So the audit-id rule enforces only under enforce; in the permissive modes it surfaces the audit gap.

  • VINCTOR_ENFORCE_TOOLS — comma-separated exact Claude tool names promoted from simulate to enforce, for example Bash. This setting is used only while VINCTOR_ENFORCEMENT_MODE=simulate; switching to observe is the kill switch and prevents every promoted tool from enforcing.

  • VINCTOR_CLAUDE_CODE_HOOK_CONFIG overrides the default config path .vinctor/claude-code-hook.json.

  • VINCTOR_HOOK_DEBUG=1 makes hook mode write one diagnostic line to stderr per event, naming the resolved config path and whether it was found — handy when a config isn't being picked up. It never touches stdout (the hook decision) and never echoes secret env values. Leave it unset in normal use.

Under enforce, a call that no rule and no built-in classifies is blocked (ask) and reported to /v1/observe before the verdict is returned, so mapping gaps are visible centrally rather than only locally. That report is bounded at 500 ms and never changes the verdict; if it fails, or if VINCTOR_ENDPOINT / VINCTOR_AGENT_KEY are not set to attempt it at all, the hook says so on stderr — without tool input or credentials.

Known core 0.6.0 gap (PKA-192). The blocked-unmapped report adds outcome and tool_name, but the current /v1/observe contract rejects those fields. The hook still returns ask and emits a fixed stderr diagnostic; the central observation is missing. Mapped observe-mode payloads remain within the service contract.

This is a Boundary Preview. Not production-ready. Not an official Claude Code plugin.

Getting a grant, and evaluating offline

Real permit/deny decisions require a running Vinctor authorization service and a grant issued for your agent. The hook itself does not run that service and does not issue grants — it only classifies a tool call and asks the service.

Run the service locally

You do not need a hosted service or a design-partner invitation. The authorization service is the open-source vinctor-core package on PyPI, and it ships a local SQLite-backed service that issues exactly the values this hook reads. Requires Python 3.11+:

pip install vinctor-core
vinctor local start --db ./vinctor-local.db

local start prints shell exports, then stays in the foreground (Ctrl+C to stop):

# Vinctor local service exports
export VINCTOR_ENDPOINT="http://127.0.0.1:8765"
export VINCTOR_AGENT_KEY="aak_…"
export VINCTOR_GRANT_REF="grt_local"
export VINCTOR_WORKSPACE_KEY="wsk_…"
# Grant expires at <timestamp>.

The first three are the env vars this hook needs — paste them into the shell that runs Claude Code. VINCTOR_WORKSPACE_KEY is for operator-side calls (grants, revocation, audit) and the hook never reads it. Save the block when it is printed: SQLite stores only hashes, so the raw keys cannot be recovered from the --db file afterwards. To keep the same keys across restarts, pass them back in (vinctor local start --db … --workspace-key … --agent-key … --grant-ref …).

The grant local start issues is time-limited, and its scope is what decides allow vs deny. Give it the scopes you want to exercise:

vinctor local start --db ./vinctor-local.db --scope "deploy:container/docker.io/acme/api"

# then, in a second shell holding the printed exports:
vinctor-claude-hook doctor    # → service-reachable ✓

printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"docker push docker.io/acme/api:1.4.2"}}' \
  | vinctor-claude-hook       # → allow                (mapped, inside the grant)
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"}}' \
  | vinctor-claude-hook       # → deny: action_denied  (mapped, outside the grant)
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
  | vinctor-claude-hook       # → ask                  (unmapped; see Tool Coverage)

Both decisions land in the audit log:

vinctor --endpoint "$VINCTOR_ENDPOINT" --workspace-key "$VINCTOR_WORKSPACE_KEY" \
  --db ./vinctor-local.db operator audit list --limit 5

vinctor --help covers the rest of the local surface — vinctor agent for requesting grants, vinctor operator for approving, revoking, and auditing them. There is no hosted Vinctor service yet; vinctor local start is the supported way to get an endpoint and a grant today.

Evaluating offline

Without any of those env vars you can still evaluate the hook offline, because its decision is observable without ever reaching a service:

  • A mapped call with missing/incomplete auth env returns deny: missing_auth_env — that is the success signal that the hook classified the call and would have asked the service.
  • An unmapped call returns ask — the hook could not classify it and defers to Claude Code's permission flow.
  • A mapped call whose service is unreachable returns deny: service_unavailable — the hook fails closed.

So offline you can fully validate mapping, ask, missing_auth_env, and fail-closed behavior; only the live allow / deny: action_denied outcomes need a running service with a valid grant.

Quickstart (end to end)

# 1. Install
npm install -g vinctor-claude-code-hook

# 2. (optional) Write an operator policy
mkdir -p .vinctor
cat > .vinctor/claude-code-hook.json <<'JSON'
{ "version": 1, "rules": [
  { "tool": "Edit", "matchType": "glob", "pattern": "**/.github/workflows/*.yml",
    "action": "write", "resource": "ci/workflow" }
] }
JSON

# 3. Validate the policy (offline; exit 0 valid, 1 invalid, 2 unreadable)
vinctor-claude-hook validate .vinctor/claude-code-hook.json

# 4. See how a specific call would map (offline; no service call)
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"docker push docker.io/acme/api:1.4.2"}}' > /tmp/e.json
vinctor-claude-hook explain /tmp/e.json --json   # → mapped deploy:container/docker.io/acme/api

# 5. Run one event through the hook itself (no auth env → fails closed at the boundary)
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"docker push docker.io/acme/api:1.4.2"}}' \
  | vinctor-claude-hook          # → deny: missing_auth_env  (mapped, would ask the service)

Then wire the CLI into Claude Code's settings.json (above) and set the three VINCTOR_* env vars in the session — vinctor local start prints a working set. With those set, step 5's docker push becomes a real allow or deny: action_denied from the service. Expected decisions at a glance: a mapped call in the grant → allow; mapped but not in the grant → deny; unmapped → ask; service down or env missing → deny (fail-closed).

Configuration

Operator policy lives in an optional JSON file at .vinctor/claude-code-hook.json (override the path with VINCTOR_CLAUDE_CODE_HOOK_CONFIG). It adds or overrides mappings from a Claude Code tool call to an (action, resource) pair. The file is optional — without it, the hook uses only its built-in mappings.

{
  "version": 1,
  "rules": [
    { "tool": "WebSearch", "matchType": "prefix", "pattern": "salary",
      "action": "send", "resource": "web/search/sensitive" },
    { "tool": "mcp__filesystem__read_file", "matchType": "glob", "pattern": "**/etc/**",
      "inputField": "path", "action": "read", "resource": "secret/etc" }
  ]
}

Each rule has: tool (a Claude Code tool name or mcp__<server>__<tool>), matchType (exact / prefix / glob), pattern, action (one of read/write/execute/deploy/delete/send), and resource. Mandatory fail-closed shell-control checks, pipe-to-shell execution, and destructive force-push classification run first. Among all other mappings, operator rules take precedence and the most specific matching rule wins. A call that matches nothing falls to ask.

To protect an existing file path, map Read for it — not just Write/Edit. Measured against Claude Code 2.1.169: editing an existing file makes the runtime issue a Read first. If only Write/Edit are mapped (and Read is left to ask), the model stops at the unmapped Read and never reaches the mapped Write, so the deny never fires — the guard then covers only net-new file creation. Add a Read rule for the same path to close this. See the coverage matrix §4a.

Full reference — every field, the per-tool "what pattern matches against" table, glob and specificity semantics, MCP inputField, and worked examples — is in docs/configuration.md.

Inspecting output

The hook writes a single-line JSON decision on stdout (so Claude Code can parse it). To read it by hand, pipe through jq, and use --version / --help to check the binary without sending an event:

node dist/src/cli.js --version
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' \
  | node dist/src/cli.js | jq

Checking a config or a tool call offline

Two subcommands let an agent or operator check things without a running service:

# Lint a config file (every error at once); exit 0 valid, 1 invalid, 2 unreadable
node dist/src/cli.js validate .vinctor/claude-code-hook.json --json

# Show how an event would map (action, resource, which rule won); no service call
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"docker push docker.io/acme/api:1.4.2"}}' > /tmp/e.json
node dist/src/cli.js explain /tmp/e.json --json

--json prints a structured object (intended for agents); omit it for human-readable text. explain never calls /v1/enforce.

Self-checking the install with doctor

vinctor-claude-hook doctor          # or: --json

doctor checks everything verifiable from outside a live Claude Code session and prints a // line per check: the resolved hook-command path + version (paste into settings.json), Node ≥ 20, whether a known settings.json (~/.claude/settings.json or ./.claude/settings.json) wires this hook under hooks.PreToolUse (and warns on an over-broad .* matcher), the VINCTOR_* env (presence only — values are never printed), endpoint reachability (best-effort, no credentials sent), and a classifier smoke (docker push docker.io/acme/api:1.4.2 should map to deploy:container/docker.io/acme/api). Exit 0 when nothing is blocking, 1 on a .

It deliberately cannot prove Claude Code is invoking the hook — only running a mapped tool call and watching the PreToolUse hook fire can. doctor prints that caveat every run.

For what each decision and deny code means — and where to look when a permit you expected comes back denied — see docs/troubleshooting.md.

Status

Boundary Preview. Interfaces, configuration shape, and supported tool coverage may change before a stable release. Not production-ready.

Audience

Developers and design partners evaluating runtime authorization boundaries for Claude Code tool execution.

License

MIT