From e0131ee043ea936022c6842323f726368382bd12 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 20:51:05 -0700 Subject: [PATCH 1/2] =?UTF-8?q?grants:=20=E2=9C=A8=20counted=20judge=20gra?= =?UTF-8?q?nts,=20cross-tree=20adoption,=20and=20downstream=20spending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: the owner approved the grants design with changes: multi-spend, Slack enforcement in cc-slack, and agent cross-root sharing. Summary: GrantVerdict.uses mints an N-use grant from quoted words; capt-hook grant adopt shares a grant into another tree with a logged adoption; Grants(spent_by=...) names a grant without reserving and capt-hook grant spend lets the downstream system pay. Motivation: cc-slack must enforce and spend Slack grants itself, and lanes in other roots must use what the owner gave the root. Details: one approval now keys standing, counted, and one-shot mints alike, so the same words never mint twice; grant show leads with the remaining uses. --- captain_hook/grants/cli.py | 59 +++++++++++++++++++ captain_hook/grants/declare.py | 13 ++++- captain_hook/grants/judge.py | 5 +- captain_hook/grants/records.py | 10 ++++ captain_hook/grants/store.py | 47 +++++++++++++-- docs/guide/grants.qmd | 104 +++++++++++++++++++++++++-------- tests/test_grants.py | 41 +++++++++++++ 7 files changed, 246 insertions(+), 33 deletions(-) diff --git a/captain_hook/grants/cli.py b/captain_hook/grants/cli.py index 038a2013..af0e52ff 100644 --- a/captain_hook/grants/cli.py +++ b/captain_hook/grants/cli.py @@ -161,7 +161,10 @@ def list_(kind: str | None, tree: str | None, everything: bool) -> None: def show(grant_id: str) -> None: """Show a grant's record and every use of it.""" found = store.load(grant_id) + 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}") for spend in store.spends(grant_id): click.echo( f"{store.stamp(spend.at)} {spend.state} {spend.session}/{spend.agent} {spend.summary} {spend.reason}" @@ -173,3 +176,59 @@ def show(grant_id: str) -> None: def revoke(grant_id: str) -> None: """Revoke a grant; it covers nothing from now on.""" click.echo(describe(store.revoke(grant_id))) + + +@grant.command() +@click.argument("grant_id") +@click.option("--tree", default=None, help="Session tree to adopt it into (default: this session)") +@click.option("--agent", default="main", show_default=True, help="The agent adopting it, for the log") +def adopt(grant_id: str, tree: str | None, agent: str) -> None: + """Make a grant from another session tree usable in this one; both trees share its budget. + + Any agent may adopt a grant it was handed; the adoption is logged with the session and agent. + """ + session = session_tree() + adoption = store.adopt(grant_id, tree=tree or session, session=session, agent=agent) + click.echo(f"{describe(store.load(grant_id))} adopted into {adoption.tree}") + + +@grant.command() +@click.argument("grant_id") +@click.option("--scope", "scope", multiple=True, required=True, help="One key=value of the action's scope; repeat") +@click.option("--tree", required=True, help="Session tree the action runs in") +@click.option("--session", required=True, help="Session making the call") +@click.option("--agent", default="main", show_default=True, help="Agent making the call") +@click.option("--call", "call", required=True, help="Id of the call this use pays for") +@click.option("--fingerprint", required=True, help="Digest of the action's payload; a retry repeats it") +@click.option("--summary", required=True, help="One line naming the action") +def spend( + grant_id: str, + scope: tuple[str, ...], + tree: str, + session: str, + agent: str, + call: str, + fingerprint: str, + summary: str, +) -> None: + """Spend one use of a grant for a system that enforces it downstream, such as the cc-slack daemon. + + Prints the grant and the uses left as JSON; exits non-zero with the reason when the grant cannot pay. + """ + try: + left = store.reserve( + grant_id, + tree=tree, + scope=parse_scope(scope), + state="committed", + session=session, + agent=agent, + tool_use_id=call, + fingerprint=fingerprint, + summary=summary, + reason="spent downstream", + relied_on=[], + ) + except store.SpentError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(json.dumps({"grant": json.loads(store.load(grant_id).model_dump_json()), "remaining": left})) diff --git a/captain_hook/grants/declare.py b/captain_hook/grants/declare.py index 7b3aab8f..5a6f50cc 100644 --- a/captain_hook/grants/declare.py +++ b/captain_hook/grants/declare.py @@ -74,6 +74,8 @@ class Grants: 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. + 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. """ @@ -87,6 +89,7 @@ class Grants: ttl: timedelta | None = timedelta(days=1) standing_ttl: timedelta | None = None standing_rules: tuple[str, ...] = () + spent_by: str | None = None would_allow: str = "Ask the user for permission for exactly this action." hook: str = field(default="grants") @@ -223,10 +226,10 @@ def from_evidence( evt, scope=dict(action.scope), evidence=[quoted, *(item for item in relied if item.id != said.id)], - uses=None, + uses=verdict.uses, ttl=self.standing_ttl, rules=self.standing_rules, - source_key=f"standing:{said.key}", + source_key=said.key or said.id, ) else: grant = self.grant( @@ -252,6 +255,12 @@ def from_evidence( return Denied(" ".join([*refusals, str(exc)]), self.would_allow) def spend(self, evt: BaseHookEvent, grant: Grant, action: Proposal, reason: str, relied: list[str]) -> Allowed: + if self.spent_by is not None: + at = store.now() + used = store.spends(grant.id) + if (why := store.unusable(grant, used, at)) is not None: + raise store.SpentError(why) + return Allowed(grant, store.remaining(grant, used, at), reason) reserved = _RESERVED.get() tool_use_id = call_id(evt) left = store.reserve( diff --git a/captain_hook/grants/judge.py b/captain_hook/grants/judge.py index 9f6efe91..0f5a8d0a 100644 --- a/captain_hook/grants/judge.py +++ b/captain_hook/grants/judge.py @@ -33,7 +33,9 @@ refused. Set withdrawn when the owner's words in withdraw or narrow the recorded grant so that it no longer covers actions like this one. Set standing to the owner's exact words, copied verbatim from one evidence item, only when those words permit more than this - one action (for example "reply in that thread without asking"); otherwise leave it empty. + one action (for example "reply in that thread without asking"); otherwise leave it empty. When + those words name how many such actions they permit ("send these three replies"), also set uses + to that number; leave uses empty when they set no limit. Reason first, quoting the owner words you relied on, then set allow. """ @@ -45,6 +47,7 @@ class GrantVerdict(BaseModel): allow: bool relied_on: list[str] = Field(default_factory=list[str]) standing: str | None = None + uses: int | None = Field(default=None, ge=1) withdrawn: bool = False diff --git a/captain_hook/grants/records.py b/captain_hook/grants/records.py index 7d32b4e0..2fd47d06 100644 --- a/captain_hook/grants/records.py +++ b/captain_hook/grants/records.py @@ -73,6 +73,16 @@ def standing(self) -> bool: SpendState = Literal["reserved", "committed", "released"] +class Adoption(BaseModel): + """An agent in another session tree made a grant usable there; the two trees share its budget.""" + + grant_id: str + tree: str + at: datetime + session: str + agent: str + + class Spend(BaseModel): """One use of a grant: reserved while its event is decided, then committed or released.""" diff --git a/captain_hook/grants/store.py b/captain_hook/grants/store.py index dab6414c..fe413870 100644 --- a/captain_hook/grants/store.py +++ b/captain_hook/grants/store.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -from captain_hook.grants.records import Grant, Spend, SpendState +from captain_hook.grants.records import Adoption, Grant, Spend, SpendState from captain_hook.util.paths import resolve_state_dir RESERVATION_TTL = timedelta(minutes=2) @@ -37,6 +37,14 @@ reason TEXT NOT NULL, relied_on TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS adoptions ( + grant_id TEXT NOT NULL REFERENCES grants(id), + tree TEXT NOT NULL, + at TEXT NOT NULL, + session TEXT NOT NULL, + agent TEXT NOT NULL, + PRIMARY KEY (grant_id, tree) +); CREATE INDEX IF NOT EXISTS grants_by_kind ON grants (kind, tree); CREATE INDEX IF NOT EXISTS spends_by_grant ON spends (grant_id); CREATE INDEX IF NOT EXISTS spends_by_call ON spends (tool_use_id, state); @@ -145,10 +153,15 @@ def load(grant_id: str) -> Grant: def grants(kind: str | None = None, tree: str | None = None) -> list[Grant]: - clauses = [(column, value) for column, value in (("kind", kind), ("tree", tree)) if value is not None] - where = " AND ".join(f"{column} = ?" for column, _ in clauses) or "1" + """The grants of *kind* usable in *tree*: minted there or adopted into it.""" + clauses = [("kind = ?", [kind])] if kind is not None else [] + if tree is not None: + clauses.append(("(tree = ? OR id IN (SELECT grant_id FROM adoptions WHERE tree = ?))", [tree, tree])) + where = " AND ".join(clause for clause, _ in clauses) or "1" with connect() as db: - rows = db.execute(f"SELECT body FROM grants WHERE {where}", [value for _, value in clauses]).fetchall() + rows = db.execute( + f"SELECT body FROM grants WHERE {where}", [value for _, values in clauses for value in values] + ).fetchall() return sorted((Grant.model_validate_json(row[0]) for row in rows), key=lambda grant: grant.created) @@ -217,7 +230,8 @@ def reserve( at = now() with connect() as db, immediate(db): grant = Grant.model_validate_json(db.execute("SELECT body FROM grants WHERE id = ?", (grant_id,)).fetchone()[0]) - if grant.tree != tree or grant.scope != dict(scope): + adopted = db.execute("SELECT 1 FROM adoptions WHERE grant_id = ? AND tree = ?", (grant_id, tree)).fetchone() + if (grant.tree != tree and adopted is None) or grant.scope != dict(scope): raise SpentError(f"grant {grant.id} covers {grant.scope} in another session tree or destination.") 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] @@ -263,6 +277,29 @@ def settle(tool_use_id: str, *, allowed: bool) -> bool: return not (allowed and stale) +def adopt(grant_id: str, *, tree: str, session: str, agent: str) -> Adoption: + """Make *grant_id* usable in *tree* too, sharing its budget, and log who adopted it.""" + load(grant_id) + adoption = Adoption(grant_id=grant_id, tree=tree, at=now(), session=session, agent=agent) + with connect() as db: + db.execute( + "INSERT OR IGNORE INTO adoptions (grant_id, tree, at, session, agent) VALUES (?, ?, ?, ?, ?)", + (grant_id, tree, adoption.at.isoformat(), session, agent), + ) + return adoption + + +def adoptions(grant_id: str) -> list[Adoption]: + with connect() as db: + rows = db.execute( + "SELECT grant_id, tree, at, session, agent FROM adoptions WHERE grant_id = ? ORDER BY at", (grant_id,) + ).fetchall() + return [ + Adoption(grant_id=row[0], tree=row[1], at=datetime.fromisoformat(row[2]), session=row[3], agent=row[4]) + for row in rows + ] + + def revoke(grant_id: str) -> Grant: grant = load(grant_id) revoked = grant.model_copy(update={"revoked": now()}) diff --git a/docs/guide/grants.qmd b/docs/guide/grants.qmd index d00b5bee..3330f591 100644 --- a/docs/guide/grants.qmd +++ b/docs/guide/grants.qmd @@ -6,7 +6,7 @@ description: "Declare scoped permission, judge the owner's evidence, and reserve A grant records permission that a hook can spend. It names the action's scope, the owner's evidence, a use budget, and an expiry. A root session and its agents share that budget. Use grants when an approval must survive a retry or reach -another agent in the same session tree. +another agent or session tree. ## Declare the action that needs permission @@ -103,7 +103,7 @@ otherwise. Narrow the event with `only_if` and `skip_if`; `action` must return a |-------|---------| | `id` | Stable handle, normally 12 hex characters | | `kind` | Declaration that accepts it, such as `message.write` | -| `tree` | Root session id allowed to spend it | +| `tree` | Root session id where it was minted; other trees can adopt it | | `scope` | Exact destination values as `dict[str, str]` | | `approved` | Approved payload as a dictionary, or `None` | | `uses` | Total use budget of `1`, another positive integer, or `None` for unlimited | @@ -135,7 +135,8 @@ constructing the proposal; scope matching does not resolve aliases. Tree binding is separate from scope. The framework uses `evt.ctx.root_path.stem` when a spawning session is known, otherwise `evt.session_id`. A lane can spend -its root's grant from another process. An unrelated root cannot select it. +its root's grant from another process. Another root must adopt the grant before +selecting it. Both trees then spend from the same budget. The shared store lives at `~/.claude/state/hooks/grants.db`. Set `CAPTAIN_HOOK_STATE_DIR` to move its state root, as with [other hook state](state.qmd). @@ -162,7 +163,8 @@ text field is named `content`. `always=True` to apply it to every grant. `ContentMatches` defaults to `always=True`. -`standing_rules` supplies rule names when evidence mints an unlimited grant. +`standing_rules` supplies rule names when evidence mints a standing grant, +including one with a finite budget. In the example, standing grants assert `no-edit` and `no-broadcast`; a one-use approval can cover an explicit edit or broadcast. A name activates a rule already present in the declaration. It does not define a new rule. @@ -180,8 +182,13 @@ Without a `Judge`, a matching stored grant passes unless a rule denies it. `Judge` receives the proposal, the evaluated rules, and the evidence. For a stored grant, it also receives that grant's evidence and the owner's later words. -Its result has `allow`, `reason`, `relied_on`, and optional `standing` fields. -`relied_on` names evidence ids. `standing` is a quote string. +Its `GrantVerdict` has `allow`, `reason`, `relied_on`, and optional `standing` and +`uses` fields. `relied_on` names evidence ids. + +`standing` holds the owner's verbatim quote permitting more than one action. +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. `contexts` accepts the same prompt contexts as other LLM hooks. @@ -239,7 +246,8 @@ permission, revoke the grant of the declaration's kind. `Grants.check(evt)` stops at the first allow: -1. Read stored grants of this kind and tree with the exact scope, newest first. +1. Read stored grants of this kind minted in or adopted into this tree with the + 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 @@ -251,21 +259,22 @@ permission, revoke the grant of the declaration's kind. 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 `uses=None`, `standing_ttl`, and `standing_rules` instead. + item, mint with the verdict's `uses`, `standing_ttl`, and `standing_rules` instead. + A count allows that many uses; `uses=None` allows unlimited uses. The quote must be a verbatim substring after folding whitespace. An unverified standing quote leaves the ordinary `mint` budget in place. 5. Run the declaration's applicable rules against the minted grant. A `deny` - returns `Denied`; otherwise, reserve a use. + returns `Denied`; otherwise, reserve a use unless `spent_by` names a downstream + system that spends it. -Ordinary minting uses the first cited evidence item with a nonempty key as its -`source_key`. Standing minting uses `standing:` plus the matching owner's evidence -key. The store reuses a grant for the same kind, tree, and source key. Concurrent -judges therefore share that grant's budget. +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. 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 may omit evidence citations. When no cited item has a key, -ordinary minting uses `action:` plus the action fingerprint as `source_key`. +same 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 @@ -279,8 +288,9 @@ defers, and `Never` never returns an allow. `mint=1` and `ttl=timedelta(days=1)` are the defaults for ordinary evidence mints. Set `mint` to a positive integer for a larger budget, or `ttl=None` for no expiry. -A verified standing quote produces unlimited uses. `standing_ttl=None` is its -default lifetime; set a `timedelta` to bound it. +A verified standing quote uses the judge's count, or unlimited uses when the +quote sets no limit. `standing_ttl=None` is its default lifetime; set a +`timedelta` to bound it. On a `Grant`, `uses=1` means one use, `uses=N` allows N uses, and `uses=None` is unlimited. Expiry and revocation stop all three. `Grants.grant(...)` is the @@ -298,9 +308,9 @@ it does not make a message send or other write idempotent. ## Settle uses with the event -During dispatch, a successful check reserves a use. Reserved uses count against -the budget, and the store serializes reservations so two racing calls cannot -both reserve the last use. +By default, a successful check during dispatch reserves a use. Reserved uses +count against the budget, and the store serializes reservations so two racing +calls cannot both reserve the last use. The dispatcher commits reservations when the event's final verdict allows. If any hook denies, it releases them. A failure after the tool starts does not @@ -309,6 +319,34 @@ refund a committed use. Unsettled reservations stop counting after two minutes. Outside dispatch's reservation context, `check(evt)` commits immediately. Use the dispatcher when testing a sequence whose final verdict can release a use. +## Let a downstream system spend + +Set `spent_by="cc-slack"` in the `Grants(...)` declaration when the `cc-slack` +daemon spends the grant. The hook names the covering grant and reports its +remaining budget without reserving a use. The downstream system must spend it +before performing the action. + +Run `capt-hook grant spend` with the grant id, exact scope, and call details. +Replace the uppercase placeholders with the grant, tree, session, call id, and +payload digest for the action: + +```bash +uvx capt-hook grant spend GRANT_ID \ + --scope channel=C_EXAMPLE --scope thread=1.2 \ + --tree ROOT_SESSION_ID --session SESSION_ID --agent sender \ + --call TOOL_USE_ID --fingerprint PAYLOAD_DIGEST \ + --summary 'Reply in C_EXAMPLE/1.2' +``` + +Repeat `--scope` for each scope key. All shown options are required except +`--agent`, which defaults to `main`. `--fingerprint` is the action's payload +digest; a retry repeats it. + +The CLI atomically commits a use and prints JSON with `grant` and `remaining` +fields. `remaining` is `null` for an unlimited budget. If the grant cannot cover +the action, it exits non-zero with the reason. A hook's earlier allow does not +hold a use for this spend. + ## Handle a verdict in your own hook Call `MESSAGE_WRITES.check(evt)` when a handler needs the grant record or remaining @@ -352,7 +390,7 @@ policy explicitly. ## Manage grants from the CLI -`capt-hook grant` provides `add`, `import`, `list`, `show`, and `revoke`. +`capt-hook grant` provides `add`, `import`, `adopt`, `spend`, `list`, `show`, and `revoke`. Run the examples through `uvx capt-hook`, like other commands in these guides. Replace `ROOT_SESSION_ID` with the intended root session id. @@ -397,6 +435,19 @@ 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. +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 +grant's budget. Adopt a grant using its printed id: + +```bash +uvx capt-hook grant adopt GRANT_ID --tree ROOT_SESSION_ID --agent sender +``` + +Omit `--tree` to adopt into the current session; `--agent` defaults to `main`. +The CLI reads the adopting session from the session environment variables even +when `--tree` is explicit. `grant show` logs each adoption with its destination +tree, timestamp, session, and agent. + Inspect and revoke a grant with its printed id in place of `GRANT_ID`: ```bash @@ -408,8 +459,10 @@ 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` prints the record and every spend, including -reserved, committed, and released uses. `revoke` prevents future spends and retries. +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. ## Test grants inline @@ -491,8 +544,9 @@ uvx capt-hook test ``` An attached grant's allow returns a context note, so its inline expectation is -`Warn`, even though the hook lifts its block. A `standing` override only produces -an unlimited mint when an evidence provider supplies the matching owner words. +`Warn`, even though the hook lifts its block. A `standing` override needs matching +owner words from an evidence provider. Include `uses` in +the `llm` override for a counted grant; leaving it unset allows unlimited uses. Inline stubs test the declaration and verdict handling. They do not test whether the model interprets permission correctly. diff --git a/tests/test_grants.py b/tests/test_grants.py index 5a7cf733..536c4fcc 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -531,3 +531,44 @@ def test_the_judge_runs_on_luna_at_low_effort() -> None: judge = Judge("rules") assert LlmBackends.for_specialty(judge.specialty).resolve_model(judge.model) == "gpt-6-luna:low" + + +def test_the_judge_mints_a_counted_grant_when_the_owner_names_a_number(tmp_path: Path) -> None: + words = "send these three replies in that thread" + grants = declared(judge=Judge("rules"), evidence=(Fixed((owner(words),)),)) + first = grants.check(event(tmp_path, "one", allow=True, reason="ok", relied_on=["words:1"], standing=words, uses=3)) + assert isinstance(first, Allowed) and first.grant.uses == 3 and first.remaining == 2 + again = {"allow": True, "reason": "ok", "relied_on": ["words:1"]} + assert [bool(grants.check(event(tmp_path, f"t{n}", call=f"c{n}", **again))) for n in range(3)] == [True, True, False] + + +def test_an_adopted_grant_covers_the_adopting_tree_and_shares_its_budget(tmp_path: Path) -> None: + grant = minted(uses=2) + assert not declared().check(event(tmp_path, "lane", session="lane-root")) + adoption = store.adopt(grant.id, tree="lane-root", session="lane-root", agent="comms") + assert adoption.agent == "comms" and [found.tree for found in store.adoptions(grant.id)] == ["lane-root"] + assert declared().check(event(tmp_path, "lane", session="lane-root", call="c1")) + assert declared().check(event(tmp_path, "root", call="c2")) + assert not declared().check(event(tmp_path, "again", session="lane-root", call="c3")) + + +def test_a_downstream_spender_names_the_grant_and_pays_through_the_cli(tmp_path: Path) -> None: + grant = minted(uses=1) + named = declared(spent_by="cc-slack").check(event(tmp_path, "one")) + assert isinstance(named, Allowed) and named.grant.id == grant.id and store.spends(grant.id) == [] + argv = ["spend", grant.id, "--scope", "channel=C1", "--scope", "thread=1.2", "--tree", TREE] + argv += ["--session", TREE, "--call", "post-1", "--fingerprint", "f1", "--summary", "reply"] + paid = CliRunner().invoke(grant_cli, argv) + assert paid.exit_code == 0 and '"remaining": 0' in paid.output + assert [spend.state for spend in store.spends(grant.id)] == ["committed"] + refused = CliRunner().invoke(grant_cli, [*argv[:-6], "--call", "post-2", "--fingerprint", "f2", "--summary", "reply"]) + assert refused.exit_code == 1 and f"grant {grant.id} was spent at" in refused.output + assert isinstance(declared(spent_by="cc-slack").check(event(tmp_path, "two", call="c2")), Denied) + + +def test_a_downstream_spend_refuses_another_tree(tmp_path: Path) -> None: + grant = minted() + argv = ["spend", grant.id, "--scope", "channel=C1", "--scope", "thread=1.2", "--tree", "elsewhere"] + argv += ["--session", "elsewhere", "--call", "p", "--fingerprint", "f", "--summary", "reply"] + refused = CliRunner().invoke(grant_cli, argv) + assert refused.exit_code == 1 and "another session tree" in refused.output From 5939e218a599e1f2e254146521d19e4a359d38e3 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Fri, 2 Oct 2026 21:41:56 -0700 Subject: [PATCH 2/2] =?UTF-8?q?grants:=20=F0=9F=90=9B=20stub=20None-defaul?= =?UTF-8?q?t=20verdict=20fields,=20honor=20retries=20downstream,=20name=20?= =?UTF-8?q?unknown=20grants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: the cc-slack lane consuming #277 found three defects. Summary: the inline-test LLM stub fills None-default fields with None; a spent_by check lets a one-shot retry of the same payload through; grant spend on an unknown id exits with its reason. Motivation: the stub filled GrantVerdict.uses with an empty string, failing every judge-stubbed hook test, and a lost reply could never be retried through cc-slack. Details: two tests cover the retry and the unknown id; Slack hook (399) and general pack (941) inline tests pass. --- captain_hook/grants/declare.py | 2 +- captain_hook/grants/store.py | 4 +++- captain_hook/testing/helpers.py | 2 +- tests/test_grants.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/captain_hook/grants/declare.py b/captain_hook/grants/declare.py index 5a6f50cc..0d3c60f3 100644 --- a/captain_hook/grants/declare.py +++ b/captain_hook/grants/declare.py @@ -258,7 +258,7 @@ def spend(self, evt: BaseHookEvent, grant: Grant, action: Proposal, reason: str, if self.spent_by is not None: at = store.now() used = store.spends(grant.id) - if (why := store.unusable(grant, used, at)) is not None: + if (why := store.unusable(grant, used, at, fingerprint(action))) is not None: raise store.SpentError(why) return Allowed(grant, store.remaining(grant, used, at), reason) reserved = _RESERVED.get() diff --git a/captain_hook/grants/store.py b/captain_hook/grants/store.py index fe413870..5ed546a2 100644 --- a/captain_hook/grants/store.py +++ b/captain_hook/grants/store.py @@ -229,7 +229,9 @@ def reserve( """ at = now() with connect() as db, immediate(db): - grant = Grant.model_validate_json(db.execute("SELECT body FROM grants WHERE id = ?", (grant_id,)).fetchone()[0]) + if (row := db.execute("SELECT body FROM grants WHERE id = ?", (grant_id,)).fetchone()) is None: + raise SpentError(f"no grant {grant_id}.") + grant = Grant.model_validate_json(row[0]) adopted = db.execute("SELECT 1 FROM adoptions WHERE grant_id = ? AND tree = ?", (grant_id, tree)).fetchone() if (grant.tree != tree and adopted is None) or grant.scope != dict(scope): raise SpentError(f"grant {grant.id} covers {grant.scope} in another session tree or destination.") diff --git a/captain_hook/testing/helpers.py b/captain_hook/testing/helpers.py index 065809f4..a304bd1e 100644 --- a/captain_hook/testing/helpers.py +++ b/captain_hook/testing/helpers.py @@ -82,7 +82,7 @@ def call_llm( values = STUB_FIELD_VALUES | self.llm return response_model( **{ - name: values.get(name, "") + name: values.get(name, None if info.default is None else "") for name, info in response_model.model_fields.items() if name in values or info.default is None } diff --git a/tests/test_grants.py b/tests/test_grants.py index 536c4fcc..51b97fae 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -572,3 +572,17 @@ def test_a_downstream_spend_refuses_another_tree(tmp_path: Path) -> None: argv += ["--session", "elsewhere", "--call", "p", "--fingerprint", "f", "--summary", "reply"] refused = CliRunner().invoke(grant_cli, argv) assert refused.exit_code == 1 and "another session tree" in refused.output + + +def test_a_downstream_spender_names_a_spent_one_shot_for_the_same_payload_again(tmp_path: Path) -> None: + grant = minted() + assert declared().check(event(tmp_path, "same")) + again = declared(spent_by="cc-slack").check(event(tmp_path, "same", call="toolu_2")) + assert isinstance(again, Allowed) and again.grant.id == grant.id + assert isinstance(declared(spent_by="cc-slack").check(event(tmp_path, "other", call="toolu_3")), Denied) + + +def test_spending_an_unknown_grant_names_it(tmp_path: Path) -> None: + argv = ["spend", "000000000000", "--scope", "channel=C1", "--scope", "thread=1.2", "--tree", TREE] + refused = CliRunner().invoke(grant_cli, [*argv, "--session", TREE, "--call", "p", "--fingerprint", "f", "--summary", "s"]) + assert refused.exit_code == 1 and "no grant 000000000000" in refused.output