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.
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:loadPatterns()returns[]at:70when that file is absent, andloadHashContext()returnsnullat: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/_LIFEOSis stripped from the release by design.ls skills/_*in the payload returns nothing — no underscore-prefixed skill ships. Your own comment inInstallEngine.ts:153-155states it: the private maintenance skill "exists ONLY in the author's source repo — never in a public install (release tooling strips all_ALLCAPSskills)."2. Creating it locally is worse than leaving it empty.
detectDevTree()atInstallEngine.ts:159-161is exactlyexistsSync(join(configRoot, "skills", "_LIFEOS")), and seven tools refuse to run when it returns true:LinkUser.tsInstallHooks.tsDeployCore.tsDeployComponents.tsSeedPulse.tsScaffoldUser.tsActivateImports.tsSo 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:39writesOUT_PATH = join(CLAUDE, "skills", "_LIFEOS", "DENY_HASHES.json"), and the warning text names that tool as the fix. #1488's accepted early-returnthere 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,
SystemFileGuardis 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
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 incontainment-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):with
DeriveDenyHashes.tsOUT_PATHfollowing, and the warning text pointing at the new location.evaluateWrite()already acceptsdenyListPath/hashesPathoverrides (:165), so the override surface needs no change and existing callers are unaffected.Two things I'd leave to you rather than assume:
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.DENYLIST_SALThas to be generated per-user into.envandDeriveDenyHashes'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.