From cef811ff11d06d80607edaf1b9aaca767418c422 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 17:51:09 +0800 Subject: [PATCH] feat(core): add the file contract parser and evaluator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission rules match on the tool, not the path. The only path-aware match is a prefix compare against primaryInput(), and a real file_path is usually absolute — so `Read(.env*)` matches nothing, and "never read .env" is a sentence settings.json cannot express. Adds the missing axis: glob × {read, write, execute} × {allow, ask, deny}. The verdict type is PermissionVerdict, the same lattice tool rules already produce, so composing the two needs no new vocabulary. Nothing is wired up yet — this PR is parse and decide only, so the diff that touches the dispatcher can be read on its own. Shape: - Evaluation is pure; loading is a separate module. That split is what makes the decision table exhaustively testable. - More specific glob wins (fewer **, then more segments, then more literals), ties go to the later rule, so narrowing needs no reordering. - Writes to the contract itself are denied unconditionally. A contract that can grant itself write access is not a contract. - Paths outside the workspace get no verdict rather than an invented one. - A malformed contract reports `invalid`, never `absent`: falling back to "no contract" would silently drop every deny the author wrote. The parser is strict for the same reason — a dropped line here is a permission granted. The glob matcher is hand-written; the repo carries no YAML or glob dependency and this file has exactly one shape, so a strict small parser beats a permissive general one. Documented in docs/file-contract.md, which states plainly that this is policy and not a security boundary: it constrains dispatcher tool calls, not what a shell command does after Bash starts. Only the sandbox bounds that. Co-Authored-By: Claude Opus 5 --- docs/file-contract.md | 124 ++++++ .../core/src/config/file-contract-loader.ts | 151 +++++++ .../core/src/config/file-contract.test.ts | 361 +++++++++++++++ packages/core/src/config/file-contract.ts | 421 ++++++++++++++++++ packages/core/src/config/index.ts | 27 ++ packages/core/src/index.ts | 23 + 6 files changed, 1107 insertions(+) create mode 100644 docs/file-contract.md create mode 100644 packages/core/src/config/file-contract-loader.ts create mode 100644 packages/core/src/config/file-contract.test.ts create mode 100644 packages/core/src/config/file-contract.ts diff --git a/docs/file-contract.md b/docs/file-contract.md new file mode 100644 index 0000000..9ecf58f --- /dev/null +++ b/docs/file-contract.md @@ -0,0 +1,124 @@ +# File contract + +A file contract states, per path, what DeepCode may read, write, or execute. It +is optional: with no contract file, nothing changes. + +It exists because `settings.json` permission rules match on the **tool**, not the +path. `Bash(git diff:*)` and `WebFetch(domain:github.com)` work well, but there +is no way to write "never read `.env`" — the only path-aware match is a prefix +compare against the tool's primary argument, and a real `file_path` is usually +absolute, so `Read(.env*)` matches nothing at all. + +> **This is policy, not a security boundary.** A contract constrains tool calls +> that go through DeepCode's dispatcher. It does not constrain what a shell +> command does after Bash starts — `cat .env` is a string, and statically +> analysing shell to decide otherwise would be guesswork that reads as a +> guarantee. Only the **sandbox** bounds Bash. See +> [security-model.md](security-model.md). + +## Where it lives + +The first file found wins; there is no merging. + +1. `/.deepcode/file-contract.yaml` +2. `/.deepcode/file-contract.yml` +3. `~/.deepcode/file-contract.yaml` +4. `~/.deepcode/file-contract.yml` + +Project beats user rather than merging, so "which file denied this?" is always +answerable by opening one file. + +## Format + +```yaml +version: 1 + +defaults: + read: allow + write: allow + execute: allow + +rules: + - glob: '**/.env*' + owner: human + read: deny + write: deny + reason: 'Secrets are human-only.' + + - glob: '{AGENTS.md,CLAUDE.md,DEEPCODE.md}' + owner: shared + write: ask + reason: 'Agent instructions shape every future run — review before writing.' +``` + +| Field | Values | Meaning | +| ------------------------ | ------------------------------ | ------------------------------------------------------------ | +| `glob` | pattern | Which paths this rule covers (workspace-relative) | +| `read` `write` `execute` | `allow` \| `ask` \| `deny` | Decision for that axis; omit an axis to say nothing about it | +| `owner` | `human` \| `agent` \| `shared` | Responsibility, not access control — it shapes wording | +| `reason` | free text | Shown verbatim when the rule produces `ask` or `deny` | + +`ask` is the useful middle state: the change is legitimate but wants eyes on it +before it lands. Without it, everything high-impact has to be either waved +through or forbidden. + +### Glob syntax + +| Pattern | Matches | +| -------- | -------------------------------------- | +| `*` | Any characters within one path segment | +| `**` | Any characters across segments | +| `a/**/b` | Also matches `a/b` — zero directories | +| `?` | Exactly one non-separator character | +| `{a,b}` | Either alternative | + +Everything else is literal, including `.`, so `**/.env*` cannot accidentally +match `axenv`. + +### Precedence + +1. The **more specific** glob wins — fewer `**`, then more path segments, then + more literal characters. +2. On an exact tie, the **later** rule wins. + +So a broad rule can be narrowed further down the file without reordering. + +### Paths outside the workspace + +A contract has no authority over `/etc`, so paths resolving outside the project +get no verdict at all and fall through to the tool rules and the sandbox. + +Note that path resolution is string math — it does not call `realpath`. A +symlink inside the workspace pointing outside still looks inside. This is the +same reason the box at the top matters: the sandbox is the boundary. + +## Self-protection + +Writes to `.deepcode/file-contract.yaml` (and `.yml`) are always denied, +regardless of what the file says. A contract that can grant itself +`write: allow` is not a contract. Reading it stays allowed — auditing it is the +whole point. + +## When it is malformed + +An unparseable contract is reported as **invalid**, not treated as absent. +Falling back to "no contract" would silently drop every `deny` the author wrote, +which is the worst possible failure for this particular file. DeepCode keeps +running under the tool rules alone and says so, naming the file and line. + +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. + +## Interaction with `settings.json` + +The two rule sets compose by **most-restrictive-wins**: + +``` +final = mostRestrictive(toolVerdict, pathVerdict) deny > ask > allow +``` + +A contract can only tighten. It never overrides a `deny` in `settings.json` into +an allow, and an absent contract yields no verdict at all — which is what makes +"no contract file, no behaviour change" exactly true rather than approximately +true. diff --git a/packages/core/src/config/file-contract-loader.ts b/packages/core/src/config/file-contract-loader.ts new file mode 100644 index 0000000..5151a9a --- /dev/null +++ b/packages/core/src/config/file-contract-loader.ts @@ -0,0 +1,151 @@ +// Loading side of the file contract — kept apart from `file-contract.ts` so the +// decision logic stays free of `node:fs` and remains exhaustively testable. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.A + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + FileContractError, + parseFileContract, + type ContractDecision, + type FileContract, +} from './file-contract.js'; + +/** + * Outcome of looking for a contract. + * + * `invalid` exists because the two obvious alternatives are both wrong: falling + * back to "no contract" silently drops every `deny` the author wrote, and + * refusing to start turns a typo into a broken install. Reporting it lets the + * caller keep working under the tool rules alone while saying so loudly. + */ +export type FileContractStatus = 'absent' | 'loaded' | 'invalid'; + +export interface LoadedFileContract { + status: FileContractStatus; + contract?: FileContract; + /** Absolute path of the file used, when one was found. */ + path?: string; + /** Parse failure detail, present only when status is `invalid`. */ + error?: string; +} + +export interface LoadFileContractOpts { + cwd: string; + /** Override $HOME (tests). */ + home?: string; + /** Direct DeepCode data directory (contains file-contract.yaml). */ + directory?: string; +} + +/** Candidate locations, most specific first. */ +export function fileContractPaths(opts: LoadFileContractOpts): string[] { + const home = opts.home ?? homedir(); + const directory = opts.directory ?? join(home, '.deepcode'); + return [ + join(opts.cwd, '.deepcode', 'file-contract.yaml'), + join(opts.cwd, '.deepcode', 'file-contract.yml'), + join(directory, 'file-contract.yaml'), + join(directory, 'file-contract.yml'), + ]; +} + +/** + * Load the first contract that exists. + * + * Project beats user rather than merging them. Merging two rule lists would + * make precedence depend on concatenation order across files nobody sees + * together, and "which file denied this?" is a question the user has to be able + * to answer by opening one file. + */ +export async function loadFileContract(opts: LoadFileContractOpts): Promise { + for (const path of fileContractPaths(opts)) { + let raw: string; + try { + raw = await fs.readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue; + return { status: 'invalid', path, error: (err as Error).message }; + } + try { + return { status: 'loaded', path, contract: parseFileContract(raw) }; + } catch (err) { + const message = + err instanceof FileContractError ? err.message : `unparseable contract: ${String(err)}`; + return { status: 'invalid', path, error: message }; + } + } + return { status: 'absent' }; +} + +/** + * Starter contract for `deepcode contract init`. + * + * Defaults stay `allow` on all three axes. A coding agent that writes code is + * doing its job, so the useful contract denies the handful of paths that are + * never the job, rather than asking about everything and training the user to + * approve reflexively. + */ +export const RECOMMENDED_FILE_CONTRACT = `# DeepCode file contract — permission rules on the path axis. +# Docs: https://github.com/oratis/deepcode/blob/main/docs/file-contract.md +# +# Decisions: allow | ask | deny. Axes: read | write | execute. +# More specific glob wins; equal specificity means the later rule wins. +# +# This constrains tool calls (Read/Write/Edit/Grep/Glob). It does NOT constrain +# what a shell command does once Bash starts — only the sandbox does that. + +version: 1 + +defaults: + read: allow + write: allow + execute: allow + +rules: + # Secrets are never the job. + - glob: "**/.env*" + owner: human + read: deny + write: deny + reason: "Secrets are human-only." + + - glob: "**/*.{pem,key,p12,pfx,keystore,jks}" + owner: human + read: deny + write: deny + reason: "Private keys are human-only." + + - glob: "**/{id_rsa,id_ed25519,id_ecdsa,.npmrc,.pypirc,.netrc}" + owner: human + read: deny + write: deny + reason: "Credential file — human-only." + + # High impact: allowed, but worth a look before it lands. + - glob: "{AGENTS.md,CLAUDE.md,DEEPCODE.md}" + owner: shared + write: ask + reason: "Agent instructions shape every future run — review before writing." + + - glob: ".github/workflows/**" + owner: human + write: ask + reason: "CI runs with repository credentials." + + - glob: ".deepcode/settings.json" + owner: human + write: ask + reason: "Settings hold the permission rules themselves." +`; + +/** Decisions in this contract that only take effect while the sandbox is on. */ +export function contractNeedsSandbox(contract: FileContract | undefined): boolean { + if (!contract) return false; + const denies = (d: ContractDecision | undefined): boolean => d === 'deny'; + return ( + contract.defaults.read === 'deny' || + contract.rules.some((r) => denies(r.read) || denies(r.execute)) + ); +} diff --git a/packages/core/src/config/file-contract.test.ts b/packages/core/src/config/file-contract.test.ts new file mode 100644 index 0000000..36b16b7 --- /dev/null +++ b/packages/core/src/config/file-contract.test.ts @@ -0,0 +1,361 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + evaluatePath, + globMatches, + normalizeContractPath, + parseFileContract, + specificity, + type FileContract, +} from './file-contract.js'; +import { + RECOMMENDED_FILE_CONTRACT, + contractNeedsSandbox, + loadFileContract, +} from './file-contract-loader.js'; + +function contract(body: string): FileContract { + return parseFileContract(`version: 1\n${body}`); +} + +describe('globMatches', () => { + it('* stays inside one segment', () => { + expect(globMatches('src/*.ts', 'src/a.ts')).toBe(true); + expect(globMatches('src/*.ts', 'src/nested/a.ts')).toBe(false); + }); + + it('** crosses segments', () => { + expect(globMatches('src/**', 'src/a/b/c.ts')).toBe(true); + expect(globMatches('**/*.ts', 'a/b/c.ts')).toBe(true); + }); + + it('a/**/b also matches a/b — the zero-directory case', () => { + expect(globMatches('src/**/index.ts', 'src/index.ts')).toBe(true); + expect(globMatches('src/**/index.ts', 'src/a/b/index.ts')).toBe(true); + }); + + it('**/x matches x at the root', () => { + expect(globMatches('**/.env', '.env')).toBe(true); + expect(globMatches('**/.env', 'config/.env')).toBe(true); + }); + + it('{a,b} alternates', () => { + expect(globMatches('**/*.{pem,key}', 'certs/server.pem')).toBe(true); + expect(globMatches('**/*.{pem,key}', 'certs/server.key')).toBe(true); + expect(globMatches('**/*.{pem,key}', 'certs/server.txt')).toBe(false); + }); + + it('? matches one non-separator character', () => { + expect(globMatches('a?.ts', 'ab.ts')).toBe(true); + expect(globMatches('a?.ts', 'a/.ts')).toBe(false); + }); + + it('treats regex metacharacters as literals', () => { + // A glob is not a regex; `.` must not match an arbitrary character, or + // `**/.env*` would also deny `axenv`. + expect(globMatches('a.txt', 'axtxt')).toBe(false); + expect(globMatches('a+b.ts', 'a+b.ts')).toBe(true); + expect(globMatches('(x).ts', '(x).ts')).toBe(true); + }); +}); + +describe('specificity ordering', () => { + const rank = (g: string) => specificity(g); + + it('fewer ** beats more', () => { + expect(rank('src/*.ts').doubleStars).toBeLessThan(rank('**/*.ts').doubleStars); + }); + + it('more segments beats fewer at equal **', () => { + expect(rank('src/a/**').segments).toBeGreaterThan(rank('src/**').segments); + }); + + it('more literal characters breaks the remaining tie', () => { + expect(rank('**/.env*').literals).toBeGreaterThan(rank('**/*').literals); + }); +}); + +describe('evaluatePath', () => { + it('returns no-match without a contract, so an absent file changes nothing', () => { + expect(evaluatePath(undefined, { path: 'a.ts', action: 'write' }).verdict).toBe('no-match'); + }); + + it('falls back to defaults when no rule covers the action', () => { + const c = contract(` +defaults: + write: ask +rules: + - glob: "src/**" + read: allow +`); + expect(evaluatePath(c, { path: 'src/a.ts', action: 'write' }).verdict).toBe('ask'); + expect(evaluatePath(c, { path: 'src/a.ts', action: 'read' }).verdict).toBe('allow'); + }); + + it('defaults to allow on every axis when no defaults block is given', () => { + const c = contract(` +rules: + - glob: "**/.env*" + read: deny +`); + expect(evaluatePath(c, { path: 'src/a.ts', action: 'write' }).verdict).toBe('allow'); + expect(evaluatePath(c, { path: '.env', action: 'read' }).verdict).toBe('deny'); + }); + + it('the more specific glob wins regardless of file order', () => { + const c = contract(` +rules: + - glob: "**/.env*" + read: deny + - glob: "src/**" + read: allow +`); + // Both match src/.env.local; the deny is more literal, so it wins even + // though the allow is written later. + expect(evaluatePath(c, { path: 'src/.env.local', action: 'read' }).verdict).toBe('deny'); + }); + + it('equal specificity resolves to the later rule', () => { + const c = contract(` +rules: + - glob: "src/*.ts" + write: deny + - glob: "src/*.ts" + write: allow +`); + expect(evaluatePath(c, { path: 'src/a.ts', action: 'write' }).verdict).toBe('allow'); + }); + + it('surfaces the rule and its reason so a refusal is explainable', () => { + const c = contract(` +rules: + - glob: "**/.env*" + read: deny + reason: "Secrets are human-only." +`); + const out = evaluatePath(c, { path: '.env', action: 'read' }); + expect(out.rule).toBe('**/.env*'); + expect(out.reason).toBe('Secrets are human-only.'); + }); + + it('ignores rules that say nothing about the action being asked about', () => { + const c = contract(` +defaults: + read: allow +rules: + - glob: "src/**" + write: deny +`); + expect(evaluatePath(c, { path: 'src/a.ts', action: 'read' }).verdict).toBe('allow'); + }); + + // ── adversarial ────────────────────────────────────────────────────── + describe('a contract cannot widen itself', () => { + it('refuses writes to the contract even when a rule allows them', () => { + const c = contract(` +rules: + - glob: ".deepcode/file-contract.yaml" + write: allow +`); + const out = evaluatePath(c, { path: '.deepcode/file-contract.yaml', action: 'write' }); + expect(out.verdict).toBe('deny'); + expect(out.reason).toMatch(/cannot amend itself/); + }); + + it('holds when the permissive rule is broad rather than exact', () => { + const c = contract(` +defaults: + write: allow +rules: + - glob: "**" + write: allow +`); + expect( + evaluatePath(c, { path: '.deepcode/file-contract.yml', action: 'write' }).verdict, + ).toBe('deny'); + }); + + it('still permits reading it — auditing the contract is the point', () => { + const c = contract(` +rules: + - glob: "**" + read: allow +`); + expect( + evaluatePath(c, { path: '.deepcode/file-contract.yaml', action: 'read' }).verdict, + ).toBe('allow'); + }); + }); +}); + +describe('normalizeContractPath', () => { + const cwd = '/work/repo'; + + it('makes absolute in-workspace paths relative', () => { + expect(normalizeContractPath(cwd, '/work/repo/src/a.ts')).toBe('src/a.ts'); + }); + + it('resolves relative paths against the workspace', () => { + expect(normalizeContractPath(cwd, 'src/a.ts')).toBe('src/a.ts'); + expect(normalizeContractPath(cwd, './src/../src/a.ts')).toBe('src/a.ts'); + }); + + it('returns null for paths outside the workspace', () => { + // Out-of-workspace paths belong to the sandbox; the contract declines to + // have an opinion rather than inventing one. + expect(normalizeContractPath(cwd, '/etc/passwd')).toBeNull(); + expect(normalizeContractPath(cwd, '../other/a.ts')).toBeNull(); + expect(normalizeContractPath(cwd, '/work/repo/../secrets/.env')).toBeNull(); + }); + + it('does not let ../ traversal re-enter and dodge a rule', () => { + // src/../.env normalizes to .env, so a rule on .env still applies. + expect(normalizeContractPath(cwd, '/work/repo/src/../.env')).toBe('.env'); + }); + + it('returns null for the workspace root and for empty input', () => { + expect(normalizeContractPath(cwd, cwd)).toBeNull(); + expect(normalizeContractPath(cwd, '')).toBeNull(); + }); + + it('is not fooled by a sibling directory sharing the workspace prefix', () => { + expect(normalizeContractPath('/work/repo', '/work/repo-evil/.env')).toBeNull(); + }); +}); + +describe('parseFileContract', () => { + it('parses the shipped recommended contract', () => { + const parsed = parseFileContract(RECOMMENDED_FILE_CONTRACT); + expect(parsed.version).toBe(1); + expect(parsed.rules.length).toBeGreaterThan(0); + expect(evaluatePath(parsed, { path: '.env.local', action: 'read' }).verdict).toBe('deny'); + expect(evaluatePath(parsed, { path: 'src/a.ts', action: 'write' }).verdict).toBe('allow'); + expect(evaluatePath(parsed, { path: 'AGENTS.md', action: 'write' }).verdict).toBe('ask'); + expect( + evaluatePath(parsed, { path: '.github/workflows/ci.yml', action: 'write' }).verdict, + ).toBe('ask'); + }); + + it('keeps # inside a quoted reason', () => { + const c = contract(` +rules: + - glob: "a" + write: deny + reason: "see issue #42" +`); + expect(c.rules[0]!.reason).toBe('see issue #42'); + }); + + it('strips a trailing comment', () => { + const c = contract(` +rules: + - glob: "a" # the a file + write: deny +`); + expect(c.rules[0]!.glob).toBe('a'); + }); + + // Every one of these must throw rather than silently drop a rule: a dropped + // line in this file is a permission quietly granted. + it.each([ + ['missing version', 'rules:\n - glob: "a"\n read: deny\n'], + ['unsupported version', 'version: 2\n'], + ['unknown top-level key', 'version: 1\nrulez:\n'], + ['unknown rule field', 'version: 1\nrules:\n - glob: "a"\n rewrite: deny\n'], + ['unknown default axis', 'version: 1\ndefaults:\n delete: deny\n'], + ['invalid decision', 'version: 1\nrules:\n - glob: "a"\n read: maybe\n'], + ['invalid owner', 'version: 1\nrules:\n - glob: "a"\n owner: robot\n read: deny\n'], + ['rule without glob', 'version: 1\nrules:\n - read: deny\n'], + ['rule that decides nothing', 'version: 1\nrules:\n - glob: "a"\n'], + ['line without a colon', 'version: 1\nrules:\n - glob: "a"\n deny\n'], + ['non-integer version', 'version: one\n'], + ])('rejects %s', (_label, body) => { + expect(() => parseFileContract(body)).toThrow(); + }); + + it('reports the offending line number', () => { + expect(() => parseFileContract('version: 1\nrules:\n - glob: "a"\n read: maybe\n')).toThrow( + /line 4/, + ); + }); +}); + +describe('loadFileContract', () => { + let cwd: string; + let home: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-contract-cwd-')); + 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('reports absent when there is no contract anywhere', async () => { + expect((await loadFileContract({ cwd, home })).status).toBe('absent'); + }); + + it('loads the project contract', 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 loaded = await loadFileContract({ cwd, home }); + expect(loaded.status).toBe('loaded'); + expect(evaluatePath(loaded.contract, { path: '.env', action: 'read' }).verdict).toBe('deny'); + }); + + it('prefers the project contract over the user one instead of merging', async () => { + await mkdir(join(cwd, '.deepcode'), { recursive: true }); + await mkdir(join(home, '.deepcode'), { recursive: true }); + await writeFile( + join(home, '.deepcode', 'file-contract.yaml'), + 'version: 1\nrules:\n - glob: "**"\n write: deny\n', + ); + await writeFile( + join(cwd, '.deepcode', 'file-contract.yaml'), + 'version: 1\nrules:\n - glob: "**"\n write: allow\n', + ); + const loaded = await loadFileContract({ cwd, home }); + expect(evaluatePath(loaded.contract, { path: 'a.ts', action: 'write' }).verdict).toBe('allow'); + }); + + it('falls back to the user contract when the project has none', async () => { + await mkdir(join(home, '.deepcode'), { recursive: true }); + await writeFile( + join(home, '.deepcode', 'file-contract.yaml'), + 'version: 1\nrules:\n - glob: "**/.env*"\n read: deny\n', + ); + expect((await loadFileContract({ cwd, home })).status).toBe('loaded'); + }); + + it('reports a malformed contract as invalid rather than as absent', async () => { + // Reporting `absent` would silently drop every deny the author wrote. + await mkdir(join(cwd, '.deepcode'), { recursive: true }); + await writeFile(join(cwd, '.deepcode', 'file-contract.yaml'), 'version: 1\nrules:\n - oops\n'); + const loaded = await loadFileContract({ cwd, home }); + expect(loaded.status).toBe('invalid'); + expect(loaded.error).toBeTruthy(); + expect(loaded.contract).toBeUndefined(); + }); +}); + +describe('contractNeedsSandbox', () => { + it('is true when any read or execute deny exists', () => { + expect(contractNeedsSandbox(contract('rules:\n - glob: "a"\n read: deny\n'))).toBe(true); + expect(contractNeedsSandbox(contract('rules:\n - glob: "a"\n execute: deny\n'))).toBe(true); + }); + + it('is false for a write-only contract, which Bash cannot bypass silently', () => { + expect(contractNeedsSandbox(contract('rules:\n - glob: "a"\n write: deny\n'))).toBe(false); + }); + + it('is false without a contract', () => { + expect(contractNeedsSandbox(undefined)).toBe(false); + }); +}); diff --git a/packages/core/src/config/file-contract.ts b/packages/core/src/config/file-contract.ts new file mode 100644 index 0000000..10840d4 --- /dev/null +++ b/packages/core/src/config/file-contract.ts @@ -0,0 +1,421 @@ +// File contract — permission rules on the *path* axis. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.A +// +// `permissions.ts` answers "may the agent call this tool with these arguments". +// It cannot answer "may the agent read this file", because its only path-aware +// match is a prefix compare against `primaryInput()`, and a real `file_path` is +// usually absolute — so `Read(.env*)` matches nothing. This file adds the +// missing axis: glob × {read, write, execute} × {allow, ask, deny}. +// +// Two properties the rest of the system depends on: +// +// 1. The verdict type is `PermissionVerdict`, the same lattice the tool rules +// already produce, so composing them needs no new vocabulary. +// 2. Evaluation is pure. Loading touches the filesystem; deciding does not, +// which is what makes the decision table exhaustively testable. +// +// Deliberately NOT a security boundary. A contract constrains tool calls that +// go through the dispatcher; it says nothing about what a shell command does +// once Bash has started. Only the sandbox constrains that. + +import { isAbsolute, relative, resolve } from 'node:path'; +import type { PermissionVerdict } from './permissions.js'; + +export type ContractAction = 'read' | 'write' | 'execute'; +export type ContractDecision = 'allow' | 'ask' | 'deny'; +/** Responsibility, not access control — it shapes wording and conflict handling. */ +export type ContractOwner = 'human' | 'agent' | 'shared'; + +const ACTIONS: readonly ContractAction[] = ['read', 'write', 'execute']; +const DECISIONS: readonly string[] = ['allow', 'ask', 'deny']; +const OWNERS: readonly string[] = ['human', 'agent', 'shared']; + +export interface FileContractRule { + glob: string; + owner?: ContractOwner; + read?: ContractDecision; + write?: ContractDecision; + execute?: ContractDecision; + /** Shown verbatim when this rule produces an `ask` or `deny`. */ + reason?: string; +} + +export interface FileContract { + version: 1; + defaults: Record; + rules: FileContractRule[]; +} + +/** Applied when a contract declares no `defaults` block. */ +export const DEFAULT_CONTRACT_DEFAULTS: Record = { + read: 'allow', + write: 'allow', + execute: 'allow', +}; + +/** + * Paths the contract may never widen, enforced regardless of file content. + * + * A contract that can grant itself `write: allow` is not a contract — the first + * thing an agent talked into editing it would do is remove its own limits. + */ +const IMMUTABLE_DENY_WRITE = ['.deepcode/file-contract.yaml', '.deepcode/file-contract.yml']; + +export class FileContractError extends Error { + constructor( + message: string, + readonly line?: number, + ) { + super(line === undefined ? message : `line ${line}: ${message}`); + this.name = 'FileContractError'; + } +} + +// ── Evaluation ────────────────────────────────────────────────────────── + +export interface ContractRequest { + /** Workspace-relative POSIX path, from `normalizeContractPath`. */ + path: string; + action: ContractAction; +} + +export interface ContractEvaluation { + verdict: PermissionVerdict; + /** The glob that decided, absent when the answer came from `defaults`. */ + rule?: string; + /** Author-supplied explanation, surfaced to the user on ask/deny. */ + reason?: string; +} + +/** + * Decide one (path, action) pair. Pure. + * + * Precedence follows the contract's own stated rule: the more specific glob + * wins, and equal specificity resolves to the later rule — so a broad default + * near the top can be narrowed further down without reordering the file. + */ +export function evaluatePath( + contract: FileContract | undefined, + req: ContractRequest, +): ContractEvaluation { + if (!contract) return { verdict: 'no-match' }; + + // Self-protection beats everything, including a rule that says otherwise. + if (req.action === 'write' && IMMUTABLE_DENY_WRITE.includes(req.path)) { + return { + verdict: 'deny', + rule: req.path, + reason: 'The file contract cannot amend itself.', + }; + } + + let best: { rule: FileContractRule; rank: SpecificityRank } | undefined; + for (const rule of contract.rules) { + if (rule[req.action] === undefined) continue; + if (!globMatches(rule.glob, req.path)) continue; + const rank = specificity(rule.glob); + // `>= 0` (not `> 0`) is what makes "later wins" true on a tie. + if (!best || compareSpecificity(rank, best.rank) >= 0) best = { rule, rank }; + } + + if (best) { + const decision = best.rule[req.action]!; + return { + verdict: decision, + rule: best.rule.glob, + ...(best.rule.reason ? { reason: best.rule.reason } : {}), + }; + } + return { verdict: contract.defaults[req.action] }; +} + +/** + * Express `filePath` as a workspace-relative POSIX path, or null when it falls + * outside the workspace. + * + * Outside paths are the sandbox's problem, not the contract's: a contract is a + * per-project document and has no authority over `/etc`. Returning null keeps + * `evaluatePath` from inventing an opinion it cannot justify. + * + * Pure string math — no `realpath`. A symlink inside the workspace pointing out + * of it still normalizes to an inside-looking path. See the module header: the + * sandbox is the boundary, this is policy. + */ +export function normalizeContractPath(cwd: string, filePath: string): string | null { + if (!filePath) return null; + const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath); + const rel = relative(resolve(cwd), abs); + if (rel === '') return null; // the workspace root itself + if (rel.startsWith('..') || isAbsolute(rel)) return null; + return rel.split(/[/\\]+/).join('/'); +} + +// ── Glob matching ─────────────────────────────────────────────────────── + +/** + * Supported syntax: `*` (within a segment), `**` (across segments), `?`, and + * `{a,b}` alternation. Intentionally small — every construct here has to be + * explainable in the docs, and a contract nobody can read is a contract nobody + * audits. + */ +export function globMatches(glob: string, path: string): boolean { + let re = globRegexCache.get(glob); + if (!re) { + re = new RegExp(`^${globToRegexSource(glob)}$`); + globRegexCache.set(glob, re); + } + return re.test(path); +} + +const globRegexCache = new Map(); + +function globToRegexSource(glob: string): string { + let out = ''; + let i = 0; + while (i < glob.length) { + const c = glob[i]!; + if (c === '*') { + const isDouble = glob[i + 1] === '*'; + if (isDouble) { + // `a/**/b` must also match `a/b`, so consume the trailing slash and + // make the whole "slash plus segments" group optional. + if (glob[i + 2] === '/') { + out += '(?:.*/)?'; + i += 3; + continue; + } + out += '.*'; + i += 2; + continue; + } + out += '[^/]*'; + i += 1; + continue; + } + if (c === '?') { + out += '[^/]'; + i += 1; + continue; + } + if (c === '{') { + const close = glob.indexOf('}', i); + if (close === -1) { + out += '\\{'; + i += 1; + continue; + } + const alternatives = glob.slice(i + 1, close).split(','); + out += `(?:${alternatives.map(globToRegexSource).join('|')})`; + i = close + 1; + continue; + } + out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + i += 1; + } + return out; +} + +type SpecificityRank = { doubleStars: number; segments: number; literals: number }; + +/** + * Rank a glob so "more specific" is decidable without asking the author to + * order the file carefully. + * + * Fewer `**` beats more (`src/*.ts` over `**\/*.ts`); then more path segments + * beats fewer (`src/a/**` over `src/**`); then more literal characters beats + * fewer (`**\/.env*` over `**\/*`). + */ +export function specificity(glob: string): SpecificityRank { + const segments = glob.split('/'); + return { + doubleStars: segments.filter((s) => s.includes('**')).length, + segments: segments.length, + literals: glob.replace(/[*?{},]/g, '').length, + }; +} + +function compareSpecificity(a: SpecificityRank, b: SpecificityRank): number { + if (a.doubleStars !== b.doubleStars) return b.doubleStars - a.doubleStars; + if (a.segments !== b.segments) return a.segments - b.segments; + return a.literals - b.literals; +} + +// ── Parsing ───────────────────────────────────────────────────────────── + +/** + * Parse the contract's YAML subset. + * + * The repo carries no YAML dependency on purpose (see `skills/frontmatter.ts`), + * and a contract file has exactly one shape, so a focused strict parser beats + * a general permissive one: anything it does not recognise throws instead of + * being dropped. A silently-ignored `deny` line is the worst possible failure + * for this particular file. + */ +export function parseFileContract(raw: string): FileContract { + const lines = raw.split(/\r?\n/); + let version: number | undefined; + const defaults: Partial> = {}; + const rules: FileContractRule[] = []; + + type Section = 'top' | 'defaults' | 'rules'; + let section: Section = 'top'; + let current: FileContractRule | undefined; + + for (let n = 0; n < lines.length; n++) { + const rawLine = stripComment(lines[n]!); + if (rawLine.trim() === '') continue; + const indent = rawLine.length - rawLine.trimStart().length; + const line = rawLine.trim(); + const lineNo = n + 1; + + if (indent === 0) { + current = undefined; + const kv = splitKeyValue(line, lineNo); + switch (kv.key) { + case 'version': { + const parsed = Number(kv.value); + if (!Number.isInteger(parsed)) { + throw new FileContractError(`version must be an integer, got "${kv.value}"`, lineNo); + } + version = parsed; + section = 'top'; + continue; + } + case 'defaults': + requireEmptyValue(kv.value, 'defaults', lineNo); + section = 'defaults'; + continue; + case 'rules': + requireEmptyValue(kv.value, 'rules', lineNo); + section = 'rules'; + continue; + default: + throw new FileContractError(`unknown top-level key "${kv.key}"`, lineNo); + } + } + + if (section === 'defaults') { + const kv = splitKeyValue(line, lineNo); + if (!isAction(kv.key)) { + throw new FileContractError( + `unknown default "${kv.key}" (expected read/write/execute)`, + lineNo, + ); + } + defaults[kv.key] = requireDecision(kv.value, kv.key, lineNo); + continue; + } + + if (section === 'rules') { + const isItemStart = line.startsWith('- '); + const body = isItemStart ? line.slice(2).trim() : line; + if (isItemStart) { + current = { glob: '' }; + rules.push(current); + } + if (!current) throw new FileContractError('rule field outside any rule item', lineNo); + const kv = splitKeyValue(body, lineNo); + applyRuleField(current, kv.key, kv.value, lineNo); + continue; + } + + throw new FileContractError(`unexpected indented line under "${section}"`, lineNo); + } + + if (version === undefined) throw new FileContractError('missing required key "version"'); + if (version !== 1) throw new FileContractError(`unsupported version ${version} (expected 1)`); + for (const [index, rule] of rules.entries()) { + if (!rule.glob) throw new FileContractError(`rule #${index + 1} is missing "glob"`); + if (!ACTIONS.some((a) => rule[a] !== undefined) && !rule.owner) { + throw new FileContractError( + `rule "${rule.glob}" sets no read/write/execute decision and no owner, so it does nothing`, + ); + } + } + + return { version: 1, defaults: { ...DEFAULT_CONTRACT_DEFAULTS, ...defaults }, rules }; +} + +function applyRuleField(rule: FileContractRule, key: string, value: string, lineNo: number): void { + if (key === 'glob') { + if (!value) throw new FileContractError('glob must not be empty', lineNo); + rule.glob = value; + return; + } + if (key === 'reason') { + rule.reason = value; + return; + } + if (key === 'owner') { + if (!OWNERS.includes(value)) { + throw new FileContractError( + `owner must be one of ${OWNERS.join('/')}, got "${value}"`, + lineNo, + ); + } + rule.owner = value as ContractOwner; + return; + } + if (isAction(key)) { + rule[key] = requireDecision(value, key, lineNo); + return; + } + throw new FileContractError(`unknown rule field "${key}"`, lineNo); +} + +function isAction(key: string): key is ContractAction { + return (ACTIONS as readonly string[]).includes(key); +} + +function requireDecision(value: string, key: string, lineNo: number): ContractDecision { + if (!DECISIONS.includes(value)) { + throw new FileContractError( + `${key} must be one of ${DECISIONS.join('/')}, got "${value}"`, + lineNo, + ); + } + return value as ContractDecision; +} + +function requireEmptyValue(value: string, key: string, lineNo: number): void { + if (value !== '') + throw new FileContractError(`"${key}" takes a nested block, not a value`, lineNo); +} + +function splitKeyValue(line: string, lineNo: number): { key: string; value: string } { + const idx = line.indexOf(':'); + if (idx === -1) throw new FileContractError(`expected "key: value", got "${line}"`, lineNo); + const key = line.slice(0, idx).trim(); + if (!key) throw new FileContractError(`empty key in "${line}"`, lineNo); + return { key, value: unquote(line.slice(idx + 1).trim()) }; +} + +function unquote(value: string): string { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + } + return value; +} + +/** Drop a trailing `#` comment, leaving `#` inside quotes alone. */ +function stripComment(line: string): string { + let quote: string | undefined; + for (let i = 0; i < line.length; i++) { + const c = line[i]!; + if (quote) { + if (c === quote) quote = undefined; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } + // Only a `#` that starts a token is a comment, so `a#b` stays intact. + if (c === '#' && (i === 0 || /\s/.test(line[i - 1]!))) return line.slice(0, i); + } + return line; +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 020cc35..b696fde 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -66,3 +66,30 @@ export { type PermissionVerdict, type PermissionRequest, } from './permissions.js'; + +export { + evaluatePath, + globMatches, + normalizeContractPath, + parseFileContract, + specificity, + DEFAULT_CONTRACT_DEFAULTS, + FileContractError, + type ContractAction, + type ContractDecision, + type ContractEvaluation, + type ContractOwner, + type ContractRequest, + type FileContract, + type FileContractRule, +} from './file-contract.js'; + +export { + contractNeedsSandbox, + fileContractPaths, + loadFileContract, + RECOMMENDED_FILE_CONTRACT, + type FileContractStatus, + type LoadedFileContract, + type LoadFileContractOpts, +} from './file-contract-loader.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e7f3061..5e25873 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,6 +124,29 @@ export { type VoiceConfig, } from './config/index.js'; +// File contract — path-axis permission rules (plan §2.A) +export { + evaluatePath, + globMatches, + normalizeContractPath, + parseFileContract, + contractNeedsSandbox, + fileContractPaths, + loadFileContract, + DEFAULT_CONTRACT_DEFAULTS, + FileContractError, + RECOMMENDED_FILE_CONTRACT, + type ContractAction, + type ContractDecision, + type ContractEvaluation, + type ContractOwner, + type ContractRequest, + type FileContract, + type FileContractRule, + type FileContractStatus, + type LoadedFileContract, +} from './config/index.js'; + // Credentials (M2; M3c adds ApiKeyHelperRefresher) export { CredentialsStore,