Skip to content

feat(automation): let a routine keep a standing grant for a tool with no target - #645

Open
djpentz wants to merge 1 commit into
andrewyng:mainfrom
djpentz:feat/task-tool-grants
Open

feat(automation): let a routine keep a standing grant for a tool with no target#645
djpentz wants to merge 1 commit into
andrewyng:mainfrom
djpentz:feat/task-tool-grants

Conversation

@djpentz

@djpentz djpentz commented Sep 6, 2026

Copy link
Copy Markdown

The problem

"Allow every time" on a scheduled run's approval card calls mint_task_rule, which
requires standing_rule_candidate — external risk and a declared target argument
and a non-empty target. A routine whose work is web_search satisfies none of that,
so the grant is refused and quietly downgraded to a one-off (ApprovalOutcome.ONCE),
while POST /v1/inbox/{id}/resolve still returns {"ok": true}.

The practical effect: the routine asks the same question on every run, forever. The
session-scoped ALWAYS_TOOL doesn't help either — each scheduled run is a fresh session,
so "always" lasts one run. There is no other path: PATCH /v1/automations/{id} can
revoke a rule but cannot add one.

We hit this with a daily monitoring routine on a headless deployment. It asked to search
the web at 07:00 every morning for four days running; answering "Always" changed nothing,
because the refusal is silent.

Why the refusal looks upside down

From risk.py:

_STRICTNESS = {READ: 0, EGRESS: 1, EXTERNAL: 2, WRITE_LOCAL: 3, EXEC: 3}

Egress ranks below external. Yet an external call earns a durable, unattended
standing rule the moment it names a target, while an egress call can never earn one.
A routine can be trusted forever to send a message off the machine, but not to read a
web page. The target binding is what makes the external grant safe — and a tool that
takes no destination (a search takes a query) simply fell outside that shape.

The change

standing_tool_candidate(tool) — egress only — gates a new target-less grant.
ScheduledTask.add_tool_rule(tool) appends a name-only entry to the same
always_allowed_tools list the engine already reads on every run via
_seed_task_permissions, so nothing new has to be honoured anywhere.

  • Egress with no bindable target → tool-scoped grant for that task.
  • External → unchanged, still target-bound. "Message this chat" is a grant; "send
    anything anywhere" is not.
  • Write-local and exec → unchanged. Shell asks forever.
  • Revoking is unchanged: the entry appears in public()["always_allowed"] with
    target: null and PATCH {"revoke": entry} removes it.

Scope is one task. A grant on the daily sweep says nothing about any other routine or
any interactive session.

Tests

tests/test_task_tool_grants.py — the gate admits egress and refuses exec/write; a
web_search grant persists on the task, reads back with a null target, and revokes; a
run_shell call still earns nothing. The persistence test fails against main.

Full suite on this branch: 1955 passed, 1 skipped. (Three deselected locally — they bind
the fixed OAuth callback port, which a container on this machine holds; unrelated to
this change.)


🤖 Generated with Claude Code

https://claude.ai/code/session_01PgGyV9GDu4oLhonom37BU5

…rget

"Allow every time" on a run's approval card mints a standing rule on the task, but
only when the call is external-risk AND names a target. A routine whose work is web
search therefore asks the same question on every single run: the session-scoped
"always" dies with the run, and each scheduled run is a fresh session.

The refusal is upside down against the engine's own strictness table. Egress ranks
1, external ranks 2 — so a routine can be trusted forever to send a message off the
machine, but never to read a web page. The target binding is what makes an external
grant safe; a tool that takes no destination simply fell outside the shape and got
downgraded to a one-off, silently, while the API reported success.

Egress calls with no bindable target now mint a name-only rule on the task —
`ScheduledTask.add_tool_rule`, the same list, read by _seed_task_permissions on
every run and revocable through the existing PATCH …/{id} {"revoke": entry}.
Write-local and exec are unchanged (shell asks forever), and external stays
target-bound: "message this chat" is a grant, "send anything anywhere" is not.

Found running a daily monitoring routine on a headless box: it asked permission to
search the web every morning for four days, and "Always" could not stop it.
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