agentgovern 2.1.0__tar.gz → 2.2.0__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 (64) hide show
  1. agentgovern-2.2.0/CHANGELOG.md +174 -0
  2. {agentgovern-2.1.0 → agentgovern-2.2.0}/PKG-INFO +3 -3
  3. agentgovern-2.2.0/agentgovern/_version.py +1 -0
  4. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/data/hook.py +98 -25
  5. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/installer.py +7 -5
  6. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/settings.py +1 -1
  7. {agentgovern-2.1.0 → agentgovern-2.2.0}/pyproject.toml +2 -2
  8. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_outbox_queue.py +66 -1
  9. agentgovern-2.1.0/CHANGELOG.md +0 -104
  10. agentgovern-2.1.0/agentgovern/_version.py +0 -1
  11. {agentgovern-2.1.0 → agentgovern-2.2.0}/.gitignore +0 -0
  12. {agentgovern-2.1.0 → agentgovern-2.2.0}/LICENSE +0 -0
  13. {agentgovern-2.1.0 → agentgovern-2.2.0}/README.md +0 -0
  14. {agentgovern-2.1.0 → agentgovern-2.2.0}/RELEASING.md +0 -0
  15. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/__init__.py +0 -0
  16. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/__main__.py +0 -0
  17. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/client.py +0 -0
  18. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/exceptions.py +0 -0
  19. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/gate2.py +0 -0
  20. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/__init__.py +0 -0
  21. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/cli.py +0 -0
  22. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/data/__init__.py +0 -0
  23. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/hook/drift.py +0 -0
  24. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/instrumentors/__init__.py +0 -0
  25. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/instrumentors/_base.py +0 -0
  26. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/instrumentors/langchain.py +0 -0
  27. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/instrumentors/langgraph.py +0 -0
  28. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentgovern/types.py +0 -0
  29. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentshield/__init__.py +0 -0
  30. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentshield/client.py +0 -0
  31. {agentgovern-2.1.0 → agentgovern-2.2.0}/agentshield/types.py +0 -0
  32. {agentgovern-2.1.0 → agentgovern-2.2.0}/examples/langgraph_credit_decision_agent.py +0 -0
  33. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/__init__.py +0 -0
  34. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/__init__.py +0 -0
  35. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/conftest.py +0 -0
  36. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_bundled_hook.py +0 -0
  37. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_drift.py +0 -0
  38. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_context_snapshot.py +0 -0
  39. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_dispatch.py +0 -0
  40. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_payload_extraction.py +0 -0
  41. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_payloads.py +0 -0
  42. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_remediation_message.py +0 -0
  43. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_timing_log.py +0 -0
  44. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_transcript_reading.py +0 -0
  45. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_turn_index.py +0 -0
  46. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_hook_v2_warn_verdict.py +0 -0
  47. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_installer.py +0 -0
  48. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/hook/test_settings.py +0 -0
  49. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_base_instrumentor.py +0 -0
  50. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_client.py +0 -0
  51. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_evaluate_input.py +0 -0
  52. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_gate2_full_path.py +0 -0
  53. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_gate2_latency.py +0 -0
  54. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_gate2_verdict_handler.py +0 -0
  55. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_instrument_langchain.py +0 -0
  56. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_langchain.py +0 -0
  57. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_langchain_gate1.py +0 -0
  58. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_langchain_gate2_callbacks.py +0 -0
  59. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_langgraph.py +0 -0
  60. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_output_capture_contract.py +0 -0
  61. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_payload_schema.py +0 -0
  62. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_rate_limit_handling.py +0 -0
  63. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_timestamp.py +0 -0
  64. {agentgovern-2.1.0 → agentgovern-2.2.0}/tests/test_version_consistency.py +0 -0
@@ -0,0 +1,174 @@
1
+ # Changelog
2
+
3
+ ## 2.2.0 — 2026-08-11
4
+
5
+ ### Fix — capture-only hook events (UserPromptSubmit/PostToolUse/Stop) no longer block on a discarded synchronous verdict; async audit-event parity (NAGT-137)
6
+
7
+ `AGENTGOVERN_HOOK_MODE`'s effect is now per-event, not global:
8
+
9
+ - **`UserPromptSubmit`/`PostToolUse`/`Stop`** are capture-only — the hook
10
+ never reads their ingest response for control flow — and now default to
11
+ `evaluation_mode: async` regardless of `AGENTGOVERN_HOOK_MODE`, so they no
12
+ longer pay the policy-engine's synchronous round trip (previously up to
13
+ the full `AGENTGOVERN_HOOK_TIMEOUT_MS`/shell timeout on every turn
14
+ boundary and every tool call). Set `AGENTGOVERN_HOOK_MODE=sync` explicitly
15
+ to force a synchronous verdict on these three as well (debugging).
16
+ - **`PreToolUse` always requests `sync` now** and always enforces the
17
+ verdict it gets back — it is the only event that gates whether a tool
18
+ runs, so its verdict was never safe to discard. **Behavior change:**
19
+ `AGENTGOVERN_HOOK_MODE=async` can no longer make `PreToolUse`
20
+ non-blocking; it could in 2.1.0 and earlier. This was documented (not
21
+ merely latent) — `packages/claude-hook/README.md`'s env-var reference
22
+ table and its rate-limit troubleshooting note both described `async` as
23
+ applying uniformly to all events — both updated in this release to
24
+ reflect the new per-event behavior.
25
+ - **Server-side (`apps/web`):** `evaluateInteractiveAsync` — already the
26
+ default path for all SDK-originated traffic (`evaluation_mode` defaults
27
+ to `async`) — previously never parsed the policy engine's top-level
28
+ `verdict`/`review_id` fields, so any tenant with an enforce-mode policy
29
+ got a generic `interactive_action.failed` audit event with no
30
+ `review_id` and no block-vs-escalate distinction, for every finding.
31
+ `evaluateInteractiveAsync` now fires the same `interactive_action.blocked`
32
+ /`.warned` (with `review_id`) events `evaluateInteractiveSyncAndRespond`
33
+ already did. **Behavior change:** `interactive_action.failed` no longer
34
+ fires for a verdict-bearing block outcome under async — it's retained
35
+ only as the fallback for a malformed/unparseable engine response.
36
+ Consumers filtering the audit-event stream on `.failed` specifically will
37
+ see fewer of them; those findings now surface as `.blocked`.
38
+ - Hook script version (`HOOK_VERSION`) bumped 2.1.0 → 2.2.0 in lockstep.
39
+
40
+ Requires `agentgovern hook install --force` (or a fresh install) to pick up
41
+ the updated hook script — existing deployed hooks do not auto-update.
42
+
43
+ Full design and build record: `docs/audit-reports/nagt-137-hook-sync-blocking-2026-08-03.md`.
44
+
45
+ ### Fix — `langgraph` extra had no upper version bound (NAGT-169)
46
+
47
+ `create_react_agent` is deprecated as of LangGraph v1.0 and scheduled for
48
+ removal in v2.0. The `langgraph` extra's dependency (`langgraph>=0.2.0`) had
49
+ no ceiling, so a fresh `pip install agentgovern[langgraph]` after LangGraph
50
+ 2.0 ships would have pulled it unpinned — breaking this SDK's own
51
+ `test_langgraph.py` integration tests and the `create_react_agent`-based
52
+ onboarding sample on first run. Pinned to `langgraph>=0.2.0,<2.0.0`. The
53
+ `langchain` extra's `langchain-core` dependency was checked and left
54
+ unbounded — the instrumentor only uses the foundational, non-deprecated
55
+ `BaseCallbackHandler`, and `langchain-core` has no announced 2.0.
56
+
57
+ ### Fix — `agentgovern hook status`/`hook test`/`hook install` crash on Windows under the default `cp1252` console codepage (NAGT-090, NAGT-169)
58
+
59
+ Several CLI status/error messages wrote non-ASCII characters (`✓`, `✗`, `→`)
60
+ that `cp1252` — the default console codepage on an unconfigured Windows
61
+ machine — cannot encode, raising `UnicodeEncodeError` in place of the
62
+ intended output. Replaced with ASCII equivalents (`OK`/`MISSING`, `->`) at
63
+ all five confirmed call sites: `hook status`'s per-event detail line and
64
+ `hook test`'s coverage banner (the two originally filed under NAGT-090),
65
+ `hook test`'s invalid-API-key/actor-ID error hints, and the
66
+ `Right-click -> Properties -> Security -> ...` permission-fix hint raised
67
+ when `~/.claude/settings.json` isn't writable — found by an exhaustive
68
+ non-cp1252-character sweep of the installer/CLI/instrumentor surface
69
+ while fixing the two originally-filed sites, not by a new bug report.
70
+
71
+ Full record: `docs/audit-reports/nagt-169-sdk-packaging-hygiene-2026-08-11.md`.
72
+
73
+ ## 2.1.0 — 2026-07-20
74
+
75
+ ### Feature — durable local outbox retry queue for the Claude Code hook (NAGT-095)
76
+
77
+ Fire-and-forget hook sends (`UserPromptSubmit`/`PostToolUse`/`Stop`) that fail
78
+ at the network level are no longer silently lost. A failed send is now queued
79
+ to a local durable outbox (`~/.agentgovern/outbox/`, one file per event) and
80
+ retried opportunistically on a later hook invocation — bounded so a backlog
81
+ can never meaningfully delay the invocation performing the retry (at most 5
82
+ items per invocation, a 1.5s wall-clock budget, a 60s per-file cooldown).
83
+ Permanently-abandoned events (48h TTL, or a 500-file size cap) are recorded to
84
+ `~/.agentgovern/outbox-dead/` instead of disappearing without a trace.
85
+
86
+ - **Swallowed-exception capture**: the network exception behind a failed send
87
+ is now captured into the queued record itself, turning the next real-world
88
+ occurrence into inspectable diagnostic data instead of a silent, unlogged
89
+ drop.
90
+ - **`client_event_id`**: every fire-and-forget payload now carries a
91
+ client-generated UUID v4, captured (not yet enforced) server-side —
92
+ positions a future dedup fix to act retroactively on rows this release
93
+ ever writes. Delivery remains at-least-once, not exactly-once, until that
94
+ follow-on ships.
95
+ - **Directory hardening**: `~/.agentgovern/outbox(-dead)/` are created
96
+ owner-only (`chmod 0700` on POSIX, an `icacls` ACL lockdown on Windows).
97
+ - Hook script version (`HOOK_VERSION`, distinct from this package version)
98
+ bumped 2.0.0 → 2.1.0 in lockstep.
99
+
100
+ No API or behavior change for installs that never experience a send failure.
101
+ Requires `agentgovern hook install --force` (or a fresh install) to pick up
102
+ the updated hook script — existing deployed hooks do not auto-update.
103
+
104
+ Full design and build record: `docs/audit-reports/nagt-095-async-prompt-drop-2026-07-20.md`.
105
+
106
+ ## 2.0.2 — 2026-07-02
107
+
108
+ ### Fix — README quickstart correctness (NAGT-019)
109
+
110
+ Two documentation corrections so the published quickstart actually works for a
111
+ first-time customer. No code or API change.
112
+
113
+ - **Install line** now reads `pip install "agentgovern[langchain]"`. The base
114
+ `pip install agentgovern` installs no runtime dependencies (they moved to the
115
+ `[sdk]`/`[langchain]` extras in 0.2.0), so the LangChain quickstart that
116
+ follows it could not run as written.
117
+ - **API-key prefix** in the Claude Code Hook env-var example corrected from
118
+ `as_prod_` to `ag_prod_`, matching the prefix the server actually issues and
119
+ validates (`ag_prod_`/`ag_stg_`/`ag_dev_`).
120
+
121
+ Surfaced by the IAG-007 customer-docs Phase 1 audit (PR #237).
122
+
123
+ ## 2.0.1 — 2026-06-29
124
+
125
+ ### Fix — Gate 1 wire-shape contract
126
+
127
+ `evaluate_input()` now nests `context` and `metadata` inside the `input` field of
128
+ the request body, matching the engine's `EvaluateInputRequest` schema. Previously
129
+ these were sent at the top level, where the engine silently dropped them — so input
130
+ policies that depend on `context`/`metadata` were evaluated against missing data.
131
+ The public Python signature is unchanged; only the on-the-wire serialization is
132
+ corrected. Surfaced by the SEC-002 pre-flight contract check.
133
+
134
+ ## 0.2.0 — 2026-06-11
135
+
136
+ ### Breaking change — optional SDK dependencies
137
+
138
+ `httpx`, `pydantic`, and `python-dotenv` are no longer installed by default.
139
+ They have moved to the `[sdk]` optional extra.
140
+
141
+ **Affected users:** anyone who installed `agentgovern` and used the agent SDK
142
+ (`init()`, `track_action()`, `evaluate_input()`, framework instrumentors).
143
+
144
+ **Migration:**
145
+
146
+ ```bash
147
+ # Before
148
+ pip install agentgovern
149
+
150
+ # After — agent SDK usage
151
+ pip install agentgovern[sdk]
152
+
153
+ # After — LangChain (sdk is pulled in automatically)
154
+ pip install agentgovern[langchain]
155
+
156
+ # Hook-only users (Claude Code governance) — no change needed
157
+ pip install agentgovern
158
+ agentgovern hook install
159
+ ```
160
+
161
+ **Errata (2026-07-10):** this entry originally read "Claude Code / Cursor
162
+ governance" — Cursor was never shipped as a hook target; corrected to
163
+ Claude Code, the only host with a working client.
164
+
165
+ ### Why
166
+
167
+ The hook CLI (`agentgovern hook install/uninstall/status/test`) is pure Python
168
+ standard library and does not need httpx or pydantic. Moving these to an
169
+ optional extra reduces the installation footprint for hook-only deployments
170
+ from ~45 MB to ~1 MB.
171
+
172
+ ## 0.1.1b15 — prior
173
+
174
+ Initial beta releases. See git history.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: agentgovern
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Compliance-as-code middleware for agentic AI workflows.
5
5
  Project-URL: Homepage, https://agentgovern.zirahn.com
6
6
  Project-URL: Repository, https://github.com/ahmedkhan-zirahn/agentgovern
@@ -42,7 +42,7 @@ Requires-Dist: python-dotenv>=1.0.0; extra == 'langchain'
42
42
  Provides-Extra: langgraph
43
43
  Requires-Dist: httpx>=0.27.0; extra == 'langgraph'
44
44
  Requires-Dist: langchain-core>=0.3.0; extra == 'langgraph'
45
- Requires-Dist: langgraph>=0.2.0; extra == 'langgraph'
45
+ Requires-Dist: langgraph<2.0.0,>=0.2.0; extra == 'langgraph'
46
46
  Requires-Dist: pydantic>=2.10.0; extra == 'langgraph'
47
47
  Requires-Dist: python-dotenv>=1.0.0; extra == 'langgraph'
48
48
  Provides-Extra: openai
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0"
@@ -9,6 +9,13 @@ Configuration (environment variables):
9
9
  AGENTGOVERN_ACTOR_USER_ID Required. Clerk user ID (user_xxxxx).
10
10
  AGENTGOVERN_API_URL Default: https://agentgovern.zirahn.com
11
11
  AGENTGOVERN_HOOK_MODE Default: sync (sync | async | off)
12
+ NAGT-137: applies to UserPromptSubmit/PostToolUse/Stop only — these
13
+ capture-only events default to async (no blocking) regardless of
14
+ this variable; set 'sync' to force a synchronous verdict on them too
15
+ (debugging). PreToolUse always requests sync and always enforces the
16
+ verdict — this variable can no longer make PreToolUse non-blocking
17
+ (it could prior to v2.2.0; see CHANGELOG). 'off' disables the hook
18
+ entirely, for all events, unchanged.
12
19
  AGENTGOVERN_HOOK_VERBOSE Default: 0 (0 | 1)
13
20
  AGENTGOVERN_FAIL_ACTION Default: allow (allow | deny)
14
21
  AGENTGOVERN_HOOK_TIMEOUT_MS Default: 5000
@@ -41,7 +48,7 @@ from datetime import datetime, timezone
41
48
  from pathlib import Path
42
49
  from typing import Any
43
50
 
44
- __version__ = "2.1.0"
51
+ __version__ = "2.2.0"
45
52
 
46
53
  # ── Constants ──────────────────────────────────────────────────────────────────
47
54
 
@@ -148,9 +155,28 @@ def _now_iso() -> str:
148
155
  return _iso_timestamp_utc()
149
156
 
150
157
 
151
- def _get_evaluation_mode() -> str:
152
- """Return 'sync' or 'async' based on AGENTGOVERN_HOOK_MODE."""
153
- hook_mode = os.environ.get("AGENTGOVERN_HOOK_MODE", "sync").lower()
158
+ def _get_evaluation_mode(event_name: str) -> str:
159
+ """Return 'sync' or 'async' for this hook event type (NAGT-137).
160
+
161
+ PreToolUse always returns 'sync' — it gates whether the tool runs, so its
162
+ verdict is never discarded, and AGENTGOVERN_HOOK_MODE can no longer make
163
+ it non-blocking (removed deliberately; see the module docstring).
164
+
165
+ The three capture-only events (UserPromptSubmit, PostToolUse, Stop)
166
+ discard the response body regardless of what it contains (_send_to_ingest
167
+ never reads it for control flow) — they default to 'async' so they never
168
+ pay the policy-engine round trip for a verdict nobody reads. Set
169
+ AGENTGOVERN_HOOK_MODE='sync' explicitly to force a synchronous verdict on
170
+ these three as well (debugging escape hatch, preserved from the prior
171
+ single-switch behavior).
172
+ """
173
+ if event_name == "PreToolUse":
174
+ return "sync"
175
+ # NAGT-137: default is 'async' for these three, the opposite of
176
+ # PreToolUse/the pre-NAGT-137 global default — an unset env var must NOT
177
+ # fall back to 'sync' here, or these events would silently keep blocking
178
+ # exactly as before. Only an explicit 'sync' forces the debug escape hatch.
179
+ hook_mode = os.environ.get("AGENTGOVERN_HOOK_MODE", "").lower()
154
180
  return "sync" if hook_mode == "sync" else "async"
155
181
 
156
182
 
@@ -224,13 +250,19 @@ def format_action_name(tool_name: str, tool_input: dict[str, Any]) -> str:
224
250
  def build_payload(
225
251
  hook_event: dict[str, Any],
226
252
  actor_user_id: str,
227
- hook_mode: str,
253
+ evaluation_mode: str,
228
254
  ) -> dict[str, Any]:
229
255
  """Build the interactiveSchema-compliant PreToolUse payload from a hook event.
230
256
 
231
257
  Preserved from v1 — produces a v1-identical payload (no context_snapshot,
232
258
  no turn_index). Used directly by _handle_pre_tool_use in minimal mode.
233
259
  In enriched mode, _handle_pre_tool_use augments the result with context_snapshot.
260
+
261
+ evaluation_mode is the already-resolved 'sync'/'async' string (see
262
+ _get_evaluation_mode) — NAGT-137: this used to take the raw
263
+ AGENTGOVERN_HOOK_MODE value and normalize it internally; PreToolUse is
264
+ now the only caller and always resolves to 'sync', so the normalization
265
+ moved to the single call site instead of living here.
234
266
  """
235
267
  tool_name: str = hook_event.get("tool_name", "")
236
268
  tool_input: dict[str, Any] = hook_event.get("tool_input") or {}
@@ -238,7 +270,6 @@ def build_payload(
238
270
 
239
271
  tool_event_type, action_type = map_tool_to_event(tool_name)
240
272
  action_name = format_action_name(tool_name, tool_input)
241
- evaluation_mode = "sync" if hook_mode == "sync" else "async"
242
273
 
243
274
  payload: dict[str, Any] = {
244
275
  "schema_version": "1",
@@ -515,9 +546,26 @@ def _windows_username_principal() -> str | None:
515
546
 
516
547
  def _harden_directory_windows(path: Path) -> None:
517
548
  """Best-effort ACL lockdown via icacls — the same tool and pattern this
518
- repo's own CLAUDE.md prescribes for ~/.agentgovern/prod.env. There is no
519
- dependency-free stdlib equivalent to POSIX chmod on Windows (adding
520
- pywin32 as a new dependency for one directory lockdown is out of
549
+ repo's own CLAUDE.md prescribes for ~/.agentgovern/prod.env, including
550
+ that file's own explicit SYSTEM grant (CLAUDE.md's own success bar for
551
+ this class of file is "no Everyone/BUILTIN\\Users ACE", not "no
552
+ SYSTEM/Administrators" — SYSTEM can read any file on the box regardless
553
+ of ACLs, and a local admin can always take ownership and reset any ACL,
554
+ so neither principal's presence here grants anything they couldn't
555
+ already reach). `/inheritance:r` only strips ACEs the object *inherited*
556
+ from its parent — on a freshly-created folder near a user-profile root
557
+ (the real ~/.agentgovern/ shape, confirmed by direct testing — see
558
+ docs/audit-reports/nagt-095-async-prompt-drop-2026-07-20.md's
559
+ verification-gap note), SYSTEM and Administrators typically arrive as
560
+ *explicit* ACEs already, which /inheritance:r does not touch and
561
+ /grant:r does not remove for a principal it isn't asked about. The
562
+ explicit SYSTEM grant below doesn't change that end state — it makes it
563
+ intentional and self-documenting instead of an accident of Windows'
564
+ default folder-creation behavior, so the code doesn't silently rely on
565
+ an undocumented default that could change.
566
+
567
+ There is no dependency-free stdlib equivalent to POSIX chmod on Windows
568
+ (adding pywin32 as a new dependency for one directory lockdown is out of
521
569
  proportion), so this shells out rather than leaving the directory
522
570
  unhardened. If icacls is unavailable, times out, or fails for any reason
523
571
  (non-NTFS volume, restricted environment, etc.), this logs verbose and
@@ -535,7 +583,14 @@ def _harden_directory_windows(path: Path) -> None:
535
583
  return
536
584
  try:
537
585
  result = subprocess.run(
538
- ["icacls", str(path), "/inheritance:r", "/grant:r", f"{principal}:(OI)(CI)F"],
586
+ [
587
+ "icacls",
588
+ str(path),
589
+ "/inheritance:r",
590
+ "/grant:r",
591
+ f"{principal}:(OI)(CI)F",
592
+ "NT AUTHORITY\\SYSTEM:(OI)(CI)F",
593
+ ],
539
594
  capture_output=True,
540
595
  text=True,
541
596
  timeout=2.0,
@@ -547,7 +602,10 @@ def _harden_directory_windows(path: Path) -> None:
547
602
  "directory left at default (inherited) permissions."
548
603
  )
549
604
  else:
550
- _vlog(f"[agentgovern] Hardened {path} to owner-only via icacls.")
605
+ _vlog(
606
+ f"[agentgovern] Restricted {path} to current user + SYSTEM + "
607
+ "local Administrators via icacls (excludes other local accounts)."
608
+ )
551
609
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as exc:
552
610
  _vlog(
553
611
  f"[agentgovern] icacls hardening of {path} raised {exc} — "
@@ -556,11 +614,14 @@ def _harden_directory_windows(path: Path) -> None:
556
614
 
557
615
 
558
616
  def _ensure_hardened_dir(path: Path) -> None:
559
- """Create `path` if missing and harden it to owner-only, once, at
560
- creation time only — every later call is a single cheap is_dir() stat,
561
- never repeated chmod/icacls work, so this never adds per-invocation cost
562
- to the hot path. Applies to both OUTBOX_DIR and DEAD_LETTER_DIR, since
563
- both are only ever created via _write_json_atomic below."""
617
+ """Create `path` if missing and restrict it (current user + SYSTEM +
618
+ local Administrators on Windows, 0700 on POSIX — excludes other local
619
+ accounts; see _harden_directory_windows for why this isn't "owner-only"),
620
+ once, at creation time only — every later call is a single cheap
621
+ is_dir() stat, never repeated chmod/icacls work, so this never adds
622
+ per-invocation cost to the hot path. Applies to both OUTBOX_DIR and
623
+ DEAD_LETTER_DIR, since both are only ever created via _write_json_atomic
624
+ below."""
564
625
  if path.is_dir():
565
626
  return
566
627
  try:
@@ -970,6 +1031,10 @@ def _handle_pre_tool_use(
970
1031
  api_url = os.environ.get("AGENTGOVERN_API_URL", _DEFAULT_API_URL).rstrip("/")
971
1032
  hook_mode = os.environ.get("AGENTGOVERN_HOOK_MODE", "sync").lower()
972
1033
  fail_action = os.environ.get("AGENTGOVERN_FAIL_ACTION", "allow").lower()
1034
+ # NAGT-137: PreToolUse always requests sync, independent of hook_mode
1035
+ # above — hook_mode is still read for the "off" kill switch and
1036
+ # AGENTGOVERN_FAIL_ACTION handling below, just no longer for this.
1037
+ evaluation_mode = _get_evaluation_mode("PreToolUse")
973
1038
 
974
1039
  try:
975
1040
  timeout_seconds = (
@@ -997,7 +1062,7 @@ def _handle_pre_tool_use(
997
1062
  )
998
1063
  return 0
999
1064
 
1000
- payload = build_payload(hook_event, actor_user_id, hook_mode)
1065
+ payload = build_payload(hook_event, actor_user_id, evaluation_mode)
1001
1066
 
1002
1067
  # Enriched mode: add context_snapshot (absent in minimal mode for v1 compat).
1003
1068
  if capture_mode == CAPTURE_MODE_ENRICHED:
@@ -1008,7 +1073,7 @@ def _handle_pre_tool_use(
1008
1073
  timeout_ms_int = int(timeout_seconds * 1000)
1009
1074
  _vlog(
1010
1075
  f"[agentgovern] POST {endpoint} tool={tool_name} "
1011
- f"mode={hook_mode} timeout={timeout_ms_int}ms"
1076
+ f"mode={evaluation_mode} timeout={timeout_ms_int}ms"
1012
1077
  )
1013
1078
 
1014
1079
  t0 = time.monotonic()
@@ -1057,10 +1122,18 @@ def _handle_pre_tool_use(
1057
1122
  )
1058
1123
  return 0
1059
1124
 
1060
- # ── Async mode ────────────────────────────────────────────────────────────
1061
- if hook_mode == "async":
1062
- _vlog("[agentgovern] Async ingest accepted.")
1063
- return 0
1125
+ # NAGT-137: no async-skip branch here — PreToolUse's evaluation_mode is
1126
+ # always 'sync' now (see above), so this payload always told the server
1127
+ # to evaluate synchronously and this response always has a real verdict
1128
+ # to parse. Before this change, the removed branch (`if hook_mode ==
1129
+ # "async": return 0`) was consistent with the payload actually sent,
1130
+ # since hook_mode drove both. Leaving it keyed on the raw
1131
+ # AGENTGOVERN_HOOK_MODE env var after this change — rather than deleting
1132
+ # it — would have reintroduced exactly that inconsistency in the other
1133
+ # direction: an operator with AGENTGOVERN_HOOK_MODE=async set would still
1134
+ # send 'sync' (per _get_evaluation_mode above) and get a real verdict
1135
+ # back, but this branch would have discarded it unparsed, silently
1136
+ # disabling enforcement. Deleted, not left dead.
1064
1137
 
1065
1138
  # ── Sync mode: parse verdict ──────────────────────────────────────────────
1066
1139
  data: dict[str, Any] = response.get("data") or {}
@@ -1131,7 +1204,7 @@ def _handle_user_prompt_submit(hook_event: dict[str, Any]) -> int:
1131
1204
  "client_event_id": str(uuid.uuid4()),
1132
1205
  "input": {"prompt": prompt_text},
1133
1206
  "client_timestamp": _now_iso(),
1134
- "evaluation_mode": _get_evaluation_mode(),
1207
+ "evaluation_mode": _get_evaluation_mode("UserPromptSubmit"),
1135
1208
  "context_snapshot": _build_context_snapshot(hook_event),
1136
1209
  }
1137
1210
 
@@ -1174,7 +1247,7 @@ def _handle_post_tool_use(hook_event: dict[str, Any]) -> int:
1174
1247
  "client_event_id": str(uuid.uuid4()),
1175
1248
  "input": tool_input,
1176
1249
  "client_timestamp": _now_iso(),
1177
- "evaluation_mode": _get_evaluation_mode(),
1250
+ "evaluation_mode": _get_evaluation_mode("PostToolUse"),
1178
1251
  "context_snapshot": _build_context_snapshot(hook_event),
1179
1252
  }
1180
1253
 
@@ -1277,7 +1350,7 @@ def _handle_stop(hook_event: dict[str, Any]) -> int:
1277
1350
  "input": {},
1278
1351
  "output": {"text": assistant_text_capped},
1279
1352
  "client_timestamp": _now_iso(),
1280
- "evaluation_mode": _get_evaluation_mode(),
1353
+ "evaluation_mode": _get_evaluation_mode("Stop"),
1281
1354
  "context_snapshot": _build_context_snapshot(hook_event),
1282
1355
  }
1283
1356
 
@@ -648,7 +648,7 @@ def status() -> int:
648
648
  if exit_code == 0 and drift_report.has_drift:
649
649
  exit_code = 2
650
650
 
651
- # Per-event registration detail — every v2 event marked present (✓) or MISSING,
651
+ # Per-event registration detail — every v2 event marked present (OK) or MISSING,
652
652
  # with any matcher/timeout drift shown directly beneath its event.
653
653
  matcher_drift_by_event = {md.event_type: md for md in drift_report.matcher_drifts}
654
654
  timeout_drift_by_event = {td.event_type: td for td in drift_report.timeout_drifts}
@@ -665,7 +665,7 @@ def status() -> int:
665
665
  if entry.get("hooks"):
666
666
  cmd = entry["hooks"][0].get("command", "")
667
667
  timeout_val = entry["hooks"][0].get("timeout", "")
668
- print(f" {et:<20} ✓ matcher={matcher_val} cmd={cmd} timeout={timeout_val}s")
668
+ print(f" {et:<20} OK matcher={matcher_val} cmd={cmd} timeout={timeout_val}s")
669
669
 
670
670
  md = matcher_drift_by_event.get(et)
671
671
  for line in _format_matcher_drift_lines(md) if md is not None else []:
@@ -753,7 +753,9 @@ def _print_coverage_banner() -> None:
753
753
 
754
754
  status_label = _coverage_status_label(marker, registered)
755
755
 
756
- marks = " ".join(f"{et} {'✓' if et in registered else '✗'}" for et in _V2_HOOK_EVENT_TYPES)
756
+ marks = " ".join(
757
+ f"{et} {'OK' if et in registered else 'MISSING'}" for et in _V2_HOOK_EVENT_TYPES
758
+ )
757
759
  print("Hook registration coverage:")
758
760
  print(f" Registered events: {marks}")
759
761
  print(f" Mode: {marker if marker else 'legacy-unknown'}")
@@ -910,11 +912,11 @@ def _utc_timestamp() -> str:
910
912
  def _print_http_error_hint(status: int, code: str) -> None:
911
913
  if status == 401:
912
914
  print("\nYour AGENTGOVERN_API_KEY is invalid or revoked.")
913
- print("Create a new key in the dashboard: Settings → API Keys.")
915
+ print("Create a new key in the dashboard: Settings -> API Keys.")
914
916
  elif status == 403 and "ACTOR_NOT_IN_TENANT" in code:
915
917
  print(
916
918
  "\nYour AGENTGOVERN_ACTOR_USER_ID is not a member of this tenant.\n"
917
- "Check the user ID in the dashboard: Settings → Profile."
919
+ "Check the user ID in the dashboard: Settings -> Profile."
918
920
  )
919
921
  elif status == 429:
920
922
  print("\nRate limit exceeded. Try again in a few seconds.")
@@ -84,7 +84,7 @@ def atomic_write(path: Path, content: dict[str, Any]) -> None:
84
84
  raise PermissionError(
85
85
  f"{check_target} is not writable.\n"
86
86
  "On Linux/macOS: chmod u+w " + str(check_target) + "\n"
87
- "On Windows: Right-click → Properties → Security → "
87
+ "On Windows: Right-click -> Properties -> Security -> "
88
88
  "add write permission for your user."
89
89
  )
90
90
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentgovern"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "Compliance-as-code middleware for agentic AI workflows."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -39,7 +39,7 @@ sdk = [
39
39
  "python-dotenv>=1.0.0",
40
40
  ]
41
41
  langchain = ["agentgovern[sdk]", "langchain-core>=0.3.0"]
42
- langgraph = ["agentgovern[sdk]", "langgraph>=0.2.0", "langchain-core>=0.3.0"]
42
+ langgraph = ["agentgovern[sdk]", "langgraph>=0.2.0,<2.0.0", "langchain-core>=0.3.0"]
43
43
  crewai = ["agentgovern[sdk]", "crewai>=0.80.0"]
44
44
  openai = ["agentgovern[sdk]", "openai>=1.50.0"]
45
45
  dev = [
@@ -13,6 +13,9 @@ they never touch the real ~/.agentgovern/outbox(-dead)/.
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
+ import os
17
+ import shutil
18
+ import subprocess
16
19
  import time
17
20
  import uuid
18
21
  from datetime import datetime, timedelta, timezone
@@ -106,9 +109,24 @@ def test_posix_hardening_failure_does_not_raise(
106
109
  hook_mod._ensure_hardened_dir(tmp_path / "hardened-posix-fail") # must not raise
107
110
 
108
111
 
109
- def test_windows_hardening_invokes_icacls_with_owner_only_grant(
112
+ def test_windows_hardening_invokes_icacls_with_user_and_system_grant(
110
113
  hook_mod: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
111
114
  ) -> None:
115
+ """Verifies the COMMAND SHAPE only — deterministic and cross-platform,
116
+ since icacls itself can't run on the Linux CI runner. This is NOT a
117
+ claim that the resulting ACL is "owner-only": /inheritance:r strips only
118
+ inherited ACEs, and on the real ~/.agentgovern/ shape SYSTEM/
119
+ Administrators typically arrive as explicit ACEs it never touches (see
120
+ the module docstring on _harden_directory_windows and the "verification
121
+ gap" note in docs/audit-reports/nagt-095-async-prompt-drop-2026-07-20.md).
122
+ The explicit SYSTEM grant asserted below is intentional and matches
123
+ CLAUDE.md's own prod.env precedent — it does not remove Administrators
124
+ or change the end state, it just makes the intent visible instead of
125
+ relying on an undocumented Windows default. The real end-state
126
+ verification (no Everyone/Users/Authenticated Users ACE) is
127
+ test_windows_hardening_real_acl_excludes_other_local_accounts below,
128
+ which is Windows-only and skipped in CI.
129
+ """
112
130
  monkeypatch.setattr(hook_mod.os, "name", "nt")
113
131
  monkeypatch.setenv("USERDOMAIN", "MACHINE")
114
132
  monkeypatch.setenv("USERNAME", "ahmed")
@@ -134,6 +152,53 @@ def test_windows_hardening_invokes_icacls_with_owner_only_grant(
134
152
  assert str(target) in cmd
135
153
  assert "/inheritance:r" in cmd
136
154
  assert any("MACHINE\\ahmed:(OI)(CI)F" in part for part in cmd)
155
+ assert any("NT AUTHORITY\\SYSTEM:(OI)(CI)F" in part for part in cmd)
156
+ # The command itself must never add a broad grant — this is the one
157
+ # thing a mock CAN meaningfully assert about the real success criterion.
158
+ for part in cmd:
159
+ assert "Everyone" not in part
160
+ assert "BUILTIN\\Users" not in part
161
+ assert "Authenticated Users" not in part
162
+
163
+
164
+ @pytest.mark.skipif(os.name != "nt", reason="icacls only exists on Windows")
165
+ def test_windows_hardening_real_acl_excludes_other_local_accounts(
166
+ hook_mod: ModuleType,
167
+ ) -> None:
168
+ """Real (non-mocked) end-to-end check, run only on Windows.
169
+
170
+ This exists because the ORIGINAL verification for Finding 1 ran icacls
171
+ against a scratch directory deep under AppData/Local/Temp, where
172
+ SYSTEM/Administrators/owner all arrive as *inherited* ACEs — so
173
+ /inheritance:r stripped all three and the test (and the build-time
174
+ manual check) saw a misleadingly clean "owner alone" result. The real
175
+ ~/.agentgovern/ location is different: a freshly-created folder there
176
+ gets SYSTEM/Administrators as *explicit* ACEs from creation, which
177
+ /inheritance:r never touches. Testing against the wrong directory shape
178
+ is exactly how the false "owner-only" claim survived review — this test
179
+ therefore targets a directory created directly under
180
+ Path.home() / ".agentgovern", the real parent, not a generic tmp_path.
181
+
182
+ The real success criterion (matching CLAUDE.md's own prod.env bar) is
183
+ "no Everyone/BUILTIN\\Users/Authenticated Users ACE" — SYSTEM and
184
+ Administrators are expected and accepted, not a failure.
185
+ """
186
+ target = Path.home() / ".agentgovern" / f"pytest-acl-verify-{uuid.uuid4().hex}"
187
+ try:
188
+ hook_mod._ensure_hardened_dir(target)
189
+ assert target.is_dir()
190
+
191
+ result = subprocess.run(
192
+ ["icacls", str(target)], capture_output=True, text=True, timeout=5.0
193
+ )
194
+ assert result.returncode == 0, result.stderr
195
+
196
+ output = result.stdout
197
+ assert "Everyone" not in output, output
198
+ assert "BUILTIN\\Users" not in output, output
199
+ assert "Authenticated Users" not in output, output
200
+ finally:
201
+ shutil.rmtree(target, ignore_errors=True)
137
202
 
138
203
 
139
204
  def test_windows_hardening_failure_does_not_raise(
@@ -1,104 +0,0 @@
1
- # Changelog
2
-
3
- ## 2.1.0 — 2026-07-20
4
-
5
- ### Feature — durable local outbox retry queue for the Claude Code hook (NAGT-095)
6
-
7
- Fire-and-forget hook sends (`UserPromptSubmit`/`PostToolUse`/`Stop`) that fail
8
- at the network level are no longer silently lost. A failed send is now queued
9
- to a local durable outbox (`~/.agentgovern/outbox/`, one file per event) and
10
- retried opportunistically on a later hook invocation — bounded so a backlog
11
- can never meaningfully delay the invocation performing the retry (at most 5
12
- items per invocation, a 1.5s wall-clock budget, a 60s per-file cooldown).
13
- Permanently-abandoned events (48h TTL, or a 500-file size cap) are recorded to
14
- `~/.agentgovern/outbox-dead/` instead of disappearing without a trace.
15
-
16
- - **Swallowed-exception capture**: the network exception behind a failed send
17
- is now captured into the queued record itself, turning the next real-world
18
- occurrence into inspectable diagnostic data instead of a silent, unlogged
19
- drop.
20
- - **`client_event_id`**: every fire-and-forget payload now carries a
21
- client-generated UUID v4, captured (not yet enforced) server-side —
22
- positions a future dedup fix to act retroactively on rows this release
23
- ever writes. Delivery remains at-least-once, not exactly-once, until that
24
- follow-on ships.
25
- - **Directory hardening**: `~/.agentgovern/outbox(-dead)/` are created
26
- owner-only (`chmod 0700` on POSIX, an `icacls` ACL lockdown on Windows).
27
- - Hook script version (`HOOK_VERSION`, distinct from this package version)
28
- bumped 2.0.0 → 2.1.0 in lockstep.
29
-
30
- No API or behavior change for installs that never experience a send failure.
31
- Requires `agentgovern hook install --force` (or a fresh install) to pick up
32
- the updated hook script — existing deployed hooks do not auto-update.
33
-
34
- Full design and build record: `docs/audit-reports/nagt-095-async-prompt-drop-2026-07-20.md`.
35
-
36
- ## 2.0.2 — 2026-07-02
37
-
38
- ### Fix — README quickstart correctness (NAGT-019)
39
-
40
- Two documentation corrections so the published quickstart actually works for a
41
- first-time customer. No code or API change.
42
-
43
- - **Install line** now reads `pip install "agentgovern[langchain]"`. The base
44
- `pip install agentgovern` installs no runtime dependencies (they moved to the
45
- `[sdk]`/`[langchain]` extras in 0.2.0), so the LangChain quickstart that
46
- follows it could not run as written.
47
- - **API-key prefix** in the Claude Code Hook env-var example corrected from
48
- `as_prod_` to `ag_prod_`, matching the prefix the server actually issues and
49
- validates (`ag_prod_`/`ag_stg_`/`ag_dev_`).
50
-
51
- Surfaced by the IAG-007 customer-docs Phase 1 audit (PR #237).
52
-
53
- ## 2.0.1 — 2026-06-29
54
-
55
- ### Fix — Gate 1 wire-shape contract
56
-
57
- `evaluate_input()` now nests `context` and `metadata` inside the `input` field of
58
- the request body, matching the engine's `EvaluateInputRequest` schema. Previously
59
- these were sent at the top level, where the engine silently dropped them — so input
60
- policies that depend on `context`/`metadata` were evaluated against missing data.
61
- The public Python signature is unchanged; only the on-the-wire serialization is
62
- corrected. Surfaced by the SEC-002 pre-flight contract check.
63
-
64
- ## 0.2.0 — 2026-06-11
65
-
66
- ### Breaking change — optional SDK dependencies
67
-
68
- `httpx`, `pydantic`, and `python-dotenv` are no longer installed by default.
69
- They have moved to the `[sdk]` optional extra.
70
-
71
- **Affected users:** anyone who installed `agentgovern` and used the agent SDK
72
- (`init()`, `track_action()`, `evaluate_input()`, framework instrumentors).
73
-
74
- **Migration:**
75
-
76
- ```bash
77
- # Before
78
- pip install agentgovern
79
-
80
- # After — agent SDK usage
81
- pip install agentgovern[sdk]
82
-
83
- # After — LangChain (sdk is pulled in automatically)
84
- pip install agentgovern[langchain]
85
-
86
- # Hook-only users (Claude Code governance) — no change needed
87
- pip install agentgovern
88
- agentgovern hook install
89
- ```
90
-
91
- **Errata (2026-07-10):** this entry originally read "Claude Code / Cursor
92
- governance" — Cursor was never shipped as a hook target; corrected to
93
- Claude Code, the only host with a working client.
94
-
95
- ### Why
96
-
97
- The hook CLI (`agentgovern hook install/uninstall/status/test`) is pure Python
98
- standard library and does not need httpx or pydantic. Moving these to an
99
- optional extra reduces the installation footprint for hook-only deployments
100
- from ~45 MB to ~1 MB.
101
-
102
- ## 0.1.1b15 — prior
103
-
104
- Initial beta releases. See git history.
@@ -1 +0,0 @@
1
- __version__ = "2.1.0"
File without changes
File without changes
File without changes
File without changes