diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b8f27c..79e981f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. -- **An owner-recorded cc-notes answer authorizes one `orca terminal close`.** A Bash comment - `# ccx:owner-authorized=` lets the session guard allow `orca terminal close - --terminal ` when `ccn answer show ` returns a body naming that exact terminal id and - the command closes one literally spelled terminal. A missing `ccn`, a failed or timed-out - lookup, an answer that does not name the id, a variable id, a loop, or any other - session-ending verb keeps the deny, whose copy now names the annotation. + 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 + one use per cc-notes ruling and scope across all session trees, with a one-day expiry. + The ruling's body must name the value as a whole name, and its `updated_at` must predate + the acting session. Evidence keys are `ccn:@`; judge-less grant keys add the + canonical scope as `#`. Editing the answer invalidates its grant, + and `capt-hook grant show` shows each spend. Identical scope and payload retries reuse + a committed spend for less than two minutes, covering `PermissionRequest` after + `PreToolUse` or a prompt retry; a later identical command needs new approval. + A session may also close an idle Orca terminal its own transcript proves it created, + provided no dispatch across any Run names it, `tui-idle` succeeds, and its screen shows + an idle Claude or Codex prompt with no + pending question. Creation needs a lone, bare `orca terminal create` call without an + environment prefix or substitution, or an `orca-launch.sh` call whose output names + `terminal=` and whose matching receipt's mtime falls within the call, allowing + one second either side. This evidence is checked again before a stored grant spends. + A literal single-terminal close accepts `--tab` only when the visual layout proves the + pane is alone; batches stay blocked. A lifted close or launchctl command must run once: + first at top level and at the start of the Bash payload, with only pipe successors. + Loops, command chains, background calls, wrappers, substitutions, and respelled variables + keep the block; `TaskStop` runs once as a tool call. + The launchd lift covers service-targeted `bootout`, `kickstart`, `disable`, `kill`, + `stop`, and `remove`; domain teardown and all other session-guard blocks have no grant + lift. Denials name the later-session ruling, and + 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. ### Fixed @@ -37,14 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 advisory hook fails open and a mandatory hook withholds its completion exactly as for transcript evidence. The teammate tool approval excludes native Bash before it reads the flag, so a Bash event never pays for the second hook's walk. -- **An owner-authorized `orca terminal close` survives a loaded machine.** The guard gave - `ccn answer show` 2 s, and at a load average above 600 the read took 11–13 s, so every close - under a valid answer was denied. The read now waits up to 30 s, bounded by the caller's - deadline. A hook worker caches each answer body it reads for the session, so a batch of closes - under one answer costs one read. A timed-out read says so and asks for a retry. With the - `# ccx:owner-authorized=` marker, each literal single-terminal close in a `;` or `&&` - chain is checked against the answer on its own, where the guard denied any chain with more - than one close. - **A session may kill or renice a process it started.** The session guard reads the target's environment with `ps -E` and allows a literal `kill ` or `renice -p ` when its `CLAUDE_CODE_SESSION_ID` names the calling session, including a process reparented to @@ -59,12 +74,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 failed with `EAGAIN`, so the session guard read no process table and denied every literal `kill ` as unverifiable. The worker now raises its soft limit back to the hard limit at startup. -- **The session guard's `ccn` and `orca` probes find the binary on a launchd worker's `PATH` - and name why a probe failed.** A worker that kept launchd's `/usr/bin:/bin:/usr/sbin:/sbin` - never resolved `ccn`, so an owner-authorized `orca terminal close` was refused with - "`ccn` could not run". The probes now search `/opt/homebrew/bin`, `/usr/local/bin`, and +- **The session guard's Orca probes find the binary on a launchd worker's `PATH` + and name why a probe failed.** The probes search `/opt/homebrew/bin`, `/usr/local/bin`, and `~/.local/bin` after the inherited `PATH`. A missing binary reads "is not installed on the - hook's PATH" and a timeout reads "timed out after 2s", where both used to read "could not run". + hook's PATH" and a two-second timeout reads "timed out after 2s". - **The native prefilter flags `TaskStop`, so the host fails closed.** Go and generated Python match the exact tool name before the Bash first-party check. A stop request is mandatory under load and fails closed on host or transport failure. The worker names the @@ -108,7 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 terminal's process through `orca terminal show` and `orca diagnostics memory`, reads its subtree from the process table, and allows the close when nothing below the shell is an agent, a protected program, or a `cc-*`/`orca-*` shim. An unreadable table, an unknown terminal, or a - process Orca cannot name still denies; `kill` of the bare shell's pid is unchanged. + process Orca cannot name needs a grant; `kill` of the bare shell's pid is unchanged. - **Adding lines to an over-budget comment blocks again.** The verbose-comment block now covers an edit that lengthens a run already over budget; a same-length reword still only warns. - **The `git stash` block names a drop it allows.** Its copy resolves the entry's `stash@{N}` @@ -151,6 +164,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Grant declarations can check explicit proposals and mint without a judge.** `Grants.action` + is optional when a handler passes a `Proposal` to `check(evt, action)`; attaching a + declaration without an action through `hook(..., grants=...)` raises `ValueError`. + Without a judge, the first collected evidence item supplies approval. Minting keys on + `#` and looks across trees through `Grants.grant(..., across_trees=True)` + and `store.mint(..., across_trees=True)`, so an approval mints once per kind and canonical + scope. Stored grants need a scope match after the usual validity and rule checks. + `Asked()` and `OwnerWords()` require a judge. `Evidence.live` makes a source collect + evidence again before a stored grant spends, replacing the check specific to cc-notes + revisions. `Rulings` and + `capt-hook grant import` supply live evidence keyed by `ccn:@`. + Rulings match whole names: `com.x.helper` cannot approve `com.x`, while a sentence-ending + period still counts. `Grants.replay: timedelta`, defaulting to `timedelta.max`, bounds + how long an identical scope and payload can reuse a one-use grant's committed spend. - **Graphite's presubmit review reminder stays quiet in dispatched lanes.** A whole `ccx: role=` line in the session's dispatch prompt skips the reminder because the parent owns the review pass. The pack uses `Annotated("raw")` for its raw escapes. diff --git a/captain_hook/app.py b/captain_hook/app.py index 3741fb75..2d7cdace 100644 --- a/captain_hook/app.py +++ b/captain_hook/app.py @@ -84,13 +84,15 @@ def reject_confirm_misuse(events: Event, block: bool, confirm: Confirm | None) - def reject_grants_misuse(events: Event, block: bool, grants: Grants | None, max_fires: int | None) -> None: - """Reject ``grants`` with no block for a grant to lift, on an event with no tool call, or under a fire cap.""" + """Reject ``grants`` with no block to lift, off tool events, under a fire cap, or with no action to cover.""" if grants is not None and max_fires is not None: raise ValueError("grants cannot take max_fires: a capped gate would stop checking and let calls through.") if grants is not None and not block: raise ValueError("grants needs block=True: a grant lifts a block, and a warn has nothing to lift.") if grants is not None and events & ~TOOL_EVENTS: raise ValueError("grants cover a tool call, so they run only on tool events.") + if grants is not None and grants.action is None: + raise ValueError("grants needs a declaration with an action: the attachment maps each event to one.") def reject_transcript_events(transcript_events: int | None) -> None: diff --git a/captain_hook/builtin_packs/general/hooks/_sessions.py b/captain_hook/builtin_packs/general/hooks/_sessions.py index 0dfc7000..6b45d1f6 100644 --- a/captain_hook/builtin_packs/general/hooks/_sessions.py +++ b/captain_hook/builtin_packs/general/hooks/_sessions.py @@ -8,19 +8,22 @@ import subprocess import threading from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from functools import cached_property, partial, reduce from itertools import product from math import prod -from pathlib import PurePath -from typing import TYPE_CHECKING +from pathlib import Path, PurePath +from typing import TYPE_CHECKING, Any from cc_transcript.tools import BashCall +from loguru import logger from captain_hook import Event, Input, LambdaCondition, on from captain_hook.bindings import Resolution, Resolved, Unknown, Unresolved, program_name, references from captain_hook.cmd import Cmd -from captain_hook.command_schemas import OSASCRIPT +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.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 @@ -28,7 +31,6 @@ if TYPE_CHECKING: from collections.abc import Iterable, Iterator - from pathlib import Path from cc_transcript.command import Word @@ -124,10 +126,22 @@ SPELLING_LIMIT = 60 SESSION_ENV = re.compile(r"(? str: + return json.dumps( + [{"id": "0207568aaaa", "title": "Close terminals", "body": body, "updated_at": written.isoformat()}] + ) + + +def inline_create( + handle: str, command: str = "orca terminal create --worktree active --command codex --json" +) -> list[dict[str, Any]]: + return [ + *INLINE_TRANSCRIPT, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_create", "name": "Bash", "input": {"command": command}}], + }, + }, + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_create", + "content": json.dumps({"ok": True, "result": {"terminal": {"handle": handle}}}), + } + ], + }, + }, + ] + + +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_screen(*tail: str) -> str: + return json.dumps({"ok": True, "result": {"terminal": {"source": "screen", "tail": list(tail)}}}) + + +def inline_tab(handle: str, *panes: str) -> dict[str, str]: + leaves = [{"type": "terminal", "handle": pane, "tabId": "tab-1"} for pane in panes] + layout = leaves[0] if len(leaves) == 1 else {"type": "split", "children": leaves} + shown = {"ptyId": f"pty-{handle}", "tabId": "tab-1", "worktreePath": "/w"} + return { + f"orca terminal show --terminal {handle} --json": json.dumps({"result": {"terminal": shown}}), + "orca terminal list --worktree path:/w": json.dumps( + {"result": {"visualLayouts": [{"root": {"tabs": [{"tabId": "tab-1", "panes": layout}]}}]}} + ), + } + + +INLINE_IDLE = inline_screen("✻ Worked for 2m · done ", "", "─" * 20, CLAUDE_PROMPT, "─" * 20) +INLINE_BUSY = inline_screen("✶ Thinking… (esc to interrupt)", "─" * 20, CLAUDE_PROMPT, "─" * 20) INLINE_COMMANDS = { "ps -A -ww -o": INLINE_TABLE, "ps -E -ww -o lstart=,command= -p": "", @@ -179,13 +253,14 @@ } } ), - "ccn answer show": "{}", - f"ccn answer show {INLINE_OWNER_ANSWER}": json.dumps( - {"body": f"Owner: close exactly {INLINE_OWNER_TERMINAL} and term_agent, nothing else."} - ), + "ccn answer search": "[]", + f"ccn answer search {INLINE_OWNER_TERMINAL}": inline_ruling(f"Owner: close exactly {INLINE_OWNER_TERMINAL}."), + "orca orchestration worker-list": inline_workers(), + "orca terminal wait": json.dumps({"ok": True, "result": {"wait": {"satisfied": True}}}), + "orca terminal read": INLINE_IDLE, } -guarded = partial(Input, commands=INLINE_COMMANDS) +guarded = partial(Input, commands=INLINE_COMMANDS, transcript=INLINE_TRANSCRIPT) def nested(depth: int, payload: str, *, wrapper: str = "bash -c") -> str: @@ -262,9 +337,10 @@ def probe_timeout(reason: str, ceiling: float = PROBE_TIMEOUT) -> float | Unread return min(ceiling, budget - 0.25) -def probe(argv: tuple[str, ...], ceiling: float = PROBE_TIMEOUT) -> str | Unreadable: +def probe(argv: tuple[str, ...], ceiling: float = PROBE_TIMEOUT, *, unset: tuple[str, ...] = ()) -> str | Unreadable: if isinstance(timeout := probe_timeout(f"run `{argv[0]}`", ceiling), Unreadable): return timeout + env = {key: value for key, value in os.environ.items() if key not in unset} try: done = subprocess.run( argv, @@ -273,7 +349,7 @@ def probe(argv: tuple[str, ...], ceiling: float = PROBE_TIMEOUT) -> str | Unread stdin=subprocess.DEVNULL, timeout=timeout, check=False, - env=os.environ | {"PATH": os.pathsep.join((os.environ["PATH"], *PROBE_FALLBACK_DIRS))}, + env=env | {"PATH": os.pathsep.join((os.environ["PATH"], *PROBE_FALLBACK_DIRS))}, ) except FileNotFoundError: return Unreadable(f"`{argv[0]}` is not installed on the hook's PATH") @@ -303,30 +379,6 @@ def terminal_pid(handle: str) -> int | Unreadable: return Unreadable("Orca reports no process for the terminal") -def answer_body(answer: str, cwd: Path | None, session: str | None) -> str | Unreadable: - key = (session, answer, None if cwd is None else str(cwd)) - if (cached := ANSWER_BODIES.get(key)) is not None: - return cached - location = () if cwd is None else ("-R", str(cwd)) - shown = probe(("ccn", "answer", "show", answer, "--json", *location), ANSWER_TIMEOUT) - if isinstance(shown, Unreadable): - return shown - try: - body = json.loads(shown)["body"] - except (ValueError, TypeError, KeyError): - return Unreadable("it has no body") - if not isinstance(body, str): - return Unreadable("it has no body") - ANSWER_BODIES[key] = body - return body - - -def answer_names(answer: str, handle: str, cwd: Path | None, session: str | None) -> bool | Unreadable: - if isinstance(body := answer_body(answer, cwd, session), Unreadable): - return body - return re.search(rf"(? None: self.session = session @@ -674,9 +726,231 @@ def literal_calls(self) -> list[Call]: return [call for call in self.calls if literal_head(call)] -def block_first(evt: ToolRewriteEvent, messages: Iterable[str | None]) -> HookResult | None: - message = next((message for message in messages if message is not None), None) - return None if message is None else evt.block(message) +def orca_json(argv: tuple[str, ...], *path: str, unset: tuple[str, ...] = ()) -> Any: + if isinstance(shown := probe(argv, unset=unset), Unreadable): + return shown + try: + return reduce(lambda node, key: node[key], path, json.loads(shown)) + except (ValueError, TypeError, KeyError): + return Unreadable(f"`{' '.join(argv[:3])}` printed no {path[-1]}") + + +def created_handle(text: str) -> str | None: + try: + return json.loads(text)["result"]["terminal"]["handle"] + except (ValueError, TypeError, KeyError): + return None + + +def receipt_names(lane: str, handle: str, start: datetime, end: datetime) -> bool: + if LANE_NAME.fullmatch(lane) is None: + return False + return any( + start <= datetime.fromtimestamp(receipt.stat().st_mtime, UTC) <= end + and created_handle(receipt.read_text()) == handle + for receipt in (Path.home() / LAUNCH_RECEIPTS).glob(f"*/{lane}.terminal.json") + ) + + +def bare_orca(call: Call) -> bool: + head = call.source.words[0] if call.source.words else None + return head is not None and head.value == "orca" and not call.source.env and not call.substituted + + +def created_by(use: Any, handle: str, cwd: Path | None) -> BashCall | None: + if not isinstance(bash := use.call, BashCall) or use.result is None: + return None + line = safe_parse_command_line(bash.command) + calls = () if line is None else Cmd(line, raw=bash.command, cwd=cwd).calls() + if len(calls) == 1 and bare_orca(calls[0]): + values = ORCA.bind(calls[0]).values + if (values.get("group"), values.get("verb")) == (("terminal",), ("create",)): + return bash if created_handle(use.result.content) == handle else None + start, end = use.ts - RECEIPT_SLACK, (use.result_ts or use.ts) + RECEIPT_SLACK + launched = f"terminal={handle}" in use.result.content and any( + receipt_names(call.args[0], handle, start, end) for call in calls if call.name == "orca-launch.sh" and call.args + ) + return bash if launched else None + + +def runs_once(call: Call, scan: Scan) -> bool: + occurrence = call.occurrence + return ( + occurrence.nesting == 0 + and occurrence.index == 0 + and occurrence.line.raw.strip().startswith(call.source.raw) + and all(other is call or other.occurrence.prev_op == "|" for other in scan.calls) + ) + + +def dispatch_of(handle: str) -> str | Unreadable | None: + cursor: tuple[str, ...] = () + while True: + argv = ("orca", "orchestration", "worker-list", "--limit", str(WORKER_PAGE), *cursor, "--json") + if isinstance(page := orca_json(argv, "result", unset=("ORCA_TERMINAL_HANDLE",)), Unreadable): + return page + try: + if page["scope"]["source"] != "all": + return Unreadable("Orca scoped its worker list to one Run") + holder = next( + ( + row["dispatchId"] + for row in page["workers"] + if handle in (row.get("agentTerminalHandle"), (row.get("resource") or {}).get("terminalHandle")) + ), + None, + ) + after = (page.get("page") or {}).get("nextCursor") + except (TypeError, KeyError): + return Unreadable("Orca's worker list has no workers") + if holder is not None or not after: + return holder + cursor = ("--cursor", after) + + +def idle_screen(tail: list[str]) -> bool: + if any(marker in line for line in tail for marker in BUSY): + return False + lines = [line.strip() for line in tail if not BORDER.match(line)] + prompt = next( + ( + index + for index in range(len(lines) - 1, max(len(lines) - PROMPT_WINDOW, 0) - 1, -1) + if lines[index] == CLAUDE_PROMPT or lines[index].startswith(CODEX_PROMPT) + ), + None, + ) + if prompt is None: + return False + said = [line for line in lines[:prompt] if not DONE_MARKER.match(line)] + return not said or not said[-1].endswith("?") + + +def idle(handle: str) -> bool | Unreadable: + wait = ("orca", "terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", str(IDLE_WAIT_MS)) + if isinstance(satisfied := orca_json((*wait, "--json"), "result", "wait", "satisfied"), Unreadable): + return satisfied + read = ("orca", "terminal", "read", "--terminal", handle, "--screen", "--limit", str(SCREEN_LINES), "--json") + if isinstance(screen := orca_json(read, "result", "terminal"), Unreadable): + return screen + return satisfied is True and screen.get("source") == "screen" and idle_screen(screen.get("tail") or []) + + +def tab_nodes(node: Any) -> Iterator[dict[str, Any]]: + if isinstance(node, dict): + if "tabId" in node and "panes" in node: + yield node + for child in node.values(): + yield from tab_nodes(child) + elif isinstance(node, list): + for child in node: + yield from tab_nodes(child) + + +def lone_pane(handle: str) -> bool | Unreadable: + if isinstance( + shown := orca_json(("orca", "terminal", "show", "--terminal", handle, "--json"), "result"), Unreadable + ): + return shown + try: + tab, worktree = shown["terminal"]["tabId"], shown["terminal"]["worktreePath"] + except (TypeError, KeyError): + return Unreadable("Orca shows no tab for the terminal") + listing = ("orca", "terminal", "list", "--worktree", f"path:{worktree}", "--include-visual-layouts", "--json") + if isinstance(layouts := orca_json(listing, "result", "visualLayouts"), Unreadable): + return layouts + panes = next((node["panes"] for node in tab_nodes(layouts) if node["tabId"] == tab), None) + if not isinstance(panes, dict): + return Unreadable("Orca's layout has no such tab") + return panes.get("type") == "terminal" and panes.get("handle") == handle + + +@dataclass(frozen=True, slots=True) +class CreatedHere: + def collect(self, evt: BaseHookEvent, action: Proposal) -> list[Evidence]: + handle = action.scope["terminal"] + found = next( + ( + (use, bash) + for turn in evt.ctx.t.turns + for use in turn.tool_uses + if (bash := created_by(use, handle, evt.cwd)) is not None + ), + None, + ) + if found is None: + return [] + use, bash = found + holder = dispatch_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") + return [] + return [ + Evidence( + id=f"created:{handle}", + source="created", + quote=clip(bash.command, 200), + said_at=use.result_ts or use.ts, + detail=f"this session created {handle}; no dispatch names it and its agent idles at a prompt", + key=f"created:{evt.session_id}/{evt.agent_id or 'main'}/{handle}", + live=True, + ) + ] + + +def rulings_naming(key: str) -> Rulings: + return Rulings(search=lambda action: action.scope[key]) + + +TERMINAL_CLOSE = Grants( + "sessions.close", + ("terminal",), + evidence=(rulings_naming("terminal"), CreatedHere()), + replay=RETRY_WINDOW, + would_allow="Close an idle terminal this session created, or have the owner name the terminal id in a " + "cc-notes answer before the closing session starts.", + hook="sessions", +) +LAUNCHD_STOP = Grants( + "sessions.launchctl", + ("service",), + evidence=(rulings_naming("service"),), + replay=RETRY_WINDOW, + would_allow="Have the owner name the service label in a cc-notes answer before the acting session starts.", + hook="sessions", +) +TASK_STOP = Grants( + "sessions.task-stop", + ("task",), + evidence=(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.", + hook="sessions", +) + + +@dataclass(frozen=True, slots=True) +class Ungranted: + message: str + reason: str + + +def lift(evt: BaseHookEvent, grants: Grants, action: Proposal, message: str) -> Ungranted | None: + verdict = grants.decide(evt, action) + if isinstance(verdict, Allowed): + return None + return Ungranted(message, f"{grants.hook}: {verdict.message}" if verdict.reason else "") + + +def block_first(evt: ToolRewriteEvent, messages: Iterable[str | Ungranted | None]) -> HookResult | None: + match next((message for message in messages if message is not None), None): + case Ungranted(message, reason): + return evt.block(message, system_message=reason or None) + case str() as message: + return evt.block(message) + case _: + return None guard = partial( diff --git a/captain_hook/builtin_packs/general/hooks/sessions.py b/captain_hook/builtin_packs/general/hooks/sessions.py index 83eec1c7..bdcd9e14 100644 --- a/captain_hook/builtin_packs/general/hooks/sessions.py +++ b/captain_hook/builtin_packs/general/hooks/sessions.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from datetime import timedelta from functools import partial from pathlib import PurePath from typing import TYPE_CHECKING @@ -11,18 +12,23 @@ from captain_hook.bindings import Ref, Resolved, segments from captain_hook.builtin_packs.general.hooks._sessions import ( ARG_TOKEN_BREAK, + CLAUDE_PROMPT, GUARDED_PROGRAMS, - INLINE_OWNER_ANSWER, + INLINE_BUSY, + INLINE_COMMANDS, INLINE_OWNER_TERMINAL, INLINE_SESSION, + INLINE_STARTED, KILL_FIX, + LATER_SESSION, + LAUNCHD_STOP, LAUNCHERS, NEGATIVE_TARGET, RENICE_FIX, + TERMINAL_CLOSE, Scan, - TimedOut, + Ungranted, Unreadable, - answer_names, applescripts, block_first, clip, @@ -35,15 +41,24 @@ head_reason, hidden_behind, hosts_agent, + inline_create, + inline_ruling, + inline_screen, + inline_tab, + inline_workers, is_agent, + lift, literal_pid, + lone_pane, nested, pid_verdict, + runs_once, shell_scripts, spell, unresolvable, ) from captain_hook.command_schemas import KILL, LAUNCHCTL, ORCA, PMSET, RENICE, SOFTWAREUPDATE, TMUX +from captain_hook.grants import Grant, Proposal from captain_hook.guard_literal import QUOTING_CHARS from captain_hook.util.shell import SHELLS @@ -141,8 +156,8 @@ } HELP_FLAGS = frozenset({"--help", "-h"}) ORCA_READ = "Read sessions with `orca terminal list|show|read|wait` and leave ending them to the owner." -ORCA_CLOSE_SCOPES = frozenset({"all", "worktree", "tab"}) -OWNER_FIX = "Add `# ccx:owner-authorized=`; its body must name this terminal id." +ORCA_CLOSE_SCOPES = frozenset({"all", "worktree"}) +CLOSE_FIX = f"Close only an idle orphan this session created, or ask the owner to {LATER_SESSION}." KEY_ALIASES = { "command": "cmd", "meta": "cmd", @@ -171,11 +186,14 @@ LAUNCHCTL_ENDINGS = frozenset( {"bootout", "kill", "kickstart", "stop", "remove", "unload", "disable", "reboot", "asuser", "bsexec"} ) +LAUNCHD_SERVICE_VERBS = frozenset({"bootout", "kickstart", "disable", "stop", "remove", "kill"}) +LAUNCHD_DOMAINS = frozenset({"gui", "user", "pid", "login"}) APPLESCRIPT_ENDING = re.compile(r'(?i)\b(quit|log ?out|restart|shut ?down|sleep)\b|keystroke\s+"q"') PMSET_ENDINGS = frozenset({"sleepnow", "restart", "halt", "sleep"}) PMSET_SCHEDULES = frozenset({"shutdown", "restart", "sleep", "poweroff"}) 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.") @guard( @@ -454,7 +472,9 @@ def kill_verdict(call: Call, facts: Facts) -> str | None: ), guarded(command="kill 1445"): Block(pattern="the Orca app"), guarded(command="kill -9 14575"): Block(pattern="an agent session"), - guarded(command=f"kill 16002 # ccx:owner-authorized={INLINE_OWNER_ANSWER}"): Block(pattern="an agent session"), + guarded( + command="kill 16002", commands={**INLINE_COMMANDS, "ccn answer search 16002": inline_ruling("16002")} + ): Block(pattern="an agent session"), guarded(command="kill 15001"): Block(pattern="pid 15001"), guarded(command="kill 14545"): Block(pattern="a terminal host"), guarded(command="kill 14550"): Block(pattern="an ancestor of a protected process"), @@ -766,42 +786,48 @@ def closes_one_terminal(call: Call) -> bool: return call.name == "orca" and closed_terminal(call, ORCA.bind(call)) is not None -def verifiable_close(call: Call, scan: Scan, evt: ToolRewriteEvent) -> bool: - authorized = isinstance(evt.annotations.get("owner-authorized"), str) - return all(other is not call for other in scan.respelled) and ( - authorized or sum(map(closes_one_terminal, scan.literal_calls)) == 1 - ) +def verifiable_close(call: Call, scan: Scan) -> bool: + return all(other is not call for other in scan.respelled) and sum(map(closes_one_terminal, scan.literal_calls)) == 1 def terminal_close_denied(spelling: str, handle: str, detail: str) -> str: - return f"BLOCKED: `{spelling}` closes terminal `{clip(handle, 48)}`{detail}. {OWNER_FIX}" + return f"BLOCKED: `{spelling}` closes terminal `{clip(handle, 48)}`{detail}. {CLOSE_FIX}" -def terminal_close_verdict(call: Call, handle: str, scan: Scan, evt: ToolRewriteEvent) -> str | None: - deny = partial(terminal_close_denied, clip(call.source.raw, 50), handle) - if isinstance(answer := evt.annotations.get("owner-authorized"), str): - match answer_names(answer, handle, evt.cwd, evt._raw.get("session_id")): - case True: - return None - case TimedOut(seconds=seconds): - return deny( - f", and reading cc-notes answer `{clip(answer, 20)}` timed out after {seconds:g}s; retry the close" - ) - case Unreadable(reason): - return deny(f", and cc-notes answer `{clip(answer, 20)}` was not read ({reason})") - case _: - return deny(f", and cc-notes answer `{clip(answer, 20)}` does not name it") +def whole_tab_verdict(spelling: str, handle: str) -> str | None: + match lone_pane(handle): + case True: + return None + case Unreadable(reason): + detail = f"; the guard could not prove it holds no other pane ({reason})" + case _: + detail = ", which holds other panes" + return f"BLOCKED: `{spelling}` closes the whole tab of terminal `{clip(handle, 48)}`{detail}. Drop `--tab`." + + +def terminal_close_verdict(call: Call, handle: str, scan: Scan, evt: ToolRewriteEvent) -> str | Ungranted | None: + spelling = clip(call.source.raw, 50) + tab = "tab" in ORCA.bind(call).values + if tab and (crowded := whole_tab_verdict(spelling, handle)) is not None: + return crowded match scan.facts.terminal_tree(handle): case Unreadable(reason): - return deny(f" ({reason})") + detail = f" ({reason})" case (root, *below): agent = next((row for row in below if hosts_agent(row)), root if is_agent(root) else None) - return None if agent is None else deny(f", where {describe(agent)} runs") + if agent is None: + return None + detail = f", where {describe(agent)} runs" case _: - return deny(" (its process tree could not be read)") + detail = " (its process tree could not be read)" + denied = terminal_close_denied(spelling, handle, detail) + 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) -def orca_ending_verdict(call: Call, scan: Scan, evt: ToolRewriteEvent) -> str | None: +def orca_ending_verdict(call: Call, scan: Scan, evt: ToolRewriteEvent) -> str | Ungranted | None: if call.name != "orca": return None spelling = spell(call) @@ -809,7 +835,7 @@ def orca_ending_verdict(call: Call, scan: Scan, evt: ToolRewriteEvent) -> str | values = arguments.values if help_only(call, values): return None - if (handle := closed_terminal(call, arguments)) is not None and verifiable_close(call, scan, evt): + if (handle := closed_terminal(call, arguments)) is not None and verifiable_close(call, scan): return terminal_close_verdict(call, handle, scan, evt) found = next( ( @@ -884,53 +910,96 @@ def orca_vm_run_flag(call: Call) -> str | None: "close --terminal $t 2>&1 | tail -1; done" ) ): Block(pattern="closes terminal"), + guarded(command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --json"): Allow(), guarded( - command=( - f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --json " - f"# ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ) + command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --json", + grants=[ + Grant( + id="c105e0000001", + kind=TERMINAL_CLOSE.kind, + tree=INLINE_SESSION, + scope={"terminal": INLINE_OWNER_TERMINAL}, + author="owner-cli", + created=INLINE_STARTED, + ) + ], + commands={**INLINE_COMMANDS, f"ccn answer search {INLINE_OWNER_TERMINAL}": "[]"}, + session_id=INLINE_SESSION, ): Allow(), guarded( - command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --json # ccx:raw owner-authorized=R740" - ): Block(pattern="`# ccx:owner-authorized=`"), + command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --json", + commands={ + **INLINE_COMMANDS, + f"ccn answer search {INLINE_OWNER_TERMINAL}": inline_ruling( + f"close {INLINE_OWNER_TERMINAL}", written=INLINE_STARTED + timedelta(hours=1) + ), + }, + ): Block(pattern=f"closes terminal `{INLINE_OWNER_TERMINAL}`"), guarded( - command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --json # ccx:owner-authorized=r740" - ): Block(pattern="cc-notes answer `r740` was not read"), + command="orca terminal close --terminal term_agent --json", + commands={**INLINE_COMMANDS, "ccn answer search term_agent": inline_ruling("close term_agent2")}, + ): Block(pattern="where pid 16002"), guarded( - command=f"orca terminal close --terminal term_agent2 --json # ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ): Block(pattern="does not name it"), + command="orca terminal close --terminal term_agent --json", + commands={**INLINE_COMMANDS, "ccn answer search term_agent": inline_ruling("close term_agent, term_idle")}, + ): Allow(), + guarded(command=f"T={INLINE_OWNER_TERMINAL}; orca terminal close --terminal $T"): Block( + pattern="leave ending them to the owner" + ), + guarded(command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --terminal term_agent"): Block(), guarded( - command=( - f"T={INLINE_OWNER_TERMINAL}; orca terminal close --terminal $T " - f"# ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ) - ): Block(pattern="leave ending them to the owner"), + command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL}; orca terminal close --terminal x" + ): Block(pattern="closes terminal `"), + guarded(command="timeout 5 orca terminal close --terminal term_agent"): Block(), + guarded(command=f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --all"): Block(), guarded( - command=( - f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} --terminal term_agent " - f"# ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ) - ): Block(), - guarded( - command=( - f"orca terminal close --terminal {INLINE_OWNER_TERMINAL}; orca terminal close --terminal term_agent " - f"# ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ) + command="orca terminal close --terminal term_agent --json", transcript=inline_create("term_agent") ): Allow(), guarded( - command=( - f"orca terminal close --terminal {INLINE_OWNER_TERMINAL} && orca terminal close --terminal term_idle " - f"# ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ) - ): Block(pattern="closes terminal `term_idle`, and cc-notes answer `0207568` does not name it"), + command="orca terminal close --terminal term_agent --json", + transcript=inline_create("term_agent", "orca terminal create --json; orca terminal list --json"), + ): Block(pattern="where pid 16002"), guarded( - command=f"timeout 5 orca terminal close --terminal term_agent # ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ): Block(), + command="orca terminal close --terminal term_agent --json", + transcript=inline_create("term_agent", "./orca terminal create --json"), + ): Block(pattern="where pid 16002"), guarded( - command=f"orca terminal close --terminal term_agent --all # ccx:owner-authorized={INLINE_OWNER_ANSWER}" - ): Block(), + command="for t in 1 2; do orca terminal close --terminal term_agent --json; done", + transcript=inline_create("term_agent"), + ): Block(pattern="where pid 16002"), + guarded( + command="orca terminal close --terminal term_agent --json", transcript=inline_create("term_other") + ): Block(pattern="where pid 16002"), guarded( - command=f"orca orchestration worker-stop --dispatch ctx-1 # ccx:owner-authorized={INLINE_OWNER_ANSWER}" + command="orca terminal close --terminal term_agent --json", + transcript=inline_create("term_agent"), + commands={**INLINE_COMMANDS, "orca terminal read": INLINE_BUSY}, + ): Block(pattern="where pid 16002"), + guarded( + command="orca terminal close --terminal term_agent --json", + transcript=inline_create("term_agent"), + commands={**INLINE_COMMANDS, "orca terminal read": inline_screen("Shall I close it?", CLAUDE_PROMPT)}, + ): Block(pattern="where pid 16002"), + guarded( + command="orca terminal close --terminal term_agent --json", + transcript=inline_create("term_agent"), + commands={**INLINE_COMMANDS, "orca orchestration worker-list": inline_workers("term_agent")}, + ): Block(pattern="where pid 16002"), + guarded( + command="orca terminal close --terminal term_agent --tab --json", + transcript=inline_create("term_agent"), + commands={**INLINE_COMMANDS, **inline_tab("term_agent", "term_agent")}, + ): Allow(), + guarded( + command="orca terminal close --terminal term_idle --tab --json", + commands={**INLINE_COMMANDS, **inline_tab("term_idle", "term_idle", "term_agent")}, + ): Block(pattern="closes the whole tab of terminal `term_idle`, which holds other panes"), + guarded(command="orca terminal close --terminal term_idle --tab"): Block( + pattern="could not prove it holds no other pane" + ), + guarded( + 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"), guarded(command="orca terminal close --terminal term_idle --json"): Allow(), guarded(command="orca terminal close --terminal term_agent --json"): Block( @@ -1224,11 +1293,53 @@ def orca_input_ends_session(evt: ToolRewriteEvent) -> HookResult | None: return block_first(evt, map(orca_input_verdict, Scan.of(evt).literal_calls)) -def launchctl_verdict(call: Call) -> str | None: +def launchd_label(target: str) -> str | None: + match target.split("/"): + case ["system", label] if label: + return label + case [domain, _, label] if domain in LAUNCHD_DOMAINS and label: + return label + case _: + return None + + +def launchd_service(call: Call, arguments: Arguments, scan: Scan) -> tuple[str, str] | None: + words = arguments.words.get("targets", ()) + targets = arguments.values.get("targets", ()) + match arguments.values.get("verb", ()): + case (str() as verb,) if verb in LAUNCHD_SERVICE_VERBS: + pass + case _: + return None + if ( + call.substituted + or call.wrappers + or not arguments.complete + or not arguments.operands_complete + or any(other is call for other in scan.respelled) + or not runs_once(call, scan) + or any(word.value is None or word.expandable for word in words) + or len(targets) != (2 if verb == "kill" else 1) + or not isinstance(target := targets[-1], str) + ): + return None + label = target if verb in ("stop", "remove") and "/" not in target else launchd_label(target) + return None if label is None else (verb, label) + + +def launchctl_verdict(call: Call, scan: Scan, evt: ToolRewriteEvent) -> str | Ungranted | None: if call.name != "launchctl": return None spelling = spell(call) arguments = LAUNCHCTL.bind(call) + if (service := launchd_service(call, arguments, scan)) is not None: + verb, label = service + action = Proposal(scope={"service": label}, payload={"verb": verb}, summary=f"launchctl {verb} {label}") + message = ( + f"BLOCKED: `{spelling}` stops, unloads, or restarts launchd service `{clip(label, 40)}`, which may host " + f"sessions. Inspect it with `launchctl print`, or ask the owner to {LATER_SESSION}." + ) + return lift(evt, LAUNCHD_STOP, action, message) match arguments.values.get("verb", ()): case (None,): return ( @@ -1253,8 +1364,64 @@ def launchctl_verdict(call: Call) -> str | None: tests={ guarded(command="launchctl reboot system"): Block(pattern="launchctl reboot"), guarded(command="launchctl bootout gui/501/com.example.orca-serve"): Block( - pattern="`launchctl bootout gui/501/com.example.orca-serve`" + pattern="`launchctl bootout gui/501/com.example.orca-serve` stops, unloads, or restarts launchd service" ), + guarded( + command="launchctl bootout gui/501/com.example.orca-serve", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Allow(), + guarded( + command="launchctl kickstart -k system/com.example.orca-serve", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Allow(), + guarded( + command="launchctl bootout gui/501/com.example.orca-serve", + commands={ + **INLINE_COMMANDS, + "ccn answer search com.example.orca-serve": inline_ruling( + "restart com.example.orca-serve", written=INLINE_STARTED + timedelta(minutes=5) + ), + }, + ): Block(pattern="ask the owner to name it in a cc-notes answer for a later session"), + guarded( + command="launchctl bootout gui/501/com.example.orca-serve", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": inline_ruling("com.example.orca")}, + ): Block(), + guarded( + command="launchctl bootout gui/501", + commands={**INLINE_COMMANDS, "ccn answer search gui/501": inline_ruling("bootout gui/501")}, + ): Block(pattern="launchd service or domain"), + guarded( + command="launchctl reboot system", + commands={**INLINE_COMMANDS, "ccn answer search system": inline_ruling("reboot system")}, + ): Block(pattern="launchctl reboot"), + guarded( + command="L=com.example.orca-serve; launchctl bootout gui/501/$L", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Block(), + guarded( + command="launchctl kill SIGTERM gui/501/com.example.orca-serve", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Allow(), + guarded( + command="launchctl kickstart -k system/com.example.orca-serve 2>&1 | tail -3", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Allow(), + guarded( + command="for i in 1 2; do launchctl kickstart -k system/com.example.orca-serve; done", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Block(), + guarded( + command="cd /tmp && launchctl kickstart -k system/com.example.orca-serve", + commands={**INLINE_COMMANDS, "ccn answer search com.example.orca-serve": SERVE_RULING}, + ): Block(), + guarded( + command="launchctl stop com.example.orca-serve", + commands={ + **INLINE_COMMANDS, + "ccn answer search com.example.orca-serve": inline_ruling("Stop com.example.orca-serve.helper once."), + }, + ): Block(), guarded(command="launchctl kickstart -k system/com.example.host"): Block(), guarded(command="launchctl $verb gui/501"): Block(pattern="at run time"), guarded(command="launchctl -q bootout gui/501/com.example.orca-serve"): Block( @@ -1265,7 +1432,8 @@ def launchctl_verdict(call: Call) -> str | None: } ) def launchctl_stops_service(evt: ToolRewriteEvent) -> HookResult | None: - return block_first(evt, map(launchctl_verdict, Scan.of(evt).literal_calls)) + scan = Scan.of(evt) + return block_first(evt, (launchctl_verdict(call, scan, evt) for call in scan.literal_calls)) def osascript_verdict(call: Call) -> str | None: diff --git a/captain_hook/builtin_packs/general/hooks/stops.py b/captain_hook/builtin_packs/general/hooks/stops.py index 9b227e96..5abe0aa4 100644 --- a/captain_hook/builtin_packs/general/hooks/stops.py +++ b/captain_hook/builtin_packs/general/hooks/stops.py @@ -1,30 +1,46 @@ from __future__ import annotations +from datetime import timedelta +from functools import partial from typing import TYPE_CHECKING from captain_hook import Allow, Block, Event, Input, LambdaCondition, on -from captain_hook.builtin_packs.general.hooks._sessions import clip +from captain_hook.builtin_packs.general.hooks._sessions import ( + INLINE_COMMANDS, + INLINE_STARTED, + INLINE_TRANSCRIPT, + LATER_SESSION, + TASK_STOP, + block_first, + clip, + inline_ruling, + lift, +) +from captain_hook.grants import Proposal if TYPE_CHECKING: - from collections.abc import Mapping - from captain_hook import BaseHookEvent, HookResult, ToolRewriteEvent STOP_TOOLS = frozenset({"TaskStop"}) +stopping = partial(Input, tool="TaskStop", commands=INLINE_COMMANDS, transcript=INLINE_TRANSCRIPT) def names_a_stop_tool(evt: BaseHookEvent) -> bool: return evt.tool_name in STOP_TOOLS -def describe_target(raw: Mapping[str, object]) -> str: +def stop_target(raw: object) -> tuple[str, str] | None: match raw: case {"task_id": str() as task_id}: - return f"task `{clip(task_id)}`" + return "task", task_id case {"shell_id": str() as shell_id}: - return f"shell `{clip(shell_id)}`" + return "shell", shell_id case _: - return "an unnamed target" + return None + + +def describe_target(target: tuple[str, str] | None) -> str: + return "an unnamed target" if target is None else f"{target[0]} `{clip(target[1])}`" @on( @@ -34,34 +50,48 @@ def describe_target(raw: Mapping[str, object]) -> str: skip_planning_agents=False, mandatory=True, tests={ - Input(tool="TaskStop", tool_input={"task_id": "wcn64vfub"}): Block( + stopping(tool_input={"task_id": "wcn64vfub"}): Block( pattern="`TaskStop` on task `wcn64vfub` cannot be verified" ), - Input(tool="TaskStop", tool_input={"task_id": "wf_f79d45a5-908"}): Block(pattern="on task `wf_f79d45a5-908`"), - Input(tool="TaskStop", tool_input={"task_id": "a7e6a10ac1f61999f"}): Block( - pattern="which no session may stop, its own children included" - ), - Input(tool="TaskStop", tool_input={"task_id": "daemonkit-cache-impl"}): Block( - pattern="Let it finish, or ask the owner to end it" - ), - Input(tool="TaskStop", tool_input={"shell_id": "bash_3"}): Block( - pattern="on shell `bash_3` cannot be verified" - ), - Input(tool="TaskStop", tool_input={}): Block(pattern="on an unnamed target cannot be verified"), - Input(tool="TaskStop", tool_input={"task_id": "wcn64vfub"}, agent_id="sub-1"): Block( - pattern="cannot be verified" - ), - Input(tool="TaskStop", tool_input={"task_id": "wcn64vfub"}, permission_mode="plan"): Block( - pattern="cannot be verified" + stopping(tool_input={"task_id": "wf_f79d45a5-908"}): Block(pattern="on task `wf_f79d45a5-908`"), + stopping(tool_input={"task_id": "a7e6a10ac1f61999f"}): Block(pattern="its own children included"), + stopping(tool_input={"task_id": "daemonkit-cache-impl"}): Block( + pattern=f"Let it finish, or ask the owner to end it or {LATER_SESSION}" ), + stopping(tool_input={"shell_id": "bash_3"}): Block(pattern="on shell `bash_3` cannot be verified"), + stopping(tool_input={}): Block(pattern="on an unnamed target cannot be verified"), + stopping(tool_input={"task_id": "wcn64vfub"}, agent_id="sub-1"): Block(pattern="cannot be verified"), + stopping(tool_input={"task_id": "wcn64vfub"}, permission_mode="plan"): Block(pattern="cannot be verified"), + stopping( + tool_input={"task_id": "wcn64vfub"}, + commands={**INLINE_COMMANDS, "ccn answer search wcn64vfub": inline_ruling("Stop wcn64vfub, it hung.")}, + ): Allow(), + stopping( + tool_input={"task_id": "wcn64vfub"}, + commands={ + **INLINE_COMMANDS, + "ccn answer search wcn64vfub": inline_ruling( + "Stop wcn64vfub.", written=INLINE_STARTED + timedelta(seconds=1) + ), + }, + ): Block(pattern="cannot be verified"), + stopping( + tool_input={"task_id": "wcn64vfub"}, + commands={**INLINE_COMMANDS, "ccn answer search wcn64vfub": inline_ruling("Stop wcn64vfub2.")}, + ): Block(pattern="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(), }, ) def stop_unverified_task(evt: ToolRewriteEvent) -> HookResult | None: - return evt.block( - f"BLOCKED: `{evt.tool_name}` on {describe_target(evt.input.raw)} cannot be verified: a bare id does not tell " - "a disposable shell task from a workflow, agent, or teammate session, which no session may stop, its own " - "children included. Let it finish, or ask the owner to end it." + target = stop_target(evt.input.raw) + message = ( + f"BLOCKED: `{evt.tool_name}` on {describe_target(target)} " + "cannot be verified as a disposable shell task rather than a workflow, agent, or teammate session, its own " + f"children included. Let it finish, or ask the owner to end it or {LATER_SESSION}." ) + if target is None: + return evt.block(message) + action = Proposal(scope={"task": target[1]}, summary=f"stop {target[0]} {target[1]}") + return block_first(evt, (lift(evt, TASK_STOP, action, message),)) diff --git a/captain_hook/grants/cli.py b/captain_hook/grants/cli.py index af0e52ff..f4d29268 100644 --- a/captain_hook/grants/cli.py +++ b/captain_hook/grants/cli.py @@ -140,6 +140,7 @@ def import_(answer_id: str, **options: Any) -> None: quote=answer["body"], detail=f"ruling {answer['id'][:7]}: {answer['title']}", key=ruling_key(answer), + live=True, ) click.echo(describe(minted(MintOptions(**options), evidence=evidence, author=f"ccn:{answer['id'][:7]}"))) @@ -164,7 +165,9 @@ def show(grant_id: str) -> None: click.echo(describe(found)) click.echo(found.model_dump_json(indent=2)) for adoption in store.adoptions(grant_id): - click.echo(f"{store.stamp(adoption.at)} adopted into tree {adoption.tree} by {adoption.session}/{adoption.agent}") + click.echo( + f"{store.stamp(adoption.at)} adopted into tree {adoption.tree} by {adoption.session}/{adoption.agent}" + ) for spend in store.spends(grant_id): click.echo( f"{store.stamp(spend.at)} {spend.state} {spend.session}/{spend.agent} {spend.summary} {spend.reason}" diff --git a/captain_hook/grants/declare.py b/captain_hook/grants/declare.py index 0d3c60f3..bc10234d 100644 --- a/captain_hook/grants/declare.py +++ b/captain_hook/grants/declare.py @@ -15,8 +15,8 @@ from loguru import logger from captain_hook.grants import store -from captain_hook.grants.evidence import tree_of, verbatim -from captain_hook.grants.judge import Judge, JudgeFailed +from captain_hook.grants.evidence import Asked, OwnerWords, tree_of, verbatim +from captain_hook.grants.judge import GrantVerdict, Judge, JudgeFailed from captain_hook.grants.records import Allowed, Denied, Evidence, Grant, Proposal if TYPE_CHECKING: @@ -66,14 +66,20 @@ class Grants: Attributes: kind: The grant kind this declaration spends and mints, e.g. ``slack.write``. scope: The canonical scope keys; every action and grant carries exactly these. - action: Maps the event to the action a grant must cover. + action: Maps the event to the action a grant must cover; a hook that passes each action to + :meth:`check` itself, as a guard does for every call in one command, declares none. rules: Deterministic rules run against each candidate grant before any judge. - judge: The LLM check over the owner's words; without one, a scope match is enough. + judge: The LLM check over the owner's words. Without one, a scope match is enough for a + stored grant, and the first evidence item collected is the approval itself, minted once + 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. 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. + replay: How long after a one-use grant's spend the identical action goes again without a second + use, for a retry whose result was lost; keep it short when the effect does not dedupe. spent_by: The downstream system that spends this kind's grants with ``capt-hook grant spend``; when set, a check names the covering grant without reserving a use. would_allow: What the agent can do to get permission, appended to every deny. @@ -81,7 +87,7 @@ class Grants: kind: str scope: tuple[str, ...] - action: Callable[[BaseHookEvent], Proposal] + action: Callable[[BaseHookEvent], Proposal] | None = None rules: Sequence[Rule] = () judge: Judge | None = None evidence: Sequence[EvidenceSource] = () @@ -89,11 +95,14 @@ class Grants: ttl: timedelta | None = timedelta(days=1) standing_ttl: timedelta | None = None standing_rules: tuple[str, ...] = () + replay: timedelta = timedelta.max spent_by: str | None = None would_allow: str = "Ask the user for permission for exactly this action." hook: str = field(default="grants") def __post_init__(self) -> None: + if self.judge is None and any(isinstance(source, Asked | OwnerWords) for source in self.evidence): + raise ValueError(f"{self.kind}: the owner's words and answers need a judge to read them") DECLARED[self.kind] = self def canonical(self, scope: dict[str, str] | Proposal) -> dict[str, str]: @@ -114,8 +123,12 @@ def grant( rules: Sequence[str] = (), source_key: str | None = None, links: dict[str, str] | None = None, + across_trees: bool = False, ) -> Grant: - """Mint a grant in *evt*'s session tree, or return the one already minted from *source_key*.""" + """Mint a grant in *evt*'s session tree, or return the one already minted from *source_key*. + + With *across_trees*, the grant already minted from *source_key* in any tree is returned. + """ return store.mint( Grant( id=store.new_id(), @@ -131,29 +144,40 @@ def grant( links=links or {}, author=author(evt, self.hook), created=store.now(), - ) + ), + across_trees=across_trees, ) - def stale_rulings(self, grant: Grant, items: Sequence[Evidence]) -> str | None: - current = {item.key for item in items if item.source == "ccn-answer"} - return next( - (item.id for item in grant.evidence if item.source == "ccn-answer" and item.key not in current), None - ) + def stale(self, grant: Grant, items: Sequence[Evidence]) -> str | None: + current = {item.key for item in items if item.live} + return next((item.id for item in grant.evidence if item.live and item.key not in current), None) def applicable(self, grant: Grant) -> list[Rule]: return [rule for rule in self.rules if rule.always or rule.name in grant.rules] - def check(self, evt: BaseHookEvent) -> Allowed | Denied: - """Spend a grant that covers *evt*'s action, minting one from the owner's words when none is stored. - - Stored grants of this kind in the session tree come first, newest first: a rule that denies - skips the grant, a rule that allows settles it unless the owner has spoken since it was - minted, and anything else goes to the judge with the grant's evidence and the owner's later - words. With no stored grant, the judge reads the declared evidence; its allow mints a grant - keyed on the approval it relied on and spends it. Every use is reserved and settles with the - event's verdict. + def decide(self, evt: BaseHookEvent, action: Proposal | None = None) -> Allowed | Denied: + """:meth:`check`, failing closed: a store, evidence, or judge error is a denial naming the error.""" + try: + return self.check(evt, action) + except Exception as exc: + logger.bind(hook=self.hook, kind=self.kind).opt(exception=True).warning("grant check failed") + return Denied(f"the grant check failed ({type(exc).__name__}: {exc}).", self.would_allow) + + def check(self, evt: BaseHookEvent, action: Proposal | None = None) -> Allowed | Denied: + """Spend a grant that covers *action*, minting one from the owner's words when none is stored. + + *action* defaults to the declaration's ``action`` of *evt*. Stored grants of this kind in the + session tree come first, newest first: a rule that denies skips the grant, live evidence the + sources no longer collect skips it, a rule that allows settles it unless the owner has spoken + since it was minted, and anything else goes to the judge with the grant's evidence and the + owner's later words. With no stored grant, the judge reads the declared evidence; its allow + mints a grant keyed on the approval it relied on and spends it. Every use is reserved and + settles with the event's verdict. """ - action = self.action(evt) + if action is None: + if self.action is None: + raise TypeError(f"{self.kind} declares no action, so check needs the action to cover") + action = self.action(evt) scope = self.canonical(action) tree = tree_of(evt) collected: list[Evidence] | None = None @@ -165,23 +189,23 @@ def session() -> list[Evidence]: return collected refusals: list[str] = [] - found = store.matching(self.kind, tree, scope, fingerprint(action)) + found = store.matching(self.kind, tree, scope, fingerprint(action), self.replay) refusals.extend(why for _, why in found[:1] if why is not None) for grant in (grant for grant, why in found if why is None): rulings = [rule.evaluate(grant, action) for rule in self.applicable(grant)] if denied := next((ruling for ruling in rulings if ruling.verdict == "deny"), None): refusals.append(f"grant {grant.id}: {denied.note}") continue - if stale := self.stale_rulings(grant, session()): + if any(item.live for item in grant.evidence) and (stale := self.stale(grant, session())): refusals.append(f"grant {grant.id} rests on {stale}, which changed after the grant was minted.") continue + reason, relied = f"covered by grant {grant.id}", [item.id for item in grant.evidence] since = [ item - for item in session() + for item in (session() if self.judge is not None else ()) if item.source in OWNER_SOURCES and item.said_at is not None and item.said_at > grant.created ] allowed_by_rule = any(ruling.verdict == "allow" for ruling in rulings) - reason, relied = f"covered by grant {grant.id}", [item.id for item in grant.evidence] if self.judge is not None and (since or not allowed_by_rule): try: verdict = self.judge( @@ -201,17 +225,27 @@ def session() -> list[Evidence]: return self.spend(evt, grant, action, reason, relied) except store.SpentError as exc: refusals.append(str(exc)) - if self.judge is None or not self.evidence: + if not self.evidence: return Denied(" ".join(refusals) or f"No {self.kind} grant covers {action.summary}.", self.would_allow) - return self.from_evidence(evt, self.judge, action, session(), refusals) + return self.from_evidence(evt, action, session(), refusals) def from_evidence( - self, evt: BaseHookEvent, judge: Judge, action: Proposal, items: list[Evidence], refusals: list[str] + self, evt: BaseHookEvent, action: Proposal, items: list[Evidence], refusals: list[str] ) -> Allowed | Denied: - try: - verdict = judge(evt, hook=self.hook, action=action, evidence=items, rulings=()) - except JudgeFailed as exc: - return Denied(f"{exc}, and an action it cannot judge never goes ahead.", self.would_allow, undecided=True) + if self.judge is None: + if not items: + return Denied(" ".join(refusals), self.would_allow) + named = items[0] + verdict = GrantVerdict( + reason=f"{named.detail or named.id} covers {action.summary}", allow=True, relied_on=[named.id] + ) + else: + try: + verdict = self.judge(evt, hook=self.hook, action=action, evidence=items, rulings=()) + except JudgeFailed as exc: + return Denied( + f"{exc}, and an action it cannot judge never goes ahead.", self.would_allow, undecided=True + ) if not verdict.allow: return Denied(" ".join([*refusals, verdict.reason]), self.would_allow) relied = [item for item in items if item.id in verdict.relied_on] @@ -232,6 +266,7 @@ def from_evidence( source_key=said.key or said.id, ) else: + approval = relied[0].key or relied[0].id grant = self.grant( evt, scope=dict(action.scope), @@ -239,7 +274,8 @@ def from_evidence( uses=self.mint, ttl=self.ttl, approved=dict(action.payload) if action.payload else None, - source_key=relied[0].key or relied[0].id, + source_key=approval if self.judge is not None else f"{approval}#{json.dumps(self.canonical(action))}", + across_trees=self.judge is None, ) if grant.scope != self.canonical(action): return Denied( @@ -275,6 +311,7 @@ def spend(self, evt: BaseHookEvent, grant: Grant, action: Proposal, reason: str, summary=action.summary, reason=reason, relied_on=relied, + replay=self.replay, ) if reserved is not None: reserved.append(tool_use_id) @@ -288,11 +325,7 @@ def lifted(evt: BaseHookEvent, hook: str, result: HookResult, grants: Grants) -> The check fails closed: a store, evidence, or judge error keeps the block. The block's own message stays the agent-facing text; why no grant covered the call goes to the user as ``system_message``. """ - try: - verdict = grants.check(evt) - except Exception as exc: - logger.bind(hook=hook).opt(exception=True).warning("grant check failed; keeping the block") - return replace(result, system_message=f"{hook}: the grant check failed ({type(exc).__name__}: {exc}).") + verdict = grants.decide(evt) if isinstance(verdict, Denied): return replace(result, system_message=f"{hook}: {verdict.message}") left = "unlimited uses" if verdict.remaining is None else f"{verdict.remaining} use(s) left" diff --git a/captain_hook/grants/evidence.py b/captain_hook/grants/evidence.py index 815dbb2b..f791a21c 100644 --- a/captain_hook/grants/evidence.py +++ b/captain_hook/grants/evidence.py @@ -280,11 +280,18 @@ def ruling_key(answer: dict[str, Any]) -> str: return f"ccn:{answer['id']}@{written_at(answer).isoformat()}" +def names(body: str, term: str) -> bool: + return re.search(rf"(? list[Evidence]: said_at=written_at(answer), detail=f"ruling {answer['id'][:7]}: {answer['title']}", key=ruling_key(answer), + live=True, ) for answer in ccn_answers(evt, term) - if term in answer.get("body", "") and written_at(answer) < cutoff + if names(answer.get("body", ""), term) and written_at(answer) < cutoff ] diff --git a/captain_hook/grants/records.py b/captain_hook/grants/records.py index 2fd47d06..065e6e29 100644 --- a/captain_hook/grants/records.py +++ b/captain_hook/grants/records.py @@ -20,6 +20,8 @@ class Evidence(BaseModel): said_at: When the owner said them, when the source records it. detail: Context the judge reads beside the quote, such as the question and the options shown. key: The approval's identity; every grant minted from it shares one budget. + live: Whether its source collects it again before a stored grant spends; a grant whose live + evidence the source no longer collects covers nothing. """ id: str @@ -28,6 +30,7 @@ class Evidence(BaseModel): said_at: datetime | None = None detail: str = "" key: str = "" + live: bool = False class Grant(BaseModel): diff --git a/captain_hook/grants/store.py b/captain_hook/grants/store.py index 5ed546a2..a3391f8b 100644 --- a/captain_hook/grants/store.py +++ b/captain_hook/grants/store.py @@ -112,13 +112,17 @@ def parse_spend(row: tuple[Any, ...]) -> Spend: SPEND_COLUMNS = "id, grant_id, at, state, session, agent, tool_use_id, fingerprint, summary, reason, relied_on" -def mint(grant: Grant) -> Grant: - """Store *grant*, or return the grant already minted from the same approval in the same tree.""" +def mint(grant: Grant, *, across_trees: bool = False) -> Grant: + """Store *grant*, or return the grant already minted from the same approval in the same tree. + + With *across_trees*, a grant minted from the same approval in any tree is returned instead, so an + approval recorded outside every session is spent once however many trees reach for it. + """ with connect() as db, immediate(db): if grant.source_key is not None and ( row := db.execute( - "SELECT body FROM grants WHERE kind = ? AND tree = ? AND source_key = ?", - (grant.kind, grant.tree, grant.source_key), + "SELECT body FROM grants WHERE kind = ? AND source_key = ? AND (? OR tree = ?)", + (grant.kind, grant.source_key, across_trees, grant.tree), ).fetchone() ): return Grant.model_validate_json(row[0]) @@ -179,18 +183,22 @@ def remaining(grant: Grant, used: list[Spend], at: datetime) -> int | None: return None if grant.uses is None else grant.uses - sum(counted(spend, at) for spend in used) -def retried(grant: Grant, used: list[Spend], fingerprint: str | None) -> bool: - """Whether *fingerprint* repeats the action a one-shot grant was already spent on.""" - return grant.uses == 1 and any(spend.state == "committed" and spend.fingerprint == fingerprint for spend in used) +def retried(grant: Grant, used: list[Spend], fingerprint: str | None, at: datetime, replay: timedelta) -> bool: + """Whether *fingerprint* repeats, within *replay* of it, the action a one-shot grant was spent on.""" + return grant.uses == 1 and any( + spend.state == "committed" and spend.fingerprint == fingerprint and at - spend.at < replay for spend in used + ) -def unusable(grant: Grant, used: list[Spend], at: datetime, fingerprint: str | None = None) -> str | None: +def unusable( + grant: Grant, used: list[Spend], at: datetime, fingerprint: str | None = None, replay: timedelta = timedelta.max +) -> str | None: """Why *grant* covers nothing at *at*, or ``None`` while it is live or *fingerprint* retries its one use.""" if grant.revoked is not None: return f"grant {grant.id} was revoked at {stamp(grant.revoked)}." if grant.expires is not None and grant.expires <= at: return f"grant {grant.id} expired at {stamp(grant.expires)}." - if retried(grant, used, fingerprint): + if retried(grant, used, fingerprint, at, replay): return None if (left := remaining(grant, used, at)) is not None and left <= 0: last = next(spend for spend in reversed(used) if counted(spend, at)) @@ -198,11 +206,13 @@ def unusable(grant: Grant, used: list[Spend], at: datetime, fingerprint: str | N return None -def matching(kind: str, tree: str, scope: Mapping[str, str], fingerprint: str) -> list[tuple[Grant, str | None]]: +def matching( + kind: str, tree: str, scope: Mapping[str, str], fingerprint: str, replay: timedelta = timedelta.max +) -> list[tuple[Grant, str | None]]: """The grants of *kind* in *tree* whose scope equals *scope*, newest first, each with why it is unusable.""" at = now() return [ - (grant, unusable(grant, spends(grant.id), at, fingerprint)) + (grant, unusable(grant, spends(grant.id), at, fingerprint, replay)) for grant in reversed(grants(kind, tree)) if grant.scope == dict(scope) ] @@ -221,11 +231,13 @@ def reserve( summary: str, reason: str, relied_on: list[str], + replay: timedelta = timedelta.max, ) -> int | None: """Spend one use of *grant_id* atomically and return the uses left; raise :class:`SpentError` when none are. - A one-shot grant already spent on this exact *fingerprint* covers its retry without a second use, - so a write whose reply was lost can go again; the downstream system dedupes the effect. + A one-shot grant already spent on this exact *fingerprint*, less than *replay* ago, covers its retry + without a second use, so a write whose reply was lost can go again; the downstream system dedupes + the effect. """ at = now() with connect() as db, immediate(db): @@ -238,9 +250,9 @@ def reserve( rows = db.execute(f"SELECT {SPEND_COLUMNS} FROM spends WHERE grant_id = ? ORDER BY id", (grant_id,)).fetchall() used = [parse_spend(row) for row in rows] left = remaining(grant, used, at) - if (why := unusable(grant, used, at, fingerprint)) is not None: + if (why := unusable(grant, used, at, fingerprint, replay)) is not None: raise SpentError(why) - if retried(grant, used, fingerprint): + if retried(grant, used, fingerprint, at, replay): return left db.execute( f"INSERT INTO spends ({SPEND_COLUMNS.removeprefix('id, ')}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", diff --git a/docs/guide/grants.qmd b/docs/guide/grants.qmd index 3330f591..7c3fbe82 100644 --- a/docs/guide/grants.qmd +++ b/docs/guide/grants.qmd @@ -91,9 +91,9 @@ anything. The reason and `would_allow` go to the user as `system_message`, prefi with `: `. Other hooks still decide the event, and Claude Code's permission flow still applies. -`grants=` requires `block=True` and tool events. Registration raises `ValueError` -otherwise. Narrow the event with `only_if` and `skip_if`; `action` must return a -`Proposal`, not `None`. +`grants=` requires `block=True`, tool events, and a declaration with an `action` +function. Registration raises `ValueError` otherwise. Narrow the event with +`only_if` and `skip_if`; `action` must return a `Proposal`, not `None`. ## Read a grant record @@ -114,10 +114,14 @@ otherwise. Narrow the event with `only_if` and `skip_if`; `action` must return a | `links` | Grant ids in other systems, as `dict[str, str]` | | `author`, `created`, `revoked` | Who minted it, when, and when it was revoked | -Each evidence record has `id`, `source`, `quote`, `said_at`, `detail`, and `key`. +Each evidence record has `id`, `source`, `quote`, `said_at`, `detail`, `key`, and `live`. The judge cites `id`; minting uses `key`. `quote` holds the owner's words. `detail` holds context such as the question and the options shown. +`live` defaults to `False`. Set `live=True` for evidence its source must collect +again before a stored grant spends. If any live evidence key is missing from the +new collection, that grant no longer covers the action. + Uses live in separate spend records. Each records its timestamp, state, session, agent, tool-use id, payload fingerprint, summary, reason, and cited evidence ids. `uses` stays fixed; the store computes the remaining budget from these records. @@ -176,6 +180,7 @@ without spending a use, even when the judge allowed the mint. ::: {.callout-warning title="A deferred rule needs a judge"} Without a `Judge`, a matching stored grant passes unless a rule denies it. `ContentMatches` alone does not reject changed content: a mismatch defers. +Tree, budget, expiry, revocation, and live-evidence checks still apply. ::: ## Judge the owner's evidence @@ -190,7 +195,7 @@ For "send these three replies" the judge sets `standing` to that quote and `uses=3`, allowing three uses. `GrantVerdict.uses` is a positive integer or `None` when the words set no limit. -The defaults are `model="small"`, `specialty="general"`, and `deadline=20` seconds. +The defaults are `model="small"`, `specialty="review"`, and `deadline=20` seconds. `contexts` accepts the same prompt contexts as other LLM hooks. `transcript` and `root_transcript` default to `False`; set either to a message count, `"recent"`, or `"full"` when the judge needs more context. @@ -203,7 +208,13 @@ The evidence providers determine which words can authorize an action: |----------|--------------------| | `Asked()` | `AskUserQuestion` answers from stored `ask` grants and recent transcript calls | | `OwnerWords()` | Typed, pasted, and human-queued owner words; ordinary lane prompts are excluded | -| `Rulings(search=...)` | cc-notes answers containing the proposal's search term and last written before the acting session started | +| `Rulings(search=...)` | cc-notes answers naming the proposal's search term as a whole name and last written before the acting session started | + +A declaration without a judge treats the first collected evidence item as the +approval and mints from it. Use sources that already identify an approved action, +such as `Rulings`. A matching stored grant needs no model judgment once its rules +and live-evidence checks pass. Declaring `Asked()` or `OwnerWords()` without a +judge raises `ValueError`: those sources need a judge to interpret the owner's words. `Asked` preserves every option's label, description, and preview, along with the selection, typed answer, and per-question notes. It distinguishes what was shown @@ -232,11 +243,19 @@ EVIDENCE = ( ) ``` -`Rulings` reads `updated_at`, falling back to `created_at`. That timestamp must -be strictly earlier than the first timestamp in the acting session's transcript. -An answer written or edited during the session does not qualify. Its evidence -key includes the answer id and timestamp. This guard runs when collecting rulings; -stored grants do not revalidate the source answer's revision before spending. +`Rulings` requires the answer's `updated_at` to be strictly earlier than the first +timestamp in the acting session's transcript. An answer written or edited during +the session does not qualify. The body must name the search term as a whole name. +A preceding word character, `.`, or `-` prevents a match, as does a following word +character, `-`, or `.` followed by a word character. `term_a` does not match +`term_ab` or `term_a-2`, and `com.x` does not match `com.x.helper`. A sentence-ending +period after the term still counts. + +Its evidence key is `ccn:@`, with the timestamp in ISO format. +A judge-less declaration adds the canonical scope to the grant's `source_key`, so +one answer naming two terminals mints two grants. Rulings are live evidence, so +stored grants recheck the answer's revision before spending; editing the answer +stops its existing grant from covering the action. Stored `ask` evidence is collected only while its record is unexpired and unrevoked. `Asked` also reads recent transcript answers. To stop an already minted @@ -250,14 +269,17 @@ permission, revoke the grant of the declaration's kind. exact scope, newest first. Skip revoked, expired, or exhausted grants, except for an eligible identical retry. Evaluate the applicable deterministic rules. -2. Collect the configured evidence. If it includes `ask` or `words` evidence - timestamped after the grant's creation, run the judge before spending, even - when a rule allowed. Otherwise, a rule's allow skips the judge. Without a - rule's allow, the configured judge decides from the grant and evidence. -3. If no stored candidate covers the call, use the judge and configured evidence - to decide whether to mint. A denied verdict returns its reason. An allow - mints with the declaration's `mint` budget and `ttl` and stores the proposed - payload as `approved`. An empty payload sets `approved=None`. +2. If the grant has live evidence, collect the configured sources again and skip + the grant if any live key is missing. When a judge is configured, collect its + evidence too: later `ask` or `words` evidence makes it judge before spending, + even when a rule allowed. Otherwise, a rule's allow skips the judge; without + one, the configured judge decides. A declaration without a judge accepts the + matching grant after these checks. +3. If no stored candidate covers the call, use the configured evidence to decide + whether to mint. A judge decides when present; otherwise, the first collected + item supplies approval, and no items means a deny. An allow mints with the + declaration's `mint` budget and `ttl` and stores the proposed payload as + `approved`. An empty payload sets `approved=None`. 4. If the allow includes a `standing` quote found in an `ask` or `words` evidence item, mint with the verdict's `uses`, `standing_ttl`, and `standing_rules` instead. A count allows that many uses; `uses=None` allows unlimited uses. @@ -267,14 +289,24 @@ permission, revoke the grant of the declaration's kind. returns `Denied`; otherwise, reserve a use unless `spent_by` names a downstream system that spends it. -Ordinary minting uses the first cited evidence item's key, or its id when the key -is empty, as `source_key`. Standing minting uses the matching owner's evidence key -or id. One approval mints once per kind and tree. A later mint from the same words -returns the same grant with its remaining budget. Concurrent judges share it too. +With a judge, ordinary minting uses the first cited evidence item's key, or its id +when the key is empty, as `source_key`. Standing minting uses the matching owner's +evidence key or id. One approval mints once per kind and tree. A later mint from the +same words returns the same grant with its remaining budget. Concurrent judges +share it too. + +Without a judge, minting uses `#` as `source_key`, with the id in +place of an empty key and the canonical scope serialized as JSON. It calls +`Grants.grant(..., across_trees=True)`, which passes the option to `store.mint`. +The lookup covers every session tree: one approval mints once per kind and scope. +A second tree reaching for it gets the existing grant and, unless it has adopted +that grant, a refusal saying the grant covers the scope in another session tree. +Naming two scopes in one answer permits a separate grant for each. Ask keys name the tool-use id and question index. Ruling keys name the answer and -revision. Owner-word keys hash the text, so repeating identical words reuses the -same key. An allow without a citation to collected evidence cannot mint a grant. +revision. Owner-word keys hash the text and timestamp, so +collecting the same message again reuses its key. An allow without a citation to +collected evidence cannot mint a grant. Keep `Asked()` and `OwnerWords()` in `evidence` when later owner instructions must reach the judge. The stored path sees only evidence from the configured providers @@ -297,11 +329,17 @@ is unlimited. Expiry and revocation stop all three. `Grants.grant(...)` is the Python minting method; its `uses` defaults to `1` and its `ttl` to `None`. It does not inherit the declaration's evidence-minting lifetime. -A committed one-use grant can cover repeated retries of the identical scope and -payload without spending again, including retries with a new tool-use id. -The fingerprint excludes `summary`, so changing only the summary still counts as -a retry. Changed payloads need another available use or approval, and expiry and -revocation still reject an identical retry. +`Grants.replay` is a `timedelta`, defaulting to `timedelta.max`. A one-use grant +covers an identical action without a second use only while less than `replay` has +elapsed since its committed spend. The scope and payload fingerprint must match; +a new tool-use id is allowed. The fingerprint excludes `summary`, so changing only +the summary still counts as a retry. + +Set `replay=timedelta(minutes=2)` for a two-minute window; retries do not extend it. +After the window, another approval is needed. Changed payloads need another +available use or approval, and expiry and +revocation still reject an identical retry. The store's `retried`, `unusable`, +`matching`, and `reserve` checks take the replay window too. The downstream tool must deduplicate the effect. A grant tracks permission; it does not make a message send or other write idempotent. @@ -353,6 +391,12 @@ Call `MESSAGE_WRITES.check(evt)` when a handler needs the grant record or remain budget. It returns a truthy `Allowed(grant, remaining, reason)` or a falsey `Denied(reason, would_allow)`. `Denied.message` joins the refusal and remediation. +For a hook that checks several calls within one event, pass each `Proposal` +directly to `check(evt, action)`. The explicit proposal overrides the declaration's +`action` function. Such a declaration may omit `action`; calling `check(evt)` on +it without a proposal raises `TypeError`. Attachments through `hook(..., grants=...)` +still require an `action` function. + As an alternative to the `hook(...)` registration above, keep the declaration and register this handler. It blocks check errors and uses the attachment's policy for incomplete checks: @@ -422,18 +466,15 @@ uvx capt-hook grant import ANSWER_ID --kind message.write \ Neither sets an approved payload. A declaration with `ContentMatches` and a judge therefore asks the judge about the first use. -The builtin guard checks agent-run Bash calls to `grant add` and `grant import`. -Both require a declared kind. For `add`, the quote must occur in owner evidence -in the session tree, with whitespace folded. If the declaration has a judge, it -also judges the proposed scope and use budget. For `import`, the answer's -`updated_at`, falling back to `created_at`, must be strictly earlier than the -acting session's first transcript timestamp. The guard refuses an answer written -or edited at or after that timestamp. +Only the owner runs `add` and `import`, from an interactive terminal. The builtin +guard blocks agent-run Bash calls to both, and the CLI rejects noninteractive +input. Agents retry the action itself so its hook can collect the owner's evidence. -That guard does not fully validate a mint. For `add`, the judge is optional and -a larger budget needs no verified standing quote. -The CLI does not enforce the declaration's exact scope keys or validate -expiry and tree against the approval. Treat direct CLI minting as an owner action. +The CLI records the owner's chosen scope, budget, expiry, and tree without judging +the approval or enforcing a declaration's exact scope keys. Imported cc-notes +evidence stores the answer's revision with `live=True`, so it keeps the revision +recheck before spending. Include `Rulings` in the declaration's evidence sources +to collect that revision again. Any agent can make a grant from another session tree usable in its own with `capt-hook grant adopt [--tree T] [--agent NAME]`. Both trees share the @@ -458,12 +499,79 @@ uvx capt-hook grant revoke GRANT_ID ``` `list` hides spent, expired, and revoked grants unless `--all` is set. -An exhausted one-use grant can still cover an identical retry even though it is -absent from the default list. `show` leads with the live status and remaining -uses, or the reason the grant is unusable. It then prints the record, adoptions, -and every spend, including reserved, committed, and released uses. +An exhausted one-use grant can still cover an identical retry within the +declaration's replay window even though it is absent from the default list. +`show` leads with the live status and remaining uses, or the reason the grant is +unusable. It then prints the record, adoptions, and every spend, including reserved, +committed, and released uses. `revoke` prevents future spends and retries. +## 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: + +| 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` | + +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 +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. + +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 +start with it. Every other command must be a pipe successor, so +`launchctl kickstart -k system/com.example.orca-serve 2>&1 | tail -3` can qualify. +Loops, `&&` or `;` chains such as `cd /tmp && launchctl ...`, background `&`, +`sh -c`, substitutions, and commands respelled through variables keep the block. +`TaskStop` is a tool call and runs once by construction. + +For `launchctl`, spell the command and target literally, without wrappers, +substitutions, or variables. Service targets are `system/