diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e981f1..541c2296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **The general pack denies the harness's `TaskStop` tool.** The mandatory guard matches - the exact tool name on `PreToolUse` and `PermissionRequest`. A bare task id does not tell a - disposable shell task from a workflow, agent, or teammate session, so the caller's own - child workflows and subagents stay protected too. The denial names the target and asks - the caller to let it finish, or ask the owner to end it or name it in a cc-notes answer - for a later session. +- **The general pack denies `TaskStop` unless a grant permits it.** The + mandatory guard matches the exact tool name on `PreToolUse` and `PermissionRequest`. A + bare task id does not tell a disposable shell task from a workflow, agent, or teammate + session, so the caller's own child workflows and subagents need a grant too. The denial + names the target and asks the caller to let it finish, or ask the owner to end it or name + it in a cc-notes answer for a later session. - **Session guards spend grants for an owner-named terminal close, launchd service stop, or `TaskStop`.** The `sessions.close`, `sessions.launchctl`, and `sessions.task-stop` kinds scope permission to a terminal handle, service label, or task id. The budget is @@ -42,6 +42,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 grant refusal reasons reach the user as `sessions: ...`. This replaces the unreleased `# ccx:owner-authorized=` annotation, which allowed unlimited reuse and answers written during the acting session. +- **A standing owner ruling can lift a close of a settled dispatch's idle terminal.** The + general pack's `sessions.close-settled` kind runs only after the per-terminal + `sessions.close` lift denies. Only the root session coordinating the dispatch's Run + qualifies. The hook event has no `agent_id`, and the request's `ORCA_TERMINAL_HANDLE` is set + and matches that Run's `coordinator_handle` from `orca orchestration run-show`. + It requires a literal + `orca terminal close --terminal ` whose dispatch Orca's worker list records with + `dispatchStatus` of `completed` or `failed`, a successful `tui-idle` check, an idle + prompt on the screen, and a readable terminal process tree. New `StandingRulings` evidence in + `grants/evidence.py` reads cc-notes answers by id with `ccn answer show`: `c9b27c1` for + settled-dispatch `orca-gc` closes and `6190a4a` for failed-launch orphans. Only rulings + last written before the acting session started count; each is pinned live to its + revision, and an id absent from the repository (exit 3) supplies no evidence. The grants + `Judge`, capt-hook's small model, decides whether a ruling covers the terminal's Orca + record; the proposal names the Run and describes the caller as its coordinator. + The minted grant has empty scope, unlimited uses, and no expiry; each close + spends it again and is judged again. After the judge allows, the guard re-reads the + worker row and idle prompt. The same dispatch must still hold the terminal as + `completed` or `failed`, and the agent must still be idle. A failed recheck blocks the + close, releases the reserved spend, and reports the change in `systemMessage`. + Other terminal lanes, in-process teammates, callers outside Orca, and another Run's + coordinator get no lift. Live or unsettled dispatches, busy agents, + terminals without dispatches, rulings written during the session, unreadable process + trees, loops, and batches never receive this lift. +- **An agent can stop a teammate its own transcript proves it spawned.** + `sessions.task-stop` now accepts `OwnTeammate` evidence for a `@session-<8 hex>` + task id. The acting agent's transcript must record an `Agent` or `Task` call whose + `toolUseResult` has `status: teammate_spawned` and that exact `teammate_id`; the stop is + logged as a spend of a one-use deterministic grant. The team id is the Claude process's + session id at spawn and can differ from the hook payload's session id after a resume. + This fixes the refusal of `aig-no-delete-plan@session-67c0e5da` from session `900424b6` + by proving the spawn instead of matching session ids. Bare ids, teammates this agent + never spawned, a sibling's teammates, the root's teammates when a lane asks, and other + sessions' teammates receive no own-teammate lift. +- **Tests pin the sessions guard's acceptance of `orca-gc` invocations.** + The guard accepts `orca-gc --run [--dispatch ]`; no guard code change was + needed for it. +- **Grants support unlimited uses and absent cc-notes answers.** + `Grants.mint` accepts `None` for unlimited uses, and `ccn_answer` returns `None` when + `ccn answer show` reports not-found with exit 3. ### Fixed diff --git a/captain_hook/builtin_packs/general/hooks/_sessions.py b/captain_hook/builtin_packs/general/hooks/_sessions.py index 6b45d1f6..9ad7b017 100644 --- a/captain_hook/builtin_packs/general/hooks/_sessions.py +++ b/captain_hook/builtin_packs/general/hooks/_sessions.py @@ -23,7 +23,7 @@ from captain_hook.cmd import Cmd from captain_hook.command_schemas import ORCA, OSASCRIPT from captain_hook.dispatch import SYNC_DEADLINE_MARGIN_SECONDS, collect_budget -from captain_hook.grants import Allowed, Evidence, Grants, Proposal, Rulings +from captain_hook.grants import Allowed, Evidence, Grants, Judge, Proposal, Rulings, StandingRulings from captain_hook.guard_literal import FOLD_TABLE, GUARDED_WORD, QUOTING_CHARS, names_guarded from captain_hook.util import proc, reqenv from captain_hook.util.payload import command_texts @@ -142,6 +142,20 @@ RECEIPT_SLACK = timedelta(seconds=1) RETRY_WINDOW = timedelta(minutes=2) LATER_SESSION = "name it in a cc-notes answer for a later session" +SETTLED = frozenset({"completed", "failed"}) +CLASS_RULINGS = ("c9b27c1", "6190a4a") +SETTLED_CLOSE_RULES = ( + "The owner's standing rulings in the evidence cover a class of Orca terminal closes rather than one named " + "terminal: c9b27c1 lets the root close a settled dispatch's idle terminal, as orca-gc does, and 6190a4a lets it " + "close an orphan terminal its own failed launch created. The proposed action's payload is Orca's record for the " + "terminal: the dispatch holding it, its Run, and that dispatch's status. The guard has already proven the status " + "is completed or failed, the terminal's agent idles at a prompt, and the caller is the root session coordinating " + "that Run. Allow only when a ruling's words cover closing " + "a terminal with this record, and cite that ruling. Deny when the rulings exclude this record, or limit the class " + "to terminals it is not." +) +TEAMMATE_TASK = re.compile(r"[\w.-]+@session-[0-9a-f]{8}") +SPAWN_TOOLS = frozenset({"Agent", "Task"}) INLINE_LOGIN = "/usr/bin/login -flpq dev /bin/bash --noprofile --norc -p -c orca-tcc-login" INLINE_TABLE = ( " 1 0 1 0 Thu Jan 1 00:00:00 2026 /sbin/launchd\n" @@ -204,11 +218,63 @@ def inline_create( ] +def inline_spawn(task: str, *, tool: str = "Agent", status: str = "teammate_spawned") -> list[dict[str, Any]]: + name, _, team = task.partition("@") + return [ + *INLINE_TRANSCRIPT, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_spawn", "name": tool, "input": {"name": name}}], + }, + }, + { + "type": "user", + "message": { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_spawn", "content": f"agent_id: {task}"}], + }, + "toolUseResult": {"status": status, "teammate_id": task, "agent_id": task, "name": name, "team_name": team}, + }, + ] + + def inline_workers(*handles: str) -> str: rows = [{"dispatchId": f"ctx_{index}", "agentTerminalHandle": handle} for index, handle in enumerate(handles)] return json.dumps({"ok": True, "result": {"workers": rows, "page": {}, "scope": {"source": "all"}}}) +def inline_worker(handle: str, status: str, stage: str = "settled") -> str: + row = { + "dispatchId": "ctx_settled", + "runId": "run_inline", + "dispatchStatus": status, + "agentTerminalHandle": handle, + "projection": {"stage": {"detail": stage}}, + } + return json.dumps({"ok": True, "result": {"workers": [row], "page": {}, "scope": {"source": "all"}}}) + + +def inline_class_rulings(*, written: datetime = INLINE_STARTED - timedelta(days=1)) -> dict[str, str]: + bodies = { + "c9b27c1": "Owner: after a dispatch settles the root runs orca-gc to close that dispatch's idle terminal; " + "never a live or unsettled dispatch.", + "6190a4a": "Owner: the root may close an orphan Orca terminal its own failed launch created.", + } + return { + f"ccn answer show {ident}": json.dumps( + {"id": f"{ident}aaaa", "title": "Close settled terminals", "body": body, "updated_at": written.isoformat()} + ) + for ident, body in bodies.items() + } + + +def inline_run(coordinator: str) -> dict[str, str]: + shown = {"ok": True, "result": {"run": {"id": "run_inline", "coordinator_handle": coordinator}}} + return {"orca orchestration run-show --id run_inline": json.dumps(shown)} + + def inline_screen(*tail: str) -> str: return json.dumps({"ok": True, "result": {"terminal": {"source": "screen", "tail": list(tail)}}}) @@ -783,7 +849,7 @@ def runs_once(call: Call, scan: Scan) -> bool: ) -def dispatch_of(handle: str) -> str | Unreadable | None: +def worker_of(handle: str) -> dict[str, Any] | Unreadable | None: cursor: tuple[str, ...] = () while True: argv = ("orca", "orchestration", "worker-list", "--limit", str(WORKER_PAGE), *cursor, "--json") @@ -794,7 +860,7 @@ def dispatch_of(handle: str) -> str | Unreadable | None: return Unreadable("Orca scoped its worker list to one Run") holder = next( ( - row["dispatchId"] + row for row in page["workers"] if handle in (row.get("agentTerminalHandle"), (row.get("resource") or {}).get("terminalHandle")) ), @@ -881,10 +947,11 @@ def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: if found is None: return [] use, bash = found - holder = dispatch_of(handle) + holder = worker_of(handle) ready = idle(handle) if holder is None else False if holder is not None or ready is not True: - logger.bind(terminal=handle, dispatch=holder, idle=ready).info("a terminal this session created is busy") + dispatch = holder.get("dispatchId") if isinstance(holder, dict) else holder + logger.bind(terminal=handle, dispatch=dispatch, idle=ready).info("a terminal this session created is busy") return [] return [ Evidence( @@ -899,6 +966,68 @@ def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: ] +def settled_worker(handle: str) -> dict[str, Any] | None: + worker = worker_of(handle) + if not isinstance(worker, dict) or worker.get("dispatchStatus") not in SETTLED or idle(handle) is not True: + return None + return worker + + +def coordinates(evt: BaseHookEvent, run: str | None) -> bool: + caller = reqenv.getenv("ORCA_TERMINAL_HANDLE") + if evt.agent_id is not None or not caller or not run: + return False + shown = ("orca", "orchestration", "run-show", "--id", run, "--json") + return orca_json(shown, "result", "run", "coordinator_handle") == caller + + +def settled_close(evt: BaseHookEvent, handle: str, tab: bool) -> Proposal | None: + if (worker := settled_worker(handle)) is None or not coordinates(evt, run := worker.get("runId")): + return None + dispatch, status = worker["dispatchId"], worker["dispatchStatus"] + stage = ((worker.get("projection") or {}).get("stage") or {}).get("detail") + return Proposal( + scope={}, + payload={"terminal": handle, "tab": tab, "dispatch": dispatch, "run": run, "status": status}, + summary=f"the coordinator of run {run} closes terminal {handle}, whose dispatch {dispatch} is {status} " + f"({stage}) and whose agent idles at a prompt", + ) + + +def still_settled(action: Proposal) -> bool: + worker = settled_worker(action.payload["terminal"]) + return worker is not None and worker["dispatchId"] == action.payload["dispatch"] + + +def spawned(use: Any, task: str) -> bool: + if use.call.name not in SPAWN_TOOLS or use.result is None: + return False + result = use.result.tool_use_result + return isinstance(result, dict) and result.get("status") == "teammate_spawned" and result.get("teammate_id") == task + + +@dataclass(frozen=True, slots=True) +class OwnTeammate: + def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: + task = action.scope["task"] + if TEAMMATE_TASK.fullmatch(task) is None: + return [] + found = next((use for turn in evt.ctx.t.turns for use in turn.tool_uses if spawned(use, task)), None) + if found is None: + return [] + return [ + Evidence( + id=f"teammate:{task}", + source="teammate", + quote=task, + said_at=found.result_ts or found.ts, + detail=f"this agent's own transcript records spawning {task} as its teammate", + key=f"teammate:{evt.session_id}/{evt.agent_id or 'main'}/{task}", + live=True, + ) + ] + + def rulings_naming(key: str) -> Rulings: return Rulings(search=lambda action: action.scope[key]) @@ -923,9 +1052,21 @@ def rulings_naming(key: str) -> Rulings: TASK_STOP = Grants( "sessions.task-stop", ("task",), - evidence=(rulings_naming("task"),), + evidence=(OwnTeammate(), rulings_naming("task")), replay=RETRY_WINDOW, - would_allow="Have the owner name the task id in a cc-notes answer before the stopping session starts.", + would_allow="Stop only a teammate this agent spawned, by its `@session-` task id, or have the owner " + "name the task id in a cc-notes answer before the stopping session starts.", + hook="sessions", +) +SETTLED_CLOSE = Grants( + "sessions.close-settled", + (), + judge=Judge(rules=SETTLED_CLOSE_RULES), + evidence=(StandingRulings(CLASS_RULINGS),), + mint=None, + ttl=None, + would_allow="Close only an idle terminal whose dispatch Orca records as settled, under a standing owner ruling " + "that predates the closing session.", hook="sessions", ) diff --git a/captain_hook/builtin_packs/general/hooks/sessions.py b/captain_hook/builtin_packs/general/hooks/sessions.py index bdcd9e14..29b238c0 100644 --- a/captain_hook/builtin_packs/general/hooks/sessions.py +++ b/captain_hook/builtin_packs/general/hooks/sessions.py @@ -25,6 +25,7 @@ LAUNCHERS, NEGATIVE_TARGET, RENICE_FIX, + SETTLED_CLOSE, TERMINAL_CLOSE, Scan, Ungranted, @@ -41,10 +42,13 @@ head_reason, hidden_behind, hosts_agent, + inline_class_rulings, inline_create, inline_ruling, + inline_run, inline_screen, inline_tab, + inline_worker, inline_workers, is_agent, lift, @@ -53,8 +57,10 @@ nested, pid_verdict, runs_once, + settled_close, shell_scripts, spell, + still_settled, unresolvable, ) from captain_hook.command_schemas import KILL, LAUNCHCTL, ORCA, PMSET, RENICE, SOFTWAREUPDATE, TMUX @@ -194,6 +200,11 @@ TMUX_ENDINGS = frozenset({"kill-server", "kill-session", "kill-pane", "kill-window"}) STDIN_SCRIPTS = frozenset({"-", "/dev/stdin", "/dev/fd/0"}) SERVE_RULING = inline_ruling("Owner: restart com.example.orca-serve once.") +AGENT_CLOSE = "orca terminal close --terminal term_agent --json" +SETTLED_COMMANDS = {**INLINE_COMMANDS, **inline_class_rulings(), **inline_run("term_root")} +CLASS_ALLOW = {"allow": True, "relied_on": ["ccn:c9b27c1"]} +settling = partial(guarded, env={"ORCA_TERMINAL_HANDLE": "term_root"}) +ORCA_GC = ".agents/skills/orca/scripts/orca-gc" @guard( @@ -810,6 +821,7 @@ def terminal_close_verdict(call: Call, handle: str, scan: Scan, evt: ToolRewrite tab = "tab" in ORCA.bind(call).values if tab and (crowded := whole_tab_verdict(spelling, handle)) is not None: return crowded + agent = None match scan.facts.terminal_tree(handle): case Unreadable(reason): detail = f" ({reason})" @@ -824,7 +836,13 @@ def terminal_close_verdict(call: Call, handle: str, scan: Scan, evt: ToolRewrite if not runs_once(call, scan): return denied action = Proposal(scope={"terminal": handle}, payload={"tab": tab}, summary=f"close terminal {handle}") - return lift(evt, TERMINAL_CLOSE, action, denied) + if (named := lift(evt, TERMINAL_CLOSE, action, denied)) is None or agent is None: + return named + if (settled := settled_close(evt, handle, tab)) is None: + return named + if (ungranted := lift(evt, SETTLED_CLOSE, settled, denied)) is not None or still_settled(settled): + return ungranted + return Ungranted(denied, "sessions: the terminal's dispatch or idle prompt changed while the judge decided.") def orca_ending_verdict(call: Call, scan: Scan, evt: ToolRewriteEvent) -> str | Ungranted | None: @@ -1001,6 +1019,75 @@ def orca_vm_run_flag(call: Call) -> str | None: command="orca orchestration worker-stop --dispatch ctx-1", commands={**INLINE_COMMANDS, "ccn answer search ctx-1": inline_ruling("stop ctx-1")}, ): Block(pattern="worker-stop ends the worker"), + settling( + command=AGENT_CLOSE, + commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, + llm=CLASS_ALLOW, + ): Allow(), + settling( + command=AGENT_CLOSE, + commands={ + **SETTLED_COMMANDS, + "orca orchestration worker-list": inline_worker("term_agent", "failed", "agent_readiness"), + }, + llm={"allow": True, "relied_on": ["ccn:6190a4a"]}, + ): Allow(), + settling( + command=AGENT_CLOSE, + commands={ + **SETTLED_COMMANDS, + "orca orchestration worker-list": inline_worker("term_agent", "dispatched", "input_accepted"), + }, + llm=CLASS_ALLOW, + ): Block(pattern="where pid 16002"), + settling( + command=AGENT_CLOSE, + commands={ + **SETTLED_COMMANDS, + "orca orchestration worker-list": inline_worker("term_agent", "completed"), + "orca terminal read": INLINE_BUSY, + }, + llm=CLASS_ALLOW, + ): Block(pattern="where pid 16002"), + settling( + command=AGENT_CLOSE, + commands={ + **SETTLED_COMMANDS, + **inline_class_rulings(written=INLINE_STARTED + timedelta(hours=1)), + "orca orchestration worker-list": inline_worker("term_agent", "completed"), + }, + llm=CLASS_ALLOW, + ): Block(pattern="where pid 16002"), + settling( + command=AGENT_CLOSE, + commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, + ): Block(pattern="where pid 16002"), + settling( + command="for t in term_agent; do orca terminal close --terminal $t; done", + commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, + llm=CLASS_ALLOW, + ): Block(pattern="leave ending them to the owner"), + settling( + command=AGENT_CLOSE, + commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, + env={"ORCA_TERMINAL_HANDLE": "term_lane"}, + llm=CLASS_ALLOW, + ): Block(pattern="where pid 16002"), + settling( + command=AGENT_CLOSE, + commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, + agent_id="sibling-lane", + llm=CLASS_ALLOW, + ): Block(pattern="where pid 16002"), + guarded( + command=AGENT_CLOSE, + commands={**SETTLED_COMMANDS, "orca orchestration worker-list": inline_worker("term_agent", "completed")}, + llm=CLASS_ALLOW, + ): Block(pattern="where pid 16002"), + guarded(command=f"{ORCA_GC} --run run_7715a23a5657 --dispatch ctx_d83bbb927995"): Allow(), + guarded(command=f"{ORCA_GC} --run run_7715a23a5657 --dispatch ctx_1 --dispatch ctx_2"): Allow(), + guarded(command=f"cd /Users/dev/monorepo && {ORCA_GC} --run run_1 --dispatch ctx_1 2>&1 | tail -20"): Allow(), + guarded(command=f"{ORCA_GC} --run run_1 --dry-run"): Allow(), guarded(command="orca terminal close --terminal term_idle --json"): Allow(), guarded(command="orca terminal close --terminal term_agent --json"): Block( pattern=r"where pid 16002 \(`claude" diff --git a/captain_hook/builtin_packs/general/hooks/stops.py b/captain_hook/builtin_packs/general/hooks/stops.py index 5abe0aa4..9ecebd39 100644 --- a/captain_hook/builtin_packs/general/hooks/stops.py +++ b/captain_hook/builtin_packs/general/hooks/stops.py @@ -14,6 +14,7 @@ block_first, clip, inline_ruling, + inline_spawn, lift, ) from captain_hook.grants import Proposal @@ -22,6 +23,7 @@ from captain_hook import BaseHookEvent, HookResult, ToolRewriteEvent STOP_TOOLS = frozenset({"TaskStop"}) +OWN_LANE = "lane-2@session-67c0e5da" stopping = partial(Input, tool="TaskStop", commands=INLINE_COMMANDS, transcript=INLINE_TRANSCRIPT) @@ -79,6 +81,28 @@ def describe_target(target: tuple[str, str] | None) -> str: tool_input={"task_id": "wcn64vfub"}, commands={**INLINE_COMMANDS, "ccn answer search wcn64vfub": inline_ruling("Stop wcn64vfub2.")}, ): Block(pattern="cannot be verified"), + stopping(tool_input={"task_id": OWN_LANE}, transcript=inline_spawn(OWN_LANE)): Allow(), + stopping(tool_input={"task_id": OWN_LANE}, transcript=inline_spawn(OWN_LANE, tool="Task")): Allow(), + stopping(tool_input={"task_id": OWN_LANE}, agent_id="lane-1", transcript=inline_spawn(OWN_LANE)): Allow(), + stopping(tool_input={"task_id": OWN_LANE}): Block(pattern=f"on task `{OWN_LANE}` cannot be verified"), + stopping(tool_input={"task_id": OWN_LANE}, transcript=inline_spawn("lane-3@session-67c0e5da")): Block( + pattern="cannot be verified" + ), + stopping(tool_input={"task_id": OWN_LANE}, transcript=inline_spawn(OWN_LANE, status="async_launched")): Block( + pattern="cannot be verified" + ), + stopping(tool_input={"task_id": OWN_LANE}, transcript=inline_spawn(OWN_LANE, tool="Bash")): Block( + pattern="cannot be verified" + ), + stopping(tool_input={"task_id": OWN_LANE}, agent_id="lane-1", root_transcript=inline_spawn(OWN_LANE)): Block( + pattern="cannot be verified" + ), + stopping(tool_input={"task_id": "aig-no-delete-plan@session-756e25cc"}): Block( + pattern="on task `aig-no-delete-plan@session-756e25cc` cannot be verified" + ), + stopping(tool_input={"task_id": "lane-2"}, transcript=inline_spawn("lane-2")): Block( + pattern="on task `lane-2` cannot be verified" + ), Input(tool="TaskOutput", tool_input={"task_id": "wcn64vfub"}): Allow(), Input(tool="mcp__orca__TaskStop", tool_input={"task_id": "wcn64vfub"}): Allow(), Input(command="printf 'TaskStop wcn64vfub'"): Allow(), diff --git a/captain_hook/grants/__init__.py b/captain_hook/grants/__init__.py index 9b154352..393fc64a 100644 --- a/captain_hook/grants/__init__.py +++ b/captain_hook/grants/__init__.py @@ -3,7 +3,15 @@ from __future__ import annotations from captain_hook.grants.declare import Grants, reservations, settle -from captain_hook.grants.evidence import Asked, EvidenceSource, OwnerWords, Rulings, tree_of, verbatim +from captain_hook.grants.evidence import ( + Asked, + EvidenceSource, + OwnerWords, + Rulings, + StandingRulings, + tree_of, + verbatim, +) from captain_hook.grants.judge import GrantVerdict, Judge, JudgeFailed from captain_hook.grants.records import Allowed, Denied, Evidence, Grant, Proposal, Spend from captain_hook.grants.rules import ContentMatches, Never, Rule, Ruling, word_diff @@ -27,6 +35,7 @@ "Ruling", "Rulings", "Spend", + "StandingRulings", "reservations", "settle", "tree_of", diff --git a/captain_hook/grants/declare.py b/captain_hook/grants/declare.py index bc10234d..8143a020 100644 --- a/captain_hook/grants/declare.py +++ b/captain_hook/grants/declare.py @@ -74,7 +74,7 @@ class Grants: per scope across every session tree, so a judge-less declaration reads only sources that name the action, such as ``Rulings``. evidence: Where the owner's words live, read when no stored grant covers the action. - mint: Uses of a grant minted from session evidence. + mint: Uses of a grant minted from session evidence; ``None`` mints a grant every later action may spend. ttl: How long a grant minted from session evidence lives. standing_ttl: How long a standing grant minted from the owner's verbatim words lives. standing_rules: Rule names a standing grant asserts. @@ -91,7 +91,7 @@ class Grants: rules: Sequence[Rule] = () judge: Judge | None = None evidence: Sequence[EvidenceSource] = () - mint: int = 1 + mint: int | None = 1 ttl: timedelta | None = timedelta(days=1) standing_ttl: timedelta | None = None standing_rules: tuple[str, ...] = () diff --git a/captain_hook/grants/evidence.py b/captain_hook/grants/evidence.py index f791a21c..56283535 100644 --- a/captain_hook/grants/evidence.py +++ b/captain_hook/grants/evidence.py @@ -31,6 +31,7 @@ OWNER_WINDOW = 60 RULINGS_TIMEOUT = 5 +CCN_NOT_FOUND = 3 OPTION_NUMBER = re.compile(r"\s*(\d+)\b") MACHINE_ENVELOPES = ( " list[dict[str, Any]]: return json.loads(done.stdout) -def ccn_answer(evt: BaseHookEvent, answer_id: str) -> dict[str, Any]: +def ccn_answer(evt: BaseHookEvent, answer_id: str) -> dict[str, Any] | None: argv = ["ccn", "answer", "show", answer_id, "--json", "-R", str(evt.cwd or reqenv.cwd())] done = subprocess.run( - argv, capture_output=True, text=True, timeout=RULINGS_TIMEOUT, env=reqenv.env_map(), check=True + argv, capture_output=True, text=True, timeout=RULINGS_TIMEOUT, env=reqenv.env_map(), check=False ) + if done.returncode == CCN_NOT_FOUND: + return None + done.check_returncode() return json.loads(done.stdout) @@ -280,6 +284,18 @@ def ruling_key(answer: dict[str, Any]) -> str: return f"ccn:{answer['id']}@{written_at(answer).isoformat()}" +def ruling_evidence(answer: dict[str, Any]) -> Evidence: + return Evidence( + id=f"ccn:{answer['id'][:7]}", + source="ccn-answer", + quote=answer["body"], + said_at=written_at(answer), + detail=f"ruling {answer['id'][:7]}: {answer['title']}", + key=ruling_key(answer), + live=True, + ) + + def names(body: str, term: str) -> bool: return re.search(rf"(? list[Evidence]: return [] cutoff = self.started(evt) return [ - Evidence( - id=f"ccn:{answer['id'][:7]}", - source="ccn-answer", - quote=answer["body"], - said_at=written_at(answer), - detail=f"ruling {answer['id'][:7]}: {answer['title']}", - key=ruling_key(answer), - live=True, - ) + ruling_evidence(answer) for answer in ccn_answers(evt, term) if names(answer.get("body", ""), term) and written_at(answer) < cutoff ] + + +@dataclass(frozen=True, slots=True) +class StandingRulings: + """The owner's standing class rulings: the cc-notes answers *ids* name, whatever action they cover. + + A ruling permits a class of actions rather than naming one, so a declaration reading it needs a judge + to decide whether the action falls in that class. As with :class:`Rulings`, only answers last written + before the acting session started count, each pinned live to its revision; an id the repository has + no answer for collects nothing. + """ + + ids: tuple[str, ...] + started: Callable[[BaseHookEvent], datetime] = field(default=session_started) + + def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: + cutoff = self.started(evt) + return [ + ruling_evidence(answer) + for answer in (ccn_answer(evt, answer_id) for answer_id in self.ids) + if answer is not None and written_at(answer) < cutoff + ] diff --git a/docs/guide/grants.qmd b/docs/guide/grants.qmd index 7c3fbe82..4ba115d9 100644 --- a/docs/guide/grants.qmd +++ b/docs/guide/grants.qmd @@ -508,21 +508,25 @@ committed, and released uses. ## Lift a session guard block -The `general` pack declares three kinds without a judge. The budget is one use -per ruling and scope across all session trees, with a one-day expiry: +The `general` pack declares four session grant kinds: | Kind | Scope | Action it can cover | |------|-------|---------------------| | `sessions.close` | `terminal=` | `orca terminal close --terminal ` | | `sessions.launchctl` | `service=` | `launchctl bootout`, `kickstart`, `disable`, `kill`, `stop`, or `remove` on one service | | `sessions.task-stop` | `task=` | `TaskStop`, using `task_id` or the retired `shell_id` | +| `sessions.close-settled` | Empty | `orca terminal close --terminal ` for an idle, settled dispatch | -All three read `Rulings(search=...)` for their scope value. Have the owner name -it in a cc-notes answer before the acting session starts. Inspect the spend with -`capt-hook grant show `. All three set `replay=timedelta(minutes=2)`: the same +The first three have no judge. Their budget is one use per evidence item and +scope across all session trees, with a one-day expiry. They read +`Rulings(search=...)` for their scope value. Have the owner name it in a cc-notes +answer before the acting session starts. Inspect the spend with +`capt-hook grant show `. + +All three set `replay=timedelta(minutes=2)`: the same scope and payload can reuse a committed spend for less than two minutes. This covers the `PermissionRequest` following `PreToolUse` for the same call, or a -prompt retry. A later identical command needs a new approval. +prompt retry. A later identical command needs new evidence. A grant lifts a terminal close or `launchctl` stop only when the guarded call runs once. It must be the first command at top level, and the Bash payload text must @@ -539,6 +543,15 @@ substitutions, or variables. Service targets are `system/