Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions docs/file-contract.md
Original file line number Diff line number Diff line change
@@ -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. `<project>/.deepcode/file-contract.yaml`
2. `<project>/.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.
151 changes: 151 additions & 0 deletions packages/core/src/config/file-contract-loader.ts
Original file line number Diff line number Diff line change
@@ -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<LoadedFileContract> {
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))
);
}
Loading
Loading