From 0282d7a6b1ca41133b9defe40553289d09e119ac Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 17:59:07 +0800 Subject: [PATCH] feat(core): enforce the file contract at the tool gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the path-axis contract from #238 into dispatchToolCall, the one central gate, so all four clients get it without touching a line of client code. Composition is most-restrictive-wins over the existing PermissionVerdict lattice. `no-match` means "no opinion" and never wins, so an absent contract collapses to the tool verdict exactly — the no-op property is exact, not approximate, and the 16-cell table is enumerated in tests rather than sampled. A contract `deny` is checked first and cannot be waived, including by bypassPermissions. It states something standing about a path rather than prompting about one call, so the mode that exists to skip prompts has no business clearing it — otherwise the contract's strongest sentence would be its easiest to disable. Contract `ask` stays ordinary: mode and hooks still apply. RuntimeHost loads the contract itself instead of each client passing one in. Four hosts each remembering an optional argument is the shape AGENTS.md rules out for anything gating tool execution. Also closed while wiring: the plugin capability bridge in apps/server called dispatchToolCall without a contract, so a plugin subprocess would have been a way around path rules. Honest reach, said out loud rather than only in docs: when a contract denies reads while the sandbox is off, the REPL, headless, and `contract show` all warn that the denial covers Read/Grep/Glob and not Bash. Write-only contracts stay quiet — there is no false-enforcement risk there, and a warning nobody needs is one users learn to ignore. Adds `deepcode contract `; RECOMMENDED_FILE_CONTRACT was unreachable without a way to install it. RuntimeHost.run stays non-async on purpose. Making it async would have turned its existing synchronous missing-cwd throw into a rejection and changed what callers catch; the async work sits behind a synchronous guard instead. An existing test caught that. Co-Authored-By: Claude Opus 5 --- apps/cli/src/cli.ts | 8 + apps/cli/src/completion.ts | 1 + apps/cli/src/contract-cmd.test.ts | 114 ++++++++++ apps/cli/src/contract-cmd.ts | 166 ++++++++++++++ apps/cli/src/headless.ts | 5 + apps/cli/src/parse-args.ts | 1 + apps/cli/src/repl.ts | 7 + apps/server/src/runtime-composition.ts | 29 ++- docs/file-contract.md | 22 ++ packages/core/src/agent.ts | 4 + .../core/src/config/contract-dispatch.test.ts | 215 ++++++++++++++++++ packages/core/src/config/contract-dispatch.ts | 133 +++++++++++ packages/core/src/config/index.ts | 9 + .../core/src/harness/tool-dispatcher.test.ts | 149 ++++++++++++ packages/core/src/harness/tool-dispatcher.ts | 59 ++++- packages/core/src/index.ts | 4 + packages/core/src/runtime/host.test.ts | 118 ++++++++++ packages/core/src/runtime/host.ts | 62 ++++- 18 files changed, 1091 insertions(+), 15 deletions(-) create mode 100644 apps/cli/src/contract-cmd.test.ts create mode 100644 apps/cli/src/contract-cmd.ts create mode 100644 packages/core/src/config/contract-dispatch.test.ts create mode 100644 packages/core/src/config/contract-dispatch.ts 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,