feat(core): give unattended runs an explicit approval policy - #237
Merged
Conversation
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>
4 tasks
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>
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
runHeadlessalready installsapproval: async () => false, so "no callback" and "no human" were never the same question — and the resulting refusal read as the genericrequires approval, which in an unattended log looks transient when it is in fact terminal.runAgentnow takes two options:unattendedonApprovalRequireddeny(default) refuses the call and continues — exactly what every host did before.abortstops the run withstopReason: 'blocked', exit code 6abortis 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
askpath 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. AbypassPermissionsset 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 (--modeis a deliberate per-run choice, so it stays quiet). The clamp itself is not in this PR — it needsTriggerProfileto exist as the opt-in, so it lands with PR 7 and will be marked breaking there.2. Aborting mid-batch left
tool_useblocks 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 listed3as "Tool denied by permissions" and5as "API key invalid", and called 3–5 "reserved for M3+". The code anddocs/quickstart.mdhave always said3api/provider ·4max-turns ·5aborted. Corrected to matchapps/cli/src/headless.ts, which owns the contract, since I'm adding6to that same table.Test plan
pnpm test— 1173 passed, 16 skipped (was 1156 + 16; +9 core, +8 cron)pnpm typecheck·pnpm lint(--max-warnings=0) ·pnpm format:check·pnpm buildnode scripts/check-docs.mjsNew coverage:
denyis the defaultunattendedabortstops the runstopReason: 'blocked', and the refusal is still in historyabortis inert when nothing needs approvalresolveUnattendedApprovaldefaultsdenycron.json'ABORT'falls back todeny, never widensabortsurvives save/loadDocumentation
docs/quickstart.md— new Scheduled jobs section covering the policy table and the inherited-defaultModecaveatdocs/cli-flags.md— exit-code table corrected +6addedRelease notes label
release-notes:feature— newonApprovalRequiredoption and exit code 6; the default preserves existing behaviour, so nothing breaksChecklist
Related
Plan:
docs/FLOATBOAT_ADOPTION_PLAN.md§2.E / §4 (PR 0). Research:docs/research/floatboat.md§3.1(a).🤖 Generated with Claude Code