Skip to content

fix: readOnlyViolation cannot see writes to already-dirty paths - #38

Open
webdivs wants to merge 1 commit into
amElnagdy:masterfrom
webdivs:fix/porcelain-tripwire-blind-spot
Open

fix: readOnlyViolation cannot see writes to already-dirty paths#38
webdivs wants to merge 1 commit into
amElnagdy:masterfrom
webdivs:fix/porcelain-tripwire-blind-spot

Conversation

@webdivs

@webdivs webdivs commented Jul 31, 2026

Copy link
Copy Markdown

The problem

readOnlyViolation compares git status --porcelain before and after a run. That comparison
cannot see a write to a path that was already dirty at dispatch, because such a write usually
does not move the status line: " M src/app.js" before, " M src/app.js" after.

This is not a new observation — claude-delegate/scripts/relay.mjs already says so:

// porcelain before and after and emits readOnlyViolation. This is a tripwire,
// not an OS boundary: changes within an already-dirty file can evade it.

The gap matters because the normal way to use a read-only run is to ask a question while you
have uncommitted work in progress
. That is precisely the state in which the tripwire reports
false no matter what the implementer did to those files.

Minimal reproduction

cd "$(mktemp -d)" && git init -q .
echo pre-existing > already-dirty.txt      # untracked; porcelain: "?? already-dirty.txt"

# a read-only run whose implementer appends one line to already-dirty.txt
# porcelain after: "?? already-dirty.txt"  — byte-identical

result.json reports readOnlyViolation: false. The file was modified.

A second, smaller defect in grok-delegate

readOnlyViolation: beforeTree !== null && touched !== null
  && JSON.stringify(beforeTree) !== JSON.stringify(touched)

When git cannot report, beforeTree is null and the expression evaluates to false
"we could not check" is reported as "nothing happened". claude-delegate already gets this
right by returning null; grok-delegate does not. Its stdout summary has the matching bug: it
tests if (result.readOnlyViolation), so a null verdict prints as if the run were clean.

The change

The porcelain comparison is kept exactly as it is, and a second signal is added beside it: at
dispatch, a read-only run fingerprints the contents of the paths that are already dirty, and
compares them again afterwards.

Only that set is fingerprinted. A path that is clean at dispatch and gets written surfaces as a
new porcelain line already, and fingerprinting a whole repository on every run would cost far
more than the case it covers.

The verdict becomes properly three-valued, with proof beating absence of proof:

if (porcelainMoved || changedDirtyPaths.length > 0) return true;   // a write is proven
if (anySignalUnknown) return null;                                  // coverage was incomplete
return false;                                                       // nothing detected

Details that turned out to matter:

  • Repository-root paths. Porcelain paths are relative to the repository root, not to the
    directory git ran in (--porcelain forces status.relativePaths off). They are joined
    against git rev-parse --show-toplevel, so a --cd pointing at a subdirectory still works.
  • -z -uall. -z keeps a path containing a space, a quote, or a newline in one field.
    -uall expands an untracked directory into its files, since a collapsed "?? dir/" line
    never changes when a file inside it does.
  • R/C in either status column. Such an entry is followed by its origin path as its own
    unprefixed field. A rename origin is added to the set (the file moved away from it); a copy
    origin is consumed but not added, since a copy source can be perfectly clean.
  • Identity, not just bytes. The fingerprint covers file type, permission bits, and symlink
    target, so a chmod, a retargeted symlink, or a file replaced by a directory all count.
  • Streamed hashing in 64 KB chunks, so an unignored multi-gigabyte artifact is not read into
    memory.
  • Unknown stays unknown. An unreadable path or a submodule (a directory in the dirty set)
    marks coverage incomplete, which yields null rather than a clean verdict.

Scope

Only claude-delegate and grok-delegate — the two relays that already ship a
readOnlyViolation tripwire. The other eight do no before/after comparison at all, so there is
nothing there to correct; extending them would be a feature, not a fix, and belongs in its own
discussion.

What this does NOT claim

  • It does not enforce read-only. It reports; it does not prevent. Same posture as today.
  • It is not complete write detection. Git-ignored paths are outside it, submodule internals
    are outside it, and a write followed by a perfect restore before the snapshot is invisible.
  • false means "no git-visible change was detected", not "the filesystem is unchanged". The
    wording in claude-delegate's header was updated to say so.
  • It does not prove the implementer caused a change. A concurrent edit by the operator is
    indistinguishable. The diff remains the authority.

Tests

test/relay-smoke.mjs gains a third read-only scenario alongside the existing
violation / clean pair:

{ name: "already-dirty", mode: "claude-read-only-append", expectedViolation: true, dirtyFirst: true }

The harness leaves already-dirty.txt untracked before dispatch and the fake CLI appends to it,
so the porcelain line is identical at both ends. On master this scenario reports false; with
this change it reports true.

Full suite passes locally on macOS.

Provenance

This came out of running a read-only consultation against a repository with uncommitted work and
noticing the answer was reported clean when it should not have been. The implementation was
reviewed across three rounds by an independent model, which found six defects in my own first
cut before this reached you — including the very same null-reported-as-false bug I am
reporting in grok-delegate. The two regression tests that cover the subtlest cases (the
subdirectory --cd and the worktree-column rename) were each verified to fail against the
pre-fix logic before being kept, since a test that passes either way proves nothing.

Happy to split this into two PRs (the grok three-valued fix is independent and much smaller)
if that is easier to review.

Summary by CodeRabbit

  • Bug Fixes
    • Improved read-only run verification for files that were already modified before execution.
    • Detects changes to file contents, permissions, symbolic links, and renamed or copied paths, even when version-control status appears unchanged.
    • Reports clear verification states, including when verification cannot be completed, with an appropriate warning.
  • Tests
    • Added coverage for detecting unauthorized edits to pre-existing dirty files.

`readOnlyViolation` compares git porcelain before and after a run, which cannot
see a write to a path that was already dirty at dispatch: " M src/app.js" before
and " M src/app.js" after are the same line. claude-delegate's own header has
documented this ("a tripwire, not an OS boundary: changes within an already-dirty
file can evade it"), and it bites in the normal case -- asking a read-only
question while you have work in progress is exactly when every dirty path is
invisible to the check.

Alongside the porcelain comparison, a read-only run now fingerprints the contents
of the paths that are ALREADY dirty and compares them again afterwards. Only that
set is covered: a path clean at dispatch surfaces as a new porcelain line anyway,
and fingerprinting a whole repository per run would cost far more than the case it
covers.

The verdict becomes properly three-valued, with proof beating absence of proof:
a detected write wins even when the other signal is unknown; null is reserved for
"coverage was incomplete", never for "nothing happened".

Also fixes a smaller defect in grok-delegate, where

  beforeTree !== null && touched !== null && JSON.stringify(...) !== JSON.stringify(...)

evaluates to false when git cannot report -- "could not check" was reported as
"nothing happened". claude-delegate already returned null here. grok's stdout
summary had the matching bug: it tested truthiness, so a null verdict printed as
if the run were clean.

Implementation notes:
- porcelain paths are repository-root-relative, so they are joined against
  `git rev-parse --show-toplevel`, not against --cd, which may be a subdirectory
- -z keeps paths containing spaces, quotes, or newlines in one field; -uall
  expands untracked directories, since "?? dir/" never moves when a file inside
  it changes
- R and C can occupy either status column; the origin field is consumed in both
  cases, added to the set for a rename, not for a copy
- the fingerprint covers file type, permission bits, and symlink target, so a
  chmod or a retargeted symlink counts
- hashing is streamed in 64KB chunks
- an unreadable path or a submodule marks coverage incomplete, yielding null

Scope is the two relays that already ship this tripwire. The other eight do no
before/after comparison at all, so there is nothing there to correct.

Test: a third read-only scenario next to the existing violation/clean pair. The
harness leaves a file dirty before dispatch and the fake CLI appends to it, so the
porcelain line is byte-identical at both ends. Verified to FAIL against the
unpatched relay before being kept.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Read-only verification

Layer / File(s) Summary
Dirty-path fingerprint engine
skills/claude-delegate/scripts/relay.mjs, skills/grok-delegate/scripts/relay.mjs
Both relays resolve repository paths, parse Git status safely, and fingerprint file content, modes, symlink targets, and file types.
Read-only verification lifecycle
skills/claude-delegate/scripts/relay.mjs, skills/grok-delegate/scripts/relay.mjs
Read-only runs capture fingerprint baselines, compare post-run state, report true, false, or null, and warn when verification coverage is incomplete.
Pre-existing dirty-file regression coverage
test/relay-smoke.mjs
The smoke test appends to an already-dirty file and verifies that the change is detected despite unchanged Git status output.

Sequence Diagram(s)

sequenceDiagram
  participant Relay
  participant Git
  participant Filesystem
  participant DelegatedCLI
  Relay->>Git: Resolve repository root and read dirty paths
  Relay->>Filesystem: Capture baseline fingerprints
  Relay->>DelegatedCLI: Execute read-only delegation
  DelegatedCLI->>Filesystem: Modify a pre-existing dirty file
  Relay->>Git: Read post-run dirty paths
  Relay->>Filesystem: Capture post-run fingerprints
  Relay->>Relay: Calculate and write the tri-state verdict
Loading

Possibly related PRs

Suggested reviewers: amelnagdy, 7awy11

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: detecting writes to paths that were already dirty.
Description check ✅ Passed The description clearly covers the problem, implementation, scope, limitations, and tests, but omits explicit Claim and CLI-version details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
skills/claude-delegate/scripts/relay.mjs (2)

712-724: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

One unfingerprintable path discards all fingerprint evidence.

changedDirtyPaths returns null when a single baseline path is a submodule or unreadable. readOnlyVerdict then cannot use the other paths, so a proven content change on a readable path is lost and the verdict becomes null. A repository that has a dirty submodule at dispatch reports null for every read-only run.

Return the partial result plus a coverage flag, so proof still wins over incomplete coverage.

♻️ Proposed partial-coverage result
 function changedDirtyPaths(before) {
-  if (!before || !before.complete) return null;
+  // Report what IS known plus whether coverage was full, so a proven change still settles the
+  // verdict when an unrelated path could not be fingerprinted.
+  if (!before) return { changed: [], complete: false };
   const now = fingerprintPaths(before.root, [...before.prints.keys()]);
-  if (!now.complete) return null;
   const changed = [];
   for (const [path, print] of before.prints) {
-    if (now.prints.get(path) !== print) changed.push(path);
+    const current = now.prints.get(path);
+    if (current === FINGERPRINT_UNREADABLE || current === FINGERPRINT_DIRECTORY) continue;
+    if (print === FINGERPRINT_UNREADABLE || print === FINGERPRINT_DIRECTORY) continue;
+    if (current !== print) changed.push(path);
   }
-  return changed.sort();
+  return { changed: changed.sort(), complete: before.complete && now.complete };
 }

readOnlyVerdict then uses changed.length > 0 for true and !complete for null.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/claude-delegate/scripts/relay.mjs` around lines 712 - 724, Update
changedDirtyPaths to return the detected changed paths together with a
completeness flag instead of returning null when fingerprinting any baseline
path is incomplete; retain all readable-path comparisons and mark the result
incomplete when coverage is missing. Adjust readOnlyVerdict to use
changed.length > 0 for a true verdict, and return null only when no changes are
proven and the partial result is incomplete.

644-699: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fingerprinting can hash very large dirty sets twice per run.

dirtyPaths uses -uall. A repository with a large untracked directory that is not ignored (for example a build output tree) enters the dirty set in full. fingerprintPaths then streams a SHA-256 over every one of those files at dispatch, and changedDirtyPaths repeats the work for each result write. In the grok relay the abort path writes results twice, so the cost is paid again.

Consider a cheap pre-filter before hashing: compare size and mtimeMs from the lstatSync result, and hash only when they match the baseline. That keeps detection of same-size, same-mtime edits while skipping most I/O.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/claude-delegate/scripts/relay.mjs` around lines 644 - 699, Optimize
the fingerprint flow around pathFingerprint, fingerprintPaths, and
changedDirtyPaths by adding a metadata pre-filter using lstatSync size and
mtimeMs against the stored baseline before computing SHA-256. Reuse the baseline
fingerprint metadata where available, hashing only when size and mtimeMs match
so same-size, same-mtime edits remain detected, and preserve existing handling
for symlinks, directories, unreadable paths, and absent files.
test/relay-smoke.mjs (1)

1328-1334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the null verdict and for the grok relay.

The new scenario covers the true case for claude. Two behaviors that this PR changes stay untested:

  • readOnlyViolation === null when coverage is incomplete. A run in a non-repository work directory is the cheapest trigger, since gitRepoRoot returns null.
  • The grok relay verdict. The PR states that grok previously reported null as clean, and no grok scenario asserts the tri-state result.

I can draft both scenarios if you want them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/relay-smoke.mjs` around lines 1328 - 1334, Extend the relay smoke-test
scenarios to cover a null verdict using a non-repository work directory where
gitRepoRoot returns null, and add a grok relay scenario that asserts its
tri-state verdict. Keep the existing claude already-dirty true-case coverage
unchanged and verify the expected null outcomes explicitly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/claude-delegate/scripts/relay.mjs`:
- Around line 1082-1084: Exclude relay run artifacts from baseline dirty-path
fingerprints so artifact writes do not produce false changes. In
skills/claude-delegate/scripts/relay.mjs at lines 1082-1084, pass the resolved
output directory to fingerprintDirtyPaths and remove baseline paths whose
relative path from the repository root is inside that directory; apply the same
change in skills/grok-delegate/scripts/relay.mjs at lines 637-639, preserving
the existing readOnly behavior.

In `@skills/grok-delegate/scripts/relay.mjs`:
- Around line 640-645: Update the child process error handler to include the
result of readOnlyFlag(...) when constructing its touchedFiles result, ensuring
spawn-error outcomes carry readOnlyViolation consistently with other results and
trigger the existing read-only warning behavior.

---

Nitpick comments:
In `@skills/claude-delegate/scripts/relay.mjs`:
- Around line 712-724: Update changedDirtyPaths to return the detected changed
paths together with a completeness flag instead of returning null when
fingerprinting any baseline path is incomplete; retain all readable-path
comparisons and mark the result incomplete when coverage is missing. Adjust
readOnlyVerdict to use changed.length > 0 for a true verdict, and return null
only when no changes are proven and the partial result is incomplete.
- Around line 644-699: Optimize the fingerprint flow around pathFingerprint,
fingerprintPaths, and changedDirtyPaths by adding a metadata pre-filter using
lstatSync size and mtimeMs against the stored baseline before computing SHA-256.
Reuse the baseline fingerprint metadata where available, hashing only when size
and mtimeMs match so same-size, same-mtime edits remain detected, and preserve
existing handling for symlinks, directories, unreadable paths, and absent files.

In `@test/relay-smoke.mjs`:
- Around line 1328-1334: Extend the relay smoke-test scenarios to cover a null
verdict using a non-repository work directory where gitRepoRoot returns null,
and add a grok relay scenario that asserts its tri-state verdict. Keep the
existing claude already-dirty true-case coverage unchanged and verify the
expected null outcomes explicitly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e5696043-55c9-40ff-b253-f0e5034b67d1

📥 Commits

Reviewing files that changed from the base of the PR and between 347f2fa and 82165af.

📒 Files selected for processing (3)
  • skills/claude-delegate/scripts/relay.mjs
  • skills/grok-delegate/scripts/relay.mjs
  • test/relay-smoke.mjs

Comment on lines +1082 to +1084
// Contents of the paths that are ALREADY dirty. Their porcelain lines will not move if the
// run edits them, so the line comparison above cannot see those writes on its own.
const beforeFingerprints = opts.readOnly ? fingerprintDirtyPaths(opts.cd) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Baseline fingerprints include the relay's own run artifacts. Both relays capture the baseline after prepareRun creates the artifacts. If the output directory sits inside the worktree and is not ignored, dirtyPaths (with -uall) lists those untracked artifacts, the baseline hashes them, and the relay's later writes to the events, final, stderr, and result files change their fingerprints. changedDirtyPaths then reports a change and the verdict becomes true for a run that wrote nothing else. The porcelain comparison tolerated this because the artifact status lines did not move; the fingerprint check does not.

  • skills/claude-delegate/scripts/relay.mjs#L1082-L1084: pass the run artifact directory to fingerprintDirtyPaths and drop baseline paths that resolve inside it, using relative(root, ...) against the resolved out-dir.
  • skills/grok-delegate/scripts/relay.mjs#L637-L639: apply the same exclusion, since run.eventsPath is appended during the run at line 678.
📍 Affects 2 files
  • skills/claude-delegate/scripts/relay.mjs#L1082-L1084 (this comment)
  • skills/grok-delegate/scripts/relay.mjs#L637-L639
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/claude-delegate/scripts/relay.mjs` around lines 1082 - 1084, Exclude
relay run artifacts from baseline dirty-path fingerprints so artifact writes do
not produce false changes. In skills/claude-delegate/scripts/relay.mjs at lines
1082-1084, pass the resolved output directory to fingerprintDirtyPaths and
remove baseline paths whose relative path from the repository root is inside
that directory; apply the same change in skills/grok-delegate/scripts/relay.mjs
at lines 637-639, preserving the existing readOnly behavior.

Comment on lines 640 to 645
// every result that reports touchedFiles carries the verdict, aborted runs included -
// an aborted --read-only review can still have modified the tree
const readOnlyFlag = (touched) =>
opts.autonomy === "read-only"
? { readOnlyViolation: beforeTree !== null && touched !== null && JSON.stringify(beforeTree) !== JSON.stringify(touched) }
? { readOnlyViolation: readOnlyVerdict(beforeTree, touched, beforeFingerprints) }
: {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The spawn-error result carries no verdict.

The comment states that every result reporting touchedFiles carries the verdict. The child.on("error") handler at lines 757-773 reports touchedFiles without spreading readOnlyFlag. For that result readOnlyViolation is absent, so the === null warning at line 835 never prints and the failed read-only run looks unverified in silence. Spread readOnlyFlag(...) in that handler as well, or narrow the comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/grok-delegate/scripts/relay.mjs` around lines 640 - 645, Update the
child process error handler to include the result of readOnlyFlag(...) when
constructing its touchedFiles result, ensuring spawn-error outcomes carry
readOnlyViolation consistently with other results and trigger the existing
read-only warning behavior.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82165af2db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Re-fingerprint exactly the baseline paths, not whatever happens to be dirty now: a path the
// run newly dirtied is already reported by the porcelain comparison, and letting an unreadable
// one of those blind this signal would be a regression, not caution.
if (!before || !before.complete) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve proven changes when another fingerprint is unknown

When any baseline dirty path is a submodule or unreadable, before.complete is false and this returns before comparing the fingerprints of readable paths. In a repository with a dirty submodule plus an already-dirty regular file, changing the regular file leaves porcelain unchanged and produces null even though its changed hash proves a violation; the identical short-circuit exists in the Grok relay. Compare known fingerprints first, and return null only when none changed and some coverage remains unknown.

Useful? React with 👍 / 👎.

Comment on lines +733 to +734
if (porcelainMoved || (changed !== null && changed.length > 0)) return true;
if (beforeTree === null || afterTree === null || changed === null) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the read-only field documentation

The new verdict makes null include incomplete fingerprints and makes true include changes within already-dirty files, but skills/claude-delegate/SKILL.md:131-135, its dispatch/review references, and the corresponding Grok documentation still define the field solely in terms of porcelain and explicitly say such edits evade detection. Orchestrators following those public contracts will misinterpret the result and miss the central behavior added here, so update every affected skill and reference surface to match the new three-state semantics.

AGENTS.md reference: AGENTS.md:L42-L44

Useful? React with 👍 / 👎.

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