Skip to content

InstallHooks.ts overwrites existing hook files; LIFEOS_CONFIG_DIR defaults identical to LIFEOS_DIR #1491

Description

@donovan-sec

Summary

Two bugs in the v7.1.1 release installer that contradict the tool's own documented "additive, never clobbering" contract. Found while doing a manual, checkpointed install against a live, populated ~/.claude (pre-existing PAI 5.0 install with ~30+ custom hooks). Both are fixable with small, targeted patches.


Bug 1: InstallHooks.ts overwrites existing hook files instead of skipping them

File: LifeOS/Tools/InstallHooks.ts, line ~101

cpSync(hooksPayloadDir, hooksDestDir, { recursive: true });

fs.cpSync defaults to force: true, so this recursively overwrites every file in hooksDestDir that shares a name with a file in the payload — including files that already existed and belong to the user, not LifeOS.

This directly contradicts:

  • Workflows/Setup.md: "hooks → bun Tools/InstallHooks.ts (trust-gated): ... merges additively per matcher bucket"
  • SKILL.md's own stated hard rule: "Additive, never clobbering... Never overwrite or rm a populated dir or a foreign file."

The settings.json merge logic in the same file (lines ~75-93) is correctly additive/guarded — this is purely a bug in the hook script file copy step, which is a separate operation from the settings.json hooks-array merge.

Repro

  1. Have an existing ~/.claude/hooks/ directory with any file whose name collides with a shipped LifeOS hook (e.g. AgentInvocation.hook.ts, SessionCleanup.hook.ts, VoiceCompletion.hook.ts — 19 collided in our case).
  2. Run bun Tools/InstallHooks.ts --apply.
  3. Every colliding file is silently replaced with LifeOS's version, no backup, no diff shown, no consent for that specific file (only the settings.json merge is shown/gated).

Impact (real-world)

In our case this broke a user-owned SecurityPipeline.hook.ts indirectly — one of the overwritten files it depended on changed, and the security hook began fail-closing on every tool call (Bash, Read, everything), effectively locking the session. Recovered via git checkout <pre-install-commit> -- hooks/ since we'd taken a full backup commit first. Without that backup this would have been a silent, hard-to-diagnose data-loss/breakage event.

Suggested fix

Copy file-by-file with an existsSync guard (matching the pattern already used everywhere else in the codebase — DeployCore.ts's copyMissing, ScaffoldUser.ts, DeployComponents.ts's agents/commands deployers):

function copyMissingRecursive(src: string, dst: string): { copied: number } {
  let copied = 0;
  for (const entry of readdirSync(src, { withFileTypes: true })) {
    const s = join(src, entry.name);
    const d = join(dst, entry.name);
    if (entry.isDirectory()) {
      mkdirSync(d, { recursive: true });
      copied += copyMissingRecursive(s, d).copied;
    } else if (!existsSync(d)) {
      copyFileSync(s, d);
      copied++;
    }
  }
  return { copied };
}

If overwriting existing hook files is ever intentional (e.g. for Update.md's idempotent re-overlay of system-owned files), that should be a distinct code path from the fresh-install path, and should only apply to files LifeOS itself previously placed (tracked via a manifest), never to files that pre-existed the first LifeOS install.


Bug 2: settings.system.json ships LIFEOS_CONFIG_DIR identical to LIFEOS_DIR

File: LifeOS/install/settings.system.json, lines ~5 and ~9

"LIFEOS_DIR": "$HOME/.claude/LIFEOS",
"LIFEOS_CONFIG_DIR": "$HOME/.claude/LIFEOS",

Both env vars resolve to the exact same path. But Tools/LinkUser.ts's own default/fallback treats these as two conceptually separate locations — LIFEOS_DIR = the harness-tree runtime, LIFEOS_CONFIG_DIR = the private data home (its own hardcoded fallback is join(home, ".config", "LIFEOS"), i.e. ~/.config/LIFEOS, deliberately outside the harness tree). This mirrors the already-shipped PAI_CONFIG_DIR pattern (~/.config/PAI) from the predecessor system.

Impact

Running LinkUser.ts --apply with the shipped default env produces:

{ "willLink": "/Users/x/.claude/LIFEOS/USER → /Users/x/.claude/LIFEOS/USER" }

i.e. it would attempt to symlink LIFEOS/USER to itself — a no-op at best, or a broken/self-referential symlink depending on setupUserSeparation's exact behavior when src === dst. We caught this only by inspecting the dry-run willLink output before applying and cross-checking against the tool's own default fallback logic; someone running the one-line curl | bash install without a code review would hit this silently.

Suggested fix

"LIFEOS_DIR": "$HOME/.claude/LIFEOS",
"LIFEOS_CONFIG_DIR": "$HOME/.config/LIFEOS",

Worth adding an explicit runtime assertion in LinkUser.ts too — refuse to proceed (loud error, not silent no-op) if configRoot/LIFEOS and configDir resolve to the same path, since that's never a valid state given the tool's own design intent.


Environment

  • LifeOS v7.1.1 (tag), macOS 26.5.2, bun 1.3.11
  • Installed manually (release tarball + individual Tools/*.ts steps) against a pre-existing, populated Claude Code ~/.claude — not a clean machine
  • Both bugs reproduced via the shipped tools directly, not an artifact of the manual install path (the manual path just made them visible/interceptable instead of silent)

Happy to send a PR for either/both if useful — the fixes above are small and match existing patterns already in the codebase.

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