tenuo-claude-code 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.
- tenuo_claude_code/__init__.py +3 -0
- tenuo_claude_code/admin.py +583 -0
- tenuo_claude_code/cli.py +2279 -0
- tenuo_claude_code/data/harness_tools.yaml +22 -0
- tenuo_claude_code/paths.py +84 -0
- tenuo_claude_code-0.1.0.dist-info/METADATA +623 -0
- tenuo_claude_code-0.1.0.dist-info/RECORD +10 -0
- tenuo_claude_code-0.1.0.dist-info/WHEEL +4 -0
- tenuo_claude_code-0.1.0.dist-info/entry_points.txt +5 -0
- tenuo_claude_code-0.1.0.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tenuo-claude-admin — the ADMIN/setup plane for Claude Code governance.
|
|
3
|
+
|
|
4
|
+
Separation of duties: this is the ONLY place that holds a tenant-admin Cloud
|
|
5
|
+
key and the ONLY code that performs admin actions (create agent, create/patch
|
|
6
|
+
trigger). It is meant to be run by platform-sec / CI — not by the developer or
|
|
7
|
+
the agent. The runtime CLI (``tenuo-claude``) has no path to these endpoints
|
|
8
|
+
and refuses to run if an admin key is reachable from its environment.
|
|
9
|
+
|
|
10
|
+
Credentials
|
|
11
|
+
-----------
|
|
12
|
+
* Tenant-admin API key (admin actions): read from $TENUO_ADMIN_KEY /
|
|
13
|
+
$TENUO_ADMIN_API_KEY, or from ~/.tenuo/admin.env. NEVER from .state/cloud.env.
|
|
14
|
+
* Runtime / authorizer API key + control-plane URL: read from .state/cloud.env.
|
|
15
|
+
Used for agent claim + trigger fire so the trigger locks to the runtime
|
|
16
|
+
service account (the identity that actually fires at session start), not the
|
|
17
|
+
admin one.
|
|
18
|
+
|
|
19
|
+
Usage
|
|
20
|
+
-----
|
|
21
|
+
TENUO_ADMIN_KEY=tc_... ./tenuo_admin.py setup # or put it in ~/.tenuo/admin.env
|
|
22
|
+
./tenuo_admin.py show
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import base64
|
|
28
|
+
import getpass
|
|
29
|
+
import os
|
|
30
|
+
import socket
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
import tenuo_claude_code.cli as tc
|
|
37
|
+
from tenuo_claude_code.paths import ADMIN_COMMAND, bind_project_paths
|
|
38
|
+
|
|
39
|
+
ADMIN_ENV = Path.home() / ".tenuo" / "admin.env"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def agent_name(cfg: dict) -> str:
|
|
43
|
+
"""Per-developer holder-agent name, DISTINCT from the authorizer name.
|
|
44
|
+
|
|
45
|
+
Defaults to `claude-code-<user>@<host>` so each developer/machine shows up
|
|
46
|
+
as its own agent in the control plane (richer audit attribution) and never
|
|
47
|
+
collides with the authorizer resource (which keeps `cfg.name`). Override via
|
|
48
|
+
tenuo.yaml `cloud.agent_name`.
|
|
49
|
+
"""
|
|
50
|
+
explicit = (cfg.get("cloud") or {}).get("agent_name")
|
|
51
|
+
if explicit:
|
|
52
|
+
return str(explicit)[:64]
|
|
53
|
+
user = (getpass.getuser() or "dev").strip() or "dev"
|
|
54
|
+
host = (socket.gethostname() or "host").split(".")[0] or "host"
|
|
55
|
+
return f"claude-code-{user}@{host}"[:64]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Credentials
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def admin_creds(cfg: dict) -> dict:
|
|
63
|
+
"""Admin key from env/~/.tenuo/admin.env; URL + authorizer key from cloud.env."""
|
|
64
|
+
runtime = tc.cloud_creds(cfg) # url, api_key (authorizer), root — no admin key
|
|
65
|
+
admin_key = os.environ.get("TENUO_ADMIN_KEY") or os.environ.get("TENUO_ADMIN_API_KEY")
|
|
66
|
+
if not admin_key and ADMIN_ENV.exists():
|
|
67
|
+
af = tc.read_env_file(ADMIN_ENV)
|
|
68
|
+
admin_key = af.get("TENUO_ADMIN_KEY") or af.get("TENUO_ADMIN_API_KEY")
|
|
69
|
+
return {**runtime, "admin_key": admin_key}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Policy -> trigger warrant_config translation (admin-only)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def to_wire_constraint(spec: str, sandbox: str):
|
|
77
|
+
"""Constraint DSL string -> Cloud trigger {"_type","_value"} JSON.
|
|
78
|
+
|
|
79
|
+
Emits the wire form the trigger API expects (server maps these to
|
|
80
|
+
tenuo-core constraint type IDs).
|
|
81
|
+
"""
|
|
82
|
+
spec = spec.replace("{sandbox}", sandbox)
|
|
83
|
+
kind, _, rest = spec.partition(":")
|
|
84
|
+
if kind == "subpath":
|
|
85
|
+
return {"_type": "subpath", "_value": rest}
|
|
86
|
+
if kind == "shlex":
|
|
87
|
+
return {"_type": "shlex", "_value": [v.strip() for v in rest.split(",") if v.strip()]}
|
|
88
|
+
if kind == "regex":
|
|
89
|
+
return {"_type": "regex", "_value": rest}
|
|
90
|
+
if kind == "pattern":
|
|
91
|
+
return {"_type": "pattern", "_value": rest}
|
|
92
|
+
if kind == "oneof":
|
|
93
|
+
return {"_type": "one_of", "_value": [v.strip() for v in rest.split(",")]}
|
|
94
|
+
if kind == "exact":
|
|
95
|
+
return {"_type": "exact", "_value": rest}
|
|
96
|
+
raise SystemExit(f"Unknown constraint kind '{kind}' in '{spec}'")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def domains_to_exempt_regex(domains: list[str]) -> dict:
|
|
100
|
+
"""Allowlisted domains -> a single `regex` constraint for an Exempt gate.
|
|
101
|
+
|
|
102
|
+
The approval gate uses `Exempt(<constraint>)`: hosts matching it skip approval.
|
|
103
|
+
Tenuo-core rejects `any`/`wildcard`/`not` as Exempt inner constraints (they have
|
|
104
|
+
identity-only subsumption and would freeze delegation), so a multi-domain
|
|
105
|
+
allowlist can't be an AnyOf — we fold it into ONE anchored regex instead. The
|
|
106
|
+
allowlist's `*` matches a single label (same as the Pattern host constraint),
|
|
107
|
+
so it maps to `[^.]+`.
|
|
108
|
+
"""
|
|
109
|
+
import re
|
|
110
|
+
alts = [re.escape(d).replace(r"\*", r"[^.]+") for d in domains]
|
|
111
|
+
return {"_type": "regex", "_value": "^(?:" + "|".join(alts) + ")$"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def url_safe_ssrf_wire(policy: dict) -> dict:
|
|
115
|
+
"""Structured `url_safe` for Cloud triggers: SSRF hygiene only, no domain allowlist.
|
|
116
|
+
|
|
117
|
+
Domain control lives elsewhere — on the separate `host` constraint in strict
|
|
118
|
+
mode, or on the approval gate's Exempt regex when human approval is enabled.
|
|
119
|
+
Passing a bare domain list as `_value` is the legacy form and pins
|
|
120
|
+
`allow_domains` on the url arg, which blocks off-allowlist URLs at the
|
|
121
|
+
constraint layer before they can reach the approval gate.
|
|
122
|
+
"""
|
|
123
|
+
schemes = [str(s) for s in policy.get("schemes") or
|
|
124
|
+
(["https"] if not policy.get("cidrs") else ["http", "https"])]
|
|
125
|
+
return {
|
|
126
|
+
"_type": "url_safe",
|
|
127
|
+
"_value": {
|
|
128
|
+
"schemes": schemes,
|
|
129
|
+
# Explicit null — omitting the key makes Cloud default to [], which
|
|
130
|
+
# blocks every domain. Null means "no domain allowlist" (SSRF-only).
|
|
131
|
+
"allow_domains": None,
|
|
132
|
+
"block_private": not bool(policy.get("cidrs")),
|
|
133
|
+
"block_loopback": True,
|
|
134
|
+
"block_metadata": True,
|
|
135
|
+
"block_reserved": True,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def web_to_wire(policy: dict):
|
|
141
|
+
"""WebFetch org policy -> trigger constraints for the `url` and `host` args.
|
|
142
|
+
|
|
143
|
+
Mirrors the local two-field design: `url` is url_safe (secure defaults block
|
|
144
|
+
private/loopback/metadata/encoded IPs), `host` is an AnyOf of the allowed
|
|
145
|
+
domains so the hostname the hook extracts is matched explicitly. Both fields
|
|
146
|
+
must be present because resolve_tool always sends {url, host}.
|
|
147
|
+
|
|
148
|
+
NOTE: internal-CIDR egress (cidrs) is local-only in v1 — url_safe blocks
|
|
149
|
+
private IPs here; see README.
|
|
150
|
+
"""
|
|
151
|
+
domains = [str(d) for d in policy.get("domains") or []]
|
|
152
|
+
if not domains:
|
|
153
|
+
raise SystemExit("WebFetch cloud policy needs at least one domain (cidrs are local-only in v1)")
|
|
154
|
+
host_alts = [{"_type": "pattern", "_value": d} for d in domains]
|
|
155
|
+
# Structured url_safe with explicit allow_domains (not the legacy bare-list
|
|
156
|
+
# `_value`) so Cloud/core honour the full SSRF field set.
|
|
157
|
+
url_val = url_safe_ssrf_wire(policy)
|
|
158
|
+
url_val["_value"]["allow_domains"] = domains
|
|
159
|
+
return {
|
|
160
|
+
"url": url_val,
|
|
161
|
+
"host": {"_type": "any", "_value": host_alts},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_warrant_config(cfg: dict, approval_policy_id: str | None = None) -> dict:
|
|
166
|
+
"""Translate tenuo.yaml `enforce` (+ mcp) into a trigger warrant_config.
|
|
167
|
+
|
|
168
|
+
actions = capability names; per_action_constraints = {cap: {arg: wire}}.
|
|
169
|
+
|
|
170
|
+
The holder is DYNAMIC (`${event.agent_id}`): the firing side passes its own
|
|
171
|
+
registered agent id at fire time, so one trigger serves many per-developer
|
|
172
|
+
agents and each warrant binds to that developer's claimed key (PoP). A static
|
|
173
|
+
holder would bind every developer's warrant to one key and break PoP for all
|
|
174
|
+
but one.
|
|
175
|
+
|
|
176
|
+
When WebFetch.approval is linked to a Cloud policy, web_fetch relaxes to
|
|
177
|
+
SSRF-only url_safe + wildcard host, with approval_gates exempting allowlisted hosts.
|
|
178
|
+
"""
|
|
179
|
+
sandbox = cfg["_sandbox_abs"]
|
|
180
|
+
gov = tc.governed_map(cfg)
|
|
181
|
+
approval = tc.webfetch_approval(cfg)
|
|
182
|
+
gate_webfetch = bool(approval and approval_policy_id)
|
|
183
|
+
per_action: dict = {}
|
|
184
|
+
approval_gates: dict = {}
|
|
185
|
+
for g in gov.values():
|
|
186
|
+
cap = g["cap"]
|
|
187
|
+
if cap in per_action:
|
|
188
|
+
continue
|
|
189
|
+
if "web" in g:
|
|
190
|
+
if gate_webfetch:
|
|
191
|
+
domains = [str(d) for d in g["web"].get("domains") or []]
|
|
192
|
+
per_action[cap] = {
|
|
193
|
+
"url": url_safe_ssrf_wire(g["web"]),
|
|
194
|
+
"host": {"_type": "wildcard"},
|
|
195
|
+
}
|
|
196
|
+
approval_gates[cap] = {
|
|
197
|
+
"args": {"host": {"exempt": domains_to_exempt_regex(domains)}}}
|
|
198
|
+
else:
|
|
199
|
+
per_action[cap] = web_to_wire(g["web"])
|
|
200
|
+
else:
|
|
201
|
+
per_action[cap] = {g["arg"]: to_wire_constraint(g["spec"], sandbox)}
|
|
202
|
+
for mtool, spec in (cfg.get("mcp", {}).get("enforce") or {}).items():
|
|
203
|
+
per_action.setdefault(mtool, {"path": to_wire_constraint(spec, sandbox)})
|
|
204
|
+
# AUDIT-ALLOW capabilities (unconstrained): the hook routes audit-listed
|
|
205
|
+
# tools to /verify/<cap>, so the warrant must GRANT them or every WebSearch/
|
|
206
|
+
# TodoWrite/… is denied in enforce mode. Mirrors mint_local_warrant exactly —
|
|
207
|
+
# including the "audit" catch-all when default: audit (and never "unlisted").
|
|
208
|
+
for cap in tc.audit_map(cfg).values():
|
|
209
|
+
per_action.setdefault(cap, {})
|
|
210
|
+
if tc.default_mode(cfg) == "audit":
|
|
211
|
+
per_action.setdefault(tc.CATCHALL_AUDIT, {})
|
|
212
|
+
# Subagent spawn: a signed capability whose subagent_type is constrained to
|
|
213
|
+
# the declared roles. Lets the runtime route the Agent/Task spawn through the
|
|
214
|
+
# authorizer for a root-signed decision (instead of a local-only policy gate).
|
|
215
|
+
roles = tc.subagent_roles(cfg)
|
|
216
|
+
if roles:
|
|
217
|
+
per_action["spawn_agent"] = {
|
|
218
|
+
"subagent_type": {"_type": "one_of", "_value": list(roles.keys())}}
|
|
219
|
+
wc = {
|
|
220
|
+
"holder": "${event.agent_id}",
|
|
221
|
+
"actions": sorted(per_action.keys()),
|
|
222
|
+
"per_action_constraints": per_action,
|
|
223
|
+
"ttl": 3600,
|
|
224
|
+
# max_depth=1 permits exactly ONE attenuation: the session warrant
|
|
225
|
+
# (depth 0) -> a subagent child (depth 1, terminal). Core enforces this
|
|
226
|
+
# cryptographically on every WarrantStack — a child can't sub-delegate
|
|
227
|
+
# (depth 2 is rejected). This is the real, verified delegation limit.
|
|
228
|
+
"max_depth": 1,
|
|
229
|
+
}
|
|
230
|
+
if approval_gates:
|
|
231
|
+
# `_policy_id` links the gate to the Cloud approval policy: at fire time
|
|
232
|
+
# the control plane pulls the policy's approver keys + threshold INTO the
|
|
233
|
+
# issued warrant, so it's self-contained for offline approval verification.
|
|
234
|
+
approval_gates["_policy_id"] = approval_policy_id
|
|
235
|
+
wc["approval_gates"] = approval_gates
|
|
236
|
+
return wc
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# WebFetch human-approval: resolve the approver identity + Cloud approval policy
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def resolve_approver_identity(url: str, admin: str, display_name: str) -> tuple[str, str]:
|
|
244
|
+
"""Find an EXISTING Cloud identity binding by display name -> (id, public_key_hex).
|
|
245
|
+
|
|
246
|
+
The identity carries the approver's KMS public key and notification routing on
|
|
247
|
+
their configured channel. We never create or mutate it here — platform-sec owns
|
|
248
|
+
identities; setup only references one. Fails loudly if absent or keyless.
|
|
249
|
+
"""
|
|
250
|
+
status, body = tc.cloud_api("GET", url, admin, "/v1/identities")
|
|
251
|
+
if status != 200 or not isinstance(body, dict):
|
|
252
|
+
raise SystemExit(f"List identities failed ({status}): {body}")
|
|
253
|
+
matches = [i for i in (body.get("identities") or [])
|
|
254
|
+
if str(i.get("display_name", "")).strip() == display_name.strip()]
|
|
255
|
+
if not matches:
|
|
256
|
+
raise SystemExit(
|
|
257
|
+
f"No Cloud identity named '{display_name}' (cloud.approver_identity).\n"
|
|
258
|
+
" Create an identity binding in the dashboard first:\n"
|
|
259
|
+
" https://docs.tenuo.ai/guides/adding-channels\n"
|
|
260
|
+
" https://docs.tenuo.ai/integrations/identity-bindings\n"
|
|
261
|
+
" Dashboard → Channels → Identity Bindings — use the exact Display Name.")
|
|
262
|
+
ident = matches[0]
|
|
263
|
+
pub = str(ident.get("public_key") or "")
|
|
264
|
+
if not pub:
|
|
265
|
+
raise SystemExit(f"Identity '{display_name}' has no public key — it can't sign approvals.")
|
|
266
|
+
return str(ident["id"]), pub
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def ensure_webfetch_approval_policy(url: str, admin: str, name: str, threshold: int,
|
|
270
|
+
approver_key: str, identity_id: str) -> str:
|
|
271
|
+
"""Create-or-reuse a web_fetch approval policy and link the approver. -> policy_id.
|
|
272
|
+
|
|
273
|
+
The policy holds the approver key set + threshold + TTL; linking the identity
|
|
274
|
+
is what routes the approval prompt to the human. Idempotent.
|
|
275
|
+
"""
|
|
276
|
+
status, body = tc.cloud_api("GET", url, admin, "/v1/approvals/policies")
|
|
277
|
+
existing = None
|
|
278
|
+
if status == 200 and isinstance(body, dict):
|
|
279
|
+
for p in body.get("policies") or []:
|
|
280
|
+
if str(p.get("name", "")) == name:
|
|
281
|
+
existing = p
|
|
282
|
+
break
|
|
283
|
+
policy_body = {
|
|
284
|
+
"name": name,
|
|
285
|
+
"description": "Claude Code WebFetch: human approval for off-allowlist URLs",
|
|
286
|
+
"tool_pattern": "web_fetch",
|
|
287
|
+
"threshold": int(threshold),
|
|
288
|
+
"approver_keys": [approver_key],
|
|
289
|
+
"ttl_seconds": 300,
|
|
290
|
+
"escalation_after_seconds": 60,
|
|
291
|
+
}
|
|
292
|
+
if existing:
|
|
293
|
+
policy_id = str(existing["id"])
|
|
294
|
+
s, b = tc.cloud_api("PATCH", url, admin, f"/v1/approvals/policies/{policy_id}",
|
|
295
|
+
{"threshold": int(threshold), "approver_keys": [approver_key],
|
|
296
|
+
"enabled": True})
|
|
297
|
+
if s not in (200, 201):
|
|
298
|
+
raise SystemExit(f"Update approval policy failed ({s}): {b}")
|
|
299
|
+
print(f" approval : policy {policy_id} '{name}' (reused)")
|
|
300
|
+
else:
|
|
301
|
+
s, b = tc.cloud_api("POST", url, admin, "/v1/approvals/policies", policy_body)
|
|
302
|
+
if s not in (200, 201) or not isinstance(b, dict) or not b.get("id"):
|
|
303
|
+
raise SystemExit(f"Create approval policy failed ({s}): {b}")
|
|
304
|
+
policy_id = str(b["id"])
|
|
305
|
+
print(f" approval : policy {policy_id} '{name}' (created)")
|
|
306
|
+
# Link the identity so the approver is notified and authorized to sign.
|
|
307
|
+
# Treat already-linked (409/422) as success.
|
|
308
|
+
s, b = tc.cloud_api("POST", url, admin, f"/v1/identities/{identity_id}/add-to-policy",
|
|
309
|
+
{"policy_id": policy_id})
|
|
310
|
+
if s not in (200, 201, 204, 409, 422):
|
|
311
|
+
raise SystemExit(f"Link approver to policy failed ({s}): {b}")
|
|
312
|
+
return policy_id
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Commands
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def cmd_setup(_args) -> None:
|
|
320
|
+
"""One-time: register the holder agent + create the trigger from tenuo.yaml.
|
|
321
|
+
|
|
322
|
+
Admin key creates the agent + trigger. The runtime key in cloud.env
|
|
323
|
+
claims the holder key and fires (so the trigger locks to the runtime SA).
|
|
324
|
+
Idempotent: re-running reuses the agent and updates the trigger.
|
|
325
|
+
"""
|
|
326
|
+
from tenuo import SigningKey
|
|
327
|
+
|
|
328
|
+
cfg = tc.load_config()
|
|
329
|
+
creds = admin_creds(cfg)
|
|
330
|
+
if not creds["url"] or not creds["api_key"]:
|
|
331
|
+
raise SystemExit(
|
|
332
|
+
"Set runtime Cloud credentials in .state/cloud.env:\n"
|
|
333
|
+
" TENUO_CONNECT_TOKEN (Quick Connect) or TENUO_CONTROL_PLANE_URL + TENUO_API_KEY")
|
|
334
|
+
if not creds["admin_key"]:
|
|
335
|
+
raise SystemExit(
|
|
336
|
+
"tenuo-admin setup needs a tenant-admin API key.\n"
|
|
337
|
+
" export TENUO_ADMIN_KEY=tc_... (or add it to ~/.tenuo/admin.env)")
|
|
338
|
+
|
|
339
|
+
url, api_key, admin = creds["url"], creds["api_key"], creds["admin_key"]
|
|
340
|
+
state = tc.load_cloud_state()
|
|
341
|
+
authz_name = cfg.get("name", "claude-code-demo") # authorizer / PEP resource name
|
|
342
|
+
aname = agent_name(cfg) # distinct, per-developer holder
|
|
343
|
+
# Resolve a USABLE trigger id before creating the agent (the agent binds
|
|
344
|
+
# allowed_triggers=[tid]). Cloud soft-deletes triggers: a deleted id is
|
|
345
|
+
# permanently burned (Create -> 409, Update -> trigger_deleted), so on
|
|
346
|
+
# re-onboarding we fall back to a fresh, unique sibling id.
|
|
347
|
+
tid = state.get("trigger_id") or tc.trigger_id(cfg) or f"trig_{tc.slug(authz_name)}"
|
|
348
|
+
s_probe, info_probe = tc.cloud_api("GET", url, admin, f"/v1/triggers/{tid}")
|
|
349
|
+
if s_probe == 200 and isinstance(info_probe, dict):
|
|
350
|
+
if info_probe.get("status") == "deleted":
|
|
351
|
+
tid = f"{tid}_{int(time.time())}" # burned id; derive a fresh one
|
|
352
|
+
trigger_exists = False
|
|
353
|
+
else:
|
|
354
|
+
trigger_exists = True # active/paused -> update in place
|
|
355
|
+
else:
|
|
356
|
+
trigger_exists = False # 404 / unknown -> create fresh
|
|
357
|
+
|
|
358
|
+
# Holder keypair (reuse init's holder key so PoP matches the claimed key).
|
|
359
|
+
if not tc.HOLDER_KEY.exists():
|
|
360
|
+
tc.STATE.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
holder = SigningKey.generate()
|
|
362
|
+
tc.HOLDER_KEY.write_text(base64.b64encode(bytes(holder.secret_key_bytes())).decode())
|
|
363
|
+
else:
|
|
364
|
+
holder = SigningKey.from_bytes(base64.b64decode(tc.HOLDER_KEY.read_text()))
|
|
365
|
+
holder_hex = holder.public_key.to_bytes().hex()
|
|
366
|
+
|
|
367
|
+
# 1) Agent — create (admin) or reuse. Named per-developer and bound to this
|
|
368
|
+
# trigger (allowed_triggers) so it can only ever hold warrants from it.
|
|
369
|
+
agent_id = state.get("agent_id")
|
|
370
|
+
if not agent_id:
|
|
371
|
+
create_body = {"name": aname, "allowed_triggers": [tid],
|
|
372
|
+
"description": f"Claude Code holder for {aname}"}
|
|
373
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/agents", create_body)
|
|
374
|
+
code = (body.get("error") or {}).get("code") if isinstance(body, dict) else None
|
|
375
|
+
if status == 409 and code == "agent_name_parked":
|
|
376
|
+
# Name was held by a revoked agent (e.g. a prior wipe / re-onboard).
|
|
377
|
+
# Reclaim it: Cloud issues a fresh agent id + registration token.
|
|
378
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/agents",
|
|
379
|
+
{**create_body, "reuse_revoked_name": True})
|
|
380
|
+
if status == 201:
|
|
381
|
+
agent_id, reg_token = body["id"], body["registration_token"]
|
|
382
|
+
elif status == 409:
|
|
383
|
+
# Name is taken in Cloud but local state was wiped (or never saved).
|
|
384
|
+
# Active -> rotate key and re-claim; revoked/parked -> reclaim name.
|
|
385
|
+
s, info = tc.cloud_api("GET", url, admin, f"/v1/agents/by-name/{aname}")
|
|
386
|
+
if s != 200 or not isinstance(info, dict):
|
|
387
|
+
raise SystemExit(f"Agent '{aname}' exists but could not be adopted ({s}): {info}")
|
|
388
|
+
agent_id = info["id"]
|
|
389
|
+
agent_status = (info.get("status") or "").lower()
|
|
390
|
+
if agent_status == "pending":
|
|
391
|
+
# Stuck mid-rotation (rotate issued a token but claim never ran).
|
|
392
|
+
# Delete and recreate — cannot rotate or reuse while pending.
|
|
393
|
+
s_del, del_body = tc.cloud_api("DELETE", url, admin, f"/v1/agents/{agent_id}")
|
|
394
|
+
if s_del not in (200, 204):
|
|
395
|
+
raise SystemExit(
|
|
396
|
+
f"Agent '{aname}' is pending (incomplete claim); "
|
|
397
|
+
f"delete failed ({s_del}): {del_body}")
|
|
398
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/agents", create_body)
|
|
399
|
+
park = (body.get("error") or {}).get("code") if isinstance(body, dict) else None
|
|
400
|
+
if status == 409 and park == "agent_name_parked":
|
|
401
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/agents",
|
|
402
|
+
{**create_body, "reuse_revoked_name": True})
|
|
403
|
+
if status != 201:
|
|
404
|
+
raise SystemExit(
|
|
405
|
+
f"Agent '{aname}' was pending; recreate failed ({status}): {body}")
|
|
406
|
+
agent_id, reg_token = body["id"], body["registration_token"]
|
|
407
|
+
print(f" agent : {agent_id} '{aname}' (replaced pending agent)")
|
|
408
|
+
elif agent_status != "active":
|
|
409
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/agents",
|
|
410
|
+
{**create_body, "reuse_revoked_name": True})
|
|
411
|
+
if status != 201:
|
|
412
|
+
raise SystemExit(
|
|
413
|
+
f"Agent '{aname}' is {agent_status or 'not active'}; "
|
|
414
|
+
f"reuse_revoked_name failed ({status}): {body}")
|
|
415
|
+
agent_id, reg_token = body["id"], body["registration_token"]
|
|
416
|
+
print(f" agent : {agent_id} '{aname}' (reclaimed {agent_status} name)")
|
|
417
|
+
else:
|
|
418
|
+
s, rot = tc.cloud_api("POST", url, admin, f"/v1/agents/{agent_id}/rotate", {})
|
|
419
|
+
if s not in (200, 201) or not isinstance(rot, dict):
|
|
420
|
+
rot_code = (rot.get("error") or {}).get("code") if isinstance(rot, dict) else None
|
|
421
|
+
if rot_code == "agent_not_active":
|
|
422
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/agents",
|
|
423
|
+
{**create_body, "reuse_revoked_name": True})
|
|
424
|
+
if status != 201:
|
|
425
|
+
raise SystemExit(
|
|
426
|
+
f"Agent '{aname}' is not active; "
|
|
427
|
+
f"reuse_revoked_name failed ({status}): {body}")
|
|
428
|
+
agent_id, reg_token = body["id"], body["registration_token"]
|
|
429
|
+
print(f" agent : {agent_id} '{aname}' (reclaimed inactive name)")
|
|
430
|
+
else:
|
|
431
|
+
raise SystemExit(f"Rotate key for '{aname}' failed ({s}): {rot}")
|
|
432
|
+
else:
|
|
433
|
+
reg_token = rot["registration_token"]
|
|
434
|
+
print(f" agent : {agent_id} '{aname}' (adopted existing; key rotated)")
|
|
435
|
+
else:
|
|
436
|
+
raise SystemExit(f"Create agent failed ({status}): {body}")
|
|
437
|
+
# 2) Claim — bind the holder public key (hex). Runtime key (Quick Connect).
|
|
438
|
+
status, body = tc.cloud_api("POST", url, api_key, "/v1/agents/claim",
|
|
439
|
+
{"agent_id": agent_id, "public_key": holder_hex,
|
|
440
|
+
"registration_token": reg_token})
|
|
441
|
+
if status != 200:
|
|
442
|
+
hint = ""
|
|
443
|
+
if status == 403:
|
|
444
|
+
hint = (
|
|
445
|
+
"\n .state/cloud.env must hold the Quick Connect / authorizer "
|
|
446
|
+
"runtime key (RBAC: agent claim + trigger fire), not the admin key.")
|
|
447
|
+
raise SystemExit(f"Claim agent failed ({status}): {body}{hint}")
|
|
448
|
+
print(f" agent : {agent_id} '{aname}' (registered + key claimed)")
|
|
449
|
+
tc.save_cloud_state({"agent_id": agent_id, "agent_name": aname})
|
|
450
|
+
else:
|
|
451
|
+
# Reuse. Reconcile the per-developer name AND allowed_triggers so the
|
|
452
|
+
# agent can fire the resolved trigger (the id may have changed if a prior
|
|
453
|
+
# trigger was deleted). Keeps issuance history; just fixes name + binding.
|
|
454
|
+
_, cur = tc.cloud_api("GET", url, admin, f"/v1/agents/{agent_id}")
|
|
455
|
+
cur_name = cur.get("name") if isinstance(cur, dict) else None
|
|
456
|
+
cur_trigs = cur.get("allowed_triggers") if isinstance(cur, dict) else None
|
|
457
|
+
patch = {}
|
|
458
|
+
if cur_name != aname:
|
|
459
|
+
patch["name"] = aname
|
|
460
|
+
if not cur_trigs or tid not in cur_trigs:
|
|
461
|
+
patch["allowed_triggers"] = [tid]
|
|
462
|
+
if patch:
|
|
463
|
+
s3, b3 = tc.cloud_api("PATCH", url, admin, f"/v1/agents/{agent_id}", patch)
|
|
464
|
+
if s3 in (200, 201):
|
|
465
|
+
tc.save_cloud_state({"agent_name": aname})
|
|
466
|
+
print(f" agent : {agent_id} '{aname}' (reused; reconciled {', '.join(patch)})")
|
|
467
|
+
else:
|
|
468
|
+
raise SystemExit(f"Reconcile agent failed ({s3}): {b3}")
|
|
469
|
+
else:
|
|
470
|
+
print(f" agent : {agent_id} '{aname}' (reused)")
|
|
471
|
+
|
|
472
|
+
# 2b) WebFetch human approval (optional): resolve the configured approver
|
|
473
|
+
# identity and create/reuse a Cloud approval policy, so the trigger can
|
|
474
|
+
# bake the approver's KMS key + threshold into every issued warrant.
|
|
475
|
+
approval = tc.webfetch_approval(cfg)
|
|
476
|
+
approval_policy_id = None
|
|
477
|
+
if approval:
|
|
478
|
+
approver_name = (cfg.get("cloud") or {}).get("approver_identity")
|
|
479
|
+
if not approver_name:
|
|
480
|
+
raise SystemExit("enforce.WebFetch.approval is set but cloud.approver_identity is missing.")
|
|
481
|
+
identity_id, approver_key = resolve_approver_identity(url, admin, approver_name)
|
|
482
|
+
approval_policy_id = ensure_webfetch_approval_policy(
|
|
483
|
+
url, admin, f"{tc.slug(authz_name)}-webfetch-approval",
|
|
484
|
+
int(approval.get("threshold", 1)), approver_key, identity_id)
|
|
485
|
+
tc.save_cloud_state({"web_fetch_approval_policy_id": approval_policy_id,
|
|
486
|
+
"web_fetch_approver": approver_name})
|
|
487
|
+
print(f" approval : approver '{approver_name}' ({approver_key[:16]}…)")
|
|
488
|
+
|
|
489
|
+
# 3) Trigger — create or update with the warrant_config from tenuo.yaml.
|
|
490
|
+
wc = build_warrant_config(cfg, approval_policy_id)
|
|
491
|
+
# Start permissive on the initiator so the first fire succeeds; we lock it
|
|
492
|
+
# to the discovered service account below.
|
|
493
|
+
if trigger_exists:
|
|
494
|
+
status, body = tc.cloud_api("PATCH", url, admin, f"/v1/triggers/{tid}",
|
|
495
|
+
{"warrant_config": wc, "status": "active"})
|
|
496
|
+
if status not in (200, 201):
|
|
497
|
+
raise SystemExit(f"Update trigger failed ({status}): {body}")
|
|
498
|
+
print(f" trigger : {tid} (updated)")
|
|
499
|
+
else:
|
|
500
|
+
create_body = {"id": tid, "name": f"{authz_name} — session warrant",
|
|
501
|
+
"warrant_config": wc, "initiators": {"allow_api_key": True}}
|
|
502
|
+
status, body = tc.cloud_api("POST", url, admin, "/v1/triggers", create_body)
|
|
503
|
+
if status not in (200, 201):
|
|
504
|
+
raise SystemExit(f"Create trigger failed ({status}): {body}")
|
|
505
|
+
print(f" trigger : {tid} (created)")
|
|
506
|
+
tc.save_cloud_state({"trigger_id": tid})
|
|
507
|
+
|
|
508
|
+
# 4) Dry-run fire — validate before issuing.
|
|
509
|
+
event = {"sandbox": cfg["_sandbox_abs"], "agent_id": agent_id}
|
|
510
|
+
status, body = tc.cloud_api("POST", url, api_key, f"/v1/triggers/{tid}/fire",
|
|
511
|
+
{"event_data": event, "dry_run": True})
|
|
512
|
+
dr = (body or {}).get("dry_run", {}) if isinstance(body, dict) else {}
|
|
513
|
+
if status != 200 or not dr.get("would_issue", False):
|
|
514
|
+
raise SystemExit(f"Dry-run fire not OK ({status}): {body}")
|
|
515
|
+
print(" dry-run : would_issue=true, no validation issues")
|
|
516
|
+
|
|
517
|
+
# 5) Real fire (authorizer key) — and discover the runtime SA from the warrant.
|
|
518
|
+
warrant_b64, root = tc.fire_session_warrant(cfg, creds)
|
|
519
|
+
tc.WARRANT.write_text(warrant_b64)
|
|
520
|
+
sa = None
|
|
521
|
+
try:
|
|
522
|
+
from tenuo import Warrant
|
|
523
|
+
raw = Warrant.from_base64(warrant_b64).extension("tenuo.initiator_identity")
|
|
524
|
+
sa = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
print(f" fired : warrant issued, signed by tenant root {root[:16]}…")
|
|
528
|
+
|
|
529
|
+
# 6) Lock the initiator policy to the discovered service account (RBAC).
|
|
530
|
+
if sa:
|
|
531
|
+
sa_name = sa[3:] if sa.startswith("sa:") else sa
|
|
532
|
+
status, body = tc.cloud_api("PATCH", url, admin, f"/v1/triggers/{tid}",
|
|
533
|
+
{"initiators": {"allowed_service_accounts": [sa_name]}})
|
|
534
|
+
if status in (200, 201):
|
|
535
|
+
tc.save_cloud_state({"service_account": sa_name})
|
|
536
|
+
print(f" locked : initiators -> service account '{sa_name}' (allow_api_key off)")
|
|
537
|
+
else:
|
|
538
|
+
print(f" warning : could not lock initiators ({status}); left allow_api_key on")
|
|
539
|
+
else:
|
|
540
|
+
print(" warning : could not read initiator identity from warrant; left allow_api_key on")
|
|
541
|
+
|
|
542
|
+
tc.save_cloud_state({"holder_pub_hex": holder_hex, "root": root})
|
|
543
|
+
print(f"\nSetup complete. `tenuo-claude up` now fires {tid} for a root-signed session warrant.")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def cmd_show(_args) -> None:
|
|
547
|
+
"""Print the current cloud setup state (no secrets)."""
|
|
548
|
+
st = tc.load_cloud_state()
|
|
549
|
+
if not st:
|
|
550
|
+
print("No cloud setup yet. Run `tenuo-admin setup`.")
|
|
551
|
+
return
|
|
552
|
+
cfg = tc.load_config()
|
|
553
|
+
print("Cloud setup state:")
|
|
554
|
+
for k in ("agent_id", "agent_name", "trigger_id", "service_account", "root"):
|
|
555
|
+
v = st.get(k)
|
|
556
|
+
if v:
|
|
557
|
+
print(f" {k:16}: {v[:24] + '…' if k == 'root' and len(v) > 24 else v}")
|
|
558
|
+
if tc.webfetch_approval(cfg):
|
|
559
|
+
who = st.get("web_fetch_approver") or (cfg.get("cloud") or {}).get("approver_identity") or "?"
|
|
560
|
+
pid = st.get("web_fetch_approval_policy_id")
|
|
561
|
+
wired = pid or "NOT set up (run `tenuo-admin setup`)"
|
|
562
|
+
print(f" {'web-approval':16}: off-allowlist WebFetch -> {who} | policy {wired}")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
COMMANDS = {"setup": cmd_setup, "show": cmd_show}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def main() -> None:
|
|
569
|
+
bind_project_paths(tc)
|
|
570
|
+
parser = argparse.ArgumentParser(prog=ADMIN_COMMAND, description=__doc__,
|
|
571
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
572
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
573
|
+
for name in COMMANDS:
|
|
574
|
+
sub.add_parser(name)
|
|
575
|
+
args = parser.parse_args()
|
|
576
|
+
if not args.cmd:
|
|
577
|
+
parser.print_help()
|
|
578
|
+
return
|
|
579
|
+
COMMANDS[args.cmd](args)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__":
|
|
583
|
+
main()
|