agentgov-cli 0.1.6__tar.gz → 0.1.7__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 (27) hide show
  1. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/PKG-INFO +1 -1
  2. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +20 -0
  3. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +28 -0
  4. agentgov_cli-0.1.7/agentgov_cli/commands/doctor.py +355 -0
  5. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/pyproject.toml +1 -1
  6. agentgov_cli-0.1.6/agentgov_cli/commands/doctor.py +0 -168
  7. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/.gitignore +0 -0
  8. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/README.md +0 -0
  9. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/__init__.py +0 -0
  10. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/commands/workitem.md +0 -0
  11. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
  12. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
  13. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -0
  14. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/__init__.py +0 -0
  15. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/gateway.py +0 -0
  16. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/install.py +0 -0
  17. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/login.py +0 -0
  18. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/register_device.py +0 -0
  19. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/status.py +0 -0
  20. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/uninstall.py +0 -0
  21. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/workitem.py +0 -0
  22. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/wrap.py +0 -0
  23. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/gateway_process.py +0 -0
  24. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/loopback.py +0 -0
  25. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/main.py +0 -0
  26. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/port_resolver.py +0 -0
  27. {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentgov-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
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):
@@ -51,6 +51,30 @@ def _block(message: str) -> NoReturn:
51
51
  sys.exit(2)
52
52
 
53
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
+
54
78
  def _binding(session_id: str) -> dict | None:
55
79
  """Ask the gateway about this session. None = gateway unreachable."""
56
80
  try:
@@ -124,6 +148,10 @@ def main() -> None:
124
148
  if not session_id:
125
149
  _allow()
126
150
 
151
+ # Not routed through the gateway -> not governed -> not ours to block.
152
+ if not _is_governed():
153
+ _allow()
154
+
127
155
  # Let the developer run the command that fixes the problem.
128
156
  if user_input.startswith(ESCAPE_PREFIXES):
129
157
  _allow()
@@ -0,0 +1,355 @@
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
+ if "snapshot applied" in tail:
204
+ add(STEP_TOWER, "policy sync", "OK", tower)
205
+ elif "401" in tail:
206
+ add(
207
+ STEP_TOWER,
208
+ "policy sync",
209
+ "FAIL",
210
+ "control tower rejected the gateway token (401) — re-run "
211
+ '`agentgov register-device --name "$(hostname)"`',
212
+ )
213
+ else:
214
+ add(STEP_TOWER, "policy sync", "WARN", f"no snapshot seen yet in {log}")
215
+
216
+ # ---- 6. UPSTREAM -------------------------------------------------------
217
+ try:
218
+ httpx.get("https://api.anthropic.com/v1/models", timeout=5.0)
219
+ add(STEP_UPSTREAM, "api.anthropic.com", "OK", "reachable from this machine")
220
+ except Exception as e:
221
+ add(STEP_UPSTREAM, "api.anthropic.com", "FAIL", f"unreachable: {type(e).__name__}")
222
+
223
+ # ---- render ------------------------------------------------------------
224
+ table = Table("step", "check", "result", "what to do", show_lines=False)
225
+ for row in rows:
226
+ table.add_row(*row)
227
+ console.print(table)
228
+ console.print(f"\n[bold]{ok} OK · {warn} WARN · {fail} FAIL[/]")
229
+
230
+ first_bad = next((r for r in rows if "OK" not in r[2]), None)
231
+ if first_bad:
232
+ console.print(
233
+ f"\n[bold]Start with step {first_bad[0]}[/] — {first_bad[1]}.\n"
234
+ " Requests travel the steps in order, so the earliest failure is usually the cause "
235
+ "of everything after it."
236
+ )
237
+ else:
238
+ console.print("\n[green]Whole path is healthy.[/]")
239
+
240
+ repaired = _repair(fix)
241
+ if repaired and not fix:
242
+ console.print(
243
+ "\n[yellow]Repairable automatically:[/] "
244
+ + "; ".join(repaired)
245
+ + "\n Run: [cyan]agentgov doctor --fix[/]"
246
+ )
247
+
248
+ if fail:
249
+ raise typer.Exit(2)
250
+ if warn:
251
+ raise typer.Exit(1)
252
+
253
+
254
+ def _registered_hooks() -> tuple[int, list[str]]:
255
+ """(count of AgentGov registrations, those pointing at files that don't exist)."""
256
+ settings = Path.home() / ".claude" / "settings.json"
257
+ if not settings.exists():
258
+ return 0, []
259
+ try:
260
+ data = json.loads(settings.read_text() or "{}")
261
+ except json.JSONDecodeError:
262
+ return 0, []
263
+ if not isinstance(data, dict):
264
+ return 0, []
265
+ count = 0
266
+ dangling: list[str] = []
267
+ for entries in (data.get("hooks") or {}).values():
268
+ if not isinstance(entries, list):
269
+ continue
270
+ for e in entries:
271
+ for h in (e or {}).get("hooks", []) if isinstance(e, dict) else []:
272
+ cmd = str(h.get("command", ""))
273
+ if "agentgov-hooks" in cmd:
274
+ count += 1
275
+ if not Path(cmd).exists():
276
+ dangling.append(cmd)
277
+ return count, dangling
278
+
279
+
280
+ def _creds_field(key: str) -> str:
281
+ try:
282
+ data = json.loads((Path.home() / ".agentgov" / "gateway_creds.json").read_text())
283
+ return str(data.get(key) or "")
284
+ except (OSError, ValueError):
285
+ return ""
286
+
287
+
288
+ def _repair(apply: bool) -> list[str]:
289
+ """Fix only what is UNAMBIGUOUSLY safe to fix.
290
+
291
+ Deliberately narrow: the current state must be broken, the correct state must
292
+ not be a judgement call, and the repair must not destroy anything the
293
+ developer chose. That excludes logging in, registering a device, and anything
294
+ needing sudo — those stay manual.
295
+ """
296
+ found: list[str] = []
297
+ settings = Path.home() / ".claude" / "settings.json"
298
+ hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
299
+
300
+ _, dangling = _registered_hooks()
301
+ if dangling:
302
+ found.append(f"{len(dangling)} hook registration(s) pointing at missing files")
303
+ if apply:
304
+ data = json.loads(settings.read_text() or "{}")
305
+ hooks = data.get("hooks") or {}
306
+ for ev in list(hooks):
307
+ kept = [
308
+ e
309
+ for e in hooks[ev]
310
+ if not any(
311
+ "agentgov-hooks" in str(h.get("command", ""))
312
+ and not Path(str(h.get("command", ""))).exists()
313
+ for h in (e or {}).get("hooks", [])
314
+ )
315
+ ]
316
+ if kept:
317
+ hooks[ev] = kept
318
+ else:
319
+ del hooks[ev]
320
+ if hooks:
321
+ data["hooks"] = hooks
322
+ else:
323
+ data.pop("hooks", None)
324
+ settings.write_text(json.dumps(data, indent=2) + "\n")
325
+ console.print(
326
+ f"[green]Fixed:[/] removed {len(dangling)} dangling hook registration(s). "
327
+ "Restart Claude Code."
328
+ )
329
+
330
+ if hooks_dir.is_dir():
331
+ not_exec = [h for h in hooks_dir.glob("*.py") if not h.stat().st_mode & 0o111]
332
+ if not_exec:
333
+ found.append(f"{len(not_exec)} hook(s) not executable")
334
+ if apply:
335
+ for h in not_exec:
336
+ h.chmod(0o755)
337
+ console.print(f"[green]Fixed:[/] made {len(not_exec)} hook(s) executable.")
338
+
339
+ if apply and not found:
340
+ console.print("\n[green]Nothing to repair.[/]")
341
+ return found
342
+
343
+
344
+ def _managed_settings_path() -> Path:
345
+ sysname = platform.system()
346
+ if sysname == "Darwin":
347
+ return Path("/Library/Application Support/ClaudeCode/managed-settings.json")
348
+ if sysname == "Windows":
349
+ return Path(r"C:\ProgramData\ClaudeCode\managed-settings.json")
350
+ return Path("/etc/claude-code/managed-settings.json")
351
+
352
+
353
+ def _machine_fingerprint() -> str:
354
+ """SHA-256 of hostname + a stable MAC — same recipe as the gateway."""
355
+ return hashlib.sha256(f"{socket.gethostname()}:{uuid.getnode():012x}".encode()).hexdigest()
@@ -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.6"
5
+ version = "0.1.7"
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" }
@@ -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