vinctor-claude-code-hook
v0.6.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 — 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 set — only 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 pushis 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 asdelete:shell/gitwithout needing the target) and a push naming an explicit GitHub URL. A grant forwrite:github/<owner>/<repo>/contentstherefore does not cover thegit push origin mainyour agent actually runs — that call returnsask. Map it in operator config if you need it covered. The same "name the target explicitly or getask" rule governsgit fetch,git pull, and the three mappedghsubcommands (which read the repo from--repo/-Ronly, never from local git config). npm publishis cwd-dependent. The name indeploy:pkg/npm/<name>is read frompackage.jsonin the event'scwd(falling back to the hook process's own cwd when the event carries none). No readablepackage.jsonthere — or anamethat isn't a valid npm package name — means no name to authorize over, so the call returnsask, not a deny. Publishing a folder or tarball (npm publish ./dist,npm publish pkg.tgz) publishes something other than the cwd package and isaskas well.askis not a block. Everyaskabove 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.islocalhost). Everything else isnet/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 forsend: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 onnet/externalas 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). Defaultname— 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 exactlystrictto withhold the universalnet/<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 understrict, since operator config is consulted before the built-in.What
strictdoes 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.strictnarrows which names get a built-in mapping; it does not makenet/externalan 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 … | shmaps to nothing at all — not to thedeploy:pkg/npm/<name>its first segment would earn on its own, and not to theexecute:shell/curlits 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 toexecute:shell/<first-token>. Static shell quotes and escapes are resolved for destructivegit pushclassification for anchoredgitcommands, the explicit!/env/commandwrappers, 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 retaindelete. This boundary is 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."
Compound operations are authorized on every effect they cause (PKA-145). An
mcp__filesystem__move_filecauses three: the destination gains state (write), and the source is disclosed at a new location (read) and removed (delete). The hook makes one/v1/enforcecall 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_filestill 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. Underobserve, 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) — 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. 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. Liveallow/denydecisions require these;vinctor local startprints a set.
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— boundary id from the local Vinctor service. It is required by freshvinctor-core0.6.0 databases; upgraded databases retain their previous mandate default. The hook sends it asX-Vinctor-Boundary-Id.VINCTOR_ENFORCEMENT_MODE—enforce(default),simulate, orobserve. Observe mode always allows the tool call and sends onlyclassification, plus the mappedaction/resourcewhen available, toPOST /v1/observe. It never sends raw tool input and does not requireVINCTOR_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.simulatesends mapped calls toPOST /v1/simulateand always allows the tool call, including a recordedwould_deny. Unmapped calls continue through/v1/observeso 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_idis treated as no answer. On the enforce path that is a fail-closed deny. Inobserveandsimulateit 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 underenforce; in the permissive modes it surfaces the audit gap.VINCTOR_ENFORCE_TOOLS— comma-separated exact Claude tool names promoted from simulate to enforce, for exampleBash. This setting is used only whileVINCTOR_ENFORCEMENT_MODE=simulate; switching toobserveis the kill switch and prevents every promoted tool from enforcing.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.
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
outcomeandtool_name, but the current/v1/observecontract rejects those fields. The hook still returnsaskand 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.dblocal 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 5vinctor --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
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
