Skip to content

feat(core): add the file contract parser and evaluator - #238

Merged
oratis merged 1 commit into
mainfrom
feat/file-contract
Aug 8, 2026
Merged

feat(core): add the file contract parser and evaluator#238
oratis merged 1 commit into
mainfrom
feat/file-contract

Conversation

@oratis

@oratis oratis commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

PR 1 of docs/FLOATBOAT_ADOPTION_PLAN.md §2.A. Parse and decide only — nothing is wired into the dispatcher, so the diff that touches the permission path can be reviewed on its own (that's PR 2).

Zero runtime behaviour change: no caller invokes this yet.

The gap it closes

settings.json permission rules match on the tool. Bash(git diff:*) works; Read(.env*) matches nothing, because the only path-aware match is a prefix compare against primaryInput() and a real file_path is absolute. So "never read .env" is a sentence the permission layer cannot express.

The sandbox can express part of it via sandbox.filesystem.denyRead, but that's inert when unconfigured (resolveSandboxMode falls to danger-full-access), its hardcoded deny list covers home-directory credential stores rather than in-project .env, and it's a flat prefix list with no ask state.

This adds the missing axis: glob × {read, write, execute} × {allow, ask, deny}.

Design decisions worth reviewing

The verdict type is PermissionVerdict. Selfware's require_discussion maps exactly onto DeepCode's existing ask, so there's no new lattice and no new vocabulary — composition in PR 2 is a min over a lattice that already exists.

Pure evaluation, separate loader. file-contract.ts has no node:fs; file-contract-loader.ts does the I/O. That split is what makes the decision table exhaustively testable.

A malformed contract is invalid, never absent. Both obvious alternatives are wrong: falling back to "no contract" silently drops every deny the author wrote, and hard-failing turns a typo into a broken install. The parser is strict for the same reason — unknown keys, bad decision values, a rule with no glob, and a rule that decides nothing are all errors, because a silently-ignored line in this file is a permission quietly granted.

Hand-written glob + YAML subset. The repo carries neither dependency by policy (see skills/frontmatter.ts), and this file has exactly one shape.

Honest limitations, stated in the docs

docs/file-contract.md leads with a callout that this is policy, not a security boundary:

  • Bash is out of scope. cat .env is a string; statically analysing shell would be guesswork that reads as a guarantee. Only the sandbox bounds Bash.
  • No realpath. Path normalization is string math, so a symlink inside the workspace pointing out still looks inside.

Test plan

  • pnpm test1224 passed, 16 skipped (+49 in this PR)
  • pnpm typecheck · pnpm lint · pnpm format:check · pnpm build · node scripts/check-docs.mjs

Coverage includes the adversarial cases AGENTS.md requires for permission changes:

Area Cases
Glob semantics segment vs cross-segment, a/**/b matching a/b, braces, ?, regex metacharacters treated as literals (or **/.env* would match axenv)
Precedence specificity beats file order; exact ties go to the later rule
Self-amendment Exact-glob write: allow on the contract → still deny; broad ** write: allow → still deny; reading it stays allowed
Path traversal ../ escape → no verdict; src/../.env re-entry → rule still applies; /work/repo-evil/.env does not pass as inside /work/repo
Fail-closed parsing 11 malformed inputs each throw, with line numbers
Loader absent / loaded / invalid ≠ absent; project wins over user without merging

Documentation

  • New docs/file-contract.md — format, glob syntax, precedence, self-protection, failure behaviour, and the composition rule PR 2 will implement

Release notes label

  • release-notes:internal — no user-visible behaviour until PR 2 wires it in

Checklist

  • PR 标题是 conventional commits 格式
  • 所有 commits 都是 conventional commits 格式
  • 添加了对应测试
  • CI 全绿(待 CI 运行)

Related

Plan §2.A. Research: docs/research/floatboat.md §4.4(1).

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@oratis
oratis merged commit 70ebd59 into main Aug 8, 2026
5 checks passed
@oratis
oratis deleted the feat/file-contract branch August 8, 2026 09:51
oratis added a commit that referenced this pull request Aug 8, 2026
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 <show|init|check>`; 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: oratis <happyllammar@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant