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.
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/PKG-INFO +1 -1
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py +20 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/userpromptsubmit_workitem.py +28 -0
- agentgov_cli-0.1.7/agentgov_cli/commands/doctor.py +355 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/pyproject.toml +1 -1
- agentgov_cli-0.1.6/agentgov_cli/commands/doctor.py +0 -168
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/.gitignore +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/README.md +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/__init__.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/commands/workitem.md +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/sessionstart_register.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/managed-settings.json.tmpl +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/statusline/agentgov_statusline.sh +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/__init__.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/gateway.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/install.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/login.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/register_device.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/status.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/uninstall.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/workitem.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/commands/wrap.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/gateway_process.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/loopback.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/main.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/port_resolver.py +0 -0
- {agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/hatch_build.py +0 -0
{agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/pretooluse_pathguard.py
RENAMED
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
{agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/hooks/sessionstart_register.py
RENAMED
|
File without changes
|
{agentgov_cli-0.1.6 → agentgov_cli-0.1.7}/agentgov_cli/client_assets/managed-settings.json.tmpl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|