categorizeChange() and normalizeToRelativePath() decide whether a file is inside LifeOS using a
bare String.prototype.startsWith. That is a test on the string, not on the path — any sibling
directory whose name merely begins with a root's name is classified as inside it.
Live at 58381b3 (2026-08-07), LifeOS/install/hooks/lib/change-detection.ts:
183: if (absolutePath.startsWith(LIFEOS_DIR)) {
226: if (!absolutePath.startsWith(CLAUDE_DIR) && !absolutePath.startsWith(LIFEOS_DIR)) {
With CLAUDE_DIR = ~/.claude and LIFEOS_DIR = ~/.claude/LIFEOS, both roots are affected.
Reproduction
No install needed — the defect is entirely in the string comparison:
bun -e '
const CLAUDE_DIR = "/Users/x/.claude", LIFEOS_DIR = "/Users/x/.claude/LIFEOS";
const inside = (p) => p.startsWith(CLAUDE_DIR) || p.startsWith(LIFEOS_DIR);
for (const p of [
"/Users/x/.claude-backup/skills/A/SKILL.md",
"/Users/x/.claudeold/settings.json",
"/Users/x/.claude/LIFEOS-backup/MEMORY/a.md",
"/Users/x/.claude/LIFEOS/MEMORY/a.md",
]) console.log(inside(p) ? "INSIDE " : "outside", p);'
INSIDE /Users/x/.claude-backup/skills/A/SKILL.md ← wrong
INSIDE /Users/x/.claudeold/settings.json ← wrong
INSIDE /Users/x/.claude/LIFEOS-backup/MEMORY/a.md ← wrong
INSIDE /Users/x/.claude/LIFEOS/MEMORY/a.md ← correct
.claude-backup isn't a contrived name — it's what you get from cp -r ~/.claude ~/.claude-backup
before an upgrade.
Impact
Low, and I'd rather say so than oversell it. The consumer is hooks/handlers/SystemIntegrity.ts, so
the effect is that a backup or scratch copy of the tree is treated as a live system change:
integrity/doc-sync work runs over files that aren't system files, and normalizeToRelativePath()
returns ../LIFEOS-backup/... — a path that then gets pattern-matched against skills/,
Workflows/ and friends as though it were internal. Noise and wasted work, not a security boundary.
Worth fixing because it's four lines and provably wrong.
Patch
import { sep } from 'path';
/**
* Is this path the root itself, or inside it?
* A bare startsWith is a STRING test; the separator is what makes it a PATH test.
*/
function isWithin(absolutePath: string, root: string): boolean {
return absolutePath === root || absolutePath.startsWith(root + sep);
}
- if (absolutePath.startsWith(LIFEOS_DIR)) {
+ if (isWithin(absolutePath, LIFEOS_DIR)) {
- if (!absolutePath.startsWith(CLAUDE_DIR) && !absolutePath.startsWith(LIFEOS_DIR)) {
+ if (!isWithin(absolutePath, CLAUDE_DIR) && !isWithin(absolutePath, LIFEOS_DIR)) {
I ran the same change in my fork: sibling directories now categorize null, genuine
~/.claude/LIFEOS/... paths still categorize normally, and handlers/SystemIntegrity.ts loads
unchanged. Mutation-checked by reverting to the bare prefix and confirming the sibling cases flip
back to being treated as inside.
I grepped LifeOS/install/hooks for the same shape — these two lines are the only instances, so this
is one commit, not a refactor. There's no test file for hooks/lib/, so I haven't proposed a
regression test; happy to add one if you'd like a harness there.
One note on why this survived
The reason I went looking wasn't this file. In my own fork I had a path-policy guard whose deny-side
was tested thoroughly and whose allow-side was tested only with /tmp and /opt — both outside the
directory the rule guarded. The rule was refusing every real project on the machine, and the suite
stayed green because nothing in it ever exercised the case the feature existed to serve.
The generalisation, for whatever it's worth to LifeOS's own doctrine: a policy test whose allow-side
never exercises the primary use case validates the policy's safety and never its usefulness. A guard
tested only against what it should refuse looks perfect while refusing everything. Sweeping for that
shape is what surfaced the lines above.
categorizeChange()andnormalizeToRelativePath()decide whether a file is inside LifeOS using abare
String.prototype.startsWith. That is a test on the string, not on the path — any siblingdirectory whose name merely begins with a root's name is classified as inside it.
Live at
58381b3(2026-08-07),LifeOS/install/hooks/lib/change-detection.ts:With
CLAUDE_DIR = ~/.claudeandLIFEOS_DIR = ~/.claude/LIFEOS, both roots are affected.Reproduction
No install needed — the defect is entirely in the string comparison:
.claude-backupisn't a contrived name — it's what you get fromcp -r ~/.claude ~/.claude-backupbefore an upgrade.
Impact
Low, and I'd rather say so than oversell it. The consumer is
hooks/handlers/SystemIntegrity.ts, sothe effect is that a backup or scratch copy of the tree is treated as a live system change:
integrity/doc-sync work runs over files that aren't system files, and
normalizeToRelativePath()returns
../LIFEOS-backup/...— a path that then gets pattern-matched againstskills/,Workflows/and friends as though it were internal. Noise and wasted work, not a security boundary.Worth fixing because it's four lines and provably wrong.
Patch
I ran the same change in my fork: sibling directories now categorize
null, genuine~/.claude/LIFEOS/...paths still categorize normally, andhandlers/SystemIntegrity.tsloadsunchanged. Mutation-checked by reverting to the bare prefix and confirming the sibling cases flip
back to being treated as inside.
I grepped
LifeOS/install/hooksfor the same shape — these two lines are the only instances, so thisis one commit, not a refactor. There's no test file for
hooks/lib/, so I haven't proposed aregression test; happy to add one if you'd like a harness there.
One note on why this survived
The reason I went looking wasn't this file. In my own fork I had a path-policy guard whose deny-side
was tested thoroughly and whose allow-side was tested only with
/tmpand/opt— both outside thedirectory the rule guarded. The rule was refusing every real project on the machine, and the suite
stayed green because nothing in it ever exercised the case the feature existed to serve.
The generalisation, for whatever it's worth to LifeOS's own doctrine: a policy test whose allow-side
never exercises the primary use case validates the policy's safety and never its usefulness. A guard
tested only against what it should refuse looks perfect while refusing everything. Sweeping for that
shape is what surfaced the lines above.