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.
@@ -0,0 +1,3 @@
1
+ """Tenuo governance for Claude Code — CLI, hooks, and MCP proxy."""
2
+
3
+ __version__ = "0.1.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()