vinctor-claude-code-hook
v0.5.0
Published
Claude Code PreToolUse hook routing tool calls through Vinctor runtime authorization.
Maintainers
Readme
Vinctor Claude Code Hook
Status: Boundary Preview
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:
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
PreToolUseevent, the hook maps the tool input to an(action, resource)pair, and then calls the authorization service for a permit-or-deny decision dynamically.Acts on the decision.
- Service permit → the hook returns
allowand Claude Code proceeds. - Service deny, unreachable, timeout, malformed input, missing required env → the hook fails closed and returns
denywith 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.
- Service permit → the hook returns
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. For example,
npm test / npm publish / git commit / git push / docker push /
curl … | sh are mapped (per the Vinctor Action Taxonomy canon), but e.g.
git checkout, npm view, docker ps, and unknown first tokens are not — they
return ask. 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.), and IPv6 loopback / ULA (fc00::/7) / link-local (fe80::/10). Everything else isnet/external. Operator config can override per-host.
Bash sees only the first token/segment. Prefix wrappers (
sudo,env,VAR=…,bash -c,xargs) drop a command toask; chaining with&&/|classifies only the first segment and leaves the appended command unseen — e.g.npm publish && curl …is enforced only asdeploy:pkg/npm/<name>, andcat .env | curl …only asread:secret/env. (One deliberate exception: piping into a shell —curl … | sh— is classified as its own operation,execute:shell/<first-token>, per the taxonomy canon.) This applies to operator config rules too, not just built-in defaults — a config rule is matched against the same whole command, so anexactrule dies on any wrapper/chaining and aprefix/globrule maps only its matched segment. Do not treat a config rule as a command-level security boundary; it authorizes the classified scope, not the literal command line. Measured limitation, detailed in the coverage matrix §3.
v0.3.0 ships built-in classifiers for the
filesystem,github, andslackMCP servers. Coverage tracks each server's documented tool set (forslack, the reference@modelcontextprotocol/server-slackslack_*tools and the Zencoder-forkconversations_*/channels_*tools, matched on those servers' field names). Tool names or input fields outside a server's recognized set fall toask; add an operator config rule to route them. Built-in MCP coverage is not exhaustive and is not "any tool on these servers."
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) — thePreToolUsehook does fire (the docs list it "as seen by hooks", so this hook candenya cell), but VS Code adds a separate Quick Pick confirmation that the docs state is "separate fromPreToolUsehooks." A hookallowtherefore 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. (You can still evaluate the hook offline without one — see Getting a grant, and evaluating offline.)
- A valid agent grant — the agent API key (
aak_…) and grant reference (grt_…) issued for your agent by the Vinctor service. Liveallow/denydecisions require these.
Install (Boundary Preview)
From npm (recommended):
npm install -g vinctor-claude-code-hook
vinctor-claude-hook --versionOr 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.jsWire 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 serviceVINCTOR_AGENT_KEY— agent API key (aak_…)VINCTOR_GRANT_REF— opaque grant reference (grt_…)
Optional:
VINCTOR_BOUNDARY_ID— optional boundary id from the local Vinctor service. When set, the hook sends it asX-Vinctor-Boundary-Idso service audit rows can record which configured runtime boundary made the enforce call.VINCTOR_CLAUDE_CODE_HOOK_CONFIGoverrides the default config path.vinctor/claude-code-hook.json.VINCTOR_HOOK_DEBUG=1makes 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.
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.
VINCTOR_ENDPOINT,VINCTOR_AGENT_KEY(aak_…),VINCTOR_GRANT_REF(grt_…), and optionalVINCTOR_BOUNDARY_IDcome from the Vinctor authorization service or local evaluation setup, not from this repo.- There is no public Vinctor service yet. Design partners receive an endpoint and
the
aak_…/grt_…values from the Vinctor service / local evaluation setup (maintained separately). - Without those env vars you can still evaluate the hook offline, because its
decision is observable without ever reaching the 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.
- A mapped call with missing/incomplete auth env returns
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, once you
have a service endpoint + grant, set the three VINCTOR_* env vars in the session.
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. Operator rules
always take precedence over built-in mappings; the most specific matching rule
wins. A call that matches nothing falls to ask.
To protect an existing file path, map
Readfor it — not justWrite/Edit. Measured against Claude Code 2.1.169: editing an existing file makes the runtime issue aReadfirst. If onlyWrite/Editare mapped (andReadis left toask), the model stops at the unmappedReadand never reaches the mappedWrite, so thedenynever fires — the guard then covers only net-new file creation. Add aReadrule 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 | jqChecking 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: --jsondoctor 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
