agentgovern 2.7.0__tar.gz → 2.7.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {agentgovern-2.7.0 → agentgovern-2.7.3}/CHANGELOG.md +45 -0
  2. {agentgovern-2.7.0 → agentgovern-2.7.3}/PKG-INFO +22 -15
  3. {agentgovern-2.7.0 → agentgovern-2.7.3}/README.md +20 -12
  4. {agentgovern-2.7.0 → agentgovern-2.7.3}/RELEASING.md +8 -0
  5. agentgovern-2.7.3/agentgovern/_version.py +1 -0
  6. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/cli.py +2 -1
  7. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/data/hook.py +88 -8
  8. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/installer.py +23 -0
  9. {agentgovern-2.7.0 → agentgovern-2.7.3}/pyproject.toml +2 -3
  10. agentgovern-2.7.3/tests/hook/fixtures/action_name_cuts.json +35 -0
  11. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/fixtures/hook_2_4_0_golden_outputs.json +0 -32
  12. agentgovern-2.7.3/tests/hook/test_action_name_cuts_fixture.py +50 -0
  13. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_nagt400.py +13 -9
  14. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_nagt400_compat.py +6 -2
  15. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_installer.py +62 -0
  16. agentgovern-2.7.0/agentgovern/_version.py +0 -1
  17. {agentgovern-2.7.0 → agentgovern-2.7.3}/.gitignore +0 -0
  18. {agentgovern-2.7.0 → agentgovern-2.7.3}/LICENSE +0 -0
  19. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/__init__.py +0 -0
  20. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/__main__.py +0 -0
  21. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/client.py +0 -0
  22. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/exceptions.py +0 -0
  23. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/gate2.py +0 -0
  24. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/__init__.py +0 -0
  25. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/data/__init__.py +0 -0
  26. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/drift.py +0 -0
  27. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/hook/settings.py +0 -0
  28. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/instrumentors/__init__.py +0 -0
  29. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/instrumentors/_base.py +0 -0
  30. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/instrumentors/langchain.py +0 -0
  31. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/instrumentors/langgraph.py +0 -0
  32. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentgovern/types.py +0 -0
  33. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentshield/__init__.py +0 -0
  34. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentshield/client.py +0 -0
  35. {agentgovern-2.7.0 → agentgovern-2.7.3}/agentshield/types.py +0 -0
  36. {agentgovern-2.7.0 → agentgovern-2.7.3}/examples/langgraph_credit_decision_agent.py +0 -0
  37. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/__init__.py +0 -0
  38. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/conftest.py +0 -0
  39. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/__init__.py +0 -0
  40. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/conftest.py +0 -0
  41. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/fixtures/claude_code_pretooluse_output_keys.json +0 -0
  42. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_bundled_hook.py +0 -0
  43. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_drift.py +0 -0
  44. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_context_snapshot.py +0 -0
  45. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_dispatch.py +0 -0
  46. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_outbox_queue.py +0 -0
  47. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_payload_extraction.py +0 -0
  48. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_payloads.py +0 -0
  49. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_remediation_message.py +0 -0
  50. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_system_message.py +0 -0
  51. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_timing_log.py +0 -0
  52. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_transcript_reading.py +0 -0
  53. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_turn_index.py +0 -0
  54. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_hook_v2_warn_verdict.py +0 -0
  55. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_matcher_coverage.py +0 -0
  56. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_settings.py +0 -0
  57. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/hook/test_tool_map_classification.py +0 -0
  58. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_base_instrumentor.py +0 -0
  59. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_client.py +0 -0
  60. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_evaluate_input.py +0 -0
  61. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_gate2_full_path.py +0 -0
  62. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_gate2_latency.py +0 -0
  63. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_gate2_verdict_handler.py +0 -0
  64. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_import_guard.py +0 -0
  65. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_instrument_langchain.py +0 -0
  66. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_langchain.py +0 -0
  67. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_langchain_1x_agent.py +0 -0
  68. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_langchain_gate1.py +0 -0
  69. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_langchain_gate2_callbacks.py +0 -0
  70. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_langgraph.py +0 -0
  71. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_nagt380_enforce_propagation.py +0 -0
  72. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_no_localhost_3000_config.py +0 -0
  73. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_no_localhost_3000_traffic.py +0 -0
  74. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_output_capture_contract.py +0 -0
  75. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_payload_schema.py +0 -0
  76. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_policy_engine_url.py +0 -0
  77. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_rate_limit_handling.py +0 -0
  78. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_register_contract.py +0 -0
  79. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_timestamp.py +0 -0
  80. {agentgovern-2.7.0 → agentgovern-2.7.3}/tests/test_version_consistency.py +0 -0
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.3 — release date is the `sdk-v2.7.3` tag's (prepared 2026-10-22)
4
+
5
+ **This release bundles Claude Code hook 2.5.1** (it was 2.5.0), and **`agentgovern hook install` registers the Python that runs it, by absolute path, instead of a bare `python3`.** The dependencies and the Python requirement (3.10 or later; the hook itself runs on 3.9 or later) are unchanged from 2.7.2. After upgrading, run `agentgovern hook install --force`: it installs hook 2.5.1 and registers the Python by absolute path. The install snippet already passes the absolute path from 2026-10-22. The hook's behaviour changes only if you set `AGENTGOVERN_FAIL_ACTION=deny`.
6
+
7
+ ### Fix — `AGENTGOVERN_FAIL_ACTION=deny` blocks every tool call AgentGovern didn't check (NAGT-484)
8
+
9
+ - **Before (hook 2.5.0 and earlier):** `deny` blocked a tool call only when the hook got no answer at all (its own 5-second timeout, or a network error). Two other unchecked cases still let the call run:
10
+ - **an error answer** from AgentGovern (any HTTP 4xx or 5xx, a 429 rate limit included);
11
+ - **a `timeout` verdict:** AgentGovern answered, but its policy engine could not evaluate the call. The dashboard shows these calls as "Evaluation Failed".
12
+ - **Now:** with `deny`, the hook blocks in those two cases as well, so every call it couldn't get checked is blocked. The reason Claude Code shows is the hook's own text and, for an error, the HTTP status. Nothing from AgentGovern's response is shown.
13
+ - **With a 429 under `deny`,** the call is blocked, and the "rate limit reached, allowed" notice from 2.5.0 is not shown, since it would no longer be true.
14
+ - **The default (`allow`) is unchanged:** an unchecked call still runs.
15
+ - **Not changed:** a verdict the hook doesn't recognise still lets the call run, so a newer server can't stop an older hook; prompt, tool-result and Stop events never block.
16
+ - **Also blocked under `deny`: a tool call while `AGENTGOVERN_API_KEY` or `AGENTGOVERN_ACTOR_USER_ID` is unset or empty (NAGT-500).** Up to 2.5.0 the hook printed "… not set — governance unavailable, proceeding unaudited." and let the call run, whatever the setting. Now Claude Code shows: *"AgentGovern isn't set up in this shell: AGENTGOVERN_API_KEY is not set, so this tool call was not checked. AGENTGOVERN_FAIL_ACTION=deny: blocking it. To fix it, paste your install snippet again from Settings → Developers in the AgentGovern dashboard, or set AGENTGOVERN_API_KEY and open a new terminal."* It names only the variables that are missing, and never shows any value. Under `allow` the message and behaviour are unchanged. `AGENTGOVERN_HOOK_MODE=off` still turns the hook off.
17
+
18
+ ### Fix — the hook runs with the Python you installed, whatever Claude Code's PATH is (NAGT-490)
19
+
20
+ - **What it did:** `hook install` wrote `"command": "python3"` (`python` on Windows) into `~/.claude/settings.json`. Claude Code looks that name up on its own PATH when it runs the hook, and that need not be your shell's: an app started from the Dock gets `/usr/bin:/bin:/usr/sbin:/sbin`.
21
+ - **So the hook could run under a different Python from the one you installed with,** and on a Mac without the Command Line Tools, `/usr/bin/python3` is a stub that only asks you to install them. There the hook never ran, and every tool call went ahead unchecked.
22
+ - **Now:** it writes the absolute path of the Python running `agentgovern hook install`: in a virtual environment, that environment's own `python`. It falls back to the bare name only if Python can't say where it is.
23
+ - **`--python-command` still overrides it.**
24
+
25
+ ## 2.7.2 — release date is the `sdk-v2.7.2` tag's (prepared 2026-10-19)
26
+
27
+ **`agentgovern hook uninstall` now leaves Claude Code's settings exactly as they were before the install.** The bundled Claude Code hook (2.5.0), the dependencies and the Python requirement (3.10 or later) are unchanged from 2.7.1. There is no need to reinstall the hook.
28
+
29
+ ### Fix — the uninstall leaves nothing behind in settings.json (NAGT-391 F8)
30
+
31
+ - **The install-mode marker (`_agentgovern_install_mode`) is removed with the hook entries.** It was left in `~/.claude/settings.json`.
32
+ - **The backup the uninstall writes (`settings.json.bak`) is removed once the new settings are written.** Left behind, it was a copy of the settings that still registered the hook, and restoring it would have pointed Claude Code at a hook script the uninstall steps delete. If the write fails, the backup stays.
33
+ - Found by the install rehearsal workflow (`.github/workflows/guard-install-rehearsal.yml`), which installs and uninstalls on clean Linux, macOS and Windows runners and compares the home folder before and after.
34
+
35
+ ## 2.7.1 — release date is the `sdk-v2.7.1` tag's (prepared 2026-10-16)
36
+
37
+ **Documentation and package metadata only. The code, its dependencies and the bundled Claude Code hook (2.5.0) are unchanged from 2.7.0.** There is no need to reinstall the hook.
38
+
39
+ ### Fix — the links on PyPI work (NAGT-391 F2)
40
+
41
+ - **The project's Repository and Issues links pointed at a GitHub repository that is not public,** so they returned "not found" for everyone outside Zirahn. They are replaced by a **Support** link, https://zirahn.com/contact. The README's own links are corrected the same way.
42
+
43
+ ### Fix — the README says which Python you need (NAGT-391 F2)
44
+
45
+ - **The install line now needs Python 3.10 or later and asks for `agentgovern>=2.7.0`.** On Python 3.9, such as the macOS system Python, an unpinned `pip install agentgovern` installed 2.5.0 without a word; the pinned line fails with "No matching distribution found" instead.
46
+ - **The Claude Code hook section says what each policy mode does,** and that a new organization starts with nothing in enforce mode. It said the hook enforces your policies, which read as if it always blocked.
47
+
3
48
  ## 2.7.0 — release date is the `sdk-v2.7.0` tag's (prepared 2026-10-16)
4
49
 
5
50
  **The Claude Code hook bundled with this release is hook 2.5.0** (it was 2.4.0). Install it with `agentgovern hook install --force` after upgrading; see "Upgrading an existing install" in the README. Until you do, the hook you have keeps working exactly as before, and the AgentGovern server shows it a once-per-session notice that a newer hook is available.
@@ -1,10 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgovern
3
- Version: 2.7.0
3
+ Version: 2.7.3
4
4
  Summary: Compliance-as-code middleware for agentic AI workflows.
5
5
  Project-URL: Homepage, https://agentgovern.zirahn.com
6
- Project-URL: Repository, https://github.com/ahmedkhan-zirahn/agentgovern
7
- Project-URL: Issues, https://github.com/ahmedkhan-zirahn/agentgovern/issues
6
+ Project-URL: Support, https://zirahn.com/contact
8
7
  Author-email: Ahmed Khan <ahmed.khan@zirahn.com>
9
8
  License: MIT
10
9
  License-File: LICENSE
@@ -44,14 +43,16 @@ Description-Content-Type: text/markdown
44
43
 
45
44
  **Compliance-as-code for agentic AI workflows.**
46
45
 
47
- > **Beta** — API may change before 1.0. [Report issues](https://github.com/ahmedkhan-zirahn/agentgovern/issues).
46
+ > **Beta** — API may change before 1.0. Report issues through [zirahn.com/contact](https://zirahn.com/contact).
48
47
 
49
48
  AgentGovern intercepts AI agent actions, evaluates them against configurable compliance policies (the EU AI Act today; NIST AI RMF and ISO 42001 are on the roadmap — see the table below), and generates audit-ready evidence in near real-time. Tracking runs in a background thread and fails open. An `enforce`-mode block raises in your code (see *Enforcement modes* below for exactly what it stops). *(Corrected again 2026-10-03: the first correction said "the SDK blocks only if you enable Gate 2 `enforce` mode, where a block verdict raises `OutputBlocked`", which reads as if enforce mode halts an agent; through the integrations it does not — NAGT-380.)* This SDK auto-instruments LangChain and LangGraph; any other framework (CrewAI, OpenAI Agents and the rest) can report actions with `track_action()`. *(Corrected 2026-10-03: this said NIST AI RMF and ISO 42001 were supported, that the SDK never blocks, and that it instruments CrewAI and OpenAI Agents code.)*
50
49
 
51
50
  ## Install
52
51
 
52
+ Needs **Python 3.10 or later**. The macOS system Python is 3.9, which is too old: install a newer one first, from python.org or with Homebrew (`brew install python@3.12`). On 3.9 this command fails with "No matching distribution found" rather than installing an old release.
53
+
53
54
  ```bash
54
- pip install "agentgovern[langchain]"
55
+ python3 -m pip install "agentgovern[langchain]>=2.7.0"
55
56
  ```
56
57
 
57
58
  ## Quickstart — LangChain
@@ -212,19 +213,25 @@ Enable policy packs from the [AgentGovern dashboard](https://agentgovern.zirahn.
212
213
  **Policy engine address (2.4.0).** The default engine address is now the stable name
213
214
  `https://engine.zirahn.com`. Versions 2.3.0 and earlier call `https://agentgovern.onrender.com`
214
215
  directly. That address keeps working, so nothing breaks if you stay on an older version, but only
215
- the stable name can follow the engine if it moves. Upgrade with `pip install --upgrade agentgovern`.
216
+ the stable name can follow the engine if it moves. Upgrade with `python3 -m pip install --upgrade "agentgovern>=2.7.0"`.
216
217
  If you set `policy_engine_url` or `AGENTGOVERN_POLICY_ENGINE_URL` yourself, it still takes precedence.
217
218
 
218
219
  ## Claude Code Hook
219
220
 
220
221
  The SDK ships a built-in CLI for installing the AgentGovern hook into Claude Code. The hook
221
- intercepts `PreToolUse` events for every tool call and enforces your tenant policies in real
222
- time — before the tool executes. A tool AgentGovern doesn't yet have a specific classification
223
- for is still captured and evaluated, tagged `action_type=unmapped_tool` rather than silently
224
- skipped.
222
+ checks every tool call against your tenant's policies before the tool runs. A policy in
223
+ **enforce** mode blocks the call and Claude Code shows the reason; **warn** lets it run and
224
+ shows a warning; **log** only records it. A new organization starts with no policy in enforce
225
+ mode. A tool AgentGovern doesn't yet have a specific classification for is still captured and
226
+ evaluated, tagged `action_type=unmapped_tool` rather than silently skipped.
225
227
 
226
228
  ### Install
227
229
 
230
+ **The simplest way is the dashboard:** on **Settings → Developers**, **Add Developer** gives an
231
+ install snippet for bash and PowerShell that checks for Python 3.10 or later, installs this
232
+ package into its own virtual environment, saves your two settings for new terminals, and runs
233
+ the command below. To do it by hand:
234
+
228
235
  ```bash
229
236
  agentgovern hook install
230
237
  ```
@@ -242,7 +249,7 @@ reports it as already installed and changes nothing. Upgrade, read what is insta
242
249
  reinstall with `--force`:
243
250
 
244
251
  ```bash
245
- python3 -m pip install --upgrade agentgovern # or the extra you installed with, e.g. "agentgovern[langchain]"
252
+ python3 -m pip install --upgrade "agentgovern>=2.7.0" # or the extra you installed with, e.g. "agentgovern[langchain]>=2.7.0"
246
253
  agentgovern hook status # note each line's cmd= value and the "Install mode" line
247
254
  agentgovern hook install --force # plus the flags below, if status calls for them
248
255
  agentgovern hook status # reports no [DRIFT] lines once the hook is current
@@ -268,7 +275,7 @@ Your environment variables are not touched. Restart any running Claude Code sess
268
275
 
269
276
  ```bash
270
277
  python3 -m venv ~/.agentgovern-venv
271
- ~/.agentgovern-venv/bin/python -m pip install --upgrade agentgovern
278
+ ~/.agentgovern-venv/bin/python -m pip install --upgrade "agentgovern>=2.7.0"
272
279
  ~/.agentgovern-venv/bin/agentgovern hook status
273
280
  ~/.agentgovern-venv/bin/agentgovern hook install --force # plus the flags status called for
274
281
  ```
@@ -315,7 +322,7 @@ In PowerShell (the variable is removed again when claude exits):
315
322
  try { $env:AGENTGOVERN_HOOK_MODE='off'; claude } finally { Remove-Item Env:AGENTGOVERN_HOOK_MODE }
316
323
  ```
317
324
 
318
- See `packages/claude-hook/README.md` for the full option reference.
325
+ `agentgovern hook install --help` lists the install options, and `agentgovern hook status` shows what is installed. The environment variables are in *Required env vars* and *Bypass* above.
319
326
 
320
327
  ## Design guarantees
321
328
 
@@ -327,8 +334,8 @@ See `packages/claude-hook/README.md` for the full option reference.
327
334
  ## Links
328
335
 
329
336
  - **Dashboard:** https://agentgovern.zirahn.com
330
- - **Documentation:** https://github.com/ahmedkhan-zirahn/agentgovern
331
- - **Issues:** https://github.com/ahmedkhan-zirahn/agentgovern/issues
337
+ - **Documentation:** this README
338
+ - **Support and issues:** https://zirahn.com/contact
332
339
 
333
340
  ## License
334
341
 
@@ -2,14 +2,16 @@
2
2
 
3
3
  **Compliance-as-code for agentic AI workflows.**
4
4
 
5
- > **Beta** — API may change before 1.0. [Report issues](https://github.com/ahmedkhan-zirahn/agentgovern/issues).
5
+ > **Beta** — API may change before 1.0. Report issues through [zirahn.com/contact](https://zirahn.com/contact).
6
6
 
7
7
  AgentGovern intercepts AI agent actions, evaluates them against configurable compliance policies (the EU AI Act today; NIST AI RMF and ISO 42001 are on the roadmap — see the table below), and generates audit-ready evidence in near real-time. Tracking runs in a background thread and fails open. An `enforce`-mode block raises in your code (see *Enforcement modes* below for exactly what it stops). *(Corrected again 2026-10-03: the first correction said "the SDK blocks only if you enable Gate 2 `enforce` mode, where a block verdict raises `OutputBlocked`", which reads as if enforce mode halts an agent; through the integrations it does not — NAGT-380.)* This SDK auto-instruments LangChain and LangGraph; any other framework (CrewAI, OpenAI Agents and the rest) can report actions with `track_action()`. *(Corrected 2026-10-03: this said NIST AI RMF and ISO 42001 were supported, that the SDK never blocks, and that it instruments CrewAI and OpenAI Agents code.)*
8
8
 
9
9
  ## Install
10
10
 
11
+ Needs **Python 3.10 or later**. The macOS system Python is 3.9, which is too old: install a newer one first, from python.org or with Homebrew (`brew install python@3.12`). On 3.9 this command fails with "No matching distribution found" rather than installing an old release.
12
+
11
13
  ```bash
12
- pip install "agentgovern[langchain]"
14
+ python3 -m pip install "agentgovern[langchain]>=2.7.0"
13
15
  ```
14
16
 
15
17
  ## Quickstart — LangChain
@@ -170,19 +172,25 @@ Enable policy packs from the [AgentGovern dashboard](https://agentgovern.zirahn.
170
172
  **Policy engine address (2.4.0).** The default engine address is now the stable name
171
173
  `https://engine.zirahn.com`. Versions 2.3.0 and earlier call `https://agentgovern.onrender.com`
172
174
  directly. That address keeps working, so nothing breaks if you stay on an older version, but only
173
- the stable name can follow the engine if it moves. Upgrade with `pip install --upgrade agentgovern`.
175
+ the stable name can follow the engine if it moves. Upgrade with `python3 -m pip install --upgrade "agentgovern>=2.7.0"`.
174
176
  If you set `policy_engine_url` or `AGENTGOVERN_POLICY_ENGINE_URL` yourself, it still takes precedence.
175
177
 
176
178
  ## Claude Code Hook
177
179
 
178
180
  The SDK ships a built-in CLI for installing the AgentGovern hook into Claude Code. The hook
179
- intercepts `PreToolUse` events for every tool call and enforces your tenant policies in real
180
- time — before the tool executes. A tool AgentGovern doesn't yet have a specific classification
181
- for is still captured and evaluated, tagged `action_type=unmapped_tool` rather than silently
182
- skipped.
181
+ checks every tool call against your tenant's policies before the tool runs. A policy in
182
+ **enforce** mode blocks the call and Claude Code shows the reason; **warn** lets it run and
183
+ shows a warning; **log** only records it. A new organization starts with no policy in enforce
184
+ mode. A tool AgentGovern doesn't yet have a specific classification for is still captured and
185
+ evaluated, tagged `action_type=unmapped_tool` rather than silently skipped.
183
186
 
184
187
  ### Install
185
188
 
189
+ **The simplest way is the dashboard:** on **Settings → Developers**, **Add Developer** gives an
190
+ install snippet for bash and PowerShell that checks for Python 3.10 or later, installs this
191
+ package into its own virtual environment, saves your two settings for new terminals, and runs
192
+ the command below. To do it by hand:
193
+
186
194
  ```bash
187
195
  agentgovern hook install
188
196
  ```
@@ -200,7 +208,7 @@ reports it as already installed and changes nothing. Upgrade, read what is insta
200
208
  reinstall with `--force`:
201
209
 
202
210
  ```bash
203
- python3 -m pip install --upgrade agentgovern # or the extra you installed with, e.g. "agentgovern[langchain]"
211
+ python3 -m pip install --upgrade "agentgovern>=2.7.0" # or the extra you installed with, e.g. "agentgovern[langchain]>=2.7.0"
204
212
  agentgovern hook status # note each line's cmd= value and the "Install mode" line
205
213
  agentgovern hook install --force # plus the flags below, if status calls for them
206
214
  agentgovern hook status # reports no [DRIFT] lines once the hook is current
@@ -226,7 +234,7 @@ Your environment variables are not touched. Restart any running Claude Code sess
226
234
 
227
235
  ```bash
228
236
  python3 -m venv ~/.agentgovern-venv
229
- ~/.agentgovern-venv/bin/python -m pip install --upgrade agentgovern
237
+ ~/.agentgovern-venv/bin/python -m pip install --upgrade "agentgovern>=2.7.0"
230
238
  ~/.agentgovern-venv/bin/agentgovern hook status
231
239
  ~/.agentgovern-venv/bin/agentgovern hook install --force # plus the flags status called for
232
240
  ```
@@ -273,7 +281,7 @@ In PowerShell (the variable is removed again when claude exits):
273
281
  try { $env:AGENTGOVERN_HOOK_MODE='off'; claude } finally { Remove-Item Env:AGENTGOVERN_HOOK_MODE }
274
282
  ```
275
283
 
276
- See `packages/claude-hook/README.md` for the full option reference.
284
+ `agentgovern hook install --help` lists the install options, and `agentgovern hook status` shows what is installed. The environment variables are in *Required env vars* and *Bypass* above.
277
285
 
278
286
  ## Design guarantees
279
287
 
@@ -285,8 +293,8 @@ See `packages/claude-hook/README.md` for the full option reference.
285
293
  ## Links
286
294
 
287
295
  - **Dashboard:** https://agentgovern.zirahn.com
288
- - **Documentation:** https://github.com/ahmedkhan-zirahn/agentgovern
289
- - **Issues:** https://github.com/ahmedkhan-zirahn/agentgovern/issues
296
+ - **Documentation:** this README
297
+ - **Support and issues:** https://zirahn.com/contact
290
298
 
291
299
  ## License
292
300
 
@@ -93,4 +93,12 @@ To re-run a publish after a failed run, trigger the workflow manually **from the
93
93
  - its bundled `hook.py` is hook 2.4.0, byte-identical to the tag commit's;
94
94
  - each file's PyPI attestation names publisher `ZirahnAI/agentgovern`, workflow `publish-sdk.yml`, environment `pypi`, with a subject digest equal to the file's;
95
95
  - an unpinned install resolves 2.6.0 on Python 3.12 and 2.5.0 on Python 3.9.6 (`/usr/bin/python3`, macOS 15.8.1).
96
+ - **2026-10-16:** 2.7.0 was published by run `38849510745` from tag `sdk-v2.7.0` at `90a5d6db` (#947's merge). **The tag was pushed by Claude Code under Ahmed's explicit, one-time authorisation for this tag**; the push printed the tag ruleset's "Cannot create ref due to creations being restricted" line and created the tag (the admin bypass). Build 04:13:32–04:13:52Z, Publish 04:13:56–04:14:27Z; files uploaded 04:14:22Z and 04:14:23Z. Verified afterwards, read-only:
97
+ - the wheel and sdist sha256 match PyPI's digests (`57105511…`, `e266e7e9…`), compared with the run's own `dist` artifact;
98
+ - the wheel's `METADATA` says `Version: 2.7.0`, `Requires-Python: >=3.10`;
99
+ - its bundled `hook.py` is **hook 2.5.0**, sha256 `f43c3c95010120dd34e8a91982de11516d9d3c582b5e2fd4bb7791c8fa6ffec1`, byte-identical to the tag commit's;
100
+ - each file's PyPI attestation names publisher `ZirahnAI/agentgovern`, workflow `publish-sdk.yml`, environment `pypi`;
101
+ - a fresh Python 3.12 venv installing `agentgovern==2.7.0` from PyPI reports `__version__` 2.7.0, and `hook install` into a temporary HOME writes hook 2.5.0 with all four events OK (`hook test` not run: it calls production);
102
+ - Python 3.9.6 (`/usr/bin/python3`) resolves `agentgovern-2.5.0`;
103
+ - the hook version changed, so the released-hook manifest moved to 2.5.0 after the tag (NAGT-400 (c), #953, `b748b1f7`).
96
104
 
@@ -0,0 +1 @@
1
+ __version__ = "2.7.3"
@@ -57,7 +57,8 @@ def add_hook_subparsers(hook_parser: argparse.ArgumentParser) -> None:
57
57
  default=None,
58
58
  help=(
59
59
  "Override the python command written to settings.json "
60
- "(default: 'python' on Windows, 'python3' elsewhere)."
60
+ "(default: the absolute path of the Python running this command; "
61
+ "'python' on Windows or 'python3' elsewhere if it is unknown)."
61
62
  ),
62
63
  )
63
64
  install.add_argument(
@@ -18,6 +18,10 @@ Configuration (environment variables):
18
18
  entirely, for all events, unchanged.
19
19
  AGENTGOVERN_HOOK_VERBOSE Default: 0 (0 | 1)
20
20
  AGENTGOVERN_FAIL_ACTION Default: allow (allow | deny)
21
+ What a PreToolUse does when AgentGovern did not check the call. 'deny'
22
+ blocks it: on no answer at all, on an HTTP error answer, and (since
23
+ v2.5.1, NAGT-484) on a `timeout` verdict, the server's "Evaluation
24
+ Failed". 'allow' lets it run.
21
25
  AGENTGOVERN_HOOK_TIMEOUT_MS Default: 5000
22
26
  AGENTGOVERN_CAPTURE_MODE Default: enriched (enriched | minimal)
23
27
  enriched — all 4 events (UserPromptSubmit, PreToolUse, PostToolUse, Stop)
@@ -49,6 +53,12 @@ policies_violated entry whose enforcement_mode matches the verdict
49
53
  falling back to the first entry; and async events dropped on an HTTP error
50
54
  are counted per session and reported once per session in verbose mode.
51
55
 
56
+ NAGT-484 (v2.5.1), docs/audit-reports/nagt-484-deny-on-errors-2026-10-22.md:
57
+ AGENTGOVERN_FAIL_ACTION=deny also blocks a PreToolUse that gets an HTTP error
58
+ back (a 429 included) or a `timeout` verdict, not only one that gets no answer.
59
+ The deny reason is the hook's own text and the HTTP status; nothing from the
60
+ response body reaches it. With the default (allow), nothing changes.
61
+
52
62
  See README.md for installation instructions.
53
63
  """
54
64
  from __future__ import annotations
@@ -70,7 +80,7 @@ from datetime import datetime, timezone
70
80
  from pathlib import Path
71
81
  from typing import Any
72
82
 
73
- __version__ = "2.5.0"
83
+ __version__ = "2.5.1"
74
84
 
75
85
  # ── Constants ──────────────────────────────────────────────────────────────────
76
86
 
@@ -487,6 +497,35 @@ def format_warn_message(evaluation: dict[str, Any]) -> str:
487
497
  )
488
498
 
489
499
 
500
+ def _unchecked_deny_reason(what: str) -> str:
501
+ """NAGT-484: the deny reason when AGENTGOVERN_FAIL_ACTION=deny blocks an unchecked call.
502
+
503
+ `what` is the hook's own words (and at most an HTTP status number): nothing
504
+ from the response body, which can come from an edge proxy, reaches Claude.
505
+ """
506
+ return (
507
+ f"AgentGovern {what}, so this tool call was not checked. "
508
+ "AGENTGOVERN_FAIL_ACTION=deny: blocking it."
509
+ )
510
+
511
+
512
+ def _missing_config_deny_reason(missing: list[str]) -> str:
513
+ """NAGT-500: the deny reason when AGENTGOVERN_FAIL_ACTION=deny blocks a call
514
+ the hook can't send for checking, because its configuration is missing.
515
+
516
+ `missing` holds variable NAMES only; their values are empty by definition,
517
+ and no other environment value is included.
518
+ """
519
+ names = " and ".join(missing)
520
+ verb = "is" if len(missing) == 1 else "are"
521
+ return (
522
+ f"AgentGovern isn't set up in this shell: {names} {verb} not set, "
523
+ "so this tool call was not checked. AGENTGOVERN_FAIL_ACTION=deny: blocking it. "
524
+ "To fix it, paste your install snippet again from Settings → Developers "
525
+ f"in the AgentGovern dashboard, or set {names} and open a new terminal."
526
+ )
527
+
528
+
490
529
  def render_deny_output(reason: str) -> str:
491
530
  """Return the JSON string for a PreToolUse deny decision."""
492
531
  return json.dumps({
@@ -657,10 +696,11 @@ def _claim_session_notice(session_id: Any, kind: str = "stale_hook") -> bool:
657
696
 
658
697
 
659
698
  # NAGT-400 (b): a PreToolUse HTTP 429 means the tenant's ingest rate limit was
660
- # reached, so this tool call was not evaluated and is being allowed (HTTP
661
- # errors never deny, AGENTGOVERN_FAIL_ACTION included). The stderr line reaches
662
- # only the debug log; this is the developer-visible half. The text is the
663
- # hook's own: nothing from the 429's body is shown.
699
+ # reached, so this tool call was not evaluated and is being allowed (with
700
+ # AGENTGOVERN_FAIL_ACTION=deny it is blocked instead, NAGT-484, and this message
701
+ # is not shown). The stderr line reaches only the debug log; this is the
702
+ # developer-visible half. The text is the hook's own: nothing from the 429's
703
+ # body is shown.
664
704
  _RATE_LIMIT_MESSAGE = (
665
705
  "AgentGovern: your team reached Guard's rate limit, so this tool call was "
666
706
  "not checked by Guard and was allowed. Please tell your team's AgentGovern admin."
@@ -1555,6 +1595,26 @@ def _handle_pre_tool_use(
1555
1595
  _vlog("[agentgovern] Mode=off — skipping.")
1556
1596
  return 0
1557
1597
 
1598
+ # NAGT-500: under deny, a call the hook can't send for checking (no key, no
1599
+ # actor id) is as unchecked as one AgentGovern didn't answer, so it is
1600
+ # blocked too. Under allow (the default) both cases below are unchanged.
1601
+ missing = [
1602
+ name
1603
+ for name, value in (
1604
+ ("AGENTGOVERN_API_KEY", api_key),
1605
+ ("AGENTGOVERN_ACTOR_USER_ID", actor_user_id),
1606
+ )
1607
+ if not value
1608
+ ]
1609
+ if missing and fail_action == "deny":
1610
+ _err(
1611
+ f"[agentgovern] {' and '.join(missing)} not set — "
1612
+ "blocking (AGENTGOVERN_FAIL_ACTION=deny)."
1613
+ )
1614
+ sys.stdout.write(render_deny_output(_missing_config_deny_reason(missing)))
1615
+ sys.stdout.flush()
1616
+ return 0
1617
+
1558
1618
  if not api_key:
1559
1619
  _err(
1560
1620
  "[agentgovern] AGENTGOVERN_API_KEY not set — "
@@ -1623,15 +1683,27 @@ def _handle_pre_tool_use(
1623
1683
  if isinstance(err_obj, dict):
1624
1684
  code = err_obj.get("code", "")
1625
1685
  detail = f" ({code})" if code else ""
1686
+ if fail_action == "deny":
1687
+ # NAGT-484: an error answer leaves the call as unchecked as no
1688
+ # answer does. Only the status number is used, never the body.
1689
+ _err(f"[agentgovern] HTTP {status}{detail} from governance endpoint — blocking.")
1690
+ what = (
1691
+ "reached your team's rate limit (HTTP 429)"
1692
+ if status == 429
1693
+ else f"answered with an error (HTTP {status if isinstance(status, int) else '?'})"
1694
+ )
1695
+ sys.stdout.write(render_deny_output(_unchecked_deny_reason(what)))
1696
+ sys.stdout.flush()
1697
+ return 0
1626
1698
  _err(
1627
1699
  f"[agentgovern] HTTP {status}{detail} from governance endpoint "
1628
1700
  "— proceeding unaudited."
1629
1701
  )
1630
1702
  if status == 429:
1631
1703
  # NAGT-400 (b): the developer-visible half, at most once a minute
1632
- # per session. systemMessage only, never a permissionDecision; the
1633
- # call is still allowed whatever AGENTGOVERN_FAIL_ACTION says, as
1634
- # every HTTP error is. Fail-open inside.
1704
+ # per session. systemMessage only, never a permissionDecision; with
1705
+ # the default fail action the call is still allowed, as every HTTP
1706
+ # error is. Fail-open inside.
1635
1707
  _surface_rate_limited(hook_event.get("session_id"))
1636
1708
  return 0
1637
1709
 
@@ -1670,6 +1742,14 @@ def _handle_pre_tool_use(
1670
1742
  return 0
1671
1743
 
1672
1744
  if verdict == "timeout":
1745
+ if fail_action == "deny":
1746
+ # NAGT-484: the server could not evaluate the call (it is marked
1747
+ # "Evaluation Failed"), so it was not checked.
1748
+ _err("[agentgovern] Policy evaluation timed out server-side — blocking.")
1749
+ reason = _unchecked_deny_reason("couldn't evaluate this tool call")
1750
+ sys.stdout.write(render_deny_output(reason))
1751
+ sys.stdout.flush()
1752
+ return 0
1673
1753
  _err(
1674
1754
  "[agentgovern] Policy evaluation timed out server-side "
1675
1755
  "— tool call proceeding unaudited."
@@ -103,6 +103,19 @@ def _hook_target_path() -> Path:
103
103
 
104
104
 
105
105
  def _detect_python_command() -> str:
106
+ """The interpreter the hook entry runs: the one running this installer.
107
+
108
+ NAGT-490: a bare `python3` is looked up on the PATH Claude Code has when it
109
+ runs the hook, which need not be the shell's. Started from the Dock, it is
110
+ /usr/bin:/bin:..., where a Mac without the Command Line Tools has only a stub
111
+ python3 that asks to install them and runs nothing. sys.executable is the
112
+ absolute path of a Python that has this package, so 3.10 or later: in a
113
+ venv, the venv's own python. Bare names remain the fallback for an
114
+ interpreter that cannot say where it is.
115
+ """
116
+ exe = sys.executable
117
+ if exe and os.path.isabs(exe) and os.path.isfile(exe):
118
+ return exe
106
119
  return "python" if sys.platform == "win32" else "python3"
107
120
 
108
121
 
@@ -703,6 +716,10 @@ def uninstall() -> int:
703
716
  print("AgentGovern hook is not installed (no entry found in settings.json).")
704
717
  return 0
705
718
 
719
+ # NAGT-391 F8: the install-mode marker is Guard's own key, written by the
720
+ # install; it goes with the hook, so the settings are left as they were.
721
+ new_settings.pop(INSTALL_MODE_KEY, None)
722
+
706
723
  if settings_path.exists():
707
724
  backup(settings_path)
708
725
 
@@ -712,6 +729,12 @@ def uninstall() -> int:
712
729
  print(f"Error: {exc}", file=sys.stderr)
713
730
  return 1
714
731
 
732
+ # NAGT-391 F8: the backup only guards the write above, which succeeded. Left
733
+ # behind, it is a copy of the settings that still registers Guard's hook, and
734
+ # restoring it would point Claude Code at a hook script the uninstall
735
+ # snippet then deletes. (Found by the install rehearsal workflow.)
736
+ settings_path.with_suffix(".json.bak").unlink(missing_ok=True)
737
+
715
738
  print(f"AgentGovern hook removed from {settings_path} (all event types).")
716
739
  print(f"The hook script at {hook_target} was not deleted.")
717
740
  return 0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentgovern"
3
- version = "2.7.0"
3
+ version = "2.7.3"
4
4
  description = "Compliance-as-code middleware for agentic AI workflows."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -64,8 +64,7 @@ agentgovern = "agentgovern.__main__:main"
64
64
 
65
65
  [project.urls]
66
66
  Homepage = "https://agentgovern.zirahn.com"
67
- Repository = "https://github.com/ahmedkhan-zirahn/agentgovern"
68
- Issues = "https://github.com/ahmedkhan-zirahn/agentgovern/issues"
67
+ Support = "https://zirahn.com/contact"
69
68
 
70
69
  [build-system]
71
70
  requires = ["hatchling"]
@@ -0,0 +1,35 @@
1
+ {
2
+ "_comment": "NAGT-403: action names made by the Guard hook's own functions (packages/claude-hook/agentgovern_hook.py: _truncate_for_action_name for prompts and replies, format_action_name for Bash). {GH} stands for a fake GitHub token, ghp_ followed by 36 'a', and {GHP} for its ghp_ prefix where a name cut it short; each test substitutes both (literal fragments would trip the repository's secret scan). test_action_name_cuts_fixture.py regenerates every name from the hook and fails if one differs.",
3
+ "cases": [
4
+ {
5
+ "id": "prompt-cut-through-token",
6
+ "kind": "prompt",
7
+ "source": "Create a .env file in this folder with the lines DEBUG=true and GITHUB_TOKEN={GH} and nothing else.",
8
+ "hook_action_name": "Create a .env file in this folder with the lines DEBUG=true and GITHUB_TOKEN={GHP}aaaaaaaaaaaaaaaa..."
9
+ },
10
+ {
11
+ "id": "prompt-newlines-flattened",
12
+ "kind": "prompt",
13
+ "source": "Please set up the project.\nUse these settings:\nDEBUG=true\nAPI token for GitHub is GITHUB_TOKEN={GH}\nThanks",
14
+ "hook_action_name": "Please set up the project. Use these settings: DEBUG=true API token for GitHub is GITHUB_TOKEN=gh..."
15
+ },
16
+ {
17
+ "id": "reply-cut-through-token",
18
+ "kind": "reply",
19
+ "source": "I created `/Users/someone/project/.env` with two lines. The second sets GITHUB_TOKEN={GH} as you asked.",
20
+ "hook_action_name": "I created `/Users/someone/project/.env` with two lines. The second sets GITHUB_TOKEN={GHP}aaaaaaaa..."
21
+ },
22
+ {
23
+ "id": "bash-cut-at-200-through-token",
24
+ "kind": "bash",
25
+ "source": "echo 'configuring the deployment environment for the staging cluster, then writing the token into the local env file so that the gh CLI can authenticate' && export GITHUB_TOKEN={GH} && gh auth status",
26
+ "hook_action_name": "Bash: echo 'configuring the deployment environment for the staging cluster, then writing the token into the local env file so that the gh CLI can authenticate' && export GITHUB_TOKEN={GHP}aaaaaaaaaaaaa"
27
+ },
28
+ {
29
+ "id": "prompt-not-cut",
30
+ "kind": "prompt",
31
+ "source": "Use GITHUB_TOKEN={GH} please",
32
+ "hook_action_name": "Use GITHUB_TOKEN={GH} please"
33
+ }
34
+ ]
35
+ }
@@ -324,38 +324,6 @@
324
324
  "stdout": ""
325
325
  }
326
326
  ],
327
- "http-503-deny-mode": [
328
- {
329
- "rc": 0,
330
- "sent": [
331
- {
332
- "action_name": "Bash: ls -la",
333
- "action_type": "code_execution",
334
- "actor_user_id": "user_test",
335
- "client_timestamp": "<TS>",
336
- "command_executed": "ls -la",
337
- "context_snapshot": {
338
- "claude_code_version": "unknown",
339
- "cwd": null,
340
- "git_branch": null,
341
- "git_commit_sha": null,
342
- "hook_version": "<HOOK_VERSION>"
343
- },
344
- "conversation_id": "sess-1",
345
- "evaluation_mode": "sync",
346
- "input": {
347
- "command": "ls -la"
348
- },
349
- "schema_version": "1",
350
- "source_type": "interactive_cli",
351
- "source_version": "agentgovern-claude-hook/<HOOK_VERSION>",
352
- "tool_event_type": "execute"
353
- }
354
- ],
355
- "stderr": "[agentgovern] Response in <N>ms\n[agentgovern] HTTP 503 from governance endpoint — proceeding unaudited.\n",
356
- "stdout": ""
357
- }
358
- ],
359
327
  "minimal-block": [
360
328
  {
361
329
  "rc": 0,
@@ -0,0 +1,50 @@
1
+ """NAGT-403: the action-name cut fixture matches the hook that makes the names.
2
+
3
+ The web app's ingest masking (apps/web/lib/secret-masking/ingest.ts) is tested
4
+ against fixtures/action_name_cuts.json: prompts, replies and Bash commands with a
5
+ fake GitHub token, and the action name the Guard hook builds from each. This test
6
+ rebuilds every name with the hook's own functions, so a change to how the hook
7
+ cuts names fails here instead of leaving the web tests on a stale copy.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ FIXTURE = Path(__file__).parent / "fixtures" / "action_name_cuts.json"
17
+ GH = "ghp_" + "a" * 36
18
+ PLACEHOLDER = "{GH}"
19
+ PREFIX_PLACEHOLDER = "{GHP}"
20
+
21
+
22
+ def _expand(text: str) -> str:
23
+ return text.replace(PLACEHOLDER, GH).replace(PREFIX_PLACEHOLDER, "ghp_")
24
+
25
+
26
+ def _name(hook_mod: ModuleType, kind: str, source: str) -> str:
27
+ if kind == "bash":
28
+ return hook_mod.format_action_name("Bash", {"command": source})
29
+ return hook_mod._truncate_for_action_name(source)
30
+
31
+
32
+ def test_every_fixture_name_is_what_the_hook_builds(hook_mod: ModuleType) -> None:
33
+ cases = json.loads(FIXTURE.read_text())["cases"]
34
+ assert {c["kind"] for c in cases} == {"prompt", "reply", "bash"}
35
+ for case in cases:
36
+ built = _name(hook_mod, case["kind"], _expand(case["source"]))
37
+ assert built == _expand(case["hook_action_name"]), case["id"]
38
+
39
+
40
+ def test_the_cut_cases_really_cut_the_token_short(hook_mod: ModuleType) -> None:
41
+ """Each *-cut-* case leaves a token fragment the masking pattern cannot match
42
+ ({20,} after the prefix), which is the defect NAGT-403 fixes."""
43
+ cases = json.loads(FIXTURE.read_text())["cases"]
44
+ cut = [c for c in cases if "-cut-" in c["id"]]
45
+ assert len(cut) == 3
46
+ for case in cut:
47
+ assert PLACEHOLDER not in case["hook_action_name"], case["id"]
48
+ name = _expand(case["hook_action_name"])
49
+ tail = name.removesuffix("...").rsplit("ghp_", 1)[1]
50
+ assert 0 <= len(tail) < 20, case["id"]
@@ -135,9 +135,9 @@ def _only_system_message(stdout: str) -> str:
135
135
 
136
136
 
137
137
  def test_version_is_2_5_0(env):
138
- assert env.__version__ == "2.5.0"
139
- assert env.HOOK_VERSION == "2.5.0"
140
- assert env._SOURCE_VERSION == "agentgovern-claude-hook/2.5.0"
138
+ assert env.__version__ == "2.5.1"
139
+ assert env.HOOK_VERSION == "2.5.1"
140
+ assert env._SOURCE_VERSION == "agentgovern-claude-hook/2.5.1"
141
141
 
142
142
 
143
143
  # ── 1. usage_limit notice ─────────────────────────────────────────────────────
@@ -373,13 +373,17 @@ def test_rate_limit_marker_that_is_not_a_regular_file_means_do_not_show(env, tmp
373
373
  assert abs(directory.stat().st_mtime - old) < 1
374
374
 
375
375
 
376
- def test_429_in_deny_mode_still_allows(env, monkeypatch, capsys):
376
+ def test_429_in_deny_mode_blocks_without_the_rate_limit_message(env, monkeypatch, capsys):
377
+ # NAGT-484 (hook 2.5.1): deny blocks an unchecked call, a 429 included,
378
+ # and the deny is the only output (no systemMessage beside it).
377
379
  monkeypatch.setenv("AGENTGOVERN_FAIL_ACTION", "deny")
378
- rc, out, err = _pre(env, monkeypatch, capsys, _R429)
379
- assert rc == 0
380
- _assert_rate_limit_output(out, err)
381
- rc, out, _ = _pre(env, monkeypatch, capsys, _R429) # throttled: nothing, still no deny
382
- assert rc == 0 and out == ""
380
+ for _ in range(2):
381
+ rc, out, err = _pre(env, monkeypatch, capsys, _R429)
382
+ obj = json.loads(out)
383
+ assert rc == 0 and set(obj) == {"hookSpecificOutput"}
384
+ assert obj["hookSpecificOutput"]["permissionDecision"] == "deny"
385
+ assert "rate limit (HTTP 429)" in obj["hookSpecificOutput"]["permissionDecisionReason"]
386
+ assert "HTTP 429" in err
383
387
 
384
388
 
385
389
  @pytest.mark.parametrize("status", [400, 401, 403, 404, 413, 428, 430, 500, 502, 503, 504])
@@ -18,6 +18,11 @@ Deliberately NOT in this table, because 2.5.0 changes them on purpose: an HTTP
18
18
  429 on PreToolUse (now a systemMessage), a "usage_limit" notice (now shown),
19
19
  entries carrying `enforcement_mode` (now selected by mode), and verbose mode on
20
20
  a Stop event after a dropped async event (now reported).
21
+
22
+ Removed in 2.5.1 (NAGT-484), because it changes on purpose: an HTTP error on
23
+ PreToolUse with AGENTGOVERN_FAIL_ACTION=deny (the "http-503-deny-mode" case,
24
+ now a deny; packages/claude-hook/tests/test_deny_on_errors.py). Every other
25
+ case still matches 2.4.0 byte for byte.
21
26
  """
22
27
 
23
28
  from __future__ import annotations
@@ -135,7 +140,6 @@ CASES: dict[str, list[tuple[dict[str, str], dict, Any]]] = {
135
140
  "http-503": [({}, _pre(), _http(503, "SERVICE_UNAVAILABLE"))],
136
141
  "http-403": [({}, _pre(), _http(403, "ACTOR_NOT_IN_TENANT"))],
137
142
  "http-400-no-body": [({}, _pre(), _http(400))],
138
- "http-503-deny-mode": [({"AGENTGOVERN_FAIL_ACTION": "deny"}, _pre(), _http(503))],
139
143
  "network-allow": [({}, _pre(), None)],
140
144
  "network-deny": [({"AGENTGOVERN_FAIL_ACTION": "deny"}, _pre(), None)],
141
145
  "minimal-block": [({"AGENTGOVERN_CAPTURE_MODE": "minimal"}, _pre(), _ok("block", [_P1]))],
@@ -229,6 +233,6 @@ def test_golden_file_covers_exactly_these_cases() -> None:
229
233
 
230
234
  def test_2_4_0_style_exchanges_are_byte_identical_to_2_4_0(hook_mod: ModuleType) -> None:
231
235
  golden = json.loads(GOLDEN.read_text(encoding="utf-8"))["cases"]
232
- assert hook_mod.__version__ == "2.5.0"
236
+ assert hook_mod.__version__ == "2.5.1"
233
237
  for name, steps in CASES.items():
234
238
  assert run_case(hook_mod, steps) == golden[name], name
@@ -92,10 +92,40 @@ def test_install_writes_absolute_path_to_args(
92
92
  assert "%USERPROFILE%" not in args_val
93
93
 
94
94
 
95
+ def test_install_registers_the_running_python_by_absolute_path(
96
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
97
+ ) -> None:
98
+ """NAGT-490: the interpreter running `hook install` (a venv's python), not a bare name."""
99
+ home = _fake_home(tmp_path, monkeypatch)
100
+ venv_python = tmp_path / "my venv" / "bin" / "python"
101
+ venv_python.parent.mkdir(parents=True)
102
+ venv_python.write_text("")
103
+ monkeypatch.setattr(sys, "executable", str(venv_python))
104
+ for platform in ("darwin", "linux", "win32"):
105
+ monkeypatch.setattr(sys, "platform", platform)
106
+ installer.install(_default_args(force=True))
107
+ cfg = json.loads(_settings_path(home).read_text())
108
+ for event in ("PreToolUse", "UserPromptSubmit", "PostToolUse", "Stop"):
109
+ assert cfg["hooks"][event][0]["hooks"][0]["command"] == str(venv_python), event
110
+
111
+
112
+ @pytest.mark.parametrize("executable", ["", "python3", "/no/such/python"])
113
+ def test_install_falls_back_to_a_bare_name_when_the_python_is_unknown(
114
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, executable: str
115
+ ) -> None:
116
+ """sys.executable can be empty, relative or gone; then the 2.7.2 default."""
117
+ monkeypatch.setattr(sys, "executable", executable)
118
+ monkeypatch.setattr(sys, "platform", "darwin")
119
+ assert installer._detect_python_command() == "python3"
120
+ monkeypatch.setattr(sys, "platform", "win32")
121
+ assert installer._detect_python_command() == "python"
122
+
123
+
95
124
  def test_install_uses_python3_on_non_windows(
96
125
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
97
126
  ) -> None:
98
127
  home = _fake_home(tmp_path, monkeypatch)
128
+ monkeypatch.setattr(sys, "executable", "")
99
129
  monkeypatch.setattr(sys, "platform", "linux")
100
130
  monkeypatch.setattr(installer, "_python_on_path", lambda cmd: True)
101
131
  installer.install(_default_args())
@@ -106,6 +136,7 @@ def test_install_uses_python3_on_non_windows(
106
136
 
107
137
  def test_install_uses_python_on_windows(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
108
138
  home = _fake_home(tmp_path, monkeypatch)
139
+ monkeypatch.setattr(sys, "executable", "")
109
140
  monkeypatch.setattr(sys, "platform", "win32")
110
141
  monkeypatch.setattr(installer, "_python_on_path", lambda cmd: True)
111
142
  installer.install(_default_args())
@@ -601,6 +632,37 @@ def test_uninstall_preserves_other_config(tmp_path: Path, monkeypatch: pytest.Mo
601
632
  assert cfg.get("theme") == "dark"
602
633
 
603
634
 
635
+ def test_uninstall_leaves_the_settings_exactly_as_before_the_install(
636
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
637
+ ) -> None:
638
+ """NAGT-391 F8: no install-mode marker and no backup are left behind."""
639
+ home = _fake_home(tmp_path, monkeypatch)
640
+ before = {"theme": "dark", "permissions": {"allow": ["Bash(ls:*)"]}}
641
+ _write_json(_settings_path(home), before)
642
+ installer.install(_default_args())
643
+ assert _MARKER_KEY in json.loads(_settings_path(home).read_text())
644
+ assert _settings_path(home).with_suffix(".json.bak").exists()
645
+ assert installer.uninstall() == 0
646
+ assert json.loads(_settings_path(home).read_text()) == before
647
+ assert not _settings_path(home).with_suffix(".json.bak").exists()
648
+
649
+
650
+ def test_uninstall_keeps_the_backup_when_the_write_fails(
651
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
652
+ ) -> None:
653
+ """The backup is removed only after a successful write."""
654
+ home = _fake_home(tmp_path, monkeypatch)
655
+ _write_json(_settings_path(home), {"theme": "dark"})
656
+ installer.install(_default_args())
657
+
658
+ def refuse(*_args, **_kwargs): # type: ignore[no-untyped-def]
659
+ raise PermissionError("read-only")
660
+
661
+ monkeypatch.setattr(installer, "atomic_write", refuse)
662
+ assert installer.uninstall() == 1
663
+ assert _settings_path(home).with_suffix(".json.bak").exists()
664
+
665
+
604
666
  def test_uninstall_not_installed_exits_zero(
605
667
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
606
668
  ) -> None:
@@ -1 +0,0 @@
1
- __version__ = "2.7.0"
File without changes
File without changes