agentgovern-guard
v0.1.2
Published
Local proxy in front of AgentGovern that blocks when AgentGovern is slow or unreachable
Readme
agentgovern-guard
A small local proxy that sits between AgentGovern's coding-agent hook (~/.agentgovern/hook.sh) or your own code and AgentGovern. It runs on your machine.
Upstream: by default the guard forwards to
https://agentgovern.io. To use a different AgentGovern (a staging or self-run instance), setAGG_UPSTREAM, for exampleAGG_UPSTREAM=https://staging.example.com.
tool hook ── hook.sh ──POST──▶ guard 127.0.0.1:8787 ──▶ AgentGovern /api/v1/...
│
timeout · network error · 5xx · 3xx · any answer it doesn't recognise ⇒ answers "blocked"What it adds — and what it doesn't
| Situation | Without the guard | With the guard |
|---|---|---|
| AgentGovern unreachable or erroring | hook.sh blocks | Guard blocks with a clearer reason |
| AgentGovern slow | hook.sh waits up to 8 s, then blocks | Guard blocks at AGG_UPSTREAM_TIMEOUT_MS |
| Guard itself not running | — | hook.sh can't connect and blocks |
| Hook never runs (script deleted, hook not trusted in Codex /hooks, not accepted in Hermes) | Tool runs ungoverned | Still runs ungoverned. Nothing reaches the guard. Only the tool's own fail-closed setting covers this: Cursor failClosed, Hermes fail_closed. |
The guard does not make allowed calls faster. Every allowed action has to reach AgentGovern so it is logged, counted against the agent's spending cap, and checked against pause/revoke. So allows are never cached.
Run
npx [email protected] # forwards to https://agentgovern.io
AGG_UPSTREAM=https://staging.example.com npx [email protected] # or anywhere elseRequires Node 20+. No dependencies. It listens on 127.0.0.1:8787. Keep it running with your process manager of choice (launchd, systemd, pm2).
Then point hook.sh at it. Add this to the environment the coding tool runs in, for example your shell profile:
export AGENTGOVERN_URL=http://127.0.0.1:8787Your tool's hook config doesn't change. hook.sh still sends the agent key from ~/.agentgovern/<format>.auth, and the guard forwards it untouched.
Example: Claude Code
Set up the hook exactly as AgentGovern's Integrations → Claude Code card shows. The card's install command downloads
~/.agentgovern/hook.shand saves your key. Then put this in~/.claude/settings.json:{ "hooks": { "PreToolUse": [ { "matcher": "Bash|Read|Edit|Write|MultiEdit|NotebookEdit|WebFetch|WebSearch|mcp__.*", "hooks": [ { "type": "command", "command": "sh \"$HOME/.agentgovern/hook.sh\" claude-code", "timeout": 30 } ] } ] } }Point the hook at the guard. Claude Code's hooks inherit this from the environment it starts in:
export AGENTGOVERN_URL=http://127.0.0.1:8787Each tool call now goes
hook.sh→ guard →AGG_UPSTREAM/api/v1/hooks/claude-code. AgentGovern checks the whole call: the tool and its full input, not just the tool's name.
Config
Settings come from environment variables first, then guard.config.json (or $AGG_CONFIG), then the defaults below.
| Env | Default | Meaning |
|---|---|---|
| AGG_PORT / AGG_HOST | 8787 / 127.0.0.1 | Listen address |
| AGG_UPSTREAM | https://agentgovern.io | AgentGovern base URL. Set this to use any other instance. |
| AGG_UPSTREAM_BY_TOOL | — | JSON map of per-tool upstreams, keyed by hook format or X-AG-Tool. Example: {"codex":"https://staging…"} |
| AGG_UPSTREAM_TIMEOUT_MS | 8000 | Upstream answers slower than this are blocked |
| AGG_CACHE_TTL_SEC | 0 (off) | Block cache. See below |
| AGG_LOG | on | Set 0 to silence stderr logs. One line per answer: the path, the status, and AgentGovern's decision and reason. Logs never include keys or request bodies. |
Block cache (opt-in)
When AGG_CACHE_TTL_SEC is above 0, the guard caches only explicit "decision":"blocked" answers from /api/v1/evaluate:
- The cache key is the method, path,
Authorizationheader and full body. Two agents, or two different calls, never share an entry. - Allowed, pending-approval and hook answers are never cached. An approval must be able to flip a hook answer straight away.
- A repeat of a cached block isn't written to AgentGovern's audit log, and a rule you loosen takes up to one TTL to apply.
Behaviour
The guard decides for itself whether an answer is one the caller can act on. It doesn't rely on hook.sh's parser to catch anything odd.
- Paths. Only
/api/v1/*is proxied, checked after resolving dot segments, so/api/v1/../../xand/api/v1/%2e%2e/…can't escape it. Encoded/or\(%2f,%5c) and absolute-form targets (http://host/…,//host/…) are refused.- A POST anywhere else, including
/healthzand the 0.1.0 README's/tool-use/evaluate, is answered with a block:{"decision":"block","permission":"deny",…}. It's never a 404, which an oldcurl … || echohook would read as "go ahead". - Other methods outside
/api/v1/*get a 404. GET /healthzreports status, with any credentials in the upstream URL removed.
- A POST anywhere else, including
- Hook paths (
/api/v1/hooks/:format). Only a 2xx whose first line is exactly0or2is passed on. Everything else is answered with2\n<reason>(exit 2, which blocks in every supported tool): every 3xx, every 4xx, an empty body, an HTML page, or any other first line. /api/v1/evaluate. A 2xx JSON object with adecisionis passed on. So is a 4xx JSON object, which is AgentGovern's own "not allowed" answer, for example400 invalid_cost. Anything else is answered with{"decision":"blocked","allowed":false,"reason":…}: a 3xx, a 2xx withoutdecision, or non-JSON.- Other
/api/v1/*(me,approvals,receipts): a 2xx or 4xx JSON body is passed on. Anything else is blocked. - Wrong, revoked or deleted agent key (401/403). The answer says it's a key problem, not a policy decision, and the log line reads
KEY REJECTED ….- On
/evaluate, the status is kept and the JSON getskey_rejected: trueplus that reason. - On hook paths, the answer is
2\n<that reason>.
- On
- 5xx, timeout, network error or oversized answer are blocked in the same shapes.
- Logs. One line per answer:
allowonly when the answer really allows: hook exit 0 with no output, or the tool's own JSON saying so. A Claude Code deny or ask is exit 0 pluspermissionDecision, and it's logged asdenyorask.- Anything the guard doesn't recognise is logged as
unrecognised … => block. - Logs never include keys, request bodies, or credentials from the upstream URL.
- Upstream warning. At startup the guard warns if
AGG_UPSTREAMor anyAGG_UPSTREAM_BY_TOOLentry isn'thttps://. Plainhttp://is only expected for localhost.
Test
npm test # node --test test/*.test.js — works on Node 20, 22 and 24CI runs the suite on Node 20, 22 and 24 on every push to main and every PR. It also checks that the package still has zero dependencies and ships only src/, README.md and package.json.
30 offline tests. They cover:
- pass-through, blocking on outage, and the TTL;
- the cache-bypass cases: a look-alike call (same agent and resource, different body), a bigger cost, another agent's key, allows never cached;
- a paused agent blocked on the very next call;
- key-rejected wording and logs;
- the 0.1.0 legacy-path block;
- 0.1.2's answer validation: a 3xx or 2xx HTML/empty answer on a hook path, a 2xx without
decisionon/evaluate; - dot-segment and encoded-slash paths;
- log labels, GET-only
/healthz, and credential redaction.
Upgrading from 0.1.0
0.1.0 cached every answer for 60 s, keyed on agent + resource only. A different call to the same resource could be answered from memory, with no audit row and no spend charge, and pause or revoke took up to a minute to apply. Since 0.1.1, the guard:
- turns caching off by default;
- never caches allowed or pending answers;
- keys any opt-in block cache on the
Authorizationheader and the full body; - defaults the upstream to
https://agentgovern.io.
Hook setups from the 0.1.0 README (/tool-use/evaluate, a constant {"resource":"Bash"} body) no longer check anything, so 0.1.1 and later block them with a message. Switch to AgentGovern's hook script and AGENTGOVERN_URL=http://127.0.0.1:8787 as described above.
Releasing
Releases go out from CI, never from someone's machine. 0.1.0 was published by hand from a stale clone, a week behind main, and that's what this process prevents.
- Bump the version in a PR. Change
"version"inpackage.json, and merge once CI is green. - Run the release. Go to Actions → Release → Run workflow on
main, and enter the same version. The workflow:- runs the full test matrix;
- checks that the version matches
package.jsonand isn't already on npm or tagged; - runs
npm publishfrom that exact commit; - tags the commit
v<version>.
- Check it landed.
npm view agentgovern-guard versionshould show the new version, and thev<version>tag should point at the commit you merged.
Auth. The release workflow uses npm trusted publishing (OIDC), so no long-lived token is needed. It must be set up once on npmjs.com: agentgovern-guard → Settings → Trusted publishing → GitHub Actions, with organization or user dank0296, repository agentgovern-guard, workflow release.yml, environment npm. Until that's done, the workflow falls back to an NPM_TOKEN repository secret (a granular automation token). Delete the secret once trusted publishing works.
Provenance. None, while this repo is private. npm only attaches provenance for public repositories.
Don't run npm publish locally. If you must, publish from a clean, up-to-date checkout of main and tag it yourself.
