diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 2581d7b..5072135 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -50,6 +50,8 @@ import { type AgentEvent, type Effort, isPermissiveMode, + tightenPermissions, + type PermissionRules, type McpClientHandle, type Mode, type UnattendedApprovalPolicy, @@ -94,6 +96,17 @@ export interface HeadlessOpts { * half its tool calls refused has usually produced a misleading result. */ onApprovalRequired?: UnattendedApprovalPolicy; + /** + * Extra permission rules for this run, applied so they can only tighten the + * ones loaded from settings. Used by the scheduler to give a job its own + * bounded posture. + */ + permissionsOverride?: PermissionRules; + /** + * Suppress the inherited-permissive-mode warning because the caller already + * resolved the mode deliberately (and says so in its own log). + */ + modeResolvedByCaller?: boolean; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools. Be concise and accurate. When you modify files, briefly explain what you changed and why.`; @@ -149,7 +162,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { // that reads the same settings file — including scheduled jobs firing at 3am. // Passing `--mode` explicitly is a deliberate choice for this run, so only the // inherited case is worth a warning. - if (!opts.mode && isPermissiveMode(mode)) { + if (!opts.mode && !opts.modeResolvedByCaller && isPermissiveMode(mode)) { errOutput.write( `Warning: this unattended run inherits permissions.defaultMode="${mode}" from settings, ` + `so tool calls execute without approval. Pass --mode default to override.\n`, @@ -159,6 +172,10 @@ export async function runHeadless(opts: HeadlessOpts): Promise { const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; const maxTurns = opts.maxTurns ?? DEFAULT_HEADLESS_MAX_TURNS; + // Only ever tightens — a job profile can narrow what is auto-approved but + // never widen it (see tightenPermissions). + const effectivePermissions = tightenPermissions(settings.permissions, opts.permissionsOverride); + const provider = new DeepSeekProvider({ apiKey: creds.apiKey ?? '', authToken: creds.authToken, @@ -320,7 +337,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { tools, cwd, mode, - permissions: settings.permissions, + permissions: effectivePermissions, hooks, pluginDirs: pluginContrib.dirs, autoMode: settings.autoMode, diff --git a/apps/cli/src/scheduler.ts b/apps/cli/src/scheduler.ts index 8c6305d..f18b5cd 100644 --- a/apps/cli/src/scheduler.ts +++ b/apps/cli/src/scheduler.ts @@ -15,7 +15,11 @@ import { listCronJobs, loadCronStore, saveCronStore, + describeClamp, + loadSettings, + resolveTriggerMode, resolveUnattendedApproval, + tightenSandbox, uninstallPlist, type CronJob, } from '@deepcode/core'; @@ -73,8 +77,20 @@ async function defaultRunJob(job: CronJob, home: string): Promise { const log = createWriteStream(logPath, { flags: 'a' }); try { const onApprovalRequired = resolveUnattendedApproval(job); + // Resolve the posture here rather than letting headless inherit it, so the + // job log states what it ran as instead of leaving it implied. + const { merged } = await loadSettings({ cwd: job.cwd, home }); + const resolvedMode = resolveTriggerMode( + job.profile, + merged.permissions?.defaultMode ?? 'default', + ); + const sandbox = tightenSandbox(merged.sandbox?.mode, job.profile?.sandbox); + log.write(`\n===== ${new Date().toISOString()} =====\n`); - log.write(`[job] onApprovalRequired=${onApprovalRequired}\n`); + log.write(`[job] mode=${resolvedMode.mode} onApprovalRequired=${onApprovalRequired}\n`); + const clamp = describeClamp(resolvedMode); + if (clamp) log.write(`[job] ${clamp}\n`); + const code = await runHeadless({ output: log, errOutput: log, @@ -83,6 +99,10 @@ async function defaultRunJob(job: CronJob, home: string): Promise { prompt: job.prompt, outputFormat: 'text', onApprovalRequired, + mode: resolvedMode.mode, + modeResolvedByCaller: true, + ...(sandbox ? { sandbox } : {}), + ...(job.profile?.permissions ? { permissionsOverride: job.profile.permissions } : {}), }); // Exit 6 means the run stopped because a call needed an approver. Surface it // as a failure so the scheduler log does not read like a clean run. diff --git a/docs/quickstart.md b/docs/quickstart.md index 42cbf83..8ed5221 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -73,11 +73,26 @@ cannot be answered. Each job chooses what happens then: Pick `abort` when a partially-executed job is worse than no job — a run whose first write was refused usually produces a confidently wrong summary otherwise. -One thing to check before relying on a scheduled job: it reads the same -`settings.json` you use interactively, so `permissions.defaultMode` carries over. -If you set `bypassPermissions` for your own convenience, every scheduled job -inherits it and executes without approval. DeepCode prints a warning to the job -log when that happens; pass `--mode default` to opt a run out. +### The permission posture of a scheduled job + +A job reads the same `settings.json` you use interactively. A permissive +`permissions.defaultMode` — `bypassPermissions` or `acceptEdits` — chosen for +REPL convenience is **not** inherited by unattended runs: DeepCode clamps it to +`default` and says so in the job log. + +Those are different decisions. Choosing "never ask me" while you're sitting +there watching is not the same as choosing it for a run at 3am that nobody sees. + +To opt back in, say so per job: + +| Field | Effect | +| --------- | ------------------------------------------------------------------------------ | +| `mode` | Permission mode for this job, honoured as written — including a permissive one | +| `sandbox` | Sandbox for this job; applied only when **stricter** than ambient | + +Any extra permission rules on a job can only tighten: denies and asks are added, +allows are intersected. A job profile can narrow what runs without approval, and +can never widen it. --- diff --git a/packages/core/src/cron/index.ts b/packages/core/src/cron/index.ts index dc380fa..d87e031 100644 --- a/packages/core/src/cron/index.ts +++ b/packages/core/src/cron/index.ts @@ -4,6 +4,7 @@ // Spec: docs/DEVELOPMENT_PLAN.md §3.15.4 / §0.1 (CronCreate family) import { promises as fs } from 'node:fs'; +import type { TriggerProfile } from './profile.js'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -34,6 +35,12 @@ export interface CronJob { * `resolveUnattendedApproval` instead of touching the field directly. */ onApprovalRequired?: UnattendedApprovalPolicy; + /** + * Permission posture for this job, independent of the interactive settings it + * would otherwise inherit. Absent means "use the ambient settings, clamped" — + * see `resolveTriggerMode`. + */ + profile?: TriggerProfile; } /** The effective policy for a job, defaulting to the historical `deny`. */ @@ -79,6 +86,7 @@ export async function addCronJob( prompt: string; cwd: string; onApprovalRequired?: UnattendedApprovalPolicy; + profile?: TriggerProfile; }, home: string = homedir(), ): Promise { @@ -93,6 +101,7 @@ export async function addCronJob( createdAt: new Date().toISOString(), enabled: true, ...(job.onApprovalRequired ? { onApprovalRequired: job.onApprovalRequired } : {}), + ...(job.profile ? { profile: job.profile } : {}), }; store.jobs.push(created); await saveCronStore(store, home); @@ -197,3 +206,12 @@ export function isCronDue(schedule: string, date: Date): boolean { export function dueJobs(jobs: CronJob[], now: Date): CronJob[] { return jobs.filter((j) => j.enabled && isCronDue(j.schedule, now)); } + +export { + describeClamp, + resolveTriggerMode, + tightenPermissions, + tightenSandbox, + type ResolvedTriggerMode, + type TriggerProfile, +} from './profile.js'; diff --git a/packages/core/src/cron/profile.test.ts b/packages/core/src/cron/profile.test.ts new file mode 100644 index 0000000..3d04bfe --- /dev/null +++ b/packages/core/src/cron/profile.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { + describeClamp, + resolveTriggerMode, + tightenPermissions, + tightenSandbox, +} from './profile.js'; +import type { Mode } from '../types.js'; + +describe('resolveTriggerMode', () => { + it('clamps an inherited permissive mode to default', () => { + // The whole point: `bypassPermissions` chosen for REPL convenience must not + // silently become the posture of a job that fires with nobody watching. + for (const ambient of ['bypassPermissions', 'acceptEdits'] as Mode[]) { + const resolved = resolveTriggerMode(undefined, ambient); + expect(resolved).toMatchObject({ mode: 'default', clamped: true, ambient }); + } + }); + + it('leaves a non-permissive ambient mode alone', () => { + for (const ambient of ['default', 'plan', 'dontAsk', 'auto'] as Mode[]) { + expect(resolveTriggerMode(undefined, ambient)).toMatchObject({ + mode: ambient, + clamped: false, + }); + } + }); + + it('honours an explicit profile mode, including a permissive one', () => { + // This is the opt-in that makes the clamp safe to have: users who really do + // want an unattended bypass can still say so, deliberately, per job. + const resolved = resolveTriggerMode({ mode: 'bypassPermissions' }, 'default'); + expect(resolved).toMatchObject({ mode: 'bypassPermissions', clamped: false }); + }); + + it('lets a profile pick a stricter mode than ambient', () => { + expect(resolveTriggerMode({ mode: 'plan' }, 'bypassPermissions').mode).toBe('plan'); + }); + + it('explains a clamp, and says nothing when there was none', () => { + // A silent clamp is as surprising as a silent grant, just in the other + // direction. + const clamped = describeClamp(resolveTriggerMode(undefined, 'bypassPermissions')); + expect(clamped).toContain('bypassPermissions'); + expect(clamped).toContain('profile.mode'); + expect(describeClamp(resolveTriggerMode(undefined, 'default'))).toBeUndefined(); + }); +}); + +describe('tightenPermissions', () => { + it('returns the ambient rules untouched without a profile', () => { + const ambient = { allow: ['Read'], deny: ['Bash'] }; + expect(tightenPermissions(ambient, undefined)).toBe(ambient); + }); + + it('unions denies — either source may add a restriction', () => { + const out = tightenPermissions({ deny: ['Bash'] }, { deny: ['Write'] }); + expect(out?.deny?.sort()).toEqual(['Bash', 'Write']); + }); + + it('unions asks', () => { + const out = tightenPermissions({ ask: ['Write'] }, { ask: ['Edit'] }); + expect(out?.ask?.sort()).toEqual(['Edit', 'Write']); + }); + + it('intersects allows, so a profile can narrow but never widen', () => { + const out = tightenPermissions({ allow: ['Read', 'Grep', 'Write'] }, { allow: ['Read'] }); + expect(out?.allow).toEqual(['Read']); + }); + + it('cannot introduce an allow the ambient rules did not have', () => { + // The one-way property. Whatever a profile author writes, the result is + // never more permissive than the settings already were. + const out = tightenPermissions({ allow: ['Read'] }, { allow: ['Read', 'Bash'] }); + expect(out?.allow).toEqual(['Read']); + }); + + it('keeps the ambient allows when the profile names none', () => { + const out = tightenPermissions({ allow: ['Read'] }, { deny: ['Bash'] }); + expect(out?.allow).toEqual(['Read']); + }); + + it('deduplicates', () => { + const out = tightenPermissions({ deny: ['Bash'] }, { deny: ['Bash'] }); + expect(out?.deny).toEqual(['Bash']); + }); +}); + +describe('tightenSandbox', () => { + it('takes the stricter of the two', () => { + expect(tightenSandbox('danger-full-access', 'workspace-write')).toBe('workspace-write'); + expect(tightenSandbox('workspace-write', 'read-only')).toBe('read-only'); + }); + + it('refuses to loosen', () => { + expect(tightenSandbox('read-only', 'danger-full-access')).toBe('read-only'); + expect(tightenSandbox('workspace-write', 'danger-full-access')).toBe('workspace-write'); + }); + + it('falls back sensibly when one side is absent', () => { + expect(tightenSandbox(undefined, 'read-only')).toBe('read-only'); + expect(tightenSandbox('read-only', undefined)).toBe('read-only'); + expect(tightenSandbox(undefined, undefined)).toBeUndefined(); + }); + + it('ignores an unrecognised profile value rather than trusting it', () => { + // cron.json is a plain file a user can hand-edit; a typo must not widen. + expect(tightenSandbox('workspace-write', 'yolo' as never)).toBe('workspace-write'); + }); +}); diff --git a/packages/core/src/cron/profile.ts b/packages/core/src/cron/profile.ts new file mode 100644 index 0000000..72ecdcb --- /dev/null +++ b/packages/core/src/cron/profile.ts @@ -0,0 +1,116 @@ +// Trigger profile — the permission posture a scheduled job runs under. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.E +// +// A cron job reads the same settings.json you use interactively, so a +// `defaultMode: bypassPermissions` chosen for REPL convenience silently becomes +// the posture of every job that fires at 3am with nobody watching. The +// convenience choice and the unattended choice are different decisions and +// should not share one switch. +// +// Floatboat's version of this idea is "permission scopes set per calendar +// event, not per account". The mechanism is right even though the product +// around it is not: a trigger is a bounded, temporary grant, not a standing one. + +import { isPermissiveMode } from '../modes/index.js'; +import { SANDBOX_MODES } from '../sandbox/policy.js'; +import type { PermissionRules, SandboxMode } from '../config/types.js'; +import type { Mode } from '../types.js'; + +export interface TriggerProfile { + /** + * Permission mode for this job. Setting it is an explicit decision and is + * honoured as-is — including a permissive value, which is the opt-in that + * makes the clamp below safe to have. + */ + mode?: Mode; + /** Extra rules, applied so they can only tighten the ambient ones. */ + permissions?: PermissionRules; + /** Sandbox for this job; only ever applied if it is stricter than ambient. */ + sandbox?: SandboxMode; +} + +/** Strictest first — used to make "only tighten" decidable for the sandbox. */ +const SANDBOX_STRICTNESS: SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']; + +export interface ResolvedTriggerMode { + mode: Mode; + /** + * True when a permissive ambient mode was reduced because the job did not ask + * for it. Callers surface this — a silent clamp is as surprising as a silent + * grant, just in the other direction. + */ + clamped: boolean; + ambient: Mode; +} + +/** + * The mode an unattended run should use. + * + * An explicit `profile.mode` always wins, including a permissive one. Otherwise + * a permissive ambient mode is clamped to `default`: inheriting "never ask me" + * into a context where nobody could be asked is not what the user chose, it is + * what they forgot to reconsider. + */ +export function resolveTriggerMode( + profile: TriggerProfile | undefined, + ambient: Mode, +): ResolvedTriggerMode { + if (profile?.mode) return { mode: profile.mode, clamped: false, ambient }; + if (isPermissiveMode(ambient)) return { mode: 'default', clamped: true, ambient }; + return { mode: ambient, clamped: false, ambient }; +} + +/** + * Combine ambient rules with a profile's, tightening only. + * + * Denies and asks union — either source can add a restriction. Allows + * intersect, so a profile can narrow what is auto-approved but never widen it. + * This is the same one-way property the file contract has, for the same reason: + * a mechanism that can only tighten cannot reduce existing safety, whatever the + * user writes. + */ +export function tightenPermissions( + ambient: PermissionRules | undefined, + profile: PermissionRules | undefined, +): PermissionRules | undefined { + if (!profile) return ambient; + const base = ambient ?? {}; + const out: PermissionRules = {}; + + const deny = [...(base.deny ?? []), ...(profile.deny ?? [])]; + if (deny.length > 0) out.deny = [...new Set(deny)]; + + const ask = [...(base.ask ?? []), ...(profile.ask ?? [])]; + if (ask.length > 0) out.ask = [...new Set(ask)]; + + if (profile.allow) { + const allowed = new Set(profile.allow); + out.allow = (base.allow ?? []).filter((rule) => allowed.has(rule)); + } else if (base.allow) { + out.allow = [...base.allow]; + } + + return out; +} + +/** The stricter of the two sandbox modes; a profile can never loosen it. */ +export function tightenSandbox( + ambient: SandboxMode | undefined, + profile: SandboxMode | undefined, +): SandboxMode | undefined { + if (!profile) return ambient; + if (!ambient) return profile; + if (!SANDBOX_MODES.includes(profile)) return ambient; + const rank = (m: SandboxMode): number => SANDBOX_STRICTNESS.indexOf(m); + return rank(profile) <= rank(ambient) ? profile : ambient; +} + +/** Human-readable note for the job log when a clamp happens. */ +export function describeClamp(resolved: ResolvedTriggerMode): string | undefined { + if (!resolved.clamped) return undefined; + return ( + `Permission mode "${resolved.ambient}" was not applied to this unattended run — ` + + `it is inherited from settings and nobody is present to approve. Running as "${resolved.mode}". ` + + `Set the job's profile.mode explicitly to opt back in.` + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7221af7..c90c9a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -453,6 +453,12 @@ export { isCronDue, dueJobs, resolveUnattendedApproval, + describeClamp, + resolveTriggerMode, + tightenPermissions, + tightenSandbox, + type ResolvedTriggerMode, + type TriggerProfile, type CronJob, type CronStore, type UnattendedApprovalPolicy, diff --git a/packages/core/src/tools/cron-tools.ts b/packages/core/src/tools/cron-tools.ts index c987d77..e978ced 100644 --- a/packages/core/src/tools/cron-tools.ts +++ b/packages/core/src/tools/cron-tools.ts @@ -10,6 +10,7 @@ import { removeCronJob, resolveUnattendedApproval, validateCronExpr, + type TriggerProfile, type UnattendedApprovalPolicy, } from '../cron/index.js'; import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; @@ -18,6 +19,8 @@ interface CreateInput { schedule?: string; prompt?: string; onApprovalRequired?: UnattendedApprovalPolicy; + mode?: string; + sandbox?: string; } interface DeleteInput { id?: string; @@ -43,6 +46,17 @@ export const CronCreateTool: ToolHandler = { description: 'What the unattended run does when a tool call needs approval and nobody is there: "deny" (default) refuses that call and continues; "abort" stops the run. Prefer "abort" when a partially-executed job is worse than no job.', }, + mode: { + type: 'string', + enum: ['default', 'plan', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions'], + description: + 'Permission mode for this job. Without it the job uses the ambient settings, except that a permissive defaultMode (bypassPermissions/acceptEdits) is clamped to "default" — inheriting "never ask me" into a run nobody is watching is not the same decision. Set this explicitly to opt back in.', + }, + sandbox: { + type: 'string', + enum: ['read-only', 'workspace-write', 'danger-full-access'], + description: 'Sandbox for this job. Only applied when stricter than the ambient setting.', + }, }, required: ['schedule', 'prompt'], }, @@ -58,16 +72,22 @@ export const CronCreateTool: ToolHandler = { return { content: 'Error: onApprovalRequired must be "deny" or "abort".', isError: true }; } try { + const profile: TriggerProfile = { + ...(input.mode ? { mode: input.mode as TriggerProfile['mode'] } : {}), + ...(input.sandbox ? { sandbox: input.sandbox as TriggerProfile['sandbox'] } : {}), + }; const job = await addCronJob({ schedule: input.schedule, prompt: input.prompt, cwd: ctx.cwd, ...(input.onApprovalRequired ? { onApprovalRequired: input.onApprovalRequired } : {}), + ...(Object.keys(profile).length > 0 ? { profile } : {}), }); return { content: `Scheduled "${job.id}" — \`${job.schedule}\` in ${job.cwd}.\n` + `Unattended approval policy: ${resolveUnattendedApproval(job)}.\n` + + `Permission mode: ${job.profile?.mode ?? 'inherited from settings (permissive modes clamped)'}.\n` + `It fires once the scheduler is installed (deepcode cron install).`, data: { id: job.id,