agentgovern 2.7.2__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.2 → agentgovern-2.7.3}/CHANGELOG.md +22 -0
  2. {agentgovern-2.7.2 → agentgovern-2.7.3}/PKG-INFO +1 -1
  3. agentgovern-2.7.3/agentgovern/_version.py +1 -0
  4. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/cli.py +2 -1
  5. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/data/hook.py +88 -8
  6. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/installer.py +13 -0
  7. {agentgovern-2.7.2 → agentgovern-2.7.3}/pyproject.toml +1 -1
  8. agentgovern-2.7.3/tests/hook/fixtures/action_name_cuts.json +35 -0
  9. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/fixtures/hook_2_4_0_golden_outputs.json +0 -32
  10. agentgovern-2.7.3/tests/hook/test_action_name_cuts_fixture.py +50 -0
  11. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_nagt400.py +13 -9
  12. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_nagt400_compat.py +6 -2
  13. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_installer.py +31 -0
  14. agentgovern-2.7.2/agentgovern/_version.py +0 -1
  15. {agentgovern-2.7.2 → agentgovern-2.7.3}/.gitignore +0 -0
  16. {agentgovern-2.7.2 → agentgovern-2.7.3}/LICENSE +0 -0
  17. {agentgovern-2.7.2 → agentgovern-2.7.3}/README.md +0 -0
  18. {agentgovern-2.7.2 → agentgovern-2.7.3}/RELEASING.md +0 -0
  19. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/__init__.py +0 -0
  20. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/__main__.py +0 -0
  21. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/client.py +0 -0
  22. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/exceptions.py +0 -0
  23. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/gate2.py +0 -0
  24. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/__init__.py +0 -0
  25. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/data/__init__.py +0 -0
  26. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/drift.py +0 -0
  27. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/hook/settings.py +0 -0
  28. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/instrumentors/__init__.py +0 -0
  29. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/instrumentors/_base.py +0 -0
  30. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/instrumentors/langchain.py +0 -0
  31. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/instrumentors/langgraph.py +0 -0
  32. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentgovern/types.py +0 -0
  33. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentshield/__init__.py +0 -0
  34. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentshield/client.py +0 -0
  35. {agentgovern-2.7.2 → agentgovern-2.7.3}/agentshield/types.py +0 -0
  36. {agentgovern-2.7.2 → agentgovern-2.7.3}/examples/langgraph_credit_decision_agent.py +0 -0
  37. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/__init__.py +0 -0
  38. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/conftest.py +0 -0
  39. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/__init__.py +0 -0
  40. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/conftest.py +0 -0
  41. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/fixtures/claude_code_pretooluse_output_keys.json +0 -0
  42. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_bundled_hook.py +0 -0
  43. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_drift.py +0 -0
  44. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_context_snapshot.py +0 -0
  45. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_dispatch.py +0 -0
  46. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_outbox_queue.py +0 -0
  47. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_payload_extraction.py +0 -0
  48. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_payloads.py +0 -0
  49. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_remediation_message.py +0 -0
  50. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_system_message.py +0 -0
  51. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_timing_log.py +0 -0
  52. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_transcript_reading.py +0 -0
  53. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_turn_index.py +0 -0
  54. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_hook_v2_warn_verdict.py +0 -0
  55. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_matcher_coverage.py +0 -0
  56. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_settings.py +0 -0
  57. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/hook/test_tool_map_classification.py +0 -0
  58. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_base_instrumentor.py +0 -0
  59. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_client.py +0 -0
  60. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_evaluate_input.py +0 -0
  61. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_gate2_full_path.py +0 -0
  62. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_gate2_latency.py +0 -0
  63. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_gate2_verdict_handler.py +0 -0
  64. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_import_guard.py +0 -0
  65. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_instrument_langchain.py +0 -0
  66. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_langchain.py +0 -0
  67. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_langchain_1x_agent.py +0 -0
  68. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_langchain_gate1.py +0 -0
  69. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_langchain_gate2_callbacks.py +0 -0
  70. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_langgraph.py +0 -0
  71. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_nagt380_enforce_propagation.py +0 -0
  72. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_no_localhost_3000_config.py +0 -0
  73. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_no_localhost_3000_traffic.py +0 -0
  74. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_output_capture_contract.py +0 -0
  75. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_payload_schema.py +0 -0
  76. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_policy_engine_url.py +0 -0
  77. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_rate_limit_handling.py +0 -0
  78. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_register_contract.py +0 -0
  79. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_timestamp.py +0 -0
  80. {agentgovern-2.7.2 → agentgovern-2.7.3}/tests/test_version_consistency.py +0 -0
@@ -1,5 +1,27 @@
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
+
3
25
  ## 2.7.2 — release date is the `sdk-v2.7.2` tag's (prepared 2026-10-19)
4
26
 
5
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgovern
3
- Version: 2.7.2
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
6
  Project-URL: Support, https://zirahn.com/contact
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentgovern"
3
- version = "2.7.2"
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" }
@@ -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())
@@ -1 +0,0 @@
1
- __version__ = "2.7.2"
File without changes
File without changes
File without changes
File without changes