Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions captain_hook/grants/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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}))
13 changes: 11 additions & 2 deletions captain_hook/grants/declare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand All @@ -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")

Expand Down Expand Up @@ -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(
Expand All @@ -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, fingerprint(action))) 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(
Expand Down
5 changes: 4 additions & 1 deletion captain_hook/grants/judge.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
refused. Set withdrawn when the owner's words in <owner_since_grant> 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.
"""

Expand All @@ -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


Expand Down
10 changes: 10 additions & 0 deletions captain_hook/grants/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
51 changes: 45 additions & 6 deletions captain_hook/grants/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -216,8 +229,11 @@ 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):
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.")
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]
Expand Down Expand Up @@ -263,6 +279,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()})
Expand Down
2 changes: 1 addition & 1 deletion captain_hook/testing/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading