Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import {
type AgentEvent,
type Effort,
isPermissiveMode,
tightenPermissions,
type PermissionRules,
type McpClientHandle,
type Mode,
type UnattendedApprovalPolicy,
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -149,7 +162,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
// 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`,
Expand All @@ -159,6 +172,10 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
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,
Expand Down Expand Up @@ -320,7 +337,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
tools,
cwd,
mode,
permissions: settings.permissions,
permissions: effectivePermissions,
hooks,
pluginDirs: pluginContrib.dirs,
autoMode: settings.autoMode,
Expand Down
22 changes: 21 additions & 1 deletion apps/cli/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
listCronJobs,
loadCronStore,
saveCronStore,
describeClamp,
loadSettings,
resolveTriggerMode,
resolveUnattendedApproval,
tightenSandbox,
uninstallPlist,
type CronJob,
} from '@deepcode/core';
Expand Down Expand Up @@ -73,8 +77,20 @@ async function defaultRunJob(job: CronJob, home: string): Promise<void> {
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,
Expand All @@ -83,6 +99,10 @@ async function defaultRunJob(job: CronJob, home: string): Promise<void> {
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.
Expand Down
25 changes: 20 additions & 5 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/cron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -79,6 +86,7 @@ export async function addCronJob(
prompt: string;
cwd: string;
onApprovalRequired?: UnattendedApprovalPolicy;
profile?: TriggerProfile;
},
home: string = homedir(),
): Promise<CronJob> {
Expand All @@ -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);
Expand Down Expand Up @@ -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';
110 changes: 110 additions & 0 deletions packages/core/src/cron/profile.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading