agentgov-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- agentgov_cli/commands/__init__.py +0 -0
- agentgov_cli/commands/doctor.py +168 -0
- agentgov_cli/commands/install.py +122 -0
- agentgov_cli/commands/login.py +74 -0
- agentgov_cli/commands/register_device.py +137 -0
- agentgov_cli/commands/status.py +46 -0
- agentgov_cli/commands/workitem.py +72 -0
- agentgov_cli/commands/wrap.py +271 -0
- agentgov_cli/loopback.py +122 -0
- agentgov_cli/main.py +32 -0
- agentgov_cli/port_resolver.py +68 -0
- agentgov_cli-0.1.0.dist-info/METADATA +81 -0
- agentgov_cli-0.1.0.dist-info/RECORD +16 -0
- agentgov_cli-0.1.0.dist-info/WHEEL +4 -0
- agentgov_cli-0.1.0.dist-info/entry_points.txt +2 -0
agentgov_cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
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()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`agentgov install` — copies client assets to ~/.claude/, prints managed-settings.json.
|
|
2
|
+
|
|
3
|
+
The managed-settings.json is printed to stdout (with the admin-visible path per OS)
|
|
4
|
+
so an administrator can drop it into the OS-managed path. That path is ROOT-owned;
|
|
5
|
+
we do NOT try to write it — the CLI runs as the developer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
# Asset source is discovered relative to the installed package (client_assets is
|
|
22
|
+
# a sibling directory of the cli/). In wheel installs we ship it via
|
|
23
|
+
# package data; in local dev we look up-tree.
|
|
24
|
+
ASSET_ROOTS = [
|
|
25
|
+
Path(__file__).resolve().parent.parent.parent.parent / "client_assets",
|
|
26
|
+
Path(sys.prefix) / "share" / "agentgov" / "client_assets",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(
|
|
31
|
+
gateway_url: str = "http://localhost:8000",
|
|
32
|
+
force: bool = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Install hooks + statusline + slash commands into ~/.claude/."""
|
|
35
|
+
src = _find_assets()
|
|
36
|
+
dest = Path.home() / ".claude"
|
|
37
|
+
dest.mkdir(exist_ok=True)
|
|
38
|
+
|
|
39
|
+
hooks_src = src / "hooks"
|
|
40
|
+
hooks_dest = dest / "agentgov-hooks"
|
|
41
|
+
if hooks_dest.exists() and not force:
|
|
42
|
+
console.print(f"[yellow]Skipping[/] hooks — {hooks_dest} exists (use --force)")
|
|
43
|
+
else:
|
|
44
|
+
shutil.copytree(hooks_src, hooks_dest, dirs_exist_ok=True)
|
|
45
|
+
for h in hooks_dest.glob("*.py"):
|
|
46
|
+
h.chmod(0o755)
|
|
47
|
+
console.print(f"[green]Copied[/] hooks → {hooks_dest}")
|
|
48
|
+
|
|
49
|
+
statusline_src = src / "statusline"
|
|
50
|
+
statusline_dest = dest / "agentgov-statusline"
|
|
51
|
+
if statusline_dest.exists() and not force:
|
|
52
|
+
console.print(f"[yellow]Skipping[/] statusline — {statusline_dest} exists")
|
|
53
|
+
else:
|
|
54
|
+
shutil.copytree(statusline_src, statusline_dest, dirs_exist_ok=True)
|
|
55
|
+
for h in statusline_dest.glob("*.sh"):
|
|
56
|
+
h.chmod(0o755)
|
|
57
|
+
console.print(f"[green]Copied[/] statusline → {statusline_dest}")
|
|
58
|
+
|
|
59
|
+
commands_dest = dest / "commands"
|
|
60
|
+
commands_dest.mkdir(exist_ok=True)
|
|
61
|
+
shutil.copy2(src / "commands" / "workitem.md", commands_dest / "workitem.md")
|
|
62
|
+
console.print(f"[green]Copied[/] /workitem slash command → {commands_dest / 'workitem.md'}")
|
|
63
|
+
|
|
64
|
+
settings = _build_managed_settings(gateway_url, hooks_dest, statusline_dest)
|
|
65
|
+
console.rule("[bold]managed-settings.json[/]")
|
|
66
|
+
console.print(f"Ask your admin to save this file at:\n {_managed_settings_path()}\n")
|
|
67
|
+
console.print(json.dumps(settings, indent=2))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _find_assets() -> Path:
|
|
71
|
+
for root in ASSET_ROOTS:
|
|
72
|
+
if root.exists():
|
|
73
|
+
return root
|
|
74
|
+
raise FileNotFoundError(f"client_assets/ not found in any of: {ASSET_ROOTS}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_managed_settings(
|
|
78
|
+
gateway_url: str, hooks_dest: Path, statusline_dest: Path
|
|
79
|
+
) -> dict[str, object]:
|
|
80
|
+
return {
|
|
81
|
+
"env": {
|
|
82
|
+
"ANTHROPIC_BASE_URL": gateway_url,
|
|
83
|
+
"AGENTGOV_LOOPBACK_PORT": os.environ.get("AGENTGOV_LOOPBACK_PORT", "8788"),
|
|
84
|
+
},
|
|
85
|
+
# apiKeyHelper disabled — Claude Code must use the agk_ key from the wrapper env.
|
|
86
|
+
"apiKeyHelper": "",
|
|
87
|
+
"hooks": {
|
|
88
|
+
"PreToolUse": [
|
|
89
|
+
{
|
|
90
|
+
"matcher": "Read|Write|Edit|Glob|Grep|Bash|NotebookEdit",
|
|
91
|
+
"hooks": [
|
|
92
|
+
{"type": "command", "command": str(hooks_dest / "pretooluse_pathguard.py")}
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"SessionStart": [
|
|
97
|
+
{
|
|
98
|
+
"hooks": [
|
|
99
|
+
{"type": "command", "command": str(hooks_dest / "sessionstart_register.py")}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
"statusLine": {
|
|
105
|
+
"type": "command",
|
|
106
|
+
"command": str(statusline_dest / "agentgov_statusline.sh"),
|
|
107
|
+
},
|
|
108
|
+
# Permissions baseline: developers may NOT override the base URL or hooks.
|
|
109
|
+
"permissions": {
|
|
110
|
+
"allow": [],
|
|
111
|
+
"deny": [],
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _managed_settings_path() -> str:
|
|
117
|
+
sysname = platform.system()
|
|
118
|
+
if sysname == "Darwin":
|
|
119
|
+
return "/Library/Application Support/ClaudeCode/managed-settings.json"
|
|
120
|
+
if sysname == "Windows":
|
|
121
|
+
return r"C:\ProgramData\ClaudeCode\managed-settings.json"
|
|
122
|
+
return "/etc/claude-code/managed-settings.json"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""`agentgov login` — obtain an AgentGov session token (CLAUDE.md §13.5).
|
|
2
|
+
|
|
3
|
+
v1 implements a DEV-ONLY stub: the CLI accepts a bearer token you paste
|
|
4
|
+
(from the dashboard) and stores it in ~/.agentgov/identity.json (0600).
|
|
5
|
+
The real OAuth device-code flow lands with Auth0 in Phase 5; the file
|
|
6
|
+
layout + tower endpoint are already device-code-shaped so the swap is
|
|
7
|
+
transparent to `wrap`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
tower_url: str = typer.Option(
|
|
25
|
+
None, "--tower", envvar="CONTROL_TOWER_URL", help="Control tower URL"
|
|
26
|
+
),
|
|
27
|
+
token: str = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--token",
|
|
30
|
+
help="Paste your dev-admin token (development mode only). Prompts if omitted.",
|
|
31
|
+
prompt=False,
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Attach this machine to a user in the AgentGov control tower."""
|
|
35
|
+
tower = tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000"
|
|
36
|
+
|
|
37
|
+
if not token:
|
|
38
|
+
console.print(
|
|
39
|
+
"[bold]Dev-mode login[/] — paste a dev-admin token from the dashboard, or "
|
|
40
|
+
"the literal string 'agentgov-dev-admin' to use the seeded ADMIN.",
|
|
41
|
+
)
|
|
42
|
+
token = typer.prompt("token", hide_input=True).strip()
|
|
43
|
+
else:
|
|
44
|
+
token = token.strip()
|
|
45
|
+
|
|
46
|
+
# Verify the token can list /api/devices (a low-risk authenticated call).
|
|
47
|
+
try:
|
|
48
|
+
r = httpx.get(
|
|
49
|
+
f"{tower.rstrip('/')}/api/devices",
|
|
50
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
51
|
+
timeout=10.0,
|
|
52
|
+
)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
console.print(f"[red]Cannot reach control tower at {tower}: {e}[/]")
|
|
55
|
+
raise typer.Exit(2) from e
|
|
56
|
+
if r.status_code == 401:
|
|
57
|
+
console.print("[red]Token rejected by control tower.[/]")
|
|
58
|
+
raise typer.Exit(2)
|
|
59
|
+
if r.status_code >= 400:
|
|
60
|
+
console.print(f"[red]Unexpected response from control tower: {r.status_code} {r.text}[/]")
|
|
61
|
+
raise typer.Exit(2)
|
|
62
|
+
|
|
63
|
+
# Persist.
|
|
64
|
+
identity_path = Path.home() / ".agentgov" / "identity.json"
|
|
65
|
+
identity_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
payload = {
|
|
67
|
+
"tower_url": tower,
|
|
68
|
+
"auth0_token": token,
|
|
69
|
+
# user_id + tenant_id populated on register-device response.
|
|
70
|
+
}
|
|
71
|
+
identity_path.write_text(json.dumps(payload, indent=2))
|
|
72
|
+
identity_path.chmod(0o600)
|
|
73
|
+
console.print(f"[green]✅ Logged in.[/] Identity saved to {identity_path}")
|
|
74
|
+
console.print("Next: [bold]agentgov register-device --name 'my-laptop'[/]")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""`agentgov register-device` — first-run device registration (CLAUDE.md §14.1).
|
|
2
|
+
|
|
3
|
+
Generates an X25519 keypair, sends the public key to the control tower's
|
|
4
|
+
/api/devices route, receives back a device_id + gtw_ token, and persists
|
|
5
|
+
them locally so the gateway can boot in device mode.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _machine_fingerprint() -> str:
|
|
26
|
+
"""SHA-256 of hostname + best-effort MAC (uuid.getnode())."""
|
|
27
|
+
return hashlib.sha256(f"{socket.gethostname()}:{uuid.getnode():012x}".encode()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _generate_x25519_keypair() -> tuple[str, str]:
|
|
31
|
+
"""Return (private_b64, public_b64)."""
|
|
32
|
+
from nacl.public import PrivateKey
|
|
33
|
+
|
|
34
|
+
priv = PrivateKey.generate()
|
|
35
|
+
return (
|
|
36
|
+
base64.b64encode(bytes(priv)).decode(),
|
|
37
|
+
base64.b64encode(bytes(priv.public_key)).decode(),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(
|
|
42
|
+
name: str = typer.Option(..., "--name", help="Human-readable device name, e.g. 'work-laptop'"),
|
|
43
|
+
upstream_mode: str = typer.Option(
|
|
44
|
+
"oauth_passthrough",
|
|
45
|
+
"--mode",
|
|
46
|
+
help="oauth_passthrough (default) or apikey_vault",
|
|
47
|
+
),
|
|
48
|
+
tower_url: str = typer.Option(None, "--tower", envvar="CONTROL_TOWER_URL"),
|
|
49
|
+
gateway_runtime_dir: Path = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--runtime-dir",
|
|
52
|
+
envvar="AGENTGOV_GATEWAY_RUNTIME",
|
|
53
|
+
help="Where to write device_key.priv (must match gateway's POLICY_CACHE_PATH parent)",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
if upstream_mode not in ("oauth_passthrough", "apikey_vault"):
|
|
57
|
+
console.print(f"[red]Invalid --mode {upstream_mode}[/]")
|
|
58
|
+
raise typer.Exit(2)
|
|
59
|
+
|
|
60
|
+
tower = tower_url or os.environ.get("CONTROL_TOWER_URL") or "http://localhost:3000"
|
|
61
|
+
|
|
62
|
+
identity_path = Path.home() / ".agentgov" / "identity.json"
|
|
63
|
+
if not identity_path.exists():
|
|
64
|
+
console.print("[red]No AgentGov identity found.[/] Run [bold]agentgov login[/] first.")
|
|
65
|
+
raise typer.Exit(2)
|
|
66
|
+
identity = json.loads(identity_path.read_text())
|
|
67
|
+
token = identity.get("auth0_token") or ""
|
|
68
|
+
if not token:
|
|
69
|
+
console.print("[red]Identity file is missing auth0_token.[/] Re-run `agentgov login`.")
|
|
70
|
+
raise typer.Exit(2)
|
|
71
|
+
|
|
72
|
+
# Generate keypair; write private key to the gateway runtime dir.
|
|
73
|
+
priv_b64, pub_b64 = _generate_x25519_keypair()
|
|
74
|
+
runtime_dir = gateway_runtime_dir or Path.home() / ".agentgov" / "gateway_runtime"
|
|
75
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
priv_path = runtime_dir / "device_key.priv"
|
|
77
|
+
priv_path.write_text(priv_b64 + "\n")
|
|
78
|
+
priv_path.chmod(0o600)
|
|
79
|
+
(runtime_dir / "device_key.priv.pub").write_text(pub_b64 + "\n")
|
|
80
|
+
|
|
81
|
+
fingerprint = _machine_fingerprint()
|
|
82
|
+
|
|
83
|
+
console.print(f"[green]Generated device keypair[/] → {priv_path}")
|
|
84
|
+
console.print(f" public: {pub_b64[:16]}... fingerprint: {fingerprint[:16]}...")
|
|
85
|
+
|
|
86
|
+
# POST to control tower.
|
|
87
|
+
try:
|
|
88
|
+
r = httpx.post(
|
|
89
|
+
f"{tower.rstrip('/')}/api/devices",
|
|
90
|
+
headers={
|
|
91
|
+
"Authorization": f"Bearer {token}",
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
json={
|
|
95
|
+
"name": name,
|
|
96
|
+
"public_key_b64": pub_b64,
|
|
97
|
+
"machine_fingerprint": fingerprint,
|
|
98
|
+
"upstream_mode": upstream_mode,
|
|
99
|
+
},
|
|
100
|
+
timeout=15.0,
|
|
101
|
+
)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
console.print(f"[red]Registration failed: {e}[/]")
|
|
104
|
+
raise typer.Exit(2) from e
|
|
105
|
+
if r.status_code >= 400:
|
|
106
|
+
console.print(f"[red]Control tower rejected registration: {r.status_code} {r.text}[/]")
|
|
107
|
+
raise typer.Exit(2)
|
|
108
|
+
|
|
109
|
+
payload = r.json()
|
|
110
|
+
device = payload.get("device", {})
|
|
111
|
+
gtw_token = payload.get("gateway_service_token") or ""
|
|
112
|
+
|
|
113
|
+
# Persist gateway creds.
|
|
114
|
+
creds_path = Path.home() / ".agentgov" / "gateway_creds.json"
|
|
115
|
+
creds_path.write_text(
|
|
116
|
+
json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"tower_url": tower,
|
|
119
|
+
"device_id": device.get("id"),
|
|
120
|
+
"device_name": device.get("name"),
|
|
121
|
+
"upstream_mode": device.get("upstream_mode"),
|
|
122
|
+
"gateway_service_token": gtw_token,
|
|
123
|
+
},
|
|
124
|
+
indent=2,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
creds_path.chmod(0o600)
|
|
128
|
+
|
|
129
|
+
console.print(
|
|
130
|
+
f"[green]✅ Device registered.[/] id={device.get('id')} mode={device.get('upstream_mode')}"
|
|
131
|
+
)
|
|
132
|
+
console.print(f"gateway_service_token saved to {creds_path}")
|
|
133
|
+
console.print()
|
|
134
|
+
console.print("Now start (or restart) the gateway with these env vars — see .env:")
|
|
135
|
+
console.print(f" GATEWAY_SERVICE_TOKEN={gtw_token}")
|
|
136
|
+
console.print(f" DEVICE_KEY_PATH={priv_path}")
|
|
137
|
+
console.print(f" UPSTREAM_MODE={upstream_mode}")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""`agentgov status` — show current binding + session totals for cwd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from ..port_resolver import list_active_sessions, resolve_port
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(
|
|
15
|
+
session: str = typer.Option(None, "--session", envvar="AGENTGOV_SESSION"),
|
|
16
|
+
port: int = typer.Option(0, "--port", help="Override port; 0 = resolve"),
|
|
17
|
+
) -> None:
|
|
18
|
+
resolved_port = port if port > 0 else resolve_port(session)
|
|
19
|
+
if resolved_port is None:
|
|
20
|
+
active = list_active_sessions()
|
|
21
|
+
if not active:
|
|
22
|
+
console.print("[yellow]No active AgentGov session.[/]")
|
|
23
|
+
console.print("Start one: [bold]agentgov wrap claude[/]")
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
console.print("[yellow]Multiple active sessions[/] — pass --session <id>:")
|
|
26
|
+
for sid, prt in active:
|
|
27
|
+
console.print(f" {sid} → :{prt}")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
r = httpx.post(f"http://127.0.0.1:{resolved_port}/status", timeout=5.0)
|
|
32
|
+
r.raise_for_status()
|
|
33
|
+
data = r.json()
|
|
34
|
+
except Exception:
|
|
35
|
+
console.print("[yellow]No active AgentGov session.[/]")
|
|
36
|
+
console.print("Start one: [bold]agentgov wrap claude[/]")
|
|
37
|
+
raise typer.Exit(1) from None
|
|
38
|
+
|
|
39
|
+
wi = data.get("work_item")
|
|
40
|
+
if not wi:
|
|
41
|
+
console.print("[red]📌 UNBOUND[/] — run `/workitem <ref>` in Claude Code.")
|
|
42
|
+
else:
|
|
43
|
+
console.print(
|
|
44
|
+
f"📌 {wi.get('external_id')} — [bold]{wi.get('title')}[/] ({wi.get('item_status')})"
|
|
45
|
+
)
|
|
46
|
+
console.print(f"tokens: {data.get('tokens', 0):,} cost: ${data.get('cost_usd', 0):.2f}")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`agentgov workitem <ref>` — bind current session to a work item.
|
|
2
|
+
|
|
3
|
+
Posts to the loopback admin listener started by `agentgov wrap`. Port is
|
|
4
|
+
resolved dynamically (gap-P0-3): env AGENTGOV_LOOPBACK_PORT set by wrap →
|
|
5
|
+
per-session state file → single-session convenience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from ..port_resolver import list_active_sessions, resolve_port
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(
|
|
20
|
+
ref: str = typer.Argument(..., help="JIRA key, Notion URL, or PRJ-XXX id"),
|
|
21
|
+
session: str = typer.Option(
|
|
22
|
+
None,
|
|
23
|
+
"--session",
|
|
24
|
+
envvar="AGENTGOV_SESSION",
|
|
25
|
+
help="Session id (usually inherited from `agentgov wrap`).",
|
|
26
|
+
),
|
|
27
|
+
port: int = typer.Option(
|
|
28
|
+
0,
|
|
29
|
+
"--port",
|
|
30
|
+
help="Override port explicitly. 0 = resolve from env/state.",
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Bind the current Claude Code session to <ref>."""
|
|
34
|
+
resolved_port = port if port > 0 else resolve_port(session)
|
|
35
|
+
if resolved_port is None:
|
|
36
|
+
_no_listener_help()
|
|
37
|
+
raise typer.Exit(2)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
r = httpx.post(
|
|
41
|
+
f"http://127.0.0.1:{resolved_port}/workitem",
|
|
42
|
+
json={"ref": ref},
|
|
43
|
+
timeout=15.0,
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
console.print(f"[red]No AgentGov loopback listener on :{resolved_port}.[/]")
|
|
47
|
+
_no_listener_help()
|
|
48
|
+
raise typer.Exit(2) from e
|
|
49
|
+
|
|
50
|
+
if r.status_code >= 400:
|
|
51
|
+
console.print(f"[red]bind failed:[/] {r.text}")
|
|
52
|
+
raise typer.Exit(2)
|
|
53
|
+
|
|
54
|
+
data = r.json()
|
|
55
|
+
wi = data.get("work_item") or {}
|
|
56
|
+
console.print(
|
|
57
|
+
f"[green]✅ Bound to[/] {wi.get('external_id')} — [bold]{wi.get('title')}[/] "
|
|
58
|
+
f"({wi.get('item_status')})"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _no_listener_help() -> None:
|
|
63
|
+
active = list_active_sessions()
|
|
64
|
+
if not active:
|
|
65
|
+
console.print(
|
|
66
|
+
"[red]No active AgentGov session.[/] Start one: [bold]agentgov wrap claude[/]"
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
if len(active) > 1:
|
|
70
|
+
console.print("[yellow]Multiple active sessions[/] — pass --session <id>:")
|
|
71
|
+
for sid, prt in active:
|
|
72
|
+
console.print(f" {sid} → :{prt}")
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""`agentgov wrap claude` — the launcher.
|
|
2
|
+
|
|
3
|
+
1. Resolve repo root + normalized `origin`. Fail if not a git repo.
|
|
4
|
+
2. Generate a UUIDv7 session id.
|
|
5
|
+
3. Start the loopback admin listener on 127.0.0.1:8788 in a thread.
|
|
6
|
+
4. Register the session with the gateway admin endpoint.
|
|
7
|
+
5. Exec `claude` with the env described in CLAUDE.md §R-CLI2.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
|
|
23
|
+
from ..loopback import LoopbackListener
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
cmd: list[str] = typer.Argument(None, help="Command to launch (default: claude)"),
|
|
30
|
+
gateway_url: str = typer.Option(None, "--gateway", envvar="AGENTGOV_GATEWAY_URL"),
|
|
31
|
+
# port=0 → OS-assigned free port. The listener writes the bound port to
|
|
32
|
+
# ~/.agentgov/state/<session>.port so concurrent wraps don't collide
|
|
33
|
+
# (gap-P0-3). Set AGENTGOV_LOOPBACK_PORT to a fixed port only for tests.
|
|
34
|
+
loopback_port: int = typer.Option(0, "--loopback-port", envvar="AGENTGOV_LOOPBACK_PORT"),
|
|
35
|
+
credentials_path: Path = typer.Option(
|
|
36
|
+
Path.home() / ".agentgov" / "credentials",
|
|
37
|
+
"--credentials",
|
|
38
|
+
help="Path to the file storing the agk_ governance key.",
|
|
39
|
+
),
|
|
40
|
+
resume_session: str = typer.Option(
|
|
41
|
+
None,
|
|
42
|
+
"--resume-session",
|
|
43
|
+
help=(
|
|
44
|
+
"Resume a previous AgentGov session id instead of generating a new one. "
|
|
45
|
+
"The gateway rehydrates the previous work-item binding (§13/gap-P1-7). "
|
|
46
|
+
"Use this when Claude Code's own --resume is bringing back an old conversation."
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Wrap `claude` (or another command) in a governed session.
|
|
51
|
+
|
|
52
|
+
Session-resume semantics (gap-P1-7):
|
|
53
|
+
- Default: generate a NEW session id. Fresh session, no binding, first
|
|
54
|
+
prompt triggers NO_WORK_ITEM until /workitem runs.
|
|
55
|
+
- --resume-session <id>: re-use the given id. The gateway's boot
|
|
56
|
+
rehydration (gap-P0-2) already loads open work_item_bindings from
|
|
57
|
+
Supabase; a resumed session picks up its previous binding
|
|
58
|
+
transparently. If the binding is closed (unbound), you'll hit
|
|
59
|
+
NO_WORK_ITEM as usual.
|
|
60
|
+
"""
|
|
61
|
+
repo_root, remote = _resolve_repo()
|
|
62
|
+
gov_key = _read_credentials(credentials_path)
|
|
63
|
+
if resume_session:
|
|
64
|
+
# Basic sanity: UUID-shape or reject.
|
|
65
|
+
import re
|
|
66
|
+
|
|
67
|
+
if not re.fullmatch(
|
|
68
|
+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
|
69
|
+
resume_session,
|
|
70
|
+
):
|
|
71
|
+
console.print(f"[red]--resume-session must be a UUID; got {resume_session!r}[/]")
|
|
72
|
+
raise typer.Exit(2)
|
|
73
|
+
session_id = resume_session
|
|
74
|
+
console.print(f"[cyan]Resuming session[/] {session_id}")
|
|
75
|
+
else:
|
|
76
|
+
session_id = _uuid7_hex()
|
|
77
|
+
|
|
78
|
+
target = list(cmd) if cmd else ["claude"]
|
|
79
|
+
gateway = gateway_url or "http://localhost:8000"
|
|
80
|
+
|
|
81
|
+
# Register the session with the gateway. Also send hook hashes so the
|
|
82
|
+
# gateway can compare against expected values from the signed snapshot
|
|
83
|
+
# (gap-P0-4 hook integrity check). Mismatch = warning + audit row;
|
|
84
|
+
# depending on tenant `strict_hook_verification`, may refuse the session.
|
|
85
|
+
hook_hashes = _hash_hooks_best_effort()
|
|
86
|
+
_register_session(gateway, session_id, remote, repo_root, hook_hashes)
|
|
87
|
+
|
|
88
|
+
# Start loopback listener for `/workitem`, `status`, etc.
|
|
89
|
+
listener = LoopbackListener(port=loopback_port, gateway_url=gateway, session_id=session_id)
|
|
90
|
+
t = threading.Thread(target=listener.serve_forever, daemon=True)
|
|
91
|
+
t.start()
|
|
92
|
+
# Wait briefly for the listener to bind + write its port file (usually ~ms).
|
|
93
|
+
_wait_for_port(listener)
|
|
94
|
+
|
|
95
|
+
env = os.environ.copy()
|
|
96
|
+
# Export the ACTUAL bound port so slash commands + `agentgov workitem`
|
|
97
|
+
# find this session's listener even under concurrent wraps.
|
|
98
|
+
if listener.port is not None:
|
|
99
|
+
env["AGENTGOV_LOOPBACK_PORT"] = str(listener.port)
|
|
100
|
+
# In personal (oauth_passthrough) mode gov_key is "" and we leave whatever
|
|
101
|
+
# ANTHROPIC_API_KEY (or Anthropic OAuth session) was already set intact.
|
|
102
|
+
if gov_key:
|
|
103
|
+
env["ANTHROPIC_API_KEY"] = gov_key
|
|
104
|
+
env["ANTHROPIC_BASE_URL"] = gateway
|
|
105
|
+
env["AGENTGOV_SESSION"] = session_id
|
|
106
|
+
env["AGENTGOV_REPO"] = remote
|
|
107
|
+
env["AGENTGOV_REPO_ROOT"] = str(repo_root)
|
|
108
|
+
# Claude Code forwards ANTHROPIC_CUSTOM_HEADERS as JSON.
|
|
109
|
+
env["ANTHROPIC_CUSTOM_HEADERS"] = _custom_headers_json(session_id, remote, repo_root)
|
|
110
|
+
|
|
111
|
+
console.print(f"[green]AgentGov[/] wrapping {' '.join(target)}")
|
|
112
|
+
console.print(f" session: {session_id}")
|
|
113
|
+
console.print(f" repo: {remote}")
|
|
114
|
+
console.print(f" gateway: {gateway}")
|
|
115
|
+
|
|
116
|
+
# Launch — subprocess.call to keep pipes correct + return exit code.
|
|
117
|
+
try:
|
|
118
|
+
rc = subprocess.call(target, env=env, cwd=str(repo_root))
|
|
119
|
+
finally:
|
|
120
|
+
_end_session(gateway, session_id)
|
|
121
|
+
sys.exit(rc)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _wait_for_port(listener: LoopbackListener, timeout_sec: float = 2.0) -> None:
|
|
125
|
+
"""Block until listener.port is set (i.e. socketserver bound) or timeout."""
|
|
126
|
+
import time as _time
|
|
127
|
+
|
|
128
|
+
deadline = _time.time() + timeout_sec
|
|
129
|
+
while listener.port is None and _time.time() < deadline:
|
|
130
|
+
_time.sleep(0.01)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve_repo() -> tuple[Path, str]:
|
|
134
|
+
try:
|
|
135
|
+
root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
|
|
136
|
+
except subprocess.CalledProcessError as e:
|
|
137
|
+
console.print(
|
|
138
|
+
"[red]Not a git repository.[/] Run inside a repo that has been granted to you."
|
|
139
|
+
)
|
|
140
|
+
raise typer.Exit(2) from e
|
|
141
|
+
try:
|
|
142
|
+
remote = subprocess.check_output(["git", "remote", "get-url", "origin"], text=True).strip()
|
|
143
|
+
except subprocess.CalledProcessError as e:
|
|
144
|
+
console.print("[red]No `origin` remote configured on this repository.[/]")
|
|
145
|
+
raise typer.Exit(2) from e
|
|
146
|
+
return Path(root), remote
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_credentials(path: Path) -> str:
|
|
150
|
+
"""Return the `agk_` governance key if present; empty string in personal mode.
|
|
151
|
+
|
|
152
|
+
Personal-mode devices (oauth_passthrough) don't need an agk_ key — Claude
|
|
153
|
+
Code uses its own OAuth. In that case we return "" and Claude Code sends
|
|
154
|
+
whatever `ANTHROPIC_API_KEY` was already set (or its OAuth bearer via
|
|
155
|
+
ANTHROPIC_BASE_URL). The gateway will identify the user by device.
|
|
156
|
+
"""
|
|
157
|
+
if not path.exists():
|
|
158
|
+
# OK in personal mode. Warn once and continue.
|
|
159
|
+
console.print(
|
|
160
|
+
f"[yellow]No agk_ key at {path}[/]. Assuming personal (oauth_passthrough) mode."
|
|
161
|
+
)
|
|
162
|
+
return ""
|
|
163
|
+
if path.stat().st_mode & 0o077:
|
|
164
|
+
console.print(
|
|
165
|
+
f"[red]Credentials at {path} have loose permissions.[/] Run: chmod 600 {path}"
|
|
166
|
+
)
|
|
167
|
+
raise typer.Exit(2)
|
|
168
|
+
return path.read_text().strip()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _uuid7_hex() -> str:
|
|
172
|
+
# Standard-library uuid doesn't provide v7 pre-3.13. We use time-based prefix
|
|
173
|
+
# plus urandom suffix for monotonicity; not RFC-perfect but sufficient here.
|
|
174
|
+
import os as _os
|
|
175
|
+
import uuid as _uuid
|
|
176
|
+
|
|
177
|
+
ms = int(time.time() * 1000)
|
|
178
|
+
prefix = ms.to_bytes(6, "big")
|
|
179
|
+
rand = _os.urandom(10)
|
|
180
|
+
b = bytearray(prefix + rand)
|
|
181
|
+
# version 7 + RFC 4122 variant bits
|
|
182
|
+
b[6] = (b[6] & 0x0F) | 0x70
|
|
183
|
+
b[8] = (b[8] & 0x3F) | 0x80
|
|
184
|
+
return str(_uuid.UUID(bytes=bytes(b)))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _register_session(
|
|
188
|
+
gateway: str,
|
|
189
|
+
session_id: str,
|
|
190
|
+
remote: str,
|
|
191
|
+
repo_root: Path,
|
|
192
|
+
hook_hashes: dict[str, str] | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
try:
|
|
195
|
+
r = httpx.post(
|
|
196
|
+
f"{gateway.rstrip('/')}/admin/session/register",
|
|
197
|
+
headers={"content-type": "application/json"},
|
|
198
|
+
json={
|
|
199
|
+
"session_id": session_id,
|
|
200
|
+
"repo": remote,
|
|
201
|
+
"repo_root": str(repo_root),
|
|
202
|
+
"hook_hashes": hook_hashes or {},
|
|
203
|
+
},
|
|
204
|
+
timeout=5.0,
|
|
205
|
+
)
|
|
206
|
+
r.raise_for_status()
|
|
207
|
+
payload = r.json()
|
|
208
|
+
# Gateway may report hook mismatch here — surface immediately to the dev.
|
|
209
|
+
warnings = payload.get("warnings") or []
|
|
210
|
+
for w in warnings:
|
|
211
|
+
console.print(f"[yellow]⚠ {w}[/]")
|
|
212
|
+
if payload.get("state") == "REFUSED":
|
|
213
|
+
console.print(
|
|
214
|
+
f"[red]Gateway refused this session:[/] {payload.get('reason', 'unknown')}"
|
|
215
|
+
)
|
|
216
|
+
raise typer.Exit(2)
|
|
217
|
+
except typer.Exit:
|
|
218
|
+
raise
|
|
219
|
+
except Exception as e:
|
|
220
|
+
console.print(
|
|
221
|
+
f"[yellow]Warning:[/] could not register session with gateway ({e}). Continuing."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _hash_hooks_best_effort() -> dict[str, str]:
|
|
226
|
+
"""Return {filename: hex_sha256} for each installed AgentGov hook.
|
|
227
|
+
|
|
228
|
+
Best-effort: missing hooks return an empty dict. The gateway decides
|
|
229
|
+
whether that's a failure (`strict_hook_verification`) or just a warning.
|
|
230
|
+
"""
|
|
231
|
+
import hashlib
|
|
232
|
+
|
|
233
|
+
hooks_dir = Path.home() / ".claude" / "agentgov-hooks"
|
|
234
|
+
out: dict[str, str] = {}
|
|
235
|
+
if not hooks_dir.exists():
|
|
236
|
+
return out
|
|
237
|
+
for p in hooks_dir.glob("*.py"):
|
|
238
|
+
try:
|
|
239
|
+
h = hashlib.sha256()
|
|
240
|
+
with p.open("rb") as f:
|
|
241
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
242
|
+
h.update(chunk)
|
|
243
|
+
out[p.name] = h.hexdigest()
|
|
244
|
+
except OSError:
|
|
245
|
+
continue
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _end_session(gateway: str, session_id: str) -> None:
|
|
250
|
+
import contextlib
|
|
251
|
+
|
|
252
|
+
# Best-effort: if the gateway is down we don't want to error on shutdown.
|
|
253
|
+
with contextlib.suppress(Exception):
|
|
254
|
+
httpx.post(
|
|
255
|
+
f"{gateway.rstrip('/')}/admin/session/register",
|
|
256
|
+
json={"session_id": session_id, "action": "end"},
|
|
257
|
+
timeout=3.0,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _custom_headers_json(session_id: str, remote: str, repo_root: Path) -> str:
|
|
262
|
+
# Claude Code forwards this JSON as request headers.
|
|
263
|
+
import json as _json
|
|
264
|
+
|
|
265
|
+
return _json.dumps(
|
|
266
|
+
{
|
|
267
|
+
"X-AgentGov-Session": session_id,
|
|
268
|
+
"X-AgentGov-Repo": remote,
|
|
269
|
+
"X-AgentGov-Repo-Root": str(repo_root),
|
|
270
|
+
}
|
|
271
|
+
)
|
agentgov_cli/loopback.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""127.0.0.1:8788 loopback admin listener (§R-CLI2 step 2).
|
|
2
|
+
|
|
3
|
+
Backing the `/workitem` slash command + a way to query current binding.
|
|
4
|
+
Talks to the gateway's admin endpoints. Every request is validated as
|
|
5
|
+
local by binding to 127.0.0.1 (not 0.0.0.0).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import http.server
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import socketserver
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoopbackListener:
|
|
24
|
+
def __init__(self, port: int, gateway_url: str, session_id: str) -> None:
|
|
25
|
+
# port=0 → OS-assigned free port. After serve_forever() binds, the real
|
|
26
|
+
# port is available on self.port. We write it to a per-session state
|
|
27
|
+
# file so `agentgov workitem` can find the right listener even when
|
|
28
|
+
# multiple wraps run concurrently (gap-P0-3).
|
|
29
|
+
self._requested_port = port
|
|
30
|
+
self._port: int | None = None
|
|
31
|
+
self._gateway_url = gateway_url.rstrip("/")
|
|
32
|
+
self._session_id = session_id
|
|
33
|
+
self._state_dir = self._prep_state_dir()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def port(self) -> int | None:
|
|
37
|
+
"""The port the listener is actually bound to. None until serve_forever()."""
|
|
38
|
+
return self._port
|
|
39
|
+
|
|
40
|
+
def serve_forever(self) -> None:
|
|
41
|
+
parent = self # closure
|
|
42
|
+
|
|
43
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
44
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
45
|
+
# Route through logging instead of stderr spam.
|
|
46
|
+
log.debug("loopback: " + fmt, *args)
|
|
47
|
+
|
|
48
|
+
def _read_json(self) -> dict[str, Any]:
|
|
49
|
+
length = int(self.headers.get("content-length") or 0)
|
|
50
|
+
raw = self.rfile.read(length) if length > 0 else b"{}"
|
|
51
|
+
try:
|
|
52
|
+
parsed: dict[str, Any] = json.loads(raw.decode("utf-8"))
|
|
53
|
+
return parsed
|
|
54
|
+
except Exception:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
def _send_json(self, status: int, body: dict[str, Any]) -> None:
|
|
58
|
+
data = json.dumps(body).encode()
|
|
59
|
+
self.send_response(status)
|
|
60
|
+
self.send_header("Content-Type", "application/json")
|
|
61
|
+
self.send_header("Content-Length", str(len(data)))
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(data)
|
|
64
|
+
|
|
65
|
+
def do_POST(self) -> None:
|
|
66
|
+
# ALL admin routes require the request comes from a loopback address.
|
|
67
|
+
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
|
68
|
+
self._send_json(403, {"error": "loopback_only"})
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
path = self.path.split("?")[0]
|
|
72
|
+
if path == "/workitem":
|
|
73
|
+
self._handle_workitem(self._read_json())
|
|
74
|
+
elif path == "/status":
|
|
75
|
+
self._handle_status()
|
|
76
|
+
else:
|
|
77
|
+
self._send_json(404, {"error": "unknown_route"})
|
|
78
|
+
|
|
79
|
+
def _handle_workitem(self, payload: dict[str, Any]) -> None:
|
|
80
|
+
ref = str(payload.get("ref") or "")
|
|
81
|
+
if not ref:
|
|
82
|
+
self._send_json(400, {"error": "missing_ref"})
|
|
83
|
+
return
|
|
84
|
+
# Ask the gateway (which relays to control-tower) to resolve+bind.
|
|
85
|
+
try:
|
|
86
|
+
r = httpx.post(
|
|
87
|
+
f"{parent._gateway_url}/admin/session/bind",
|
|
88
|
+
json={"session_id": parent._session_id, "work_item_ref": ref},
|
|
89
|
+
timeout=15.0,
|
|
90
|
+
)
|
|
91
|
+
r.raise_for_status()
|
|
92
|
+
self._send_json(200, r.json())
|
|
93
|
+
except Exception as e:
|
|
94
|
+
self._send_json(502, {"error": "bind_failed", "detail": str(e)})
|
|
95
|
+
|
|
96
|
+
def _handle_status(self) -> None:
|
|
97
|
+
# Serve from the state file the gateway writes to via SSE-ack path,
|
|
98
|
+
# or fall back to "unknown".
|
|
99
|
+
statefile = parent._state_dir / f"{parent._session_id}.json"
|
|
100
|
+
if statefile.exists():
|
|
101
|
+
self._send_json(200, json.loads(statefile.read_text()))
|
|
102
|
+
else:
|
|
103
|
+
self._send_json(200, {"work_item": None, "tokens": 0, "cost_usd": 0})
|
|
104
|
+
|
|
105
|
+
with socketserver.TCPServer(("127.0.0.1", self._requested_port), Handler) as httpd:
|
|
106
|
+
# Capture the actual bound port (may differ from _requested_port
|
|
107
|
+
# when caller passed 0). Write a session-scoped state file so
|
|
108
|
+
# `agentgov workitem` can look up the port by session id.
|
|
109
|
+
self._port = httpd.server_address[1]
|
|
110
|
+
port_file = self._state_dir / f"{self._session_id}.port"
|
|
111
|
+
port_file.write_text(str(self._port))
|
|
112
|
+
try:
|
|
113
|
+
httpd.serve_forever()
|
|
114
|
+
finally:
|
|
115
|
+
# Best-effort cleanup on shutdown.
|
|
116
|
+
with contextlib.suppress(FileNotFoundError):
|
|
117
|
+
port_file.unlink()
|
|
118
|
+
|
|
119
|
+
def _prep_state_dir(self) -> Path:
|
|
120
|
+
p = Path.home() / ".agentgov" / "state"
|
|
121
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
return p
|
agentgov_cli/main.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""CLI root — dispatches to commands/*."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .commands import doctor, install, login, register_device, status, workitem, wrap
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="agentgov",
|
|
11
|
+
help="AgentGov CLI. See CLAUDE.md §6.3 + §13/§14.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
app.command("login", help="Attach this machine to a user in the control tower (§13.5).")(login.run)
|
|
15
|
+
app.command(
|
|
16
|
+
"register-device",
|
|
17
|
+
help="Generate device X25519 keypair + register with control tower (§14.1).",
|
|
18
|
+
)(register_device.run)
|
|
19
|
+
app.command(
|
|
20
|
+
"install", help="Install client assets into ~/.claude/ and print managed-settings.json."
|
|
21
|
+
)(install.run)
|
|
22
|
+
app.command("wrap", help="Wrap `claude` — start a governed Claude Code session.")(wrap.run)
|
|
23
|
+
app.command("workitem", help="Bind the current session to a work item (JIRA/Notion/PRJ-...).")(
|
|
24
|
+
workitem.run
|
|
25
|
+
)
|
|
26
|
+
app.command("status", help="Show current binding + session totals for cwd.")(status.run)
|
|
27
|
+
app.command(
|
|
28
|
+
"doctor", help="Diagnose gateway reachability, hooks, managed settings, and safety rules."
|
|
29
|
+
)(doctor.run)
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__": # pragma: no cover
|
|
32
|
+
app()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Resolve the loopback port for a session (gap-P0-3).
|
|
2
|
+
|
|
3
|
+
Two lookup paths, tried in order:
|
|
4
|
+
|
|
5
|
+
1. AGENTGOV_LOOPBACK_PORT env — set by `agentgov wrap` in the child's
|
|
6
|
+
environment. Slash commands and same-terminal invocations get this
|
|
7
|
+
automatically.
|
|
8
|
+
|
|
9
|
+
2. `~/.agentgov/state/<session>.port` file, written by the LoopbackListener
|
|
10
|
+
when it binds. Useful for a second terminal that isn't a descendant of
|
|
11
|
+
`wrap` (e.g. developer runs `agentgov status` from a separate tmux pane).
|
|
12
|
+
|
|
13
|
+
If neither works but exactly ONE port file exists in the state dir, use that
|
|
14
|
+
(single-session case). Multiple port files with no env hint → ambiguous, we
|
|
15
|
+
return None and the caller prints "which session?" guidance.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_port(session_id: str | None = None) -> int | None:
|
|
25
|
+
# 1) env
|
|
26
|
+
p_env = os.environ.get("AGENTGOV_LOOPBACK_PORT", "").strip()
|
|
27
|
+
if p_env:
|
|
28
|
+
try:
|
|
29
|
+
n = int(p_env)
|
|
30
|
+
if n > 0:
|
|
31
|
+
return n
|
|
32
|
+
except ValueError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# 2) session id → port file
|
|
36
|
+
state_dir = Path.home() / ".agentgov" / "state"
|
|
37
|
+
if session_id:
|
|
38
|
+
f = state_dir / f"{session_id}.port"
|
|
39
|
+
if f.exists():
|
|
40
|
+
try:
|
|
41
|
+
return int(f.read_text().strip())
|
|
42
|
+
except (OSError, ValueError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# 3) single-session convenience
|
|
46
|
+
if state_dir.exists():
|
|
47
|
+
port_files = list(state_dir.glob("*.port"))
|
|
48
|
+
if len(port_files) == 1:
|
|
49
|
+
try:
|
|
50
|
+
return int(port_files[0].read_text().strip())
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def list_active_sessions() -> list[tuple[str, int]]:
|
|
58
|
+
"""Return [(session_id, port), ...] for all active listeners."""
|
|
59
|
+
state_dir = Path.home() / ".agentgov" / "state"
|
|
60
|
+
if not state_dir.exists():
|
|
61
|
+
return []
|
|
62
|
+
out: list[tuple[str, int]] = []
|
|
63
|
+
for f in state_dir.glob("*.port"):
|
|
64
|
+
try:
|
|
65
|
+
out.append((f.stem, int(f.read_text().strip())))
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
continue
|
|
68
|
+
return out
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentgov-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AgentGov CLI — wrap Claude Code, bind work items, check gateway health.
|
|
5
|
+
Author: AgentGov Contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: httpx>=0.27.2
|
|
9
|
+
Requires-Dist: pydantic>=2.9.0
|
|
10
|
+
Requires-Dist: pynacl>=1.5.0
|
|
11
|
+
Requires-Dist: rich>=13.9.0
|
|
12
|
+
Requires-Dist: typer>=0.12.5
|
|
13
|
+
Requires-Dist: uuid7>=0.1.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: mypy>=1.11.2; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.6.9; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# agentgov
|
|
22
|
+
|
|
23
|
+
The developer-laptop CLI for AgentGov.
|
|
24
|
+
|
|
25
|
+
Wraps Claude Code to bind sessions to work items and enforce governance from a signed policy snapshot. Runs alongside `agentgov-gateway` (the local proxy) — this CLI handles login, device registration, session wrapping, work-item binding, and diagnostics.
|
|
26
|
+
|
|
27
|
+
Part of the AgentGov project — see [github.com/deepakahu/agentGov](https://github.com/deepakahu/agentGov) for the full monorepo.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install agentgov agentgov-gateway
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## First-time setup
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Attach this machine to your AgentGov control tower
|
|
39
|
+
agentgov login --tower https://your-agentgov.vercel.app
|
|
40
|
+
|
|
41
|
+
# Register this device (generates an X25519 keypair, gets a gtw_ token)
|
|
42
|
+
agentgov register-device --name "$(hostname)"
|
|
43
|
+
|
|
44
|
+
# Drop Claude Code hooks + statusline + slash command into ~/.claude/
|
|
45
|
+
agentgov install
|
|
46
|
+
|
|
47
|
+
# Start the gateway (background)
|
|
48
|
+
agentgov-gateway &
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Every day
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Run Claude Code through the governed gateway
|
|
55
|
+
agentgov wrap claude
|
|
56
|
+
|
|
57
|
+
# Bind the current session to a work item
|
|
58
|
+
# (or use the /workitem slash command inside Claude Code)
|
|
59
|
+
agentgov workitem PROJ-1234
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Diagnose
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
agentgov status # what's my current binding, tokens, quota
|
|
66
|
+
agentgov doctor # is everything wired correctly
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
- `agentgov login` — attach this machine to a user in the control tower
|
|
72
|
+
- `agentgov register-device` — generate device keypair + register with tower
|
|
73
|
+
- `agentgov install` — drop client assets into `~/.claude/`
|
|
74
|
+
- `agentgov wrap <cmd>` — wrap `claude` (or another command) in a governed session
|
|
75
|
+
- `agentgov workitem <ref>` — bind the current session to a work item
|
|
76
|
+
- `agentgov status` — show current binding + totals
|
|
77
|
+
- `agentgov doctor` — diagnose the installation
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
agentgov_cli/__init__.py,sha256=MhFkd8l9GX1itDFyNS9mnohokruQWpeiq5nfHfxknYc,104
|
|
2
|
+
agentgov_cli/loopback.py,sha256=NP7udIRg5yDnItDo_fJ8NgCH66TPUqHk5NgtUVqzbKA,5095
|
|
3
|
+
agentgov_cli/main.py,sha256=YmcBR2KMJM_btA0YABhqxUHgaeHDWrHKI7glL3tkq1k,1152
|
|
4
|
+
agentgov_cli/port_resolver.py,sha256=E6aKN73ABEQjN7h0OllkAh8azWDFp6-rPOGsRM8WPU0,2132
|
|
5
|
+
agentgov_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
agentgov_cli/commands/doctor.py,sha256=qHwb5lIzpiSiXQUKWZ2gB7iXoWvQ03M7bDovbZuFsuQ,5865
|
|
7
|
+
agentgov_cli/commands/install.py,sha256=Rr4TEC8vdlOLo64S5xy9k9wm9wt_hcv_jMMJ3V4mGTM,4343
|
|
8
|
+
agentgov_cli/commands/login.py,sha256=lLm7RyD7CBUWmVxhuMcF7JMq5r2p-1MAVzs_JoJE1mY,2573
|
|
9
|
+
agentgov_cli/commands/register_device.py,sha256=hNYMQv7hnxL4MYWOGwrYDPrZL43ew8BGHm595xJBp2E,4794
|
|
10
|
+
agentgov_cli/commands/status.py,sha256=Rhvdn1bCere7mqNvkYj85scbLZU_ehV6U-3C5VI3QGk,1665
|
|
11
|
+
agentgov_cli/commands/workitem.py,sha256=tuY-ExRNoWiYQAVLP1QP-K1rZqlLroJ6K3pVGK8ewSs,2163
|
|
12
|
+
agentgov_cli/commands/wrap.py,sha256=x_XvN6XtKFZ-jZA3pTEFGHqA08BapLh1aI92LdisthU,10021
|
|
13
|
+
agentgov_cli-0.1.0.dist-info/METADATA,sha256=cUXrkb7-Vxhk7uK3U__BFuBUMc2E7RRVpqR2XtbYg8U,2419
|
|
14
|
+
agentgov_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
agentgov_cli-0.1.0.dist-info/entry_points.txt,sha256=rqHQ0f65XZ2xMhubArHj5tfIqYApSGUwKM2se52U23Q,51
|
|
16
|
+
agentgov_cli-0.1.0.dist-info/RECORD,,
|