Skip to content

Commit 70ebd59

Browse files
oratisoratisclaude
authored
feat(core): add the file contract parser and evaluator (#238)
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: oratis <happyllammar@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 40d0f0f commit 70ebd59

6 files changed

Lines changed: 1107 additions & 0 deletions

File tree

docs/file-contract.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# File contract
2+
3+
A file contract states, per path, what DeepCode may read, write, or execute. It
4+
is optional: with no contract file, nothing changes.
5+
6+
It exists because `settings.json` permission rules match on the **tool**, not the
7+
path. `Bash(git diff:*)` and `WebFetch(domain:github.com)` work well, but there
8+
is no way to write "never read `.env`" — the only path-aware match is a prefix
9+
compare against the tool's primary argument, and a real `file_path` is usually
10+
absolute, so `Read(.env*)` matches nothing at all.
11+
12+
> **This is policy, not a security boundary.** A contract constrains tool calls
13+
> that go through DeepCode's dispatcher. It does not constrain what a shell
14+
> command does after Bash starts — `cat .env` is a string, and statically
15+
> analysing shell to decide otherwise would be guesswork that reads as a
16+
> guarantee. Only the **sandbox** bounds Bash. See
17+
> [security-model.md](security-model.md).
18+
19+
## Where it lives
20+
21+
The first file found wins; there is no merging.
22+
23+
1. `<project>/.deepcode/file-contract.yaml`
24+
2. `<project>/.deepcode/file-contract.yml`
25+
3. `~/.deepcode/file-contract.yaml`
26+
4. `~/.deepcode/file-contract.yml`
27+
28+
Project beats user rather than merging, so "which file denied this?" is always
29+
answerable by opening one file.
30+
31+
## Format
32+
33+
```yaml
34+
version: 1
35+
36+
defaults:
37+
read: allow
38+
write: allow
39+
execute: allow
40+
41+
rules:
42+
- glob: '**/.env*'
43+
owner: human
44+
read: deny
45+
write: deny
46+
reason: 'Secrets are human-only.'
47+
48+
- glob: '{AGENTS.md,CLAUDE.md,DEEPCODE.md}'
49+
owner: shared
50+
write: ask
51+
reason: 'Agent instructions shape every future run — review before writing.'
52+
```
53+
54+
| Field | Values | Meaning |
55+
| ------------------------ | ------------------------------ | ------------------------------------------------------------ |
56+
| `glob` | pattern | Which paths this rule covers (workspace-relative) |
57+
| `read` `write` `execute` | `allow` \| `ask` \| `deny` | Decision for that axis; omit an axis to say nothing about it |
58+
| `owner` | `human` \| `agent` \| `shared` | Responsibility, not access control — it shapes wording |
59+
| `reason` | free text | Shown verbatim when the rule produces `ask` or `deny` |
60+
61+
`ask` is the useful middle state: the change is legitimate but wants eyes on it
62+
before it lands. Without it, everything high-impact has to be either waved
63+
through or forbidden.
64+
65+
### Glob syntax
66+
67+
| Pattern | Matches |
68+
| -------- | -------------------------------------- |
69+
| `*` | Any characters within one path segment |
70+
| `**` | Any characters across segments |
71+
| `a/**/b` | Also matches `a/b` — zero directories |
72+
| `?` | Exactly one non-separator character |
73+
| `{a,b}` | Either alternative |
74+
75+
Everything else is literal, including `.`, so `**/.env*` cannot accidentally
76+
match `axenv`.
77+
78+
### Precedence
79+
80+
1. The **more specific** glob wins — fewer `**`, then more path segments, then
81+
more literal characters.
82+
2. On an exact tie, the **later** rule wins.
83+
84+
So a broad rule can be narrowed further down the file without reordering.
85+
86+
### Paths outside the workspace
87+
88+
A contract has no authority over `/etc`, so paths resolving outside the project
89+
get no verdict at all and fall through to the tool rules and the sandbox.
90+
91+
Note that path resolution is string math — it does not call `realpath`. A
92+
symlink inside the workspace pointing outside still looks inside. This is the
93+
same reason the box at the top matters: the sandbox is the boundary.
94+
95+
## Self-protection
96+
97+
Writes to `.deepcode/file-contract.yaml` (and `.yml`) are always denied,
98+
regardless of what the file says. A contract that can grant itself
99+
`write: allow` is not a contract. Reading it stays allowed — auditing it is the
100+
whole point.
101+
102+
## When it is malformed
103+
104+
An unparseable contract is reported as **invalid**, not treated as absent.
105+
Falling back to "no contract" would silently drop every `deny` the author wrote,
106+
which is the worst possible failure for this particular file. DeepCode keeps
107+
running under the tool rules alone and says so, naming the file and line.
108+
109+
The parser is strict on purpose: unknown keys, unknown decision values, a rule
110+
with no `glob`, or a rule that decides nothing are all errors. A silently-ignored
111+
line here is a permission quietly granted.
112+
113+
## Interaction with `settings.json`
114+
115+
The two rule sets compose by **most-restrictive-wins**:
116+
117+
```
118+
final = mostRestrictive(toolVerdict, pathVerdict) deny > ask > allow
119+
```
120+
121+
A contract can only tighten. It never overrides a `deny` in `settings.json` into
122+
an allow, and an absent contract yields no verdict at all — which is what makes
123+
"no contract file, no behaviour change" exactly true rather than approximately
124+
true.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Loading side of the file contract — kept apart from `file-contract.ts` so the
2+
// decision logic stays free of `node:fs` and remains exhaustively testable.
3+
// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.A
4+
5+
import { promises as fs } from 'node:fs';
6+
import { homedir } from 'node:os';
7+
import { join } from 'node:path';
8+
import {
9+
FileContractError,
10+
parseFileContract,
11+
type ContractDecision,
12+
type FileContract,
13+
} from './file-contract.js';
14+
15+
/**
16+
* Outcome of looking for a contract.
17+
*
18+
* `invalid` exists because the two obvious alternatives are both wrong: falling
19+
* back to "no contract" silently drops every `deny` the author wrote, and
20+
* refusing to start turns a typo into a broken install. Reporting it lets the
21+
* caller keep working under the tool rules alone while saying so loudly.
22+
*/
23+
export type FileContractStatus = 'absent' | 'loaded' | 'invalid';
24+
25+
export interface LoadedFileContract {
26+
status: FileContractStatus;
27+
contract?: FileContract;
28+
/** Absolute path of the file used, when one was found. */
29+
path?: string;
30+
/** Parse failure detail, present only when status is `invalid`. */
31+
error?: string;
32+
}
33+
34+
export interface LoadFileContractOpts {
35+
cwd: string;
36+
/** Override $HOME (tests). */
37+
home?: string;
38+
/** Direct DeepCode data directory (contains file-contract.yaml). */
39+
directory?: string;
40+
}
41+
42+
/** Candidate locations, most specific first. */
43+
export function fileContractPaths(opts: LoadFileContractOpts): string[] {
44+
const home = opts.home ?? homedir();
45+
const directory = opts.directory ?? join(home, '.deepcode');
46+
return [
47+
join(opts.cwd, '.deepcode', 'file-contract.yaml'),
48+
join(opts.cwd, '.deepcode', 'file-contract.yml'),
49+
join(directory, 'file-contract.yaml'),
50+
join(directory, 'file-contract.yml'),
51+
];
52+
}
53+
54+
/**
55+
* Load the first contract that exists.
56+
*
57+
* Project beats user rather than merging them. Merging two rule lists would
58+
* make precedence depend on concatenation order across files nobody sees
59+
* together, and "which file denied this?" is a question the user has to be able
60+
* to answer by opening one file.
61+
*/
62+
export async function loadFileContract(opts: LoadFileContractOpts): Promise<LoadedFileContract> {
63+
for (const path of fileContractPaths(opts)) {
64+
let raw: string;
65+
try {
66+
raw = await fs.readFile(path, 'utf8');
67+
} catch (err) {
68+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
69+
return { status: 'invalid', path, error: (err as Error).message };
70+
}
71+
try {
72+
return { status: 'loaded', path, contract: parseFileContract(raw) };
73+
} catch (err) {
74+
const message =
75+
err instanceof FileContractError ? err.message : `unparseable contract: ${String(err)}`;
76+
return { status: 'invalid', path, error: message };
77+
}
78+
}
79+
return { status: 'absent' };
80+
}
81+
82+
/**
83+
* Starter contract for `deepcode contract init`.
84+
*
85+
* Defaults stay `allow` on all three axes. A coding agent that writes code is
86+
* doing its job, so the useful contract denies the handful of paths that are
87+
* never the job, rather than asking about everything and training the user to
88+
* approve reflexively.
89+
*/
90+
export const RECOMMENDED_FILE_CONTRACT = `# DeepCode file contract — permission rules on the path axis.
91+
# Docs: https://github.com/oratis/deepcode/blob/main/docs/file-contract.md
92+
#
93+
# Decisions: allow | ask | deny. Axes: read | write | execute.
94+
# More specific glob wins; equal specificity means the later rule wins.
95+
#
96+
# This constrains tool calls (Read/Write/Edit/Grep/Glob). It does NOT constrain
97+
# what a shell command does once Bash starts — only the sandbox does that.
98+
99+
version: 1
100+
101+
defaults:
102+
read: allow
103+
write: allow
104+
execute: allow
105+
106+
rules:
107+
# Secrets are never the job.
108+
- glob: "**/.env*"
109+
owner: human
110+
read: deny
111+
write: deny
112+
reason: "Secrets are human-only."
113+
114+
- glob: "**/*.{pem,key,p12,pfx,keystore,jks}"
115+
owner: human
116+
read: deny
117+
write: deny
118+
reason: "Private keys are human-only."
119+
120+
- glob: "**/{id_rsa,id_ed25519,id_ecdsa,.npmrc,.pypirc,.netrc}"
121+
owner: human
122+
read: deny
123+
write: deny
124+
reason: "Credential file — human-only."
125+
126+
# High impact: allowed, but worth a look before it lands.
127+
- glob: "{AGENTS.md,CLAUDE.md,DEEPCODE.md}"
128+
owner: shared
129+
write: ask
130+
reason: "Agent instructions shape every future run — review before writing."
131+
132+
- glob: ".github/workflows/**"
133+
owner: human
134+
write: ask
135+
reason: "CI runs with repository credentials."
136+
137+
- glob: ".deepcode/settings.json"
138+
owner: human
139+
write: ask
140+
reason: "Settings hold the permission rules themselves."
141+
`;
142+
143+
/** Decisions in this contract that only take effect while the sandbox is on. */
144+
export function contractNeedsSandbox(contract: FileContract | undefined): boolean {
145+
if (!contract) return false;
146+
const denies = (d: ContractDecision | undefined): boolean => d === 'deny';
147+
return (
148+
contract.defaults.read === 'deny' ||
149+
contract.rules.some((r) => denies(r.read) || denies(r.execute))
150+
);
151+
}

0 commit comments

Comments
 (0)