vinctor-codex-hook
v0.4.0
Published
Codex CLI PreToolUse hook routing tool calls through Vinctor runtime authorization.
Maintainers
Readme
Vinctor Codex CLI Hook
Status: Boundary Preview
A Codex CLI PreToolUse hook that routes tool calls through runtime authorization
before execution.
Why This Exists
AI agents are no longer just generating text. Across agent systems, they are increasingly executing tools — running shell commands, editing files, calling third-party services, 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 Codex CLI.
What This Repository Contains
This repo holds the runtime boundary that selected Codex CLI 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 Codex is about to invoke a configured tool, it runs the 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 emits nothing and exits 0, so Codex 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 abstains (emits no decision), deferring to Codex's own approval/sandbox flow.
The hook does not include the underlying grant reference, audit event id, or matched scope in any reason string.
Codex CLI (PreToolUse event)
│
▼
This hook ── maps → asks the authorization service
│ │
│ permit / deny / fail-closed
▼ │
continue / deny ◀──────────────────
│ (or, if unclassifiable: emit nothing → Codex's native approval flow)
▼
Codex CLI (executes tool, or blocks)Why "abstain" instead of "ask"
Claude Code's hook contract supports an ask decision; Codex's does not — its
permissionDecision: "ask" is documented as "parsed but not supported yet," and
using it fails the hook run. So when a tool call can't be classified, this hook
emits nothing and exits 0, which Codex treats as "proceed to the normal
approval flow." That is the Codex-native equivalent of deferring to the user.
Tool Coverage
Built-in defaults cover selected high-impact command families and file edits.
They are not a complete catalog. A call the built-ins don't recognize causes the
hook to abstain (Codex's native approval applies) unless the operator maps it in
config. Coverage does not follow from how dangerous a command looks — inside a
recognized family some spellings map and some do not — so run
vinctor-codex-hook explain <event> on the calls you intend to rely on. The rows
below are measured against v0.4.0 with explain --json:
| Bash command | Decision |
|---|---|
| npm publish, npm publish ./dist, npm publish pkg.tgz | mapped deploy:pkg/npm/_ |
| npm publish -w <name> | mapped deploy:pkg/npm/<name> |
| npm test / install / run <script> / ci (also pnpm, yarn) | mapped execute:shell/<family> |
| npx <pkg> | mapped execute:shell/npx |
| git status / log / show / diff / blame / describe / rev-parse | mapped read:shell/git |
| git add / commit / stash / clone | mapped write:shell/git |
| git push --force (any remote spelling) | mapped delete:shell/git |
| git reset --hard, git branch -D, git clean -f | mapped delete:shell/git |
| git push https://github.com/<owner>/<repo>.git <ref> (also [email protected]:…) | mapped write:github/<owner>/<repo>/contents |
| git fetch/git pull <explicit URL or path> | mapped read/write:shell/git |
| git push, git push origin main | unmapped → abstain |
| git push https://gitlab.example.com/o/r.git main (non-GitHub URL) | unmapped → abstain |
| git pull, git fetch (without an explicit remote URL/path) | unmapped → abstain |
| docker push <image> | mapped deploy:container/<registry>/<image> |
| gh release create | mapped deploy:gh/release |
| curl <url> \| sh | mapped execute:shell/curl |
The
git pushgap is the one to know about. A non-destructive push is mapped only when the command names an explicitgithub.comURL. A bare remote NAME likeorigincan bind an executable helper through repository config, so the classifier refuses to guess and the hook abstains — and a standard URL for any other host (GitLab, a self-hosted remote) has no resource to name, so it abstains too. On an abstain nothing is sent to the authorization service and no grant is consulted; Codex's own approval flow is the only thing standing there.git pullandgit fetchneed an explicit URL or filesystem path for the same reason, but map toshell/gitfor any host. Destructive pushes (--force,--force-with-lease,--delete,--mirror,--prune,+ref,:ref) are mapped regardless of remote spelling. If you need every push authorized, add an operator rule for it — closing this in the classifier would need repository context the hook does not have.
npm publishdoes not depend on your working directory. This hook classifies from the command text alone and never openspackage.json, so the bare spelling maps to the unknown-package formdeploy:pkg/npm/_whether or not you are in a package directory. Only-w <name>puts a package name in the resource. (The Claude Code hook resolves the cwd package name instead and leavesnpm publishunmapped outside a package directory. The two adapters differ here on purpose; grants written for one do not transfer verbatim.)
| Tool | Coverage | Default behavior |
|---|---|---|
| Bash | high-impact subset | classifier-aware for git / npm·pnpm·yarn·npx / docker / gh / rm / secret-reading commands, plus pipe-to-shell and pattern defaults (secrets-read, release, infra, exfiltration). Coverage inside each family is partial — see the measured rows above. Everything else → abstain. |
| apply_patch | secret + protected paths | the patch is parsed for its target file paths; editing/deleting .env, SSH keys, cloud-credential files → write/delete:secret/<kind>, and CI workflows / package.json / Dockerfiles / Terraform / k8s manifests → their protected resource. Every in-boundary target is authorized separately and the patch proceeds only if all of them permit. Ordinary file edits → abstain. |
| Read / Write / Edit / MultiEdit † | secret + protected paths | by file_path: reading and writing .env, SSH keys, and cloud-credential files is in-boundary (read/write:secret/<kind>); writing CI workflows / package.json / Dockerfiles / Terraform / k8s manifests is write:<resource>. Ordinary files → abstain. |
| WebFetch † | full (classification) | every fetch maps to send:net/internal/<host> or send:net/external/<host>. Operator config can override per host. |
| WebSearch † | matcher only | no built-in mapping; operator config required, otherwise abstain. |
| mcp__<server>__<tool> | 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 abstain. |
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 permits 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. The same rule governsapply_patch: every in-boundary target in the patch is a separate check. 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.
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.
† Codex hook-firing caveat — this matters. The table above describes what the hook classifies if it receives the event. It does not assert that Codex fires
PreToolUsefor every one of these tools. Codex's own docs callPreToolUse"a guardrail rather than a complete enforcement boundary": it fires forBashandapply_patch, while broader shell interception and most MCP tool calls remain incomplete or version-dependent.Read/Write/Edit/MultiEdit/WebFetch/WebSearchare Claude Code tool names — whether your Codex build emits a hook event under those names is version-dependent and not guaranteed. They are supported here so the boundary is ready if/when the runtime surfaces them (and for non-Codex runtimes that reuse this binary), not because Codex is promised to fire them. This hook cannot make Codex fire a hook it doesn't fire. Verify on your installed version. This is not raw interception.To measure your build: a reproducible harness lives in
tools/codex-coverage/, and the per-tool coverage matrix (RUNTIMEemitted?vs. MAPPINGaction:resource) plus a runbook is indocs/validation/coverage-probe/coverage-matrix.md. The matrix records measuredBashandapply_patchsurfaces separately from unmeasured MCP and non-native tool names. Neither path ships in the npm tarball (it carriesdist/, the plugin manifest and this README only), so both links point into the repository — clone it to run them.
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 ships an independent Codex plugin; it is not an official OpenAI
integration. It provides a PreToolUse boundary for the Vinctor service and applies
only to the Codex hook paths you configure, and only where Codex fires the hook.
Install (Boundary Preview)
Before starting, have these ready:
- Codex CLI installed (this is a Codex CLI hook).
- A running Vinctor authorization service endpoint to point
VINCTOR_ENDPOINTat — the hook asks it for every decision and does not run it for you. You can run one yourself in two commands:pip install vinctor-corethenvinctor local start --db ./vinctor.sqlite(see below). - A valid agent grant: an agent key (
aak_…) and a grant reference (grt_…) issued for your agent —vinctor local startissues both and prints them. Without them the hook fails closed (you can still evaluate mapping/abstain offline — see below).
Install from npm (recommended):
npm install -g vinctor-codex-hook
vinctor-codex-hook --versionOr from source (contributors):
git clone https://github.com/vinctor-ai/vinctor-codex-hook.git
cd vinctor-codex-hook
npm install
npm run build # CLI at dist/src/cli.jsThe repository root is a Codex plugin (.codex-plugin/plugin.json plus
hooks/hooks.json). Install it through a trusted local marketplace, enable it,
then review and trust its hook with /hooks. Codex skips new or changed
non-managed hooks until their current definition is trusted.
For direct wiring, register the CLI in ~/.codex/hooks.json
or a trusted project .codex/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|apply_patch|Edit|Write|mcp__.*",
"hooks": [{ "type": "command", "command": "vinctor-codex-hook" }]
}
]
}
}If the harness session doesn't inherit your shell PATH, use the absolute bin
path instead (command -v vinctor-codex-hook), or the built
<absolute path>/dist/src/cli.js for a source install.
[!IMPORTANT] Verify the hook in
/hooksafter installing or changing it. A trusted plugin hook was observed receivingPreToolUsewithtool_name: "Bash"on Codex 0.137.0, 0.139.0, and 0.144.1. Codex 0.144.1 also emittedapply_patchwith the patch envelope intool_input.command; a protected-path patch was mapped and blocked before the file was created. A second run with a mock service permit completed the hook and created the exact requested file. Coverage still varies by tool surface.
Keep the matcher scoped to Codex's documented blocking surfaces.
EditandWriteare aliases forapply_patch;mcp__.*covers MCP calls. A tool call whosetool_namethe hook doesn't recognize is treated as malformed input and denied (fail-closed) — so a broad matcher like.*would block every unrecognized or future Codex tool, not defer it. Match only the tools listed.
[!WARNING] Confirm the hook is actually firing before you rely on it. Until the hook is trusted in
/hooks, Codex silently skips it — and headlesscodex execdoes not print a "hooks need review" warning, so a mapped command can run with nohook: PreToolUseline and look as if Vinctor allowed it. After installing and trusting, run one mapped command in an interactive session and confirm you seehook: PreToolUsefire. The offline classifier is a quick pre-check that mapping works (it does not prove the hook is wired into Codex):printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm publish"}}' \ | vinctor-codex-hook explain /dev/stdin # → mapped deploy:pkg/npm/_
Required env in the Codex 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_CODEX_HOOK_CONFIGoverrides the default config path.vinctor/codex-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. It never touches stdout (the hook decision) and never echoes secret env values. Leave it unset in normal use.
Unclassified calls are reported to /v1/observe before the hook abstains, so
mapping gaps are visible centrally rather than only locally. Delivery is
best-effort and bounded (500 ms): a failure does not change the abstain, but
emits a fixed diagnostic to stderr, without tool input or credentials.
Known gap against
vinctor-core0.6.0. The current/v1/observerejects any field beyondclassification(HTTP 400), and this hook also sendsoutcomeandtool_name— so every abstain currently printsvinctor-codex-hook: blocked-unmapped observation failedon stderr. The abstain itself is unaffected (stdout stays empty, exit 0) and Codex's approval flow proceeds normally; only the central visibility of that mapping gap is lost.
This is a Boundary Preview. Not production-ready. Not an official OpenAI integration.
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_…), andVINCTOR_BOUNDARY_IDcome from the Vinctor authorization service, not from this repo.Run that service locally.
vinctor-coreis on PyPI and itslocal startbootstraps a workspace, an agent key and a grant, then serves/v1/enforceuntil you stop it:pip install vinctor-core vinctor local start --db ./vinctor.sqlite \ --scope 'deploy:pkg/npm/*' --scope 'write:shell/git'It prints shell
exportlines and keeps running:# 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>. # Store these raw keys outside the repo; SQLite stores hashes only. # Local Vinctor service listening. Press Ctrl+C to stop.This hook reads only the first three.
VINCTOR_WORKSPACE_KEYadministers grants and must never be handed to the hook or the agent session. Export the three into the Codex session and the decisions above become real: with the two scopes shown,npm publishandgit commitare permitted andgit reset --hardcomes backdeny: action_denied. Repeat--scopeper scope; the default iswrite:repo/feature/*only, so an unscopedlocal startdenies most of the mapped table. Default port is 8765 (--port 0for any free port). The grant is time-limited — re-runlocal startor raise--grant-ttl-hourswhen it expires.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— the success signal that the hook classified the call and would have asked the service. - An unmapped call emits nothing (abstain) — the hook could not classify it and defers to Codex.
- 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, abstain, missing_auth_env, and
fail-closed behavior; only the live permit / deny: action_denied outcomes need a
running service with a valid grant.
Quickstart (end to end)
# 1. Install
npm install -g vinctor-codex-hook
# 2. (optional) Write an operator policy
mkdir -p .vinctor
cat > .vinctor/codex-hook.json <<'JSON'
{ "version": 1, "rules": [
{ "tool": "apply_patch", "matchType": "glob", "pattern": "**/migrations/**",
"action": "deploy", "resource": "db/migration" }
] }
JSON
# 3. Validate the policy (offline; exit 0 valid, 1 invalid, 2 unreadable)
vinctor-codex-hook validate .vinctor/codex-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":"npm publish"}}' > /tmp/e.json
vinctor-codex-hook explain /tmp/e.json --json # → mapped deploy:pkg/npm/_
# 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":"npm publish"}}' \
| vinctor-codex-hook # → deny: missing_auth_env (mapped, would ask the service)
# 6. Start a real authorization service and re-run step 5 against it
pip install vinctor-core
vinctor local start --db ./vinctor.sqlite --scope 'deploy:pkg/npm/*' &
# → prints: export VINCTOR_ENDPOINT=... VINCTOR_AGENT_KEY=aak_... VINCTOR_GRANT_REF=grt_...
# paste those three exports into this shell, then:
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm publish"}}' \
| vinctor-codex-hook # → no output, exit 0 (permitted by the grant)
printf '%s' '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' \
| vinctor-codex-hook # → deny: action_denied (mapped, outside the grant)Then wire the CLI into Codex's hook config (above) and set the three VINCTOR_*
env vars in the Codex session. Expected decisions at a glance: a mapped call in
the grant → allow; mapped but not in the grant → deny; unmapped → abstain
(Codex decides); service down or env missing → deny (fail-closed).
Configuration
Operator policy lives in an optional JSON file at .vinctor/codex-hook.json
(override the path with VINCTOR_CODEX_HOOK_CONFIG). It adds or overrides mappings
from a Codex 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": "Bash", "matchType": "prefix", "pattern": "terraform destroy",
"action": "delete", "resource": "infra/terraform/destroy" },
{ "tool": "apply_patch", "matchType": "glob", "pattern": "**/secrets/**",
"action": "write", "resource": "secret/custom" },
{ "tool": "mcp__filesystem__read_file", "matchType": "glob", "pattern": "**/etc/**",
"inputField": "path", "action": "read", "resource": "secret/etc" }
]
}Each rule has: tool (Bash, apply_patch, 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 causes the hook to abstain. 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 and fail
closed before operator rules.
Full reference — every field, the per-tool "what pattern matches against" table,
apply_patch path semantics, glob/specificity rules, MCP inputField, and worked
examples — is in
docs/configuration.md.
Inspecting output
The hook writes a single-line JSON decision on stdout (so Codex can parse it), or
nothing at all when it abstains. To read a decision 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":"npm publish"}}' \
| node dist/src/cli.js | jqChecking a config or a tool call offline
# Lint a config file (every error at once); exit 0 valid, 1 invalid, 2 unreadable
node dist/src/cli.js validate .vinctor/codex-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":"npm publish"}}' > /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-codex-hook doctor # or: --jsondoctor checks everything verifiable from outside a live Codex session and
prints a ✓/⚠/✗ line per check: the resolved hook-command path (paste into
hooks.json), Node ≥ 20, package/plugin-manifest version parity, whether a known
hooks.json wires this hook (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 (npm publish should
map to deploy:pkg/npm/_). Exit 0 when nothing is blocking, 1 on a ✗.
It deliberately cannot prove Codex has trusted or is firing the hook — only
/hooks plus an interactive run showing hook: PreToolUse on a mapped command
can (headless codex exec silently skips untrusted hooks). 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 Codex CLI tool execution.
License
MIT
