Skip to content

Retire the .entire/metadata deny rule and release staged transcripts - #2258

Merged
Soph merged 4 commits into
mainfrom
soph/retire-metadata-deny-rule
Sep 3, 2026
Merged

Retire the .entire/metadata deny rule and release staged transcripts#2258
Soph merged 4 commits into
mainfrom
soph/retire-metadata-deny-rule

Conversation

@Soph

@Soph Soph commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

https://entire.io/gh/entireio/cli/trails/1230

Three related changes to .entire/metadata: it turns out to be a staging buffer that nothing ever emptied, and the permission rule guarding it was breaking unattended permission modes for a whole class of ordinary commands.

1. The staged transcript is released after condensation

clearFilesystemPrompt removed prompt.txt once a session's work was condensed, but nothing ever removed full.jsonl. Every session left its full transcript in the worktree permanently, after the content was already committed and pushed.

Measured in one checkout: 460 MB across 392 session directories, largest single transcript 10.2 MB, with sessions going back nine months. Around 400 of those 1124 files were already dead formats — 168 context.md (removed by #572) and 232 summary.txt (no code has written it for some time).

Releasing it is safe, and I checked each coupling rather than assuming:

Concern Why it holds
Does a continuing session lose data? No — lifecycle.go rewrites the file wholesale from the agent's own transcript on every Stop
Who reads the on-disk copy? Only addDirectoryToChanges (shadow) and copyEntireMetadataDir (v1), both of which run before this point, plus review_context.go for prompt.txt. Every other reader goes through a git tree
sessionHasNewContent? Compares against the shadow-tree blob size, not the file
Incremental redaction cache? Keyed by prefix content hash in the git common dir, so it does not depend on this path existing

Prompt and transcript are now released together, because they are one operation under one condition rather than two — the call site's guard is shared for a real reason, since carry-forward files mean both must survive for the next condensation. One directory walk covers all three names instead of one walk per file on the PostCommit hook path.

2. The Read(./.entire/metadata/**) deny rule is retired

A deny rule is a hard block, not a hint. Per the permissions docs ("Read and Edit" section), rules resolve deny → ask → allow, first match wins, and "a deny rule can't carry allowlist exceptions" — so there is no way to soften it. The same section states Read deny rules apply "to file commands Claude Code recognizes in Bash, such as cat, head, tail, and sed", so a recursive grep from the repo root is refused and has to be approved by hand. Auto mode's classifier only sees actions that would prompt, never a block, so it cannot get past this.

The match is broader than "commands that read those files": git check-ignore -v .entire/metadata is refused, and it reads nothing. Naming the path is enough.

It also protected less than it appeared to:

  • It guarded a staging buffer, not a store. The durable copy lives in the checkpoint tree, which no permission rule covers — the same transcripts are readable with git show entire/checkpoints/v1:....
  • .entire/.gitignore already lists metadata/, and the tools reference says "Grep respects .gitignore, so gitignored files are skipped" — so the accidental-bulk-read case was already covered. Glob does not honor gitignore, but Glob returns names, not content.

Deliberately not replaced with a PreToolUse hook on Read: that fires a subprocess for every file the agent reads, on a hook path that already dominates turn cost.

How an existing repo heals

There is no per-repo "the CLI was upgraded" trigger — curl-bash-post-install only does shell completion and runs outside a repo, and versioncheck only notifies. So healing needed a deliberate shape:

Tier Behavior
entire doctor Detects and repairs. This is the one that heals an existing repo
SessionStart hook Reports it and points at entire doctor. Writes nothing
entire agent add <agent> Reaches InstallHooks, so it also removes it
entire enable Removes it on a fresh enable only — see the caveat below

Caveat on enable, found while verifying: removal inside InstallHooks is unconditional (no --force needed), but enable itself returns early with "Entire is already enabled" before it ever reaches InstallHooks. So the enable tier does not fire for an already-enabled repo — which is exactly the population carrying the stale rule. That is why the SessionStart notice points at doctor rather than at enable, and doctor is the tier I verified end to end. Making enable re-run installers on an already-enabled repo is a broader behavior change I did not want to make here.

The hook deliberately does not repair. The agent's config is normally tracked in git (this repo tracks .claude/settings.json), so a hook editing it would dirty the worktree unprompted, could land the edit in the user's next checkpoint commit, and would ping-pong across a team on mixed CLI versions.

Removal matches only the exact string Entire wrote, so a user's own deny rules are untouched, and an emptied deny array is dropped rather than left behind as {}.

One judgment call to confirm

checkRetiredDenyRule is the only doctor check that repairs without asking. My reasoning is ownership — it deletes only a byte-identical copy of a string Entire itself wrote — but it does modify a git-tracked file, and that is a product call rather than mine. Moving it behind doctor's --force is a one-line change if you would rather it asked.

Not covered

The 460 MB already on disk is not reclaimed. This only prevents future growth; existing sessions keep their transcripts because nothing revisits an already-condensed session. Reclaiming them wants the same doctor-tier treatment over FullyCondensed sessions, with its own safety question about which sessions are provably done. Happy to follow up.

Separate pre-existing gap, found while verifying: agentHookState.unchecked (config.go:228) is populated but has no consumer anywhere, so a repo with an unparseable .claude/settings.json gets "Git hooks: OK" from entire doctor and no mention of the broken file. Not introduced here, but it is why an error branch I first wrote turned out to be unreachable. Probably worth its own issue.

Test plan

  • mise run fmt && mise run lint → 0 issues; mise run test:ci passes (unit + integration + Vogon canary)
  • New: permissions_test.go covers removal, near-miss rules left alone, duplicates, unparseable deny, sibling-key preservation, and that the detector does not mutate
  • New: TestPermissionConfigOwner_* exercises the repair path end to end through the real agent — including idempotency and an absent config
  • Flipped: the install/uninstall tests in both agents now assert the rule is removed rather than installed, plus a migration test for a config an older CLI wrote and a safety test that user rules survive
  • Verified by hand against a scratch repo: enable clears it; doctor fixes it, is idempotent, preserves Bash(rm -rf *) and allow, and drops the whole permissions block when the rule was its only entry; SessionStart prints the notice and leaves the file byte-identical

Reviewed for reuse, simplification, efficiency, and altitude before opening — that pass merged the two release functions into one single-walk operation, removed a redundant Exists() stat from the SessionStart path, made doctor read the config once instead of twice, and deleted one unreachable branch.

Addressed from review

The trail finding and Copilot both caught the same real bug from different angles: RemoveMetadataDenyRule clears the deny key but cannot delete the permissions key containing it, since it is handed only the inner object — and while UninstallHooks and RepairRetiredMetadataDenyRule both delete an emptied block, the two InstallHooks write paths did not. A plain install that stripped the last deny rule left "permissions": {} behind, and this PR's own .claude/settings.json diff was the evidence. Both write paths now delete the key when the block is empty, verified against a fresh repo (Entire-owned config loses the key entirely; a config with a user's allow/deny rules keeps them and loses only the retired rule). The doc comment no longer claims a cleanup that belongs to its callers.

Also fixed two references left stale by the earlier merge of the two release functions: a comment naming RemoveNoSymlinks, which that code no longer calls, and the hook docs still naming clearFilesystemPrompt.

🤖 Generated with Claude Code

Soph and others added 3 commits September 3, 2026 14:46
.entire/metadata/<session>/ is a staging buffer for the checkpoint
writer, not a store. clearFilesystemPrompt removed prompt.txt once a
session's work was condensed, but nothing ever removed full.jsonl, so
every session left its full transcript in the worktree permanently after
the content was already committed and pushed. Measured 460 MB across 392
session directories in one checkout, largest single transcript 10.2 MB,
with sessions going back nine months.

Releasing it is safe: the file is rewritten wholesale from the agent's
own transcript on every Stop, and the only readers are the two
tree-building walks that run before this point. Every other reader of a
session's transcript goes through a git tree. sessionHasNewContent
compares against the shadow-tree blob, and the incremental redaction
cache is keyed by prefix content hash in the git common dir, so neither
depends on this path existing.

Prompt and transcript are now released together by
clearFilesystemStagedFiles, because they are one operation under one
condition rather than two: the call site's guard is shared for a real
reason, since carry-forward files mean both must survive for the next
condensation. One directory walk covers all three names instead of one
walk per file on the PostCommit hook path.

This only prevents future growth. Reclaiming what has already
accumulated needs a separate sweep over fully-condensed sessions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M1KMXAH0D66WP01ZWMSXRADN
The Stop-hook section claimed Entire writes summary.txt ("the last
assistant message, used as checkpoint summary"). Nothing has written it
for some time -- the name has zero references in the Go source -- yet 232
of them sit on disk in one checkout, alongside 168 context.md files that
PR #572 removed.

Replace the stale entry with what the hook actually stages, and say that
both files are a buffer whose durable copy lives in the checkpoint tree.
scripts/migrate-sessions.sh keeps its context.md reference: it migrates
the old format that legitimately has one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M1KMXNW2R2VKE2WBH2HN1XMJ
Entire installed Read(./.entire/metadata/**) into Claude Code and Factory
Droid permission configs. A deny rule is a hard block, not a hint: rules
resolve deny then ask then allow, first match wins, and a deny rule
cannot carry allowlist exceptions. Claude Code applies Read deny rules to
file-reading Bash commands too, so a recursive grep from the repo root --
or a command that merely names the path -- is refused and has to be
approved by hand, which defeats unattended permission modes for a whole
class of ordinary commands. `git check-ignore -v .entire/metadata` is
refused, and it reads nothing.

It also protected less than it appeared to. It guarded a staging buffer
whose durable copy lives in the checkpoint tree, which no permission rule
covers -- the same transcripts are readable with
`git show entire/checkpoints/v1:...`. And .entire/.gitignore already
lists metadata/, which Claude Code's Grep honors, so the
accidental-bulk-read case was already covered. Glob does not honor
gitignore, but Glob returns names, not content.

Install now removes the rule instead of adding it, unconditionally on a
plain `entire enable` like the existing stale-hook migration.
`entire doctor` detects and repairs it. Removal matches only the exact
string Entire wrote, so a user's own deny rules are untouched, and an
emptied deny array is dropped rather than left behind.

The SessionStart hook only reports it and points at doctor. It
deliberately does not write: the agent's config is normally tracked in
git, so a hook repairing it would dirty the worktree unprompted and could
land the edit in the user's next checkpoint commit, and it would
ping-pong across a team on mixed CLI versions.

Not replaced with a PreToolUse hook on Read -- that fires a subprocess
for every file the agent reads, on a hook path that already dominates
turn cost.

Also collapses four copies of the same deny-rule filter (install and
uninstall, in both agents) into one shared helper, and updates this
repo's own settings.json, which the dogfood test pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M1KMY4XMYH6AZ7CZXHSAMH90
Copilot AI lite review requested due to automatic review settings September 3, 2026 12:49
@Soph
Soph requested a review from a team as a code owner September 3, 2026 12:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

A few tracked/documented references and in-code comments are out of sync with the renamed staged-file cleanup behavior, and the repo’s tracked Claude settings can be cleaned up to avoid an empty permissions block.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adjusts how Entire handles .entire/metadata by (1) treating it explicitly as a staging buffer whose transient files are cleaned up after successful condensation, and (2) retiring the agent permission deny rule that blocked reads of .entire/metadata/**, plus adding “healing” paths via enable, doctor, and a SessionStart notice.

Changes:

  • Remove the retired Read(./.entire/metadata/**) deny rule from Claude Code and Factory AI Droid configs, and add detection/repair utilities plus doctor integration.
  • Release staged session files (full.jsonl, prompt.txt, legacy full.log) after condensation when no carry-forward files remain, with tests covering the new behavior.
  • Update integration tests/docs to reflect the new permission and staging-file behavior.
File summaries
File Description
docs/architecture/claude-hooks-integration.md Updates Claude hook documentation to describe staged transcript/prompt behavior.
cmd/entire/cli/strategy/phase_postcommit_test.go Adjusts PostCommit tests to assert staged files are released after condensation.
cmd/entire/cli/strategy/manual_commit_hooks.go Switches PostCommit cleanup call to clear both prompt and transcript staged files.
cmd/entire/cli/strategy/manual_commit_condensation.go Adds unified staged-file cleanup and shared session metadata filename helper.
cmd/entire/cli/strategy/manual_commit_condensation_test.go Adds unit tests pinning staged-file cleanup behavior and idempotency.
cmd/entire/cli/lifecycle.go Adds SessionStart warning when a repo still has the retired deny rule.
cmd/entire/cli/integration_test/setup_factoryai_hooks_test.go Updates Factory AI Droid setup test to assert deny rule is not installed.
cmd/entire/cli/integration_test/agent_test.go Updates agent installation tests to assert deny rule is not installed.
cmd/entire/cli/doctor.go Adds doctor check to remove the retired deny rule and report the fix.
cmd/entire/cli/agent/permissions.go Introduces shared permission-rule detection/removal + config-owner interface.
cmd/entire/cli/agent/permissions_test.go Adds tests for deny-rule detection/removal logic and invariants.
cmd/entire/cli/agent/factoryaidroid/hooks.go Removes deny-rule installation; adds migration removal and PermissionConfigOwner plumbing.
cmd/entire/cli/agent/factoryaidroid/hooks_test.go Updates Factory AI Droid tests to validate deny rule is removed/preserved appropriately.
cmd/entire/cli/agent/claudecode/hooks.go Removes deny-rule installation; adds migration removal and PermissionConfigOwner plumbing.
cmd/entire/cli/agent/claudecode/hooks_test.go Updates Claude Code tests to validate deny rule is removed/preserved appropriately.
.claude/settings.json Removes the deny rule from the repo’s tracked Claude settings.
Review details
  • Files reviewed: 16/16 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .claude/settings.json Outdated
Comment thread cmd/entire/cli/strategy/manual_commit_condensation.go Outdated
Comment thread docs/architecture/claude-hooks-integration.md Outdated
Review found that RemoveMetadataDenyRule clears the `deny` key but cannot
delete the `permissions` key that contains it -- it is handed only the
inner object. UninstallHooks and RepairRetiredMetadataDenyRule both
delete an emptied block; the two InstallHooks write paths did not, so a
plain install that stripped the last deny rule left `"permissions": {}`
behind. This PR's own .claude/settings.json diff was the evidence.

Both write paths now delete the key when the block is empty, and the doc
comment on RemoveMetadataDenyRule no longer claims a cleanup that belongs
to its callers.

Also corrects two references left stale by the earlier merge of
clearFilesystemPrompt and clearFilesystemTranscript: a comment naming
RemoveNoSymlinks, which that code no longer calls, and the hook docs
still naming clearFilesystemPrompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M1KNXE2RZ1XFKT6D9KJKXFHX
@Soph
Soph merged commit 6f9e500 into main Sep 3, 2026
13 checks passed
@Soph
Soph deleted the soph/retire-metadata-deny-rule branch September 3, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants