Skip to content

sessions: ✨ Spend grants for owner-approved session stops - #278

Merged
yasyf merged 2 commits into
mainfrom
feat/sessions-grants
Oct 3, 2026
Merged

yasyf merged 2 commits into
mainfrom
feat/sessions-grants

Conversation

@yasyf

@yasyf yasyf commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Why

The owner chose the service and task policy:

Add an owner-named lift for launchctl/TaskStop

After a failed launch left an orphan Orca terminal, the owner said:

I'll close it but update the tooling so you could close it too when appropriate

The unreleased # ccx:owner-authorized=<cc-notes answer id> annotation let an answer
naming a terminal authorize unlimited closes, including when the answer was written
during the acting session. Use grants to count and record each approval's use, and
let a session clean up an idle terminal it created when no dispatch owns it.

This PR stacks on #277.

What

  • Replace the close annotation and its ccn answer show lookup with three declarations
    without a judge: sessions.close scopes to terminal, sessions.launchctl to the
    launchd service label, and sessions.task-stop to task (task_id or the retired
    shell_id). The budget is one use per ruling and scope across all session trees,
    with the default one-day expiry. capt-hook grant show <id> shows the spend.
    All three set replay=timedelta(minutes=2): the identical scope and payload can
    reuse a committed spend for less than two minutes, covering PermissionRequest
    after PreToolUse for the same call or a prompt retry. A later identical command
    needs a new approval.
  • Read Rulings(search=<scope value>) for all three. The answer body must name the value
    as a whole name, and updated_at must predate the acting session's first transcript
    line. A preceding word character, ., or - prevents a match, as does a following
    word character, -, or . followed by a word character. com.x.helper does not
    name com.x; a sentence-ending period counts. Evidence keys are
    ccn:<answer id>@<updated_at>. Judge-less grants key on <key>#<scope json> and
    look across every tree, so an answer naming two terminals mints two grants, but
    another tree cannot mint a fresh grant for the same scope. Live evidence rechecks
    the revision before a stored grant spends; editing the answer invalidates that
    grant. capt-hook grant import also marks cc-notes evidence live=True.
  • Add the live CreatedHere evidence source for terminal closes. The session's own transcript
    must show orca terminal create as the only command in its Bash call. It must
    use bare orca without an environment prefix or substitution. ./orca and
    other paths do not qualify. The output must return the handle in
    result.terminal.handle. An orca-launch.sh <lane> ... call instead needs its
    own output to contain terminal=<handle> and a matching
    ~/.claude/scratch/orca-launch/*/<lane>.terminal.json receipt with mtime between
    call start minus one second and result plus one second. The worker-list probe drops
    ORCA_TERMINAL_HANDLE and checks every Run for a dispatch naming the terminal.
    tui-idle must succeed within one second; the screen must show Claude's or Codex's
    idle prompt among its last eight nonblank, non-border lines, with no busy marker.
    The last spoken line before the prompt must not end in a question mark. The screen
    markers come from monorepo's .agents/skills/orca/scripts/orca-gc.
  • Accept --tab on a literal single-terminal close only when Orca's visual layout
    proves that pane is alone; otherwise, say "Drop --tab". Terminals whose process
    trees host no agent still close directly. A batch stays blocked even if a ruling
    names every terminal.
  • Lift a terminal close or launchctl stop only when it runs once: the guarded call
    is first at top level, the Bash payload text starts with it, and every other
    command is a pipe successor. 2>&1 | tail -3 is allowed; loops, && or ; chains
    such as cd /tmp && launchctl ..., background &, sh -c, substitutions, and
    respelled variables keep the block. TaskStop runs once as a tool call.
  • Lift only literal service-targeted launchctl bootout, kickstart, disable,
    kill, stop, and remove, with no wrapper, substitution, or variable. Targets are
    system/<label> or <domain>/<id>/<label> for gui, user, pid, or login;
    kill takes a signal first, and stop and remove also accept a bare label.
    Domain targets, reboot, asuser, bsexec, and unload stay blocked, as do all
    other session-guard denials, including process signals, worktree removal, and
    worker-stop.
  • Name the remedy in the denial: close an idle orphan this session created, or ask
    the owner for a cc-notes answer naming the target for a later session. Spent grants,
    stale rulings, and failed checks reach the user through systemMessage prefixed
    with sessions: . Document the owner's capt-hook grant add path too.
  • Make Grants.action optional for handlers that pass a Proposal to check(evt, action).
    hook(..., grants=...) still requires an action function. A declaration without a
    judge mints from its first collected evidence item and accepts a matching stored
    grant after validity and rule checks; Asked() and OwnerWords() without a judge
    raise ValueError. Evidence.live generalizes the old cc-notes revision check.
    Grants.grant(..., across_trees=True) passes that option to store.mint for the
    judge-less mint. If another tree reaches for the same approval and scope without
    adopting the grant, it is refused as a grant covering the scope in another tree.
    Grants.replay: timedelta defaults to timedelta.max; store.retried,
    store.unusable, store.matching, and store.reserve take the replay window.

Review

An astra finder pass raised nine findings. Fixed: identical retries have a
two-minute window, and loops cannot run a lifted command twice. ./orca cannot
forge creation output. Imported grants keep the revision recheck. com.x does not
match com.x.helper. A ruling cannot mint a fresh grant for the same scope in every
session tree.

Accepted: owner rulings and self-created idle terminals are separate lifts under
the owner's ruling, not cumulative requirements. A deliberately forged and
backdated launch receipt is outside the threat model: the guard catches agent
mistakes, and the proof also needs the launch call's own output and the receipt
mtime within the call's window. launchctl remove stays liftable as a service-label
unload equivalent to bootout.

Tests

  • Inline sessions.py and stops.py cases cover rulings, a stored grant, launchctl
    and TaskStop allows and denials, answers written during the session, creation and
    idle checks, dispatch ownership, and lone, crowded, or unreadable tabs. Added rows
    allow a launchctl lift piped through | tail -3 and refuse a launchctl for loop,
    a cd /tmp && launchctl ... chain, a ruling naming com.example.orca-serve.helper
    for com.example.orca-serve, ./orca terminal create as creation proof, and a loop
    of terminal closes.
  • tests/test_pack_sessions.py covers recorded close and TaskStop spends, a second
    close changed to --tab reporting was spent, wrong or late rulings, unreadable
    cc-notes evidence, blocked batches, created:<handle> spend evidence, creation and
    idle refusals, and launch receipts written during versus after the call.
  • tests/test_grants.py covers whole-name rulings, minting and exhaustion without a
    judge, a deny with an empty reason, stale live evidence, OwnerWords needing
    a judge, and attachments needing an action. It also covers one judge-less approval
    covering each named scope once across trees and the replay window bounding free
    retries. tests/test_guard_literal.py asserts
    that the fast path flags exactly the TaskStop rows, including the allowed rows.
  • capt-hook --hooks captain_hook/builtin_packs/general/hooks test: 967 passed.
  • pytest -p no:randomly tests/test_grants.py tests/test_pack_sessions.py tests/test_guard_literal.py tests/test_dispatch_mandatory.py: 363 passed.

@yasyf
yasyf force-pushed the feat/sessions-grants branch from dba631d to c8aaebf Compare October 3, 2026 04:43
yasyf added 2 commits October 2, 2026 21:48
Context: the unreleased owner-close annotation allowed unlimited reuse and accepted cc-notes answers written during the acting session, and failed launches left idle Orca terminals their creating session could not close.
Summary: add sessions.close, sessions.launchctl, and sessions.task-stop grants minted from rulings that name the target and predate the acting session, plus self-created idle terminal evidence for closes; each ruling and scope is one use across all trees.
Motivation: the owner asked for an owner-named lift for launchctl and TaskStop, and for sessions to close idle orphan terminals they created; every other session-guard block keeps its policy.
Details: a lifted close or launchctl stop must run once at the head of the payload; Grants gains an optional action, explicit proposals, judge-less mints, live evidence rechecks, and a replay window the session kinds set to two minutes.

Claude-Session-Id: 900424b6-7393-480c-a26a-f1bd21da6e57
Context: the hook lint forbids a catch-all in a pack hook, and the sessions lift carried one.
Summary: Grants.decide runs check and turns any error into a Denied naming it; lifted and the sessions lift both use it.
Motivation: test shard 6 failed on the swallows-failures lint at _sessions.py.
Details: the system_message keeps the hook name and the error; would_allow follows it.
@yasyf
yasyf force-pushed the feat/sessions-grants branch from c8aaebf to 1c0a893 Compare October 3, 2026 04:48
@yasyf
yasyf changed the base branch from feat/grants-owner-asks to main October 3, 2026 04:48
@yasyf yasyf closed this Oct 3, 2026
@yasyf yasyf reopened this Oct 3, 2026
@yasyf
yasyf merged commit 92e77fc into main Oct 3, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant