Skip to content

SystemFileGuard's deny-list default path can never exist on an install — the #1504 warning will fire forever, and creating the directory disables seven install tools #1689

Description

@christauff

Follow-on to #1504, not a re-report of it. That issue said the guard "looks active but may be inert," and your fix — say so loudly, once — is the right call. This is about what a user can do after they see the warning, which as far as I can tell is nothing.

Verified against main @ d1d6240.

The trap

hooks/lib/system-file-guard-core.ts:21-22:

const DEFAULT_DENY_LIST_PATH = join(CLAUDE_ROOT, "skills/_LIFEOS/DENY_LIST.txt");
const DEFAULT_HASHES_PATH    = join(CLAUDE_ROOT, "skills/_LIFEOS/DENY_HASHES.json");

loadPatterns() returns [] at :70 when that file is absent, and loadHashContext() returns null at :115. So the guard evaluates every SYSTEM write against an empty filter.

That path cannot be populated on an install, for two independent reasons:

1. skills/_LIFEOS is stripped from the release by design. ls skills/_* in the payload returns nothing — no underscore-prefixed skill ships. Your own comment in InstallEngine.ts:153-155 states it: the private maintenance skill "exists ONLY in the author's source repo — never in a public install (release tooling strips all _ALLCAPS skills)."

2. Creating it locally is worse than leaving it empty. detectDevTree() at InstallEngine.ts:159-161 is exactly existsSync(join(configRoot, "skills", "_LIFEOS")), and seven tools refuse to run when it returns true:

Tool Refusal
LinkUser.ts "refusing to relink"
InstallHooks.ts "refusing to mutate"
DeployCore.ts "refusing to deploy core"
DeployComponents.ts "refusing to deploy components"
SeedPulse.ts "refusing to seed Pulse"
ScaffoldUser.ts "refusing to scaffold"
ActivateImports.ts "refusing to edit"

So a user who reads the warning, understands it, and does the obvious thing — mkdir -p skills/_LIFEOS && $EDITOR DENY_LIST.txt — silently converts their install into something the installer and deploy tooling will no longer touch. The marker is a good design; it just collides with the one directory the guard needs.

The documented remedy points at the same place. DeriveDenyHashes.ts:39 writes OUT_PATH = join(CLAUDE, "skills", "_LIFEOS", "DENY_HASHES.json"), and the warning text names that tool as the fix. #1488's accepted early-return there is correct — it stops the ENOENT crash — but it means the tool now declines to do anything rather than producing a usable filter.

Net effect: on every public install, SystemFileGuard is dispatched on every Write/Edit/MultiEdit (PreToolGuard.hook.ts:44,77), scans against nothing, and — once your fix ships — will warn about it once per session, permanently, with no supported way to resolve it.

Reproduce

# fresh install, then:
ls ~/.claude/skills/_LIFEOS/DENY_LIST.txt        # ENOENT
grep -n 'DEFAULT_DENY_LIST_PATH' ~/.claude/hooks/lib/system-file-guard-core.ts
bun ~/.claude/LIFEOS/TOOLS/DeriveDenyHashes.ts   # declines — target dir is the dev-tree marker

Any SYSTEM-file write then passes regardless of content, because the pattern list is empty.

Suggested direction

A one-line default change to somewhere that ships and is already contained. LIFEOS/USER/** is in containment-zones.ts, so anything there is excluded from the release the same way, and it's where per-install config already lives (settings.user.json, memory-review.json, OPERATIONAL_RULES.md):

const DEFAULT_DENY_LIST_PATH = join(CLAUDE_ROOT, "LIFEOS/USER/CONFIG/DENY_LIST.txt");
const DEFAULT_HASHES_PATH    = join(CLAUDE_ROOT, "LIFEOS/USER/CONFIG/DENY_HASHES.json");

with DeriveDenyHashes.ts OUT_PATH following, and the warning text pointing at the new location. evaluateWrite() already accepts denyListPath/hashesPath overrides (:165), so the override surface needs no change and existing callers are unaffected.

Two things I'd leave to you rather than assume:

  • Whether to ship a seed DENY_LIST.txt. An empty-but-present file with commented examples would make the guard's state legible (present-and-empty reads differently from absent), but it's also one more file in the payload.
  • Whether the salted-hash half should be reachable at all on an install, given DENYLIST_SALT has to be generated per-user into .env and DeriveDenyHashes's corpus list assumes files most installs won't have. Possibly the literal deny-list is the only half worth defaulting to a real path.

Disclosure

I've made this change locally to get the guard functional, using the LIFEOS/USER/CONFIG/ path above. I'd rather converge on whatever you pick than carry a private divergence, so if you go a different way I'll follow it — mentioning it only so the suggestion is read as "this is what I ran," not "this is what you should do."

Thanks for the fast turnaround on #1504 and #1488.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions