Skip to content

fix(core): make the file contract reach where it claimed to - #254

Merged
oratis merged 2 commits into
mainfrom
feat/contract-result-filter
Aug 9, 2026
Merged

fix(core): make the file contract reach where it claimed to#254
oratis merged 2 commits into
mainfrom
feat/contract-result-filter

Conversation

@oratis

@oratis oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #252. Retargets to main when that merges.

Closes the Grep/Glob gap FLOATBOAT_ADOPTION_PLAN.md §4.1 recorded as 仍未做, plus two more found while doing it.

A contract deny is deliberately not waivable — not even by bypassPermissions — which makes it the one verdict that has to hold everywhere. It did not.

1. A sub-agent did not inherit the contract

packages/core/src/agent.ts forwards mode, permissions, hooks, sandboxConfig and autoMode into the Task sub-agent's runAgent. contract was missed when it was added in #239.

So read: deny on secrets/** bound the main agent and said nothing at all to the sub-agent it spawned to do the reading. Delegation was a way around the one gate that is not supposed to have one.

Proven, not assumed — reverting the one-line fix:

× a sub-agent inherits the file contract
  AssertionError: expected '[{"model":"deepseek-chat",…' not to contain 'hunter2'

The secret's value reaches the provider payload.

2. Grep and Glob returned denied paths

Both take a search root, so the pre-call verdict only ever covered where the search started. A search rooted at the workspace is allowed — and then hands back matches from denied paths, in content mode with the matched line attached.

Results now run through evaluatePath, the same function the gate uses, on a path normalized the same way. Deliberately not translated into ripgrep's --glob !… exclusions: an approximate second copy of the rules that drifts from the original is worse than the gap it closes.

Contract says Grep Glob
read: deny hit removed, with its matched line path removed
read: ask kept kept

ask is not filtered. It means "stop and ask before reading this file" and mid-search there is nobody to ask; one Grep becoming two hundred prompts is how a contract gets deleted, and a hit is not yet a read. Reading the file still fires the ordinary approval.

Output ends with [2 results withheld by the file contract] — the count, never the paths. Silence is worse: an agent that searches and finds nothing goes looking through Bash, which the contract does not reach at all.

3. The plugin capability bridge

apps/server/src/runtime-composition.ts gated the call through dispatchToolCall and then built a ToolContext without the contract, so a plugin's Grep skipped the filter it had just been gated by.

Parsing ripgrep

The default : separator is not reversible — src/od:d.ts:1:hit has three readings, and picking wrong withholds the wrong file. Hence --null.

--null is not uniform, which nearly shipped a filter that did nothing:

Mode Record separator Shape
content, count \n path\0rest
files_with_matches \0 no newlines at all

Splitting files_with_matches output on \n yields a single row whose path is the first file and whose text carries every other path along for the ride — the filter drops nothing and looks like it worked. Verified against ripgrep 14.1.1 in all four modes.

parseRipgrepRows is a pure function with captured fixtures, so it is tested whether or not rg is installed. Output is rejoined to be byte-identical to today's.

CI actually runs the Grep suite now

hasRipgrep() self-skips when rg is absent — so the Grep tests may never have run in CI, while being what guards an output format we parse byte for byte. CI installs ripgrep and sets DC_REQUIRE_RIPGREP=1, which turns the skip into a failure.

Local run confirms it: skips drop from 16 to 12 once rg is on PATH.

Verification

  • typecheck, lint, format, docs clean.
  • Full suite with ripgrep present: 1440 passed / 12 skipped.
  • Both new gaps verified by reverting the fix and watching the test fail.

Notes

  • ripgrep already skips hidden and gitignored files, so .env never reaches this filter — the fixtures use secrets/prod.key, which does. A .env fixture would have passed vacuously.
  • Filtering happens before limit/head_limit, so denied entries cannot eat result slots and make a search look empty.
  • deepcode mcp serve executes tools with no dispatcher at all — no mode, permissions, contract or hooks. Out of scope here; tracked separately.

🤖 Generated with Claude Code

@oratis

oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Review — approve, with one bug fixed

Both gaps are real and the reasoning around them is sound. Some verification I did rather than took on trust:

The --null analysis is correct. I checked ripgrep 14.1.1 directly:

content -n : ./od:d.ts\0 1:gamma hit \n
count      : ./od:d.ts\0 1 \n
files -l   : ./od:d.ts\0 ./a.txt\0          ← no newlines at all

The files_with_matches claim is the load-bearing one and it is exactly as described — splitting that on \n yields one row carrying every other path in its text, and the filter would drop nothing while looking like it worked.

ToolContext.contract is plumbed everywhere it is constructed. Three sites: agent.ts ✅, runtime-composition.ts ✅ (this PR), mcp/serve.ts ❌ — which is exactly what the PR body says is out of scope and #257 closes. Task is in MCP_SERVE_EXCLUDE, so the sub-agent fix does not have a second entry point to leak through.

ask not being filtered is the right call, and the reason given is the real one: a hit is not a read, and the read still prompts.

Fixed: the round-trip is not byte-identical

Output is rejoined to be byte-identical to today's.

Not for a single-file search. Give ripgrep one file as the search path and it prints no filename at all — there is nothing to disambiguate — so its output carries no NUL:

$ rg --null --no-heading -n hit /tmp/a.txt | od -c
0000000    1   :   a   l   p   h   a       h   i   t  \n

parseRipgrepRows reads that as {path: '', text: '1:alpha hit'}, and formatRipgrepRow takes the text !== '' branch:

`${row.path}:${row.text}`  →  ":1:alpha hit"

A stray colon in front of every line. Grep scoped to one file is ordinary usage, and the existing "same output as before" test compares a contracted run against an uncontracted one — both corrupted identically, so it passes.

The root cause is that '' was carrying two meanings: rg printed nothing here and rg printed an empty field. Both occur. RipgrepRow now has path: string | null and text: string | null, and the separator is written back only where rg wrote one. That also fixes the mirror case — a content-mode match on an empty line is path\0, which rg prints as path:, not path.

While there: a row with no path came from a single-file search, so its file is the search root. Attributing it there rather than skipping it means the result filter no longer depends on the pre-call gate having adjudicated that call correctly. Same defence-in-depth argument the PR makes for filtering results at all.

Reverting the fix fails both new tests with null:1:alpha hit. Full suite green with ripgrep present.

@oratis
oratis changed the base branch from fix/git-env-test-isolation to main August 9, 2026 15:38
oratis and others added 2 commits August 9, 2026 23:38
Three places where a contract `deny` did not hold. A deny is deliberately not
waivable — not even by bypassPermissions — so it is the one verdict that has to
be true everywhere, and it was not.

1. A sub-agent did not inherit the contract. The Task delegation forwarded
   mode, permissions, hooks, sandbox and autoMode; contract was simply missed
   when it was added. "Never read secrets/**" bound the main agent and said
   nothing to the sub-agent it spawned to do the reading. Reverting the one-line
   fix makes the new test fail with the secret's value in the provider payload.

2. Grep and Glob returned results the contract denies reading. Both take a
   search *root*, so the pre-call verdict only ever covered where the search
   started. A search rooted at the workspace was allowed and then handed back
   matches from denied paths — in content mode, with the matched line attached.

3. The plugin capability bridge built a ToolContext without the contract, so a
   plugin's Grep skipped the same filter it had just been gated by.

The filter runs results through `evaluatePath`, the same function the gate uses,
on a path normalized the same way. Deliberately not translated into ripgrep's
--glob exclusions: an approximate second copy of the rules that drifts from the
original is worse than the gap it closes.

Only `deny` withholds. `ask` means "stop and ask before reading this file" and
mid-search there is nobody to ask; one Grep turning into two hundred prompts is
how a contract gets deleted, and a hit is not yet a read. The output ends with a
count of what was withheld and never the paths — silence is worse, because an
agent that finds nothing goes looking through Bash, which the contract does not
reach at all.

Parsing ripgrep needed --null. The default `:` separator is not reversible:
`src/od:d.ts:1:hit` has three readings, and picking wrong withholds the wrong
file. --null is not uniform either — in content and count modes it follows the
path and newline still ends the record, but in files_with_matches it *is* the
record separator and there are no newlines at all. Splitting that on '\n' yields
one row carrying every path in its text, so the filter would drop nothing and
still look like it worked. Verified against ripgrep 14.1.1 in all four modes;
the parser is a pure function with captured fixtures so it is tested whether or
not rg is installed.

CI now installs ripgrep and sets DC_REQUIRE_RIPGREP=1. The Grep suite self-skips
when rg is absent, so it may never have run in CI at all — and it is now what
guards an output format we parse byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Give `Grep` a single *file* as its search path and ripgrep omits the
filename — there is nothing to disambiguate — so its `--null` output
carries no NUL at all. `formatRipgrepRow` rejoined every record as
`path + ':' + text`, which for an absent path produced `:1:hit`: a stray
colon in front of every line of an otherwise correct result. The
round-trip was only ever verified against multi-file output.

`RipgrepRow` now distinguishes "rg printed no path" from "rg printed an
empty field" — both occur, and `''` is a value rg can genuinely print —
so the separator is written back only where rg wrote one. That also fixes
the mirror case: a content-mode match on an empty line is `path\0`, which
rg prints as `path:`, not `path`.

A row with no path came from a single-file search, so its file is the
search root; attributing it there keeps the contract filter from
depending on the pre-call gate having adjudicated that call correctly.

Verified against ripgrep 14.1.1 in all four output shapes. Reverting the
fix fails both new tests with `null:1:alpha hit`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis force-pushed the feat/contract-result-filter branch from 3cae1f2 to 4aa9a23 Compare August 9, 2026 15:39
@oratis
oratis merged commit 81ac91c into main Aug 9, 2026
5 checks passed
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