Skip to content

feat(core)!: give scheduled jobs their own permission posture - #244

Merged
oratis merged 1 commit into
mainfrom
feat/trigger-profile
Aug 8, 2026
Merged

feat(core)!: give scheduled jobs their own permission posture#244
oratis merged 1 commit into
mainfrom
feat/trigger-profile

Conversation

@oratis

@oratis oratis commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

PR 7 of docs/FLOATBOAT_ADOPTION_PLAN.md §2.E. Completes the security fix #237 deliberately left half-done.

⚠️ BREAKING. An unattended run no longer inherits a permissive permissions.defaultMode. bypassPermissions and acceptEdits are clamped to default unless the job says otherwise.

Why break it

A cron job reads the same settings.json you use interactively. A bypassPermissions chosen for REPL convenience silently became the posture of every job firing at 3am with nobody watching.

Those are different decisions. Choosing "never ask me" while sitting there watching is not the same as choosing it for a run nobody sees. They shouldn't share one switch.

#237 shipped a warning for this and explicitly deferred the clamp, because clamping without an opt-in is just breakage. The opt-in now exists, so the clamp lands with it.

The opt-in

{ "schedule": "0 3 * * *", "prompt": "...", "profile": { "mode": "bypassPermissions" } }

Or via CronCreate's new mode / sandbox arguments. An explicit profile.mode is honoured as written, including a permissive one — that's what makes the clamp a correction rather than a restriction.

Everything else only tightens

Field Rule
mode Explicit value wins outright
permissions Denies and asks union; allows intersect
sandbox Applied only when stricter than ambient

Same one-way property as the file contract, for the same reason: a mechanism that can only tighten cannot reduce existing safety, whatever a profile author writes. A profile listing allow: ["Read", "Bash"] against ambient allow: ["Read"] yields ["Read"] — there's a test for exactly that.

An unrecognised sandbox value in a hand-edited cron.json is ignored rather than trusted.

The clamp announces itself

[job] mode=default onApprovalRequired=deny
[job] Permission mode "bypassPermissions" was not applied to this unattended run — it is
      inherited from settings and nobody is present to approve. Running as "default".
      Set the job's profile.mode explicitly to opt back in.

A silent clamp is as surprising as a silent grant, just in the other direction.

Migration

Jobs that relied on the inherited bypass need profile.mode set explicitly. The job log names the exact fix on the first run after upgrading, so this surfaces itself rather than failing mysteriously.

Test plan

  • pnpm test1401 passed, 16 skipped (+16 core)
  • pnpm typecheck · pnpm lint · pnpm format:check · pnpm build · node scripts/check-docs.mjs
Area Cases
Clamp Both permissive modes clamped; all four non-permissive modes untouched
Opt-in Explicit permissive profile.mode honoured; a stricter profile mode also wins
Announcement Clamp message names the mode and the fix; silent when no clamp
One-way permissions Denies/asks union; allows intersect; a profile cannot introduce an allow ambient lacked; ambient allows survive a deny-only profile; dedupe
One-way sandbox Takes the stricter; refuses to loosen in both directions; hand-edited garbage ignored, not trusted

Documentation

  • docs/quickstart.md — rewrote the scheduled-jobs caveat into "The permission posture of a scheduled job": the clamp, why the two decisions differ, the opt-in table, and the tighten-only rule

Release notes label

  • release-notes:breaking — unattended runs stop inheriting permissive modes

Related

Plan §2.E (PR 7). Completes the deferred half of #237. Research: docs/research/floatboat.md §3.1(a) — mechanism borrowed, design entirely our own.

🤖 Generated with Claude Code

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: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit bb74ee3 into main Aug 8, 2026
5 checks passed
@oratis
oratis deleted the feat/trigger-profile branch August 8, 2026 10:22
oratis added a commit that referenced this pull request Aug 8, 2026
Records the threat-model delta, marks the adoption plan implemented with an
honest deviation log, and bumps to 0.3.0 (minor, not patch: the unattended
permission clamp in #244 is breaking on 0.x).

security-model.md gains three threats (#8 in-project secret reads, #9 unattended
permissive inheritance, #10 no audit or undo) and a residual-risk section
stating plainly what the file contract is not. It constrains dispatcher tool
calls, not Bash; path normalization is string math, not realpath. Describing it
as secret protection would be the false confidence the plan named as this
feature's main risk.

The adoption plan now carries a deviation table rather than a claim of clean
execution. Six entries, including two where the plan was simply wrong: PR 0's
premise that unattended runs might silently allow (the ask path was already
fail-closed), and the four-client consistency test (only two hosts resolve
policy independently; VS Code and LSP consume the server's answer, and the
tests say so instead of implying four verified paths). Two P2 items remain
undone and are listed as such.

Found while bumping: the repo's own version-consistency test caught Cargo.lock
still pinning 0.2.0, a sixth location RELEASING.md's four-item list never
mentioned. Both fixed.

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