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
8 changes: 8 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { runMcpCommand } from './mcp-cmd.js';
import { runOnboarding } from './onboarding.js';
import { helpText, parseArgs } from './parse-args.js';
import { startRepl } from './repl.js';
import { runContractCommand } from './contract-cmd.js';
import { runCronCommand, runSchedulerRun } from './scheduler.js';
import { runTrustCommand } from './trust-cmd.js';
import { TrustStore } from './trust.js';
Expand Down Expand Up @@ -110,6 +111,13 @@ async function main(): Promise<number> {
errOutput: process.stderr,
});
}
if (args.positional[0] === 'contract') {
return runContractCommand(args.positional.slice(1), {
cwd: process.cwd(),
output: process.stdout,
errOutput: process.stderr,
});
}
if (args.positional[0] === 'trust') {
return runTrustCommand(args.positional.slice(1), {
cwd: process.cwd(),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const SUBCOMMANDS = [
'plugins',
'skills',
'cron',
'contract',
'scheduler',
'setup-token',
'completion',
Expand Down
114 changes: 114 additions & 0 deletions apps/cli/src/contract-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runContractCommand } from './contract-cmd.js';

function capture(): { stream: PassThrough; text: () => string } {
const stream = new PassThrough();
let buf = '';
stream.on('data', (c: Buffer) => {
buf += c.toString('utf8');
});
return { stream, text: () => buf };
}

describe('deepcode contract', () => {
let cwd: string;
let home: string;

beforeEach(async () => {
cwd = await mkdtemp(join(tmpdir(), 'dc-contract-cmd-'));
home = await mkdtemp(join(tmpdir(), 'dc-contract-home-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
});

it('show reports absence and lists where it looked', async () => {
const out = capture();
const code = await runContractCommand(['show'], { cwd, home, output: out.stream });
expect(code).toBe(0);
expect(out.text()).toContain('No file contract');
expect(out.text()).toContain('file-contract.yaml');
});

it('init writes a contract that parses and takes effect', async () => {
const out = capture();
expect(await runContractCommand(['init'], { cwd, home, output: out.stream })).toBe(0);

const written = await readFile(join(cwd, '.deepcode', 'file-contract.yaml'), 'utf8');
expect(written).toContain('version: 1');

const check = capture();
await runContractCommand(['check', '.env'], { cwd, home, output: check.stream });
expect(check.text()).toContain('deny');
});

it('init refuses to clobber an existing contract without --force', async () => {
// Overwriting would silently drop rules the user wrote, which is the exact
// failure this feature exists to prevent.
await mkdir(join(cwd, '.deepcode'), { recursive: true });
await writeFile(join(cwd, '.deepcode', 'file-contract.yaml'), 'version: 1\n');
const err = capture();
const code = await runContractCommand(['init'], { cwd, home, errOutput: err.stream });
expect(code).toBe(1);
expect(err.text()).toContain('--force');
expect(await readFile(join(cwd, '.deepcode', 'file-contract.yaml'), 'utf8')).toBe(
'version: 1\n',
);
});

it('check reports all three axes with the deciding rule', async () => {
await mkdir(join(cwd, '.deepcode'), { recursive: true });
await writeFile(
join(cwd, '.deepcode', 'file-contract.yaml'),
'version: 1\nrules:\n - glob: "**/.env*"\n read: deny\n reason: "no secrets"\n',
);
const out = capture();
await runContractCommand(['check', 'config/.env.local'], { cwd, home, output: out.stream });
const text = out.text();
expect(text).toContain('read');
expect(text).toContain('deny');
expect(text).toContain('no secrets');
expect(text).toContain('write');
});

it('check says an outside path belongs to the sandbox, not the contract', async () => {
const out = capture();
await runContractCommand(['check', '/etc/passwd'], { cwd, home, output: out.stream });
expect(out.text()).toContain('outside the workspace');
});

it('show surfaces a parse error instead of pretending there are no rules', async () => {
await mkdir(join(cwd, '.deepcode'), { recursive: true });
await writeFile(
join(cwd, '.deepcode', 'file-contract.yaml'),
'version: 1\nrules:\n - glob: "a"\n read: maybe\n',
);
const out = capture();
const code = await runContractCommand(['show'], { cwd, home, output: out.stream });
expect(code).toBe(1);
expect(out.text()).toContain('Invalid contract');
expect(out.text()).toContain('line 4');
});

it('show warns that read denies leave Bash uncovered when the sandbox is off', async () => {
await mkdir(join(cwd, '.deepcode'), { recursive: true });
await writeFile(
join(cwd, '.deepcode', 'file-contract.yaml'),
'version: 1\nrules:\n - glob: "**/.env*"\n read: deny\n',
);
const out = capture();
await runContractCommand(['show'], { cwd, home, output: out.stream });
expect(out.text()).toContain('sandbox is off');
});

it('rejects an unknown subcommand with usage', async () => {
const err = capture();
expect(await runContractCommand(['bogus'], { cwd, home, errOutput: err.stream })).toBe(2);
expect(err.text()).toContain('Usage:');
});
});
166 changes: 166 additions & 0 deletions apps/cli/src/contract-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// `deepcode contract [init|show|check]` — manage the path-axis file contract.
// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.A · Docs: docs/file-contract.md

import {
RECOMMENDED_FILE_CONTRACT,
contractGovernedTools,
evaluatePath,
fileContractPaths,
fileContractWarnings,
loadFileContract,
normalizeContractPath,
resolveSandboxMode,
loadSettings,
type ContractAction,
} from '@deepcode/core';
import { promises as fs } from 'node:fs';
import { dirname, join } from 'node:path';
import type { Writable } from 'node:stream';

export interface ContractCmdDeps {
cwd: string;
home?: string;
output?: Writable;
errOutput?: Writable;
}

export async function runContractCommand(args: string[], deps: ContractCmdDeps): Promise<number> {
const out = deps.output ?? process.stdout;
const err = deps.errOutput ?? process.stderr;
const sub = args[0] ?? 'show';

switch (sub) {
case 'init':
return init(args.slice(1), deps, out, err);
case 'show':
return show(deps, out);
case 'check':
return check(args.slice(1), deps, out, err);
default:
err.write(`Unknown subcommand "${sub}".\n\n`);
usage(err);
return 2;
}
}

async function init(
args: string[],
deps: ContractCmdDeps,
out: Writable,
err: Writable,
): Promise<number> {
const target = join(deps.cwd, '.deepcode', 'file-contract.yaml');
const force = args.includes('--force');
try {
await fs.access(target);
if (!force) {
// Overwriting a contract would silently drop rules the user wrote, which
// is the one thing this feature exists to prevent.
err.write(`${target} already exists. Pass --force to overwrite it.\n`);
return 1;
}
} catch {
/* absent — the normal path */
}
await fs.mkdir(dirname(target), { recursive: true });
await fs.writeFile(target, RECOMMENDED_FILE_CONTRACT, 'utf8');
out.write(
`Wrote ${target}\n\n` +
`It denies reads of secret files and asks before agent-instruction and CI changes.\n` +
`Review it, then commit it — the contract is meant to be reviewed like any other policy.\n` +
`Check what it does with: deepcode contract check <path>\n`,
);
return 0;
}

async function show(deps: ContractCmdDeps, out: Writable): Promise<number> {
const loaded = await loadFileContract({ cwd: deps.cwd, home: deps.home });
if (loaded.status === 'absent') {
out.write('No file contract. Path rules are not in effect.\n\n');
out.write('Looked in:\n');
for (const p of fileContractPaths({ cwd: deps.cwd, home: deps.home })) out.write(` ${p}\n`);
out.write('\nCreate one with: deepcode contract init\n');
return 0;
}
if (loaded.status === 'invalid') {
out.write(`Invalid contract at ${loaded.path}\n ${loaded.error}\n`);
return 1;
}

const c = loaded.contract!;
out.write(`Contract: ${loaded.path}\n\n`);
out.write(
`Defaults read=${c.defaults.read} write=${c.defaults.write} execute=${c.defaults.execute}\n\n`,
);
for (const rule of c.rules) {
const axes = (['read', 'write', 'execute'] as ContractAction[])
.filter((a) => rule[a])
.map((a) => `${a}=${rule[a]}`)
.join(' ');
out.write(` ${rule.glob}\n ${axes}${rule.owner ? ` owner=${rule.owner}` : ''}\n`);
if (rule.reason) out.write(` ${rule.reason}\n`);
}
out.write(`\nGoverns: ${contractGovernedTools().join(', ')}\n`);
out.write(`Bash is NOT governed — only the sandbox bounds it. See docs/file-contract.md.\n`);

for (const warning of await warningsFor(deps, loaded)) out.write(`\nWarning: ${warning}\n`);
return 0;
}

async function check(
args: string[],
deps: ContractCmdDeps,
out: Writable,
err: Writable,
): Promise<number> {
const target = args[0];
if (!target) {
err.write('Usage: deepcode contract check <path>\n');
return 2;
}
const loaded = await loadFileContract({ cwd: deps.cwd, home: deps.home });
if (loaded.status === 'invalid') {
err.write(`Invalid contract at ${loaded.path}: ${loaded.error}\n`);
return 1;
}
const rel = normalizeContractPath(deps.cwd, target);
if (rel === null) {
out.write(`${target} is outside the workspace — the contract has no say; the sandbox does.\n`);
return 0;
}
out.write(`${rel}\n`);
for (const action of ['read', 'write', 'execute'] as ContractAction[]) {
const v = evaluatePath(loaded.contract, { path: rel, action });
const detail = v.rule ? ` (${v.rule})` : v.verdict === 'no-match' ? '' : ' (default)';
out.write(` ${action.padEnd(8)} ${v.verdict}${detail}\n`);
if (v.reason) out.write(` ${v.reason}\n`);
}
return 0;
}

async function warningsFor(
deps: ContractCmdDeps,
loaded: Awaited<ReturnType<typeof loadFileContract>>,
): Promise<string[]> {
const settings = await loadSettings({ cwd: deps.cwd, home: deps.home });
return fileContractWarnings({
...loaded,
// `danger-full-access` is the fallback the runtime itself uses when nothing
// is configured (sandbox/index.ts), so the warning reflects reality rather
// than an optimistic default.
sandboxMode: resolveSandboxMode(settings.merged.sandbox, 'danger-full-access'),
});
}

function usage(out: Writable): void {
out.write(
[
'Usage: deepcode contract <subcommand>',
'',
' show Print the active contract and what it governs (default)',
' init [--force] Write the recommended contract to .deepcode/file-contract.yaml',
' check <path> Show the verdict for one path on all three axes',
'',
].join('\n'),
);
}
5 changes: 5 additions & 0 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
cwd,
),
});
// Say up front how far the contract actually reaches. A read `deny` with
// the sandbox off looks like protection and is not.
for (const warning of await runtime.contractWarnings(cwd)) {
errOutput.write(`Warning: ${warning}\n`);
}
const result = await runtime.run({
systemPrompt,
userMessage,
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ USAGE
deepcode upgrade Self-update (CLI; Mac client auto-updates)
deepcode setup-token [<token>] Store a long-lived DeepSeek auth token (CI)
deepcode cron <cmd> Scheduled tasks: install/uninstall/list/status
deepcode contract <show|init|check> Inspect or create the path-axis file contract
deepcode scheduler run Run due scheduled jobs (invoked by launchd)
deepcode mcp serve Expose DeepCode tools as an MCP server (stdio)
deepcode app-server Run the experimental lifecycle server (JSONL stdio)
Expand Down
7 changes: 7 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,14 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
settings.permissions?.additionalDirectories,
cwd,
),
home: opts.home,
});
// Surfaced at startup rather than on first refusal: a read `deny` running with
// the sandbox off looks like protection and is not, and the user should learn
// that before they rely on it.
for (const warning of await runtime.contractWarnings(cwd)) {
output.write(`Warning: ${warning}\n`);
}
const ctx: SessionContext = {
cwd,
model,
Expand Down
Loading
Loading