diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 47bd63d..45721e8 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -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'; @@ -110,6 +111,13 @@ async function main(): Promise { 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(), diff --git a/apps/cli/src/completion.ts b/apps/cli/src/completion.ts index 795b592..fe5854e 100644 --- a/apps/cli/src/completion.ts +++ b/apps/cli/src/completion.ts @@ -58,6 +58,7 @@ const SUBCOMMANDS = [ 'plugins', 'skills', 'cron', + 'contract', 'scheduler', 'setup-token', 'completion', diff --git a/apps/cli/src/contract-cmd.test.ts b/apps/cli/src/contract-cmd.test.ts new file mode 100644 index 0000000..a3f4aab --- /dev/null +++ b/apps/cli/src/contract-cmd.test.ts @@ -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:'); + }); +}); diff --git a/apps/cli/src/contract-cmd.ts b/apps/cli/src/contract-cmd.ts new file mode 100644 index 0000000..adbcb30 --- /dev/null +++ b/apps/cli/src/contract-cmd.ts @@ -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 { + 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 { + 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 \n`, + ); + return 0; +} + +async function show(deps: ContractCmdDeps, out: Writable): Promise { + 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 { + const target = args[0]; + if (!target) { + err.write('Usage: deepcode contract check \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>, +): Promise { + 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 ', + '', + ' show Print the active contract and what it governs (default)', + ' init [--force] Write the recommended contract to .deepcode/file-contract.yaml', + ' check Show the verdict for one path on all three axes', + '', + ].join('\n'), + ); +} diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 99b60b9..2581d7b 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -330,6 +330,11 @@ export async function runHeadless(opts: HeadlessOpts): Promise { 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, diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index c7bb226..814a570 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -323,6 +323,7 @@ USAGE deepcode upgrade Self-update (CLI; Mac client auto-updates) deepcode setup-token [] Store a long-lived DeepSeek auth token (CI) deepcode cron Scheduled tasks: install/uninstall/list/status + deepcode contract 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) diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 1cb8579..706b9e6 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -480,7 +480,14 @@ export async function startRepl(opts: ReplOpts): Promise { 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, diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts index d66ba3c..a5ffdb8 100644 --- a/apps/server/src/runtime-composition.ts +++ b/apps/server/src/runtime-composition.ts @@ -1,10 +1,12 @@ import type { Effort, Mode, Provider } from '@deepcode/core'; import { withAdditionalWritableDirs } from '@deepcode/core'; -import type { - DeepCodeSettings, - McpServerConfig, - PermissionRules, - SandboxConfig, +import { + loadFileContract, + type DeepCodeSettings, + type FileContract, + type McpServerConfig, + type PermissionRules, + type SandboxConfig, } from '@deepcode/core/config'; import { dispatchToolCall } from '@deepcode/core/harness'; import { HookDispatcher } from '@deepcode/core/hooks'; @@ -43,7 +45,7 @@ export const DEFAULT_APP_SERVER_SYSTEM_PROMPT = 'actionable issue, using a precise workspace-relative path and line range.'; export interface RuntimeCompositionDiagnostic { - source: 'mcp' | 'plugin'; + source: 'mcp' | 'plugin' | 'config'; code: string; severity: 'warning' | 'error'; message: string; @@ -109,6 +111,17 @@ export async function composeRuntime( const { cwd, directory, settings } = options; const services = { ...DEFAULT_SERVICES, ...options.services }; const diagnostics: RuntimeCompositionDiagnostic[] = []; + // Loaded here so the plugin capability bridge is gated by the same path rules + // as the agent's own tools; a plugin subprocess must not be a way around them. + const fileContract = await loadFileContract({ cwd, directory }); + if (fileContract.status === 'invalid') { + diagnostics.push({ + source: 'config', + code: 'file_contract_invalid', + severity: 'error', + message: `File contract at ${fileContract.path} could not be parsed, so no path rules are in effect: ${fileContract.error}`, + }); + } const pluginsEnabled = settings.plugins?.globalEnabled !== false; let pluginDiscoverySucceeded = true; let pluginDirs: string[] = []; @@ -212,6 +225,7 @@ export async function composeRuntime( cwd, mode: options.mode ?? 'default', permissions: settings.permissions, + contract: fileContract.contract, hooks, provider: options.provider, autoMode: settings.autoMode, @@ -304,6 +318,8 @@ interface PluginBridgeOptions { cwd: string; mode: Mode; permissions?: PermissionRules; + /** Path-axis rules — a plugin subprocess must not be a way around them. */ + contract?: FileContract; hooks: HookDispatcher; provider?: Provider; autoMode?: DeepCodeSettings['autoMode']; @@ -323,6 +339,7 @@ export function buildPluginCapabilityBridge(options: PluginBridgeOptions): Plugi input, mode: options.mode, rules: options.permissions, + contract: options.contract, hooks: options.hooks, cwd: options.cwd, autoMode: options.autoMode, diff --git a/docs/file-contract.md b/docs/file-contract.md index 9ecf58f..3ceb5ca 100644 --- a/docs/file-contract.md +++ b/docs/file-contract.md @@ -16,6 +16,14 @@ absolute, so `Read(.env*)` matches nothing at all. > guarantee. Only the **sandbox** bounds Bash. See > [security-model.md](security-model.md). +## Getting started + +```bash +deepcode contract init # write the recommended contract +deepcode contract show # what is active, and what it governs +deepcode contract check .env # the verdict for one path, all three axes +``` + ## Where it lives The first file found wins; there is no merging. @@ -110,6 +118,20 @@ The parser is strict on purpose: unknown keys, unknown decision values, a rule with no `glob`, or a rule that decides nothing are all errors. A silently-ignored line here is a permission quietly granted. +## Which tools it governs + +`Read`, `Grep`, `Glob`, `Write`, `Edit`, `NotebookEdit`. + +**`Bash` is deliberately absent.** Everything else is ungoverned too — a tool +whose effect cannot be pinned to a single path gets no verdict rather than a +guessed one. + +A contract `deny` cannot be waived, including by `bypassPermissions`. It is a +standing statement about a path, not a per-call prompt, so the mode that exists +to skip prompts has no business clearing it — otherwise the contract's strongest +sentence would also be its easiest to disable. A contract `ask` is an ordinary +approval and follows the mode and hook chain like any other. + ## Interaction with `settings.json` The two rule sets compose by **most-restrictive-wins**: diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 0bddd60..09cef66 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -3,6 +3,7 @@ import { compact, shouldCompact } from './compaction/index.js'; import type { PermissionRules } from './config/types.js'; +import type { FileContract } from './config/file-contract.js'; import type { UnattendedApprovalPolicy } from './cron/index.js'; import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js'; import { TaskManager, type TaskRunner } from './tasks/manager.js'; @@ -69,6 +70,8 @@ export interface RunAgentOptions { /** Required dispatch mode. Every tool call goes through the central gate. */ mode: Mode; permissions?: PermissionRules; + /** Path-axis rules; RuntimeHost loads this so clients need not remember to. */ + contract?: FileContract; hooks?: HookDispatcher; approval?: ApprovalCallback; /** @@ -670,6 +673,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { input: toolUse.input, mode: runtimePolicy.mode, rules: runtimePolicy.permissions, + contract: opts.contract, hooks: opts.hooks, cwd: opts.cwd, autoMode: opts.autoMode, diff --git a/packages/core/src/config/contract-dispatch.test.ts b/packages/core/src/config/contract-dispatch.test.ts new file mode 100644 index 0000000..6bdc068 --- /dev/null +++ b/packages/core/src/config/contract-dispatch.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { + contractGovernedTools, + evaluateContract, + fileContractWarnings, + mostRestrictive, +} from './contract-dispatch.js'; +import { parseFileContract, type FileContract } from './file-contract.js'; +import type { PermissionVerdict } from './permissions.js'; + +const CWD = '/work/repo'; + +function contract(body: string): FileContract { + return parseFileContract(`version: 1\n${body}`); +} + +const secrets = contract(` +rules: + - glob: "**/.env*" + read: deny + write: deny + reason: "Secrets are human-only." + - glob: "AGENTS.md" + write: ask + reason: "Review first." +`); + +describe('mostRestrictive', () => { + const verdicts: PermissionVerdict[] = ['no-match', 'allow', 'ask', 'deny']; + + // The whole safety argument rests on this table, so it is enumerated rather + // than sampled: 4 tool verdicts × 4 contract verdicts. + const expected: Record = { + 'no-match|no-match': 'no-match', + 'no-match|allow': 'allow', + 'no-match|ask': 'ask', + 'no-match|deny': 'deny', + 'allow|no-match': 'allow', + 'allow|allow': 'allow', + 'allow|ask': 'ask', + 'allow|deny': 'deny', + 'ask|no-match': 'ask', + 'ask|allow': 'ask', + 'ask|ask': 'ask', + 'ask|deny': 'deny', + 'deny|no-match': 'deny', + 'deny|allow': 'deny', + 'deny|ask': 'deny', + 'deny|deny': 'deny', + }; + + for (const tool of verdicts) { + for (const path of verdicts) { + it(`tool=${tool} × contract=${path} → ${expected[`${tool}|${path}`]}`, () => { + expect(mostRestrictive(tool, path)).toBe(expected[`${tool}|${path}`]); + }); + } + } + + it('never loosens: a settings deny survives any contract verdict', () => { + for (const path of verdicts) expect(mostRestrictive('deny', path)).toBe('deny'); + }); + + it('is symmetric, so argument order cannot change a decision', () => { + for (const a of verdicts) { + for (const b of verdicts) { + expect(mostRestrictive(a, b)).toBe(mostRestrictive(b, a)); + } + } + }); +}); + +describe('evaluateContract', () => { + it('has no opinion without a contract', () => { + expect( + evaluateContract(undefined, { tool: 'Read', input: { file_path: '.env' }, cwd: CWD }).verdict, + ).toBe('no-match'); + }); + + it('maps Read to the read axis', () => { + const out = evaluateContract(secrets, { + tool: 'Read', + input: { file_path: '/work/repo/.env' }, + cwd: CWD, + }); + expect(out.verdict).toBe('deny'); + expect(out.reason).toBe('Secrets are human-only.'); + }); + + it('maps Write and Edit to the write axis', () => { + for (const tool of ['Write', 'Edit']) { + expect( + evaluateContract(secrets, { tool, input: { file_path: '.env.local' }, cwd: CWD }).verdict, + ).toBe('deny'); + } + }); + + it('reads NotebookEdit from notebook_path, not file_path', () => { + const c = contract('rules:\n - glob: "secret.ipynb"\n write: deny\n'); + expect( + evaluateContract(c, { + tool: 'NotebookEdit', + input: { notebook_path: 'secret.ipynb' }, + cwd: CWD, + }).verdict, + ).toBe('deny'); + }); + + it('maps Grep and Glob search roots to the read axis', () => { + const c = contract('rules:\n - glob: "vault/**"\n read: deny\n'); + expect( + evaluateContract(c, { tool: 'Grep', input: { path: 'vault/x' }, cwd: CWD }).verdict, + ).toBe('deny'); + expect( + evaluateContract(c, { tool: 'Glob', input: { path: 'vault/x' }, cwd: CWD }).verdict, + ).toBe('deny'); + }); + + it('has no opinion about Bash, by design', () => { + // Statically parsing shell to guess at paths would be guesswork presented + // as enforcement. Bash is the sandbox's job. + expect( + evaluateContract(secrets, { tool: 'Bash', input: { command: 'cat .env' }, cwd: CWD }).verdict, + ).toBe('no-match'); + }); + + it('has no opinion about tools with no path axis', () => { + expect( + evaluateContract(secrets, { tool: 'WebFetch', input: { url: 'https://x' }, cwd: CWD }) + .verdict, + ).toBe('no-match'); + }); + + it('has no opinion when the path argument is missing or not a string', () => { + expect(evaluateContract(secrets, { tool: 'Read', input: {}, cwd: CWD }).verdict).toBe( + 'no-match', + ); + expect( + evaluateContract(secrets, { tool: 'Read', input: { file_path: 42 }, cwd: CWD }).verdict, + ).toBe('no-match'); + }); + + it('has no opinion about paths outside the workspace', () => { + expect( + evaluateContract(secrets, { tool: 'Read', input: { file_path: '/etc/.env' }, cwd: CWD }) + .verdict, + ).toBe('no-match'); + }); + + it('still applies after ../ traversal resolves back inside', () => { + expect( + evaluateContract(secrets, { tool: 'Read', input: { file_path: 'src/../.env' }, cwd: CWD }) + .verdict, + ).toBe('deny'); + }); + + it('lists the tools it governs', () => { + expect(contractGovernedTools().sort()).toEqual([ + 'Edit', + 'Glob', + 'Grep', + 'NotebookEdit', + 'Read', + 'Write', + ]); + }); +}); + +describe('fileContractWarnings', () => { + it('says nothing when there is no contract', () => { + expect(fileContractWarnings({ status: 'absent' })).toEqual([]); + }); + + it('reports a malformed contract as having no rules in effect', () => { + const [warning] = fileContractWarnings({ + status: 'invalid', + path: '/work/repo/.deepcode/file-contract.yaml', + error: 'line 4: read must be one of allow/ask/deny, got "maybe"', + }); + expect(warning).toContain('no path rules are in effect'); + expect(warning).toContain('line 4'); + }); + + it('warns that read denies do not cover Bash while the sandbox is off', () => { + const [warning] = fileContractWarnings({ + status: 'loaded', + contract: secrets, + sandboxMode: 'danger-full-access', + }); + expect(warning).toContain('sandbox is off'); + expect(warning).toContain('Bash'); + }); + + it('stays quiet once the sandbox bounds Bash', () => { + expect( + fileContractWarnings({ + status: 'loaded', + contract: secrets, + sandboxMode: 'workspace-write', + }), + ).toEqual([]); + }); + + it('stays quiet for a write-only contract, which Bash cannot silently defeat', () => { + // A write deny is about tool calls; there is no false-enforcement risk to + // warn about, so warning anyway would just train users to ignore it. + expect( + fileContractWarnings({ + status: 'loaded', + contract: contract('rules:\n - glob: "a"\n write: deny\n'), + sandboxMode: 'danger-full-access', + }), + ).toEqual([]); + }); +}); diff --git a/packages/core/src/config/contract-dispatch.ts b/packages/core/src/config/contract-dispatch.ts new file mode 100644 index 0000000..beddae1 --- /dev/null +++ b/packages/core/src/config/contract-dispatch.ts @@ -0,0 +1,133 @@ +// Maps a tool call onto the file contract's (path, action) axes and composes +// the result with the tool-rule verdict. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.A + +import { + evaluatePath, + normalizeContractPath, + type ContractAction, + type ContractEvaluation, + type FileContract, +} from './file-contract.js'; +import type { PermissionVerdict } from './permissions.js'; + +/** + * Which contract axis each tool exercises, and where it keeps its path. + * + * Declared rather than inferred. A tool absent from this table gets no contract + * verdict at all, which is the honest answer for tools whose effect cannot be + * pinned to one path. + * + * `Bash` is deliberately absent. `cat .env` is a string, and statically parsing + * shell to decide otherwise would be guesswork presented as enforcement — worse + * than not trying, because it reads as a guarantee. Bash is bounded by the + * sandbox alone; `fileContractWarnings` says so out loud when a contract's + * read/execute denies are running with the sandbox off. + */ +const TOOL_AXIS: Record = { + Read: { action: 'read', field: 'file_path' }, + Grep: { action: 'read', field: 'path' }, + Glob: { action: 'read', field: 'path' }, + Write: { action: 'write', field: 'file_path' }, + Edit: { action: 'write', field: 'file_path' }, + NotebookEdit: { action: 'write', field: 'notebook_path' }, +}; + +export interface ContractDispatchRequest { + tool: string; + input: Record; + cwd: string; +} + +/** + * The contract's opinion about one tool call, or `no-match` when it has none — + * because there is no contract, the tool has no path axis, the argument is + * missing, or the path lies outside the workspace. + */ +export function evaluateContract( + contract: FileContract | undefined, + req: ContractDispatchRequest, +): ContractEvaluation { + if (!contract) return { verdict: 'no-match' }; + const axis = TOOL_AXIS[req.tool]; + if (!axis) return { verdict: 'no-match' }; + const raw = req.input[axis.field]; + if (typeof raw !== 'string' || raw === '') return { verdict: 'no-match' }; + const path = normalizeContractPath(req.cwd, raw); + if (path === null) return { verdict: 'no-match' }; + return evaluatePath(contract, { path, action: axis.action }); +} + +/** Tools the contract can speak about — exported so docs and tests share one list. */ +export function contractGovernedTools(): string[] { + return Object.keys(TOOL_AXIS); +} + +const SEVERITY: Record = { + 'no-match': 0, + allow: 1, + ask: 2, + deny: 3, +}; + +/** + * Combine the tool-rule verdict with the contract verdict, most restrictive + * winning. + * + * `no-match` means "no opinion" and never wins, which is what makes an absent + * contract a genuine no-op rather than an approximate one: it collapses to the + * tool verdict exactly. + * + * The composition is one-directional by construction — a contract can tighten + * `allow` to `ask` or `deny`, and can never loosen a `deny` from settings.json. + * That is why adding a contract cannot reduce existing safety. + */ +export function mostRestrictive(a: PermissionVerdict, b: PermissionVerdict): PermissionVerdict { + if (a === 'no-match') return b; + if (b === 'no-match') return a; + return SEVERITY[a] >= SEVERITY[b] ? a : b; +} + +export interface ContractWarningInput { + status: 'absent' | 'loaded' | 'invalid'; + contract?: FileContract; + path?: string; + error?: string; + /** Resolved sandbox mode for this run. */ + sandboxMode?: string; +} + +/** + * Operator-facing warnings about a contract's real reach. + * + * Pure and host-agnostic so the CLI, `doctor`, and the desktop app print the + * same words. Both cases here exist to prevent a false sense of enforcement, + * which is the main risk this feature introduces. + */ +export function fileContractWarnings(input: ContractWarningInput): string[] { + const out: string[] = []; + + if (input.status === 'invalid') { + out.push( + `File contract at ${input.path ?? '(unknown path)'} could not be parsed, so no path rules are in effect: ${input.error ?? 'unknown error'}`, + ); + return out; + } + + if (input.status !== 'loaded' || !input.contract) return out; + + const hasReadOrExecuteDeny = + input.contract.defaults.read === 'deny' || + input.contract.defaults.execute === 'deny' || + input.contract.rules.some((r) => r.read === 'deny' || r.execute === 'deny'); + + if (hasReadOrExecuteDeny && input.sandboxMode === 'danger-full-access') { + out.push( + 'File contract denies reads, but the sandbox is off (danger-full-access). ' + + 'Those denials cover Read/Grep/Glob only — a shell command run through Bash can still ' + + 'reach the files. Set sandbox.mode to workspace-write to bound Bash too.', + ); + } + + return out; +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index b696fde..abac390 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -93,3 +93,12 @@ export { type LoadedFileContract, type LoadFileContractOpts, } from './file-contract-loader.js'; + +export { + contractGovernedTools, + evaluateContract, + fileContractWarnings, + mostRestrictive, + type ContractDispatchRequest, + type ContractWarningInput, +} from './contract-dispatch.js'; diff --git a/packages/core/src/harness/tool-dispatcher.test.ts b/packages/core/src/harness/tool-dispatcher.test.ts index 8d344a6..56eeca7 100644 --- a/packages/core/src/harness/tool-dispatcher.test.ts +++ b/packages/core/src/harness/tool-dispatcher.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { parseFileContract } from '../config/file-contract.js'; import { HookDispatcher } from '../hooks/index.js'; import { dispatchToolCall } from './tool-dispatcher.js'; @@ -147,3 +148,151 @@ describe('dispatchToolCall', () => { expect(v.source).toBe('hook'); }); }); + +describe('dispatchToolCall — file contract', () => { + const cwd = '/work/repo'; + const secrets = parseFileContract(`version: 1 +rules: + - glob: "**/.env*" + read: deny + reason: "Secrets are human-only." + - glob: "AGENTS.md" + write: ask + reason: "Review first." +`); + + it('is a no-op when no contract is supplied', async () => { + // The property the whole feature rests on: without a contract file, the + // outcome is bit-for-bit what it was before the contract existed. + const without = await dispatchToolCall({ + tool: 'Read', + input: { file_path: '/work/repo/.env' }, + mode: 'default', + rules: { allow: ['Read'] }, + cwd, + }); + expect(without.decision).toBe('allow'); + }); + + it('denies a read the contract forbids, and explains why', async () => { + const v = await dispatchToolCall({ + tool: 'Read', + input: { file_path: '/work/repo/.env' }, + mode: 'default', + rules: { allow: ['Read'] }, + contract: secrets, + cwd, + }); + expect(v.decision).toBe('deny'); + expect(v.source).toBe('contract'); + expect(v.reason).toContain('Secrets are human-only.'); + }); + + it('a contract deny survives bypassPermissions', async () => { + // A standing "never read .env" is not a prompt, so the mode that exists to + // skip prompts must not clear it — otherwise the contract's strongest + // sentence is also its easiest to disable. + const v = await dispatchToolCall({ + tool: 'Read', + input: { file_path: '/work/repo/.env' }, + mode: 'bypassPermissions', + contract: secrets, + cwd, + }); + expect(v.decision).toBe('deny'); + expect(v.source).toBe('contract'); + }); + + it('a contract deny survives an explicit settings allow', async () => { + const v = await dispatchToolCall({ + tool: 'Read', + input: { file_path: '/work/repo/.env' }, + mode: 'dontAsk', + rules: { allow: ['Read'] }, + contract: secrets, + cwd, + }); + expect(v.decision).toBe('deny'); + }); + + it('tightens allow to ask, attributing it to the contract', async () => { + const v = await dispatchToolCall({ + tool: 'Write', + input: { file_path: '/work/repo/AGENTS.md' }, + mode: 'default', + rules: { allow: ['Write'] }, + contract: secrets, + cwd, + }); + expect(v.decision).toBe('ask'); + expect(v.source).toBe('contract'); + expect(v.reason).toContain('Review first.'); + }); + + it('never loosens: a settings deny stays denied under a permissive contract', async () => { + const permissive = parseFileContract('version: 1\nrules:\n - glob: "**"\n write: allow\n'); + const v = await dispatchToolCall({ + tool: 'Write', + input: { file_path: '/work/repo/src/a.ts' }, + mode: 'default', + rules: { deny: ['Write'] }, + contract: permissive, + cwd, + }); + expect(v.decision).toBe('deny'); + }); + + it('leaves paths outside the workspace to the sandbox', async () => { + const v = await dispatchToolCall({ + tool: 'Read', + input: { file_path: '/etc/.env' }, + mode: 'default', + rules: { allow: ['Read'] }, + contract: secrets, + cwd, + }); + expect(v.decision).toBe('allow'); + }); + + it('does not gate Bash on the contract', async () => { + const v = await dispatchToolCall({ + tool: 'Bash', + input: { command: 'cat .env' }, + mode: 'default', + rules: { allow: ['Bash'] }, + contract: secrets, + cwd, + }); + expect(v.decision).toBe('allow'); + }); + + it('a contract ask is still overridable by a PreToolUse hook', async () => { + // Contract `ask` is an ordinary approval, unlike `deny`. The hook chain + // stays the last word for it. + const hooks = new HookDispatcher({ + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: `echo '{"permissionDecision":"deny","systemMessage":"hook says no"}'`, + }, + ], + }, + ], + }, + }); + const v = await dispatchToolCall({ + tool: 'Write', + input: { file_path: '/work/repo/AGENTS.md' }, + mode: 'default', + rules: { allow: ['Write'] }, + contract: secrets, + hooks, + cwd, + }); + expect(v.decision).toBe('deny'); + expect(v.source).toBe('hook'); + }); +}); diff --git a/packages/core/src/harness/tool-dispatcher.ts b/packages/core/src/harness/tool-dispatcher.ts index 1ce67a0..16d3502 100644 --- a/packages/core/src/harness/tool-dispatcher.ts +++ b/packages/core/src/harness/tool-dispatcher.ts @@ -10,6 +10,8 @@ import { type PermissionRequest, type PermissionVerdict, } from '../config/permissions.js'; +import { evaluateContract, mostRestrictive } from '../config/contract-dispatch.js'; +import type { FileContract } from '../config/file-contract.js'; import type { AutoModeConfig, PermissionRules } from '../config/types.js'; import type { Mode } from '../types.js'; import type { HookDispatcher, HookResult } from '../hooks/index.js'; @@ -20,6 +22,8 @@ export interface DispatchRequest { input: Record; mode: Mode; rules?: PermissionRules; + /** Path-axis rules. Absent means the contract has no opinion on anything. */ + contract?: FileContract; hooks?: HookDispatcher; cwd: string; /** AutoModeConfig from settings — required when mode === 'auto'. */ @@ -32,7 +36,7 @@ export interface DispatchVerdict { /** Final decision after all gates. */ decision: 'allow' | 'ask' | 'deny' | 'plan-blocked'; /** Where the decision came from (for UI/logging). */ - source: 'mode' | 'permission' | 'hook'; + source: 'mode' | 'permission' | 'hook' | 'contract'; /** Human-readable explanation. */ reason: string; /** If hook produced JSON output, surfaced here so caller can use additionalContext etc. */ @@ -44,19 +48,48 @@ export interface DispatchVerdict { } /** - * Evaluate a tool call against mode + permission + PreToolUse hook. + * Evaluate a tool call against contract + mode + permission + PreToolUse hook. * * Decision order (per docs/design/sandbox-plan-worktree.md §5.1): + * 0. File contract `deny` (absolute — see below) * 1. Mode policy (plan-blocked / deny short-circuit immediately) - * 2. Permission rules (mode policy can demote/upgrade to ask) + * 2. Permission rules, tightened by the contract's path verdict * 3. PreToolUse hook chain (can override the prior decision via JSON output) * + * A contract `deny` is checked first and cannot be waived, including by + * `bypassPermissions`. It is a standing statement about a path ("never read + * .env"), not a per-call prompt, so a mode that exists to skip prompts has no + * business clearing it — otherwise the contract's strongest sentence would also + * be its easiest to disable. Contract `ask` is ordinary and follows mode. + * * Sandbox (M3.5) is enforced separately at the OS layer — not here. */ export async function dispatchToolCall(req: DispatchRequest): Promise { - // Step 1: Permission verdict + // Step 0: Contract path verdict + const contractVerdict = evaluateContract(req.contract, { + tool: req.tool, + input: req.input, + cwd: req.cwd, + }); + if (contractVerdict.verdict === 'deny') { + return { + decision: 'deny', + source: 'contract', + reason: contractVerdict.reason + ? `denied by file contract (${contractVerdict.rule}): ${contractVerdict.reason}` + : `denied by file contract (${contractVerdict.rule})`, + permissionVerdict: 'deny', + }; + } + + // Step 1: Permission verdict, tightened by the contract. Most-restrictive-wins + // means an absent contract yields `no-match` and collapses to the tool verdict + // exactly, so adding this step changed nothing for anyone without a contract. const permReq: PermissionRequest = { tool: req.tool, input: req.input }; - const permVerdict = evaluatePermission(permReq, req.rules); + const permVerdict = mostRestrictive( + evaluatePermission(permReq, req.rules), + contractVerdict.verdict, + ); // Step 2: Mode policy (incorporates permission verdict) const modeReq: ModeRequest = { @@ -152,11 +185,21 @@ export async function dispatchToolCall(req: DispatchRequest): Promise { ); }); }); + +describe('RuntimeHost — file contract', () => { + /** A workspace with a contract that denies writing to the given glob. */ + async function workspaceDenying(glob: string): Promise<{ cwd: string; home: string }> { + const cwd = await mkdtemp(join(tmpdir(), 'dc-host-contract-')); + const home = await mkdtemp(join(tmpdir(), 'dc-host-home-')); + await mkdir(join(cwd, '.deepcode'), { recursive: true }); + await writeFile( + join(cwd, '.deepcode', 'file-contract.yaml'), + `version: 1\nrules:\n - glob: "${glob}"\n write: deny\n reason: "not this one"\n`, + ); + return { cwd, home }; + } + + function countingWrite(): { handler: ToolHandler; count: () => number } { + let executions = 0; + return { + handler: { + name: 'Write', + definition: { name: 'Write', description: 'w', inputSchema: { type: 'object' } }, + async execute() { + executions++; + return { content: 'written' }; + }, + }, + count: () => executions, + }; + } + + it('loads the contract itself, so a client passing nothing still gets it', async () => { + // The point of doing this in the host: four clients each remembering an + // optional argument is the shape AGENTS.md rules out for a tool gate. + const { cwd, home } = await workspaceDenying('x'); + const write = countingWrite(); + try { + const host = new RuntimeHost({ + provider: new ScriptedProvider(writeThenDone()), + tools: new ToolRegistry([write.handler]), + cwd, + home, + mode: 'bypassPermissions', + }); + await host.run({ systemPrompt: '', userMessage: 'go', history: [], model: 'm' }); + expect(write.count()).toBe(0); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + + it('lets the write through when the contract does not cover it', async () => { + const { cwd, home } = await workspaceDenying('some-other-file'); + const write = countingWrite(); + try { + const host = new RuntimeHost({ + provider: new ScriptedProvider(writeThenDone()), + tools: new ToolRegistry([write.handler]), + cwd, + home, + mode: 'bypassPermissions', + }); + await host.run({ systemPrompt: '', userMessage: 'go', history: [], model: 'm' }); + expect(write.count()).toBe(1); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + + it('reports the sandbox-off warning through the host', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-host-warn-')); + const home = await mkdtemp(join(tmpdir(), 'dc-host-warn-home-')); + 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', + ); + try { + const host = new RuntimeHost({ + provider: new ScriptedProvider([]), + tools: new ToolRegistry([]), + cwd, + home, + mode: 'default', + sandboxDefaultMode: 'danger-full-access', + }); + const warnings = await host.contractWarnings(); + expect(warnings.join('\n')).toContain('sandbox is off'); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + + it('is silent and inert in a workspace with no contract', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-host-none-')); + const home = await mkdtemp(join(tmpdir(), 'dc-host-none-home-')); + const write = countingWrite(); + try { + const host = new RuntimeHost({ + provider: new ScriptedProvider(writeThenDone()), + tools: new ToolRegistry([write.handler]), + cwd, + home, + mode: 'bypassPermissions', + }); + expect(await host.contractWarnings()).toEqual([]); + await host.run({ systemPrompt: '', userMessage: 'go', history: [], model: 'm' }); + expect(write.count()).toBe(1); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/runtime/host.ts b/packages/core/src/runtime/host.ts index 4b5f5aa..666aaaa 100644 --- a/packages/core/src/runtime/host.ts +++ b/packages/core/src/runtime/host.ts @@ -10,6 +10,9 @@ import type { SandboxConfig, SandboxMode, } from '../config/types.js'; +import { fileContractWarnings } from '../config/contract-dispatch.js'; +import { loadFileContract, type LoadedFileContract } from '../config/file-contract-loader.js'; +import { resolveSandboxMode } from '../sandbox/policy.js'; import type { HookDispatcher } from '../hooks/index.js'; import type { Provider } from '../providers/types.js'; import type { ToolRegistry } from '../tools/registry.js'; @@ -35,6 +38,15 @@ export interface RuntimeHostOptions { */ sandboxDefaultMode?: SandboxMode; pluginDirs?: string[]; + /** + * Override $HOME when looking for a user-level file contract (tests). + */ + home?: string; + /** + * Skip loading the file contract. Only for callers that have no workspace to + * read it from; every real host wants the default. + */ + disableFileContract?: boolean; } type HostBoundOption = @@ -47,7 +59,8 @@ type HostBoundOption = | 'autoMode' | 'sandboxConfig' | 'sandboxDefaultMode' - | 'pluginDirs'; + | 'pluginDirs' + | 'contract'; export type RuntimeTurnOptions = Omit & { cwd?: string; @@ -64,6 +77,8 @@ export type RuntimeTurnOptions = Omit export class RuntimeHost { readonly mode: Mode; readonly permissions: PermissionRules; + /** Populated on first run; per-cwd because a turn may name its own. */ + private readonly contracts = new Map(); constructor(private readonly options: RuntimeHostOptions) { const policy = resolveRuntimePolicy(options); @@ -71,10 +86,54 @@ export class RuntimeHost { this.permissions = policy.permissions; } + /** + * Load (and cache) the contract for a workspace. + * + * The host does this rather than each client passing one in. Four clients + * each remembering an optional argument is exactly the shape AGENTS.md rules + * out for anything that gates tool execution. + */ + async fileContract(cwd?: string): Promise { + const dir = cwd ?? this.options.cwd; + if (!dir) return { status: 'absent' }; + if (this.options.disableFileContract) return { status: 'absent' }; + const cached = this.contracts.get(dir); + if (cached) return cached; + const loaded = await loadFileContract({ cwd: dir, home: this.options.home }); + this.contracts.set(dir, loaded); + return loaded; + } + + /** + * Operator warnings about how far the contract actually reaches, for hosts to + * print at startup and for `deepcode doctor`. + */ + async contractWarnings(cwd?: string): Promise { + const loaded = await this.fileContract(cwd); + return fileContractWarnings({ + ...loaded, + sandboxMode: resolveSandboxMode( + this.options.sandboxConfig, + this.options.sandboxDefaultMode ?? 'workspace-write', + ), + }); + } + + /** + * Not `async`: the missing-cwd check is a programming error and has always + * thrown synchronously. Making the whole method async would quietly turn that + * throw into a rejection and change what callers catch, so the async work + * lives in `runWithContract` behind a synchronous guard. + */ run(turn: RuntimeTurnOptions): Promise { const cwd = turn.cwd ?? this.options.cwd; if (!cwd) throw new Error('RuntimeHost requires cwd in the host or turn options'); + return this.runWithContract(turn, cwd); + } + + private async runWithContract(turn: RuntimeTurnOptions, cwd: string): Promise { const { modeOverride, approval, ...agentTurn } = turn; + const contract = (await this.fileContract(cwd)).contract; return runAgent({ ...agentTurn, provider: this.options.provider, @@ -82,6 +141,7 @@ export class RuntimeHost { cwd, mode: modeOverride ?? this.mode, permissions: this.permissions, + contract, hooks: this.options.hooks, approval: approval ?? this.options.approval, autoMode: this.options.autoMode,