Skip to content

feat(core): enforce the file contract at the tool gate - #239

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

feat(core): enforce the file contract at the tool gate#239
oratis merged 1 commit into
mainfrom
feat/file-contract-dispatch

Conversation

@oratis

@oratis oratis commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

PR 2 of docs/FLOATBOAT_ADOPTION_PLAN.md §2.A — wires the evaluator from #238 into dispatchToolCall, the one central gate. This is the step the plan flagged as the only one needing careful review.

RuntimeHost loads the contract itself, so all four clients (CLI, server, VS Code, LSP) get it without a line of client change.

The safety argument

final = mostRestrictive(toolVerdict, contractVerdict)     deny > ask > allow

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. The 16-cell table is enumerated in tests, not sampled, plus symmetry and a "settings deny survives any contract verdict" property.

A contract can only tighten. Adding one cannot reduce existing safety.

A contract deny survives bypassPermissions

The one deliberate asymmetry, and worth arguing about in review.

A deny states something standing about a path ("never read .env"); it is not a prompt. bypassPermissions exists to skip prompts. If it also cleared denials, the contract's strongest sentence would be its easiest to disable. So deny is checked before mode and cannot be waived — by bypassPermissions, by dontAsk, or by an explicit allow in settings.

Contract ask stays ordinary: mode applies, and a PreToolUse hook is still the last word.

Found and closed while wiring

The plugin capability bridge in apps/server/src/runtime-composition.ts calls dispatchToolCall directly and was not passed a contract — a plugin subprocess would have been a way around every path rule. It now loads and passes the same contract, and reports an invalid contract as a composition diagnostic.

Saying what it actually covers

The plan's stated top risk for this feature is false confidence, so the warning is code, not just prose. When a contract denies reads while the sandbox resolves to danger-full-access, the REPL, headless runs, and contract show all print that the denial covers Read/Grep/Glob and not Bash.

Write-only contracts stay silent — there's no false-enforcement risk there, and a warning nobody needs is one users learn to ignore.

New command

RECOMMENDED_FILE_CONTRACT shipped in #238 with no way to install it:

deepcode contract init      # write the recommended contract
deepcode contract show      # what's active, what it governs, warnings
deepcode contract check .env

init refuses to clobber an existing contract without --force — silently dropping rules a user wrote is the exact failure this feature exists to prevent.

One regression caught by an existing test

Making RuntimeHost.run async turned its synchronous missing-cwd throw into a rejection, changing what callers catch. run stays non-async with the await work behind a synchronous guard. Noting it because it's the kind of change that passes review by looking like a no-op.

Test plan

  • pnpm test1289 passed, 16 skipped (+65 here: 855 core, 216 cli)
  • pnpm typecheck · pnpm lint · pnpm format:check · pnpm build · node scripts/check-docs.mjs
Area Cases
Composition All 16 cells; symmetry; deny-survives-everything
No-op guarantee Same call, no contract → identical decision
Non-waivable deny Survives bypassPermissions, dontAsk, and an explicit settings allow
Attribution source: 'contract', and the author's reason reaches the user
Axis mapping NotebookEdit reads notebook_path not file_path; Grep/Glob search roots; Bash gets no verdict
End-to-end RuntimeHost with only a cwd blocks a real Write — proves loading isn't client-dependent
Hooks A contract ask is still overridable by PreToolUse
CLI 8 cases incl. init clobber-refusal and invalid-contract reporting

Documentation

  • docs/file-contract.md — added Getting started, the governed-tools list with Bash called out, and the non-waivable-deny rule

Release notes label

  • release-notes:feature — opt-in; no contract file means no behaviour change

Related

Plan §2.A (PR 2). Builds on #238.

🤖 Generated with Claude Code

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: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit 78476dd into main Aug 8, 2026
5 checks passed
@oratis
oratis deleted the feat/file-contract-dispatch branch August 8, 2026 09:59
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