agentgov-cli 0.1.5__tar.gz → 0.1.8__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 (28) hide show
  1. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/PKG-INFO +1 -1
  2. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +20 -0
  3. agentgov_cli-0.1.8/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +199 -0
  4. agentgov_cli-0.1.8/agentgov_cli/commands/doctor.py +363 -0
  5. agentgov_cli-0.1.8/agentgov_cli/commands/uninstall.py +149 -0
  6. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/main.py +15 -1
  7. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/pyproject.toml +5 -1
  8. agentgov_cli-0.1.5/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +0 -106
  9. agentgov_cli-0.1.5/agentgov_cli/commands/doctor.py +0 -168
  10. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/.gitignore +0 -0
  11. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/README.md +0 -0
  12. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/__init__.py +0 -0
  13. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/client_assets/commands/workitem.md +0 -0
  14. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
  15. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
  16. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -0
  17. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/__init__.py +0 -0
  18. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/gateway.py +0 -0
  19. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/install.py +0 -0
  20. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/login.py +0 -0
  21. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/register_device.py +0 -0
  22. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/status.py +0 -0
  23. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/workitem.py +0 -0
  24. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/commands/wrap.py +0 -0
  25. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/gateway_process.py +0 -0
  26. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/loopback.py +0 -0
  27. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/agentgov_cli/port_resolver.py +0 -0
  28. {agentgov_cli-0.1.5 → agentgov_cli-0.1.8}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.5
3
+ Version: 0.1.8
4
4
  Summary: AgentGov CLI — wrap Claude Code, bind work items, check gateway health.
5
5
  Author: AgentGov Contributors
6
6
  License: Apache-2.0
@@ -138,6 +138,26 @@ def main() -> None:
138
138
  except Exception:
139
139
  _bail("bad_input", "unparsable hook JSON")
140
140
 
141
+ # Only confine sessions that are actually governed.
142
+ #
143
+ # This hook is registered in ~/.claude/settings.json, so it runs in EVERY
144
+ # Claude Code session on the machine. Enforcing repo confinement on a session
145
+ # whose traffic never touches the gateway is not governance, it is just
146
+ # breaking someone's editor — and because this guard fails closed outside a
147
+ # git repo, it would block every tool call in any non-repo directory.
148
+ #
149
+ # ANTHROPIC_BASE_URL is set by `agentgov wrap` or managed-settings.json,
150
+ # i.e. exactly when governance is on. See the same check in
151
+ # userpromptsubmit_workitem.py.
152
+ # Either signal means governance is deliberately on for this session:
153
+ # ANTHROPIC_BASE_URL — traffic is routed through the gateway
154
+ # AGENTGOV_REPO_ROOT — `agentgov wrap` scoped this session to a repo
155
+ governed = bool((os.environ.get("ANTHROPIC_BASE_URL") or "").strip()) or bool(
156
+ (os.environ.get("AGENTGOV_REPO_ROOT") or "").strip()
157
+ )
158
+ if not governed:
159
+ sys.exit(0)
160
+
141
161
  tool_name = payload.get("tool_name") or payload.get("toolName") or ""
142
162
  tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
143
163
  if not isinstance(tool_input, dict):
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """AgentGov UserPromptSubmit hook — the work-item gate.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ Work-item binding used to be enforced only at the gateway, which replied with a
7
+ synthetic "bind a work item" assistant message. That has two problems:
8
+
9
+ 1. The developer only discovers the requirement AFTER sending a prompt, and
10
+ it arrives looking like a strange answer from the model.
11
+ 2. It cannot prompt at all until something is sent — so a developer who opens
12
+ their editor and starts typing gets no guidance.
13
+
14
+ `UserPromptSubmit` fires before Claude Code sends anything, and exit code 2
15
+ blocks the prompt and shows stderr to the developer. So the ask happens in the
16
+ window they are already working in, before any network call, and costs nothing.
17
+
18
+ The gateway still enforces the same rule independently (§3.1, two-plane model).
19
+ This hook is the good experience; the gateway is the actual control. A developer
20
+ who deletes this hook does not gain ungoverned access — they just go back to
21
+ being told by the gateway instead.
22
+
23
+ Exit codes:
24
+ 0 -> allow the prompt
25
+ 2 -> block it, and show stderr to the developer
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ import urllib.request
35
+ from typing import NoReturn
36
+
37
+ GATEWAY_URL = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000")
38
+ TIMEOUT_SEC = 4.0
39
+
40
+ # Prompts that exist to FIX an unbound session must not be blocked by the
41
+ # unbound session — otherwise the developer is deadlocked with no way out.
42
+ ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
43
+
44
+
45
+ def _allow() -> NoReturn:
46
+ sys.exit(0)
47
+
48
+
49
+ def _block(message: str) -> NoReturn:
50
+ sys.stderr.write(message)
51
+ sys.exit(2)
52
+
53
+
54
+ def _is_governed() -> bool:
55
+ """Is THIS Claude Code session actually routed through the gateway?
56
+
57
+ Hooks are registered in ~/.claude/settings.json, which applies to EVERY
58
+ Claude Code session on the machine — including repositories nobody governs,
59
+ scratch directories, and support sessions. But a session only means anything
60
+ to AgentGov if its API traffic goes through the gateway.
61
+
62
+ Blocking an ungoverned session is pure harm: no tokens are attributed, no
63
+ policy applies, and the developer sees their prompt silently vanish (exit 2
64
+ erases it). That happened for real — the work-item gate froze a session that
65
+ was talking to Anthropic directly and had nothing to do with governance.
66
+
67
+ So: gate only when ANTHROPIC_BASE_URL points at our gateway. That is set by
68
+ `agentgov wrap` or by managed-settings.json, i.e. exactly when governance is
69
+ switched on. When it is not set, Claude Code talks to Anthropic directly and
70
+ we stay out of the way.
71
+ """
72
+ base = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip().rstrip("/")
73
+ if not base:
74
+ return False
75
+ return base == GATEWAY_URL.strip().rstrip("/") or "localhost" in base or "127.0.0.1" in base
76
+
77
+
78
+ def _binding(session_id: str) -> dict | None:
79
+ """Ask the gateway about this session. None = gateway unreachable."""
80
+ try:
81
+ with urllib.request.urlopen( # noqa: S310 — loopback only
82
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/{session_id}/binding",
83
+ timeout=TIMEOUT_SEC,
84
+ ) as resp:
85
+ result = json.loads(resp.read() or b"{}")
86
+ return result if isinstance(result, dict) else {}
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def _register(session_id: str, cwd: str) -> bool:
92
+ """Re-register a session the gateway has forgotten (e.g. after a restart).
93
+
94
+ The gateway holds sessions in memory, so any restart drops every editor
95
+ window that is already open. Re-registering here means the developer never
96
+ has to know that happened.
97
+ """
98
+ if not cwd:
99
+ return False
100
+ try:
101
+ repo_root = subprocess.run(
102
+ ["git", "rev-parse", "--show-toplevel"],
103
+ cwd=cwd,
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=5,
107
+ check=False,
108
+ )
109
+ if repo_root.returncode != 0:
110
+ return False
111
+ root = repo_root.stdout.strip()
112
+ remote = subprocess.run(
113
+ ["git", "remote", "get-url", "origin"],
114
+ cwd=cwd,
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=5,
118
+ check=False,
119
+ )
120
+ body = json.dumps(
121
+ {
122
+ "session_id": session_id,
123
+ "repo": remote.stdout.strip() if remote.returncode == 0 else "",
124
+ "repo_root": root,
125
+ }
126
+ ).encode()
127
+ req = urllib.request.Request(
128
+ f"{GATEWAY_URL.rstrip('/')}/admin/session/register",
129
+ data=body,
130
+ headers={"Content-Type": "application/json"},
131
+ )
132
+ urllib.request.urlopen(req, timeout=TIMEOUT_SEC).read() # noqa: S310 — loopback only
133
+ return True
134
+ except Exception:
135
+ return False
136
+
137
+
138
+ def main() -> None:
139
+ try:
140
+ payload = json.load(sys.stdin)
141
+ except Exception:
142
+ _allow() # Fail open: the gateway is the real control.
143
+
144
+ session_id = str(payload.get("session_id") or "")
145
+ user_input = str(payload.get("user_input") or "").strip()
146
+ cwd = str(payload.get("cwd") or "")
147
+
148
+ if not session_id:
149
+ _allow()
150
+
151
+ # Not routed through the gateway -> not governed -> not ours to block.
152
+ if not _is_governed():
153
+ _allow()
154
+
155
+ # Let the developer run the command that fixes the problem.
156
+ if user_input.startswith(ESCAPE_PREFIXES):
157
+ _allow()
158
+
159
+ data = _binding(session_id)
160
+ if data is None:
161
+ # Gateway down or unreachable. Do NOT block here: Claude Code cannot
162
+ # reach the model either way, and blocking would bury the real error
163
+ # ("connection refused") under a confusing governance message.
164
+ _allow()
165
+
166
+ if data.get("bound"):
167
+ _allow()
168
+
169
+ if not data.get("known"):
170
+ # The gateway does not recognise this session. This is almost always
171
+ # TRANSIENT, not an attack: the gateway keeps sessions in memory, so a
172
+ # restart or reinstall forgets every editor window that is already open.
173
+ #
174
+ # Blocking here was a serious mistake. It bricked every open Claude Code
175
+ # window after a gateway restart, with a message telling the developer to
176
+ # start a new session — while the API plane would have enforced anyway.
177
+ # Self-heal instead: re-register, then re-check.
178
+ if not _register(session_id, cwd):
179
+ _allow() # Could not re-register — let the gateway be the judge.
180
+ data = _binding(session_id)
181
+ if data is None or data.get("bound"):
182
+ _allow()
183
+
184
+ repo = data.get("repo") or "this repository"
185
+ _block(
186
+ "📌 AgentGov: bind this session to a work item before continuing.\n"
187
+ f" Repository: {repo}\n"
188
+ "\n"
189
+ " Run one of:\n"
190
+ " /workitem PROJ-1234 (JIRA issue)\n"
191
+ " /workitem PRJ-0042 (internal project)\n"
192
+ " /workitem https://notion.so/... (Notion page)\n"
193
+ "\n"
194
+ " Your prompt was not sent and no tokens were used.\n"
195
+ )
196
+
197
+
198
+ if __name__ == "__main__":
199
+ main()
@@ -0,0 +1,363 @@
1
+ """`agentgov doctor` — diagnose the request path, hop by hop.
2
+
3
+ The verified path a governed request takes:
4
+
5
+ Editor host (VS Code / Cursor / Windsurf / plain terminal)
6
+ └─ Claude Code ← the ONLY thing AgentGov governs
7
+ ├─ hooks ─────────────→ gateway /admin/* (tool plane)
8
+ └─ HTTPS to ANTHROPIC_BASE_URL
9
+ └─ AgentGov Gateway :8000 (API plane)
10
+ ├─ policy snapshot ← control tower
11
+ ├─ token events → control tower
12
+ └─ forwards ───────→ api.anthropic.com
13
+
14
+ Cursor and Windsurf are editor HOSTS, not Claude Code clients. Their own
15
+ assistants (Composer, Cascade) never route through Claude Code, so AgentGov
16
+ cannot see or govern them. Only Claude Code — CLI or extension — is governed.
17
+
18
+ Checks are grouped by which hop they belong to, so a failure points at one link
19
+ in that chain instead of a flat list of unrelated red rows. Every non-OK row
20
+ carries the exact command to run next; the matching Troubleshooting entry on
21
+ /setup uses the same step names.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import platform
31
+ import socket
32
+ import uuid
33
+ from pathlib import Path
34
+
35
+ import httpx
36
+ import typer
37
+ from rich.console import Console
38
+ from rich.table import Table
39
+
40
+ console = Console()
41
+
42
+ # The hops, in the order a request travels them.
43
+ STEP_CLIENT = "1 CLIENT"
44
+ STEP_HOOKS = "2 HOOKS"
45
+ STEP_GATEWAY = "3 GATEWAY"
46
+ STEP_IDENTITY = "4 IDENTITY"
47
+ STEP_TOWER = "5 TOWER"
48
+ STEP_UPSTREAM = "6 UPSTREAM"
49
+
50
+
51
+ def run(
52
+ gateway_url: str = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL"),
53
+ fix: bool = typer.Option(
54
+ False, "--fix", help="Repair the problems that are safe to repair automatically."
55
+ ),
56
+ ) -> None:
57
+ url = (gateway_url or "http://localhost:8000").rstrip("/")
58
+ rows: list[tuple[str, str, str, str]] = [] # step, check, result, what to do
59
+ ok = warn = fail = 0
60
+
61
+ def add(step: str, check: str, level: str, detail: str) -> None:
62
+ nonlocal ok, warn, fail
63
+ colour = {"OK": "green", "WARN": "yellow", "FAIL": "red"}[level]
64
+ rows.append((step, check, f"[{colour}]{level}[/]", detail))
65
+ if level == "OK":
66
+ ok += 1
67
+ elif level == "WARN":
68
+ warn += 1
69
+ else:
70
+ fail += 1
71
+
72
+ # ---- 1. CLIENT: is Claude Code actually pointed at the gateway? --------
73
+ # Nothing below matters if traffic never reaches us. This is also the check
74
+ # that explains "AgentGov seems installed but does nothing".
75
+ managed = _managed_settings_path()
76
+ base_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip()
77
+ if managed.exists():
78
+ add(STEP_CLIENT, "routing (managed-settings)", "OK", f"all sessions → {url}")
79
+ elif base_url:
80
+ add(STEP_CLIENT, "routing (this shell)", "OK", f"ANTHROPIC_BASE_URL={base_url}")
81
+ else:
82
+ add(
83
+ STEP_CLIENT,
84
+ "routing",
85
+ "WARN",
86
+ "Claude Code talks to Anthropic DIRECTLY — nothing is governed. "
87
+ "Use `agentgov wrap claude`, or install managed-settings (see /setup step 7).",
88
+ )
89
+
90
+ # ---- 2. HOOKS: the tool plane -----------------------------------------
91
+ hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
92
+ expected = {
93
+ "sessionstart_register.py",
94
+ "userpromptsubmit_workitem.py",
95
+ "pretooluse_pathguard.py",
96
+ }
97
+ if not hooks_dir.exists():
98
+ add(STEP_HOOKS, "hook files", "FAIL", f"missing {hooks_dir} — run `agentgov install`")
99
+ else:
100
+ present = {h.name for h in hooks_dir.glob("*.py")}
101
+ missing = expected - present
102
+ if missing:
103
+ add(
104
+ STEP_HOOKS,
105
+ "hook files",
106
+ "FAIL",
107
+ f"missing {', '.join(sorted(missing))} — run `agentgov install --force`",
108
+ )
109
+ else:
110
+ not_exec = [h.name for h in hooks_dir.glob("*.py") if not h.stat().st_mode & 0o111]
111
+ if not_exec:
112
+ add(
113
+ STEP_HOOKS,
114
+ "hook files",
115
+ "FAIL",
116
+ f"not executable: {', '.join(not_exec)} — run `agentgov doctor --fix`",
117
+ )
118
+ else:
119
+ add(STEP_HOOKS, "hook files", "OK", str(hooks_dir))
120
+
121
+ registered, dangling = _registered_hooks()
122
+ if dangling:
123
+ add(
124
+ STEP_HOOKS,
125
+ "hook registration",
126
+ "FAIL",
127
+ f"{len(dangling)} registration(s) point at MISSING files — Claude Code runs a "
128
+ "missing command every prompt. Run `agentgov doctor --fix`.",
129
+ )
130
+ elif registered == 0:
131
+ add(
132
+ STEP_HOOKS,
133
+ "hook registration",
134
+ "WARN",
135
+ "not registered in ~/.claude/settings.json — no work-item prompt, no path guard. "
136
+ "Run `agentgov install`.",
137
+ )
138
+ elif registered < 3:
139
+ add(
140
+ STEP_HOOKS,
141
+ "hook registration",
142
+ "WARN",
143
+ f"only {registered}/3 registered — run `agentgov install --force`",
144
+ )
145
+ else:
146
+ add(STEP_HOOKS, "hook registration", "OK", "3/3 in ~/.claude/settings.json")
147
+
148
+ sl = Path.home() / ".claude" / "agentgov-statusline" / "agentgov_statusline.sh"
149
+ add(
150
+ STEP_HOOKS,
151
+ "statusline",
152
+ "OK" if sl.exists() else "WARN",
153
+ str(sl) if sl.exists() else "missing — run `agentgov install` (cosmetic only)",
154
+ )
155
+
156
+ # ---- 3. GATEWAY: the API plane ----------------------------------------
157
+ gateway_up = False
158
+ try:
159
+ r = httpx.get(f"{url}/health", timeout=3.0)
160
+ if r.status_code == 200:
161
+ gateway_up = True
162
+ add(STEP_GATEWAY, "reachable", "OK", url)
163
+ else:
164
+ add(STEP_GATEWAY, "reachable", "FAIL", f"{url} → HTTP {r.status_code}")
165
+ except Exception:
166
+ add(
167
+ STEP_GATEWAY,
168
+ "reachable",
169
+ "FAIL",
170
+ f"nothing answering at {url} — run `agentgov gateway install`, "
171
+ "then `agentgov gateway logs`",
172
+ )
173
+
174
+ # ---- 4. IDENTITY -------------------------------------------------------
175
+ identity = Path.home() / ".agentgov" / "identity.json"
176
+ add(
177
+ STEP_IDENTITY,
178
+ "logged in",
179
+ "OK" if identity.exists() else "FAIL",
180
+ str(identity) if identity.exists() else "run `agentgov login --tower <url> --token <tok>`",
181
+ )
182
+ creds = Path.home() / ".agentgov" / "gateway_creds.json"
183
+ add(
184
+ STEP_IDENTITY,
185
+ "device registered",
186
+ "OK" if creds.exists() else "FAIL",
187
+ str(creds) if creds.exists() else 'run `agentgov register-device --name "$(hostname)"`',
188
+ )
189
+
190
+ # ---- 5. CONTROL TOWER --------------------------------------------------
191
+ # A gateway that cannot sync policy keeps working until the staleness
192
+ # ceiling, then denies everything. Surface it before that happens.
193
+ tower = _creds_field("control_tower_url")
194
+ if not gateway_up:
195
+ add(STEP_TOWER, "policy sync", "WARN", "cannot check — gateway is not running")
196
+ elif not tower:
197
+ add(STEP_TOWER, "policy sync", "WARN", "no control_tower_url in gateway_creds.json")
198
+ else:
199
+ log = Path.home() / ".agentgov" / "gateway.log"
200
+ tail = ""
201
+ with contextlib.suppress(OSError):
202
+ tail = "\n".join(log.read_text(errors="replace").splitlines()[-80:])
203
+ # A healthy gateway reports EITHER:
204
+ # 200 -> "snapshot applied" (policy changed, cache replaced)
205
+ # 304 -> "Not Modified" (nothing changed — the steady state)
206
+ # Only checking for "snapshot applied" flagged a perfectly synced gateway
207
+ # as WARN, because 304 is what it returns almost all of the time.
208
+ synced = "snapshot applied" in tail or (
209
+ "policy/snapshot" in tail and "304" in tail
210
+ )
211
+ if synced:
212
+ add(STEP_TOWER, "policy sync", "OK", tower)
213
+ elif "401" in tail:
214
+ add(
215
+ STEP_TOWER,
216
+ "policy sync",
217
+ "FAIL",
218
+ "control tower rejected the gateway token (401) — re-run "
219
+ '`agentgov register-device --name "$(hostname)"`',
220
+ )
221
+ else:
222
+ add(STEP_TOWER, "policy sync", "WARN", f"no snapshot seen yet in {log}")
223
+
224
+ # ---- 6. UPSTREAM -------------------------------------------------------
225
+ try:
226
+ httpx.get("https://api.anthropic.com/v1/models", timeout=5.0)
227
+ add(STEP_UPSTREAM, "api.anthropic.com", "OK", "reachable from this machine")
228
+ except Exception as e:
229
+ add(STEP_UPSTREAM, "api.anthropic.com", "FAIL", f"unreachable: {type(e).__name__}")
230
+
231
+ # ---- render ------------------------------------------------------------
232
+ table = Table("step", "check", "result", "what to do", show_lines=False)
233
+ for row in rows:
234
+ table.add_row(*row)
235
+ console.print(table)
236
+ console.print(f"\n[bold]{ok} OK · {warn} WARN · {fail} FAIL[/]")
237
+
238
+ first_bad = next((r for r in rows if "OK" not in r[2]), None)
239
+ if first_bad:
240
+ console.print(
241
+ f"\n[bold]Start with step {first_bad[0]}[/] — {first_bad[1]}.\n"
242
+ " Requests travel the steps in order, so the earliest failure is usually the cause "
243
+ "of everything after it."
244
+ )
245
+ else:
246
+ console.print("\n[green]Whole path is healthy.[/]")
247
+
248
+ repaired = _repair(fix)
249
+ if repaired and not fix:
250
+ console.print(
251
+ "\n[yellow]Repairable automatically:[/] "
252
+ + "; ".join(repaired)
253
+ + "\n Run: [cyan]agentgov doctor --fix[/]"
254
+ )
255
+
256
+ if fail:
257
+ raise typer.Exit(2)
258
+ if warn:
259
+ raise typer.Exit(1)
260
+
261
+
262
+ def _registered_hooks() -> tuple[int, list[str]]:
263
+ """(count of AgentGov registrations, those pointing at files that don't exist)."""
264
+ settings = Path.home() / ".claude" / "settings.json"
265
+ if not settings.exists():
266
+ return 0, []
267
+ try:
268
+ data = json.loads(settings.read_text() or "{}")
269
+ except json.JSONDecodeError:
270
+ return 0, []
271
+ if not isinstance(data, dict):
272
+ return 0, []
273
+ count = 0
274
+ dangling: list[str] = []
275
+ for entries in (data.get("hooks") or {}).values():
276
+ if not isinstance(entries, list):
277
+ continue
278
+ for e in entries:
279
+ for h in (e or {}).get("hooks", []) if isinstance(e, dict) else []:
280
+ cmd = str(h.get("command", ""))
281
+ if "agentgov-hooks" in cmd:
282
+ count += 1
283
+ if not Path(cmd).exists():
284
+ dangling.append(cmd)
285
+ return count, dangling
286
+
287
+
288
+ def _creds_field(key: str) -> str:
289
+ try:
290
+ data = json.loads((Path.home() / ".agentgov" / "gateway_creds.json").read_text())
291
+ return str(data.get(key) or "")
292
+ except (OSError, ValueError):
293
+ return ""
294
+
295
+
296
+ def _repair(apply: bool) -> list[str]:
297
+ """Fix only what is UNAMBIGUOUSLY safe to fix.
298
+
299
+ Deliberately narrow: the current state must be broken, the correct state must
300
+ not be a judgement call, and the repair must not destroy anything the
301
+ developer chose. That excludes logging in, registering a device, and anything
302
+ needing sudo — those stay manual.
303
+ """
304
+ found: list[str] = []
305
+ settings = Path.home() / ".claude" / "settings.json"
306
+ hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
307
+
308
+ _, dangling = _registered_hooks()
309
+ if dangling:
310
+ found.append(f"{len(dangling)} hook registration(s) pointing at missing files")
311
+ if apply:
312
+ data = json.loads(settings.read_text() or "{}")
313
+ hooks = data.get("hooks") or {}
314
+ for ev in list(hooks):
315
+ kept = [
316
+ e
317
+ for e in hooks[ev]
318
+ if not any(
319
+ "agentgov-hooks" in str(h.get("command", ""))
320
+ and not Path(str(h.get("command", ""))).exists()
321
+ for h in (e or {}).get("hooks", [])
322
+ )
323
+ ]
324
+ if kept:
325
+ hooks[ev] = kept
326
+ else:
327
+ del hooks[ev]
328
+ if hooks:
329
+ data["hooks"] = hooks
330
+ else:
331
+ data.pop("hooks", None)
332
+ settings.write_text(json.dumps(data, indent=2) + "\n")
333
+ console.print(
334
+ f"[green]Fixed:[/] removed {len(dangling)} dangling hook registration(s). "
335
+ "Restart Claude Code."
336
+ )
337
+
338
+ if hooks_dir.is_dir():
339
+ not_exec = [h for h in hooks_dir.glob("*.py") if not h.stat().st_mode & 0o111]
340
+ if not_exec:
341
+ found.append(f"{len(not_exec)} hook(s) not executable")
342
+ if apply:
343
+ for h in not_exec:
344
+ h.chmod(0o755)
345
+ console.print(f"[green]Fixed:[/] made {len(not_exec)} hook(s) executable.")
346
+
347
+ if apply and not found:
348
+ console.print("\n[green]Nothing to repair.[/]")
349
+ return found
350
+
351
+
352
+ def _managed_settings_path() -> Path:
353
+ sysname = platform.system()
354
+ if sysname == "Darwin":
355
+ return Path("/Library/Application Support/ClaudeCode/managed-settings.json")
356
+ if sysname == "Windows":
357
+ return Path(r"C:\ProgramData\ClaudeCode\managed-settings.json")
358
+ return Path("/etc/claude-code/managed-settings.json")
359
+
360
+
361
+ def _machine_fingerprint() -> str:
362
+ """SHA-256 of hostname + a stable MAC — same recipe as the gateway."""
363
+ return hashlib.sha256(f"{socket.gethostname()}:{uuid.getnode():012x}".encode()).hexdigest()
@@ -0,0 +1,149 @@
1
+ """`agentgov uninstall` — remove AgentGov from this machine, safely.
2
+
3
+ WHY THIS EXISTS
4
+ ---------------
5
+ `agentgov install` writes hook registrations into ~/.claude/settings.json that
6
+ point at ~/.claude/agentgov-hooks/*.py. Nothing removed them, so the documented
7
+ teardown —
8
+
9
+ rm -rf ~/.agentgov ~/.claude/agentgov-hooks
10
+
11
+ — left Claude Code registering three hooks whose files no longer existed. Every
12
+ session and every prompt then tried to execute a missing command. Deleting the
13
+ files made Claude Code WORSE, not neutral, and no amount of reinstalling fixed
14
+ it because the stale registrations were never the thing being reinstalled.
15
+
16
+ Order matters and is the whole point of this command: **deregister first, delete
17
+ second**. A half-removed install must never be able to break the editor.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ import typer
28
+ from rich.console import Console
29
+
30
+ console = Console()
31
+
32
+ CLAUDE_DIR = Path.home() / ".claude"
33
+ SETTINGS = CLAUDE_DIR / "settings.json"
34
+ AGENTGOV_DIR = Path.home() / ".agentgov"
35
+
36
+
37
+ def run(
38
+ keep_credentials: bool = typer.Option(
39
+ False,
40
+ "--keep-credentials",
41
+ help="Keep ~/.agentgov (device key, identity, logs). Only remove Claude Code wiring.",
42
+ ),
43
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not prompt for confirmation."),
44
+ ) -> None:
45
+ """Remove AgentGov's Claude Code hooks, statusline, and local state."""
46
+ if not yes:
47
+ console.print("This will remove AgentGov's hooks and statusline from Claude Code.")
48
+ if not keep_credentials:
49
+ console.print(
50
+ f"It will also delete [cyan]{AGENTGOV_DIR}[/] "
51
+ "(device key, identity, gateway logs)."
52
+ )
53
+ if not typer.confirm("Continue?", default=False):
54
+ raise typer.Abort()
55
+
56
+ # 1. STOP the gateway service first, so nothing restarts mid-teardown.
57
+ _stop_service()
58
+
59
+ # 2. DEREGISTER before deleting. This is the step whose absence broke
60
+ # Claude Code: settings.json outlives the files it points at.
61
+ _deregister()
62
+
63
+ # 3. Now the files are safe to delete — nothing references them.
64
+ for path in (CLAUDE_DIR / "agentgov-hooks", CLAUDE_DIR / "agentgov-statusline"):
65
+ if path.exists():
66
+ shutil.rmtree(path, ignore_errors=True)
67
+ console.print(f"[green]Removed[/] {path}")
68
+ workitem = CLAUDE_DIR / "commands" / "workitem.md"
69
+ if workitem.exists():
70
+ workitem.unlink()
71
+ console.print(f"[green]Removed[/] {workitem}")
72
+
73
+ if not keep_credentials and AGENTGOV_DIR.exists():
74
+ shutil.rmtree(AGENTGOV_DIR, ignore_errors=True)
75
+ console.print(f"[green]Removed[/] {AGENTGOV_DIR}")
76
+
77
+ console.print("\n[bold green]AgentGov removed.[/]")
78
+ console.print("Claude Code is unaffected — restart it to clear the old session.")
79
+ console.print(
80
+ "\n[dim]Machine-wide managed-settings.json (if you installed it) needs sudo:[/]\n"
81
+ " sudo rm -f /etc/claude-code/managed-settings.json"
82
+ )
83
+
84
+
85
+ def _stop_service() -> None:
86
+ """Stop + disable the gateway service, ignoring 'not installed'."""
87
+ unit = Path.home() / ".config" / "systemd" / "user" / "agentgov-gateway.service"
88
+ if not unit.exists():
89
+ return
90
+ for args in (["stop", "agentgov-gateway"], ["disable", "agentgov-gateway"]):
91
+ subprocess.run(
92
+ ["systemctl", "--user", *args], capture_output=True, check=False, timeout=30
93
+ )
94
+ unit.unlink(missing_ok=True)
95
+ subprocess.run(
96
+ ["systemctl", "--user", "daemon-reload"], capture_output=True, check=False, timeout=30
97
+ )
98
+ console.print(f"[green]Stopped[/] gateway service ({unit.name})")
99
+
100
+
101
+ def _deregister() -> None:
102
+ """Strip AgentGov's entries from ~/.claude/settings.json, leaving the rest."""
103
+ if not SETTINGS.exists():
104
+ return
105
+ try:
106
+ data = json.loads(SETTINGS.read_text() or "{}")
107
+ except json.JSONDecodeError:
108
+ console.print(
109
+ f"[yellow]Could not parse {SETTINGS}[/] — leaving it alone. "
110
+ "Remove any 'agentgov-hooks' entries by hand."
111
+ )
112
+ return
113
+ if not isinstance(data, dict):
114
+ return
115
+
116
+ removed = 0
117
+ hooks = data.get("hooks")
118
+ if isinstance(hooks, dict):
119
+ for event in list(hooks):
120
+ entries = hooks[event]
121
+ if not isinstance(entries, list):
122
+ continue
123
+ kept = [e for e in entries if not _is_agentgov(e)]
124
+ removed += len(entries) - len(kept)
125
+ if kept:
126
+ hooks[event] = kept
127
+ else:
128
+ del hooks[event]
129
+ if hooks:
130
+ data["hooks"] = hooks
131
+ else:
132
+ data.pop("hooks", None)
133
+
134
+ if "agentgov" in str(data.get("statusLine", "")):
135
+ data.pop("statusLine", None)
136
+ removed += 1
137
+
138
+ if removed:
139
+ SETTINGS.write_text(json.dumps(data, indent=2) + "\n")
140
+ console.print(f"[green]Deregistered[/] {removed} entr(ies) from {SETTINGS}")
141
+
142
+
143
+ def _is_agentgov(entry: object) -> bool:
144
+ if not isinstance(entry, dict):
145
+ return False
146
+ return any(
147
+ isinstance(h, dict) and "agentgov-hooks" in str(h.get("command", ""))
148
+ for h in (entry.get("hooks") or [])
149
+ )
@@ -4,7 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from .commands import doctor, gateway, install, login, register_device, status, workitem, wrap
7
+ from .commands import (
8
+ doctor,
9
+ gateway,
10
+ install,
11
+ login,
12
+ register_device,
13
+ status,
14
+ uninstall,
15
+ workitem,
16
+ wrap,
17
+ )
8
18
 
9
19
  app = typer.Typer(
10
20
  name="agentgov",
@@ -19,6 +29,10 @@ app.command(
19
29
  app.command(
20
30
  "install", help="Install client assets into ~/.claude/ and print managed-settings.json."
21
31
  )(install.run)
32
+ app.command(
33
+ "uninstall",
34
+ help="Remove AgentGov hooks, statusline and local state (deregisters before deleting).",
35
+ )(uninstall.run)
22
36
  app.add_typer(gateway.app, name="gateway")
23
37
  app.command("wrap", help="Wrap `claude` — start a governed Claude Code session.")(wrap.run)
24
38
  app.command("workitem", help="Bind the current session to a work item (JIRA/Notion/PRJ-...).")(
@@ -2,7 +2,7 @@
2
2
  # PyPI: `agentgov` was rejected as too similar to existing `agent-gov`.
3
3
  # Package name is agentgov-cli; the console command it installs is still `agentgov`.
4
4
  name = "agentgov-cli"
5
- version = "0.1.5"
5
+ version = "0.1.8"
6
6
  description = "AgentGov CLI — wrap Claude Code, bind work items, check gateway health."
7
7
  readme = "README.md"
8
8
  license = { text = "Apache-2.0" }
@@ -65,6 +65,7 @@ include = [
65
65
 
66
66
  [tool.ruff]
67
67
  line-length = 100
68
+ extend-exclude = ["agentgov_cli/client_assets"]
68
69
  target-version = "py310"
69
70
  [tool.ruff.lint]
70
71
  select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
@@ -73,6 +74,9 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
73
74
  ignore = ["B008"]
74
75
 
75
76
  [tool.mypy]
77
+ # agentgov_cli/client_assets is a build artifact vendored by hatch_build.py;
78
+ # the real source lives at the repo root and is checked there.
79
+ exclude = ['agentgov_cli/client_assets/']
76
80
  python_version = "3.10"
77
81
  strict = true
78
82
  disallow_untyped_defs = true
@@ -1,106 +0,0 @@
1
- #!/usr/bin/env python3
2
- """AgentGov UserPromptSubmit hook — the work-item gate.
3
-
4
- WHY THIS EXISTS
5
- ---------------
6
- Work-item binding used to be enforced only at the gateway, which replied with a
7
- synthetic "bind a work item" assistant message. That has two problems:
8
-
9
- 1. The developer only discovers the requirement AFTER sending a prompt, and
10
- it arrives looking like a strange answer from the model.
11
- 2. It cannot prompt at all until something is sent — so a developer who opens
12
- their editor and starts typing gets no guidance.
13
-
14
- `UserPromptSubmit` fires before Claude Code sends anything, and exit code 2
15
- blocks the prompt and shows stderr to the developer. So the ask happens in the
16
- window they are already working in, before any network call, and costs nothing.
17
-
18
- The gateway still enforces the same rule independently (§3.1, two-plane model).
19
- This hook is the good experience; the gateway is the actual control. A developer
20
- who deletes this hook does not gain ungoverned access — they just go back to
21
- being told by the gateway instead.
22
-
23
- Exit codes:
24
- 0 -> allow the prompt
25
- 2 -> block it, and show stderr to the developer
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import json
31
- import os
32
- import sys
33
- import urllib.request
34
-
35
- GATEWAY_URL = os.environ.get("AGENTGOV_GATEWAY_URL", "http://127.0.0.1:8000")
36
- TIMEOUT_SEC = 4.0
37
-
38
- # Prompts that exist to FIX an unbound session must not be blocked by the
39
- # unbound session — otherwise the developer is deadlocked with no way out.
40
- ESCAPE_PREFIXES = ("/workitem", "/agentgov", "/help", "/status", "/doctor")
41
-
42
-
43
- def _allow() -> None:
44
- sys.exit(0)
45
-
46
-
47
- def _block(message: str) -> None:
48
- sys.stderr.write(message)
49
- sys.exit(2)
50
-
51
-
52
- def main() -> None:
53
- try:
54
- payload = json.load(sys.stdin)
55
- except Exception:
56
- _allow() # Fail open: the gateway is the real control.
57
-
58
- session_id = str(payload.get("session_id") or "")
59
- user_input = str(payload.get("user_input") or "").strip()
60
-
61
- if not session_id:
62
- _allow()
63
-
64
- # Let the developer run the command that fixes the problem.
65
- if user_input.startswith(ESCAPE_PREFIXES):
66
- _allow()
67
-
68
- try:
69
- with urllib.request.urlopen( # noqa: S310 — loopback only
70
- f"{GATEWAY_URL.rstrip('/')}/admin/session/{session_id}/binding",
71
- timeout=TIMEOUT_SEC,
72
- ) as resp:
73
- data = json.loads(resp.read() or b"{}")
74
- except Exception:
75
- # Gateway down or unreachable. Do NOT block here: Claude Code cannot
76
- # reach the model either way, and blocking would bury the real error
77
- # ("connection refused") under a confusing governance message.
78
- _allow()
79
-
80
- if data.get("bound"):
81
- _allow()
82
-
83
- if not data.get("known"):
84
- _block(
85
- "AgentGov: this session is not registered with the gateway, so it cannot "
86
- "be governed.\n"
87
- "Start a new Claude Code session so the SessionStart hook can run. If this "
88
- "persists, run: agentgov doctor\n"
89
- )
90
-
91
- repo = data.get("repo") or "this repository"
92
- _block(
93
- "📌 AgentGov: bind this session to a work item before continuing.\n"
94
- f" Repository: {repo}\n"
95
- "\n"
96
- " Run one of:\n"
97
- " /workitem PROJ-1234 (JIRA issue)\n"
98
- " /workitem PRJ-0042 (internal project)\n"
99
- " /workitem https://notion.so/... (Notion page)\n"
100
- "\n"
101
- " Your prompt was not sent and no tokens were used.\n"
102
- )
103
-
104
-
105
- if __name__ == "__main__":
106
- main()
@@ -1,168 +0,0 @@
1
- """`agentgov doctor` — diagnose the installation (CLAUDE.md §13, §14).
2
-
3
- The old §12.1 blanket rule ("gateway must NEVER run on a governed developer's
4
- machine") has been REPLACED by the §14.4 CONDITIONAL rule:
5
-
6
- The gateway MAY run locally provided
7
- (a) upstream_mode = oauth_passthrough (no vault to steal)
8
- OR
9
- (b) every credential is user-scoped + spending-capped + device-encrypted.
10
-
11
- So this doctor:
12
- - Confirms local gateway is reachable (green: it IS local).
13
- - Reports the machine fingerprint + the upstream_mode the gateway is
14
- running in.
15
- - Flags a WARNING (not an error) only if apikey_vault is on AND we couldn't
16
- confirm device-encrypted delivery (i.e. an env-provided key overrides the
17
- sealed cred — legacy §12 path on a dev machine, which we discourage).
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import hashlib
23
- import platform
24
- import socket
25
- import uuid
26
- from pathlib import Path
27
-
28
- import httpx
29
- import typer
30
- from rich.console import Console
31
- from rich.table import Table
32
-
33
- console = Console()
34
-
35
-
36
- def run(
37
- gateway_url: str = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL"),
38
- ) -> None:
39
- ok = 0
40
- warn = 0
41
- fail = 0
42
- table = Table("check", "result", "detail", show_lines=False)
43
-
44
- # 1. Managed settings present (Linux only; on macOS/Windows we just show the path)
45
- settings_path = _managed_settings_path()
46
- if settings_path.exists():
47
- table.add_row("managed-settings.json", "[green]OK[/]", str(settings_path))
48
- ok += 1
49
- else:
50
- table.add_row("managed-settings.json", "[yellow]MISSING[/]", str(settings_path))
51
- warn += 1
52
-
53
- # 2. Hooks executable
54
- hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
55
- if not hooks_dir.exists():
56
- table.add_row("hooks installed", "[red]MISSING[/]", str(hooks_dir))
57
- fail += 1
58
- else:
59
- problems = [str(h) for h in hooks_dir.glob("*.py") if not h.stat().st_mode & 0o100]
60
- if problems:
61
- table.add_row("hooks executable", "[red]FAIL[/]", ", ".join(problems))
62
- fail += 1
63
- else:
64
- table.add_row("hooks executable", "[green]OK[/]", str(hooks_dir))
65
- ok += 1
66
-
67
- # 3. Statusline installed
68
- sl = Path.home() / ".claude" / "agentgov-statusline" / "agentgov_statusline.sh"
69
- if sl.exists():
70
- table.add_row("statusline installed", "[green]OK[/]", str(sl))
71
- ok += 1
72
- else:
73
- table.add_row("statusline installed", "[yellow]MISSING[/]", str(sl))
74
- warn += 1
75
-
76
- # 4. Identity + device registration (§13/§14)
77
- identity = Path.home() / ".agentgov" / "identity.json"
78
- creds = Path.home() / ".agentgov" / "gateway_creds.json"
79
- if identity.exists():
80
- table.add_row("identity", "[green]OK[/]", str(identity))
81
- ok += 1
82
- else:
83
- table.add_row("identity", "[yellow]MISSING[/]", "run `agentgov login`")
84
- warn += 1
85
- if creds.exists():
86
- table.add_row("device registered", "[green]OK[/]", str(creds))
87
- ok += 1
88
- else:
89
- table.add_row(
90
- "device registered", "[yellow]MISSING[/]", "run `agentgov register-device --name ...`"
91
- )
92
- warn += 1
93
-
94
- # 5. Gateway reachable
95
- url = gateway_url or "http://localhost:8000"
96
- upstream_mode = None
97
- try:
98
- r = httpx.get(f"{url}/health", timeout=3.0)
99
- if r.status_code == 200:
100
- table.add_row("gateway reachable", "[green]OK[/]", url)
101
- ok += 1
102
- # Try to read the mode from the /admin endpoint or fall back to
103
- # env — for now we just show what env says.
104
- import os as _os
105
-
106
- upstream_mode = _os.environ.get("UPSTREAM_MODE", "oauth_passthrough")
107
- else:
108
- table.add_row("gateway reachable", "[red]FAIL[/]", f"{url} → HTTP {r.status_code}")
109
- fail += 1
110
- except Exception as e:
111
- table.add_row("gateway reachable", "[red]FAIL[/]", f"{url}: {e}")
112
- fail += 1
113
-
114
- # 6. §14.4 CONDITIONAL rule check
115
- fp = _machine_fingerprint()
116
- if upstream_mode == "oauth_passthrough":
117
- table.add_row(
118
- "§14.4 rule",
119
- "[green]OK[/]",
120
- f"oauth_passthrough — no vault · fingerprint={fp[:16]}…",
121
- )
122
- ok += 1
123
- elif upstream_mode == "apikey_vault":
124
- # We can't introspect the running gateway's config remotely (yet).
125
- # This is a soft warning until we add /admin/mode endpoint.
126
- import os as _os
127
-
128
- legacy_env_key = bool(_os.environ.get("ANTHROPIC_API_KEY", ""))
129
- if legacy_env_key:
130
- table.add_row(
131
- "§14.4 rule",
132
- "[yellow]WARN[/]",
133
- (
134
- "apikey_vault WITH env-provided ANTHROPIC_API_KEY — legacy §12 path. "
135
- "Prefer per-device sealed credentials."
136
- ),
137
- )
138
- warn += 1
139
- else:
140
- table.add_row(
141
- "§14.4 rule",
142
- "[green]OK[/]",
143
- "apikey_vault via per-device sealed credential (recommended)",
144
- )
145
- ok += 1
146
-
147
- console.print(table)
148
- console.print(f"\n[bold]{ok} OK · {warn} WARN · {fail} FAIL[/]")
149
- if fail:
150
- raise typer.Exit(2)
151
- if warn:
152
- raise typer.Exit(1)
153
-
154
-
155
- def _managed_settings_path() -> Path:
156
- sysname = platform.system()
157
- if sysname == "Darwin":
158
- return Path("/Library/Application Support/ClaudeCode/managed-settings.json")
159
- if sysname == "Windows":
160
- return Path(r"C:\ProgramData\ClaudeCode\managed-settings.json")
161
- return Path("/etc/claude-code/managed-settings.json")
162
-
163
-
164
- def _machine_fingerprint() -> str:
165
- """SHA-256 of hostname + a stable MAC — same recipe as the gateway."""
166
- hostname = socket.gethostname()
167
- mac = uuid.getnode()
168
- return hashlib.sha256(f"{hostname}:{mac:012x}".encode()).hexdigest()
File without changes
File without changes