Skip to content

feat(core): give unattended runs an explicit approval policy - #237

Merged
oratis merged 1 commit into
mainfrom
feat/cron-unattended-approval
Aug 8, 2026
Merged

feat(core): give unattended runs an explicit approval policy#237
oratis merged 1 commit into
mainfrom
feat/cron-unattended-approval

Conversation

@oratis

@oratis oratis commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

PR 0 of docs/FLOATBOAT_ADOPTION_PLAN.md §4 — the one slice that is an independent security fix and depends on nothing else in that plan.

Scheduled jobs and headless CI runs have no approver, but the loop expressed that only as a missing approval callback. runHeadless already installs approval: async () => false, so "no callback" and "no human" were never the same question — and the resulting refusal read as the generic requires approval, which in an unattended log looks transient when it is in fact terminal.

runAgent now takes two options:

Option Meaning
unattended Stated by the host, not inferred from a missing callback
onApprovalRequired deny (default) refuses the call and continues — exactly what every host did before. abort stops the run with stopReason: 'blocked', exit code 6

abort is the option that didn't exist. A job whose first write is refused otherwise keeps looping against a wall, burns tokens, and reports a confidently wrong result.

Correctly scoped, per the plan

The plan claimed unattended runs might "silently allow". That was wrong and I'm not shipping it as written — the ask path already fails closed. What's actually missing is (a) the ability to stop rather than limp on, and (b) visibility. Both are here.

The real inherited-permission problem is narrower and stated honestly below.

Two things found while wiring it up

1. Unattended runs inherit permissions.defaultMode. A bypassPermissions set for interactive convenience silently becomes the posture of every 3am job. Headless now warns when the mode is inherited from settings rather than passed for this run (--mode is a deliberate per-run choice, so it stays quiet). The clamp itself is not in this PR — it needs TriggerProfile to exist as the opt-in, so it lands with PR 7 and will be marked breaking there.

2. Aborting mid-batch left tool_use blocks unanswered. A provider rejects that message sequence on resume. Tool results now flush through one helper that synthesizes "Tool call not executed: the run stopped before reaching it." for the remainder. This was caught by a test that asserted the transcript explains why the run stopped — it didn't, at first.

Drive-by doc fix

docs/cli-flags.md's exit-code table contradicted the implementation: it listed 3 as "Tool denied by permissions" and 5 as "API key invalid", and called 3–5 "reserved for M3+". The code and docs/quickstart.md have always said 3 api/provider · 4 max-turns · 5 aborted. Corrected to match apps/cli/src/headless.ts, which owns the contract, since I'm adding 6 to that same table.

Test plan

  • pnpm test1173 passed, 16 skipped (was 1156 + 16; +9 core, +8 cron)
  • pnpm typecheck · pnpm lint (--max-warnings=0) · pnpm format:check · pnpm build
  • node scripts/check-docs.mjs

New coverage:

Test Asserts
deny is the default Call refused, run continues, reason says unattended
abort stops the run stopReason: 'blocked', and the refusal is still in history
abort is inert when nothing needs approval No false positives
Attended runs untouched The approval callback is still consulted
resolveUnattendedApproval defaults Jobs stored before the field keep deny
Hand-edited typo in cron.json 'ABORT' falls back to deny, never widens
Store round-trip An explicit abort survives save/load

Documentation

  • Updated docs/quickstart.md — new Scheduled jobs section covering the policy table and the inherited-defaultMode caveat
  • Updated docs/cli-flags.md — exit-code table corrected + 6 added

Release notes label

  • release-notes:feature — new onApprovalRequired option and exit code 6; the default preserves existing behaviour, so nothing breaks

Checklist

  • PR 标题是 conventional commits 格式
  • 所有 commits 都是 conventional commits 格式
  • 添加了对应测试
  • CI 全绿(待 CI 运行)

Related

Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.E / §4 (PR 0). Research: docs/research/floatboat.md §3.1(a).

🤖 Generated with Claude Code

Scheduled jobs and headless CI runs have no approver, but the loop expressed
that only as a missing callback — which headless already fills with an
auto-deny, so "no callback" and "no human" were not the same question.

runAgent now takes `unattended` (stated by the host, not inferred) and
`onApprovalRequired`:

- `deny` (default) refuses the call and continues — what every host did before.
- `abort` stops the run with stopReason `blocked`, exit code 6. A job whose
  first write was refused otherwise grinds on and reports a confidently wrong
  result.

CronJob carries the policy; reads go through resolveUnattendedApproval so jobs
stored before the field keep the old behaviour, and a hand-edited typo in
cron.json falls back to deny rather than widening it.

Two things found while wiring this up:

- An unattended run inherits `permissions.defaultMode` from the same settings
  file used interactively, so a convenience `bypassPermissions` silently becomes
  the posture of every 3am job. Headless now warns when the mode is inherited
  rather than passed for this run. The clamp itself needs TriggerProfile to have
  an opt-in, so it lands later.
- Aborting mid-batch left `tool_use` blocks unanswered, which a provider rejects
  on resume. Tool results are now flushed through one helper that synthesizes
  "never ran" entries for the remainder.

docs/cli-flags.md's exit-code table contradicted the implementation (it listed
3 as "tool denied" and 5 as "API key invalid"); corrected to match headless.ts,
which owns the contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit 40d0f0f into main Aug 8, 2026
5 checks passed
@oratis
oratis deleted the feat/cron-unattended-approval branch August 8, 2026 09:46
oratis added a commit that referenced this pull request Aug 8, 2026
BREAKING CHANGE: an unattended run no longer inherits a permissive
permissions.defaultMode. bypassPermissions and acceptEdits are clamped to
default unless the job's profile.mode says otherwise. Jobs that relied on the
inherited bypass must set profile.mode explicitly.

A cron job reads the same settings.json used interactively, so a
bypassPermissions chosen for REPL convenience silently became the posture of
every job firing at 3am with nobody watching. Those are different decisions and
should not share one switch. PR #237 warned about this; the clamp waited until
there was an opt-in to point at.

TriggerProfile carries mode, permissions and sandbox per job:

- mode is honoured as written, including a permissive value. That opt-in is what
  makes the clamp safe rather than merely restrictive.
- permissions only tighten: denies and asks union, allows intersect. Same
  one-way property as the file contract, for the same reason — a mechanism that
  can only tighten cannot reduce existing safety whatever the user writes.
- sandbox applies only when stricter than ambient. An unrecognised value in
  hand-edited cron.json is ignored rather than trusted.

The clamp is announced in the job log. A silent clamp is as surprising as a
silent grant, just in the other direction.

Co-authored-by: oratis <happyllammar@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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