Skip to content

substituteTree strips the exec bit, silently disabling 10 wired hooks on every install #1803

Description

@mark-219

substituteTree strips the exec bit, silently disabling 10 wired hooks on every install

Affected: v7.28.3 (current release; the code path is long-standing)
Component: LifeOS/Tools/InstallEngine.tssubstituteTree()
Impact: Functional. 10 of the wired hooks never run after a fresh install. Not a security bypass — see "Security assessment" below.

Summary

substituteTree() writes each modified file to a temp path and renames it over the
original. writeFileSync creates the temp file at the default umask mode (0644), and
renameSync replaces the original inode — so the original's mode is discarded. Every
file the identity-substitution pass touches loses its executable bit.

Workflows/Setup.md step 9(d) instructs the installer to run substituteTree(<configRoot>, vars)
over the whole config root after hooks are deployed. Any hook containing an identity
placeholder is therefore rewritten to 0644 and can no longer execute.

The shipped artifact is fine: all 50 *.hook.ts in the v7.28.3 tarball are 100755,
matching the git tree. The mode is destroyed at install time by the installer itself.

Affected hooks on a stock v7.28.3 install (macOS, Claude Code)

14 of 50 lose the exec bit; 10 are wired into settings.json:

Wired (never run) Unwired (would break if wired)
Safety.hook.ts, LoadContext.hook.ts, PromptProcessing.hook.ts, AgentInvocation.hook.ts, TabState.hook.ts, EventLogger.hook.ts, DriftReminder.hook.ts, SatisfactionCapture.hook.ts, CheckpointPerISC.hook.ts, ConfigEvalFire.hook.ts FormatGate.hook.ts, ISAFoldGate.hook.ts, ModelRungGuard.hook.ts, WritingGate.hook.ts

Lost behaviour includes session context injection, event logging, drift reminders,
satisfaction capture, ISC checkpointing, and terminal tab state.

Security assessment: not exploitable

Reported as a normal bug rather than through private disclosure, on the following basis.
Correction welcome if this reads wrong.

  • None of the 14 can deny or block. No deny decision, no exit(2), in any of them.
  • Safety.hook.ts is an auto-approver, not a denylist. It emits decision: allow when
    a call classifies as safe and stays neutral otherwise. Its own header states the
    Constitutional Security Protocol in the system prompt "does the actual defense work.
    This hook is decoration." Absent, the native permission prompt fires instead of an
    auto-allow, which is more friction, not less.
  • It is designed to fail open. "Both paths fail-open: any internal error returns 0 with
    no stdout, native engine falls back to its default behavior."
  • The real denylist is unaffected. 36 native permissions.deny entries in
    settings.json are enforced by Claude Code itself, independent of file mode.

The one genuine security-adjacent loss is defence in depth, not a bypass: the PostToolUse
path no longer annotates web/mail/drive content with "treat as data, not instructions" or
flags [INJECTION SHAPE DETECTED]. That is advisory signal to the model, and the system
prompt still carries the rule.

Why it stays silent

Doctor.ts does not check hook executability, and step 9(b)'s "probe session shows the
banner fire" passes, because the injection hooks that still run are the ones without
placeholders. The first symptom is an incidental Permission denied line from an unrelated
hook — in our case TabState.hook.ts on Stop.

Reproduction (isolated, 6 lines)

mkdir repro
printf '#!/usr/bin/env bun\n// hook for {{PRINCIPAL_NAME}}\nconsole.log("hi");\n' > repro/Demo.hook.ts
chmod +x repro/Demo.hook.ts
stat -f '%Sp' repro/Demo.hook.ts     # -rwxr-xr-x
bun -e 'const {substituteTree} = await import("./Tools/InstallEngine.ts");
        substituteTree("./repro", {"{{PRINCIPAL_NAME}}": "Example"});'
stat -f '%Sp' repro/Demo.hook.ts     # -rw-r--r--   ← exec bit gone

Observed on macOS 26.5.1 (arm64), bun 1.3.14, Claude Code, umask 022.

Suggested fix

Preserve the original mode across the atomic replace, in InstallEngine.ts:

if (after !== before) {
  const tmp = filePath + ".lifeos.tmp";
  writeFileSync(tmp, after);
  chmodSync(tmp, statSync(filePath).mode);   // carry the original mode across the rename
  renameSync(tmp, filePath);
  modified++;
}

Two related hardening suggestions:

  1. Verify, don't assume. Setup step 9(d) already re-verifies substitution with
    checkSurvivingPlaceholders. An equivalent post-substitution check that every hook
    command wired in settings.json is executable would have caught this at install time.
  2. Doctor.ts should test executability of each wired hook command, since this failure
    mode is silent and a declined-vs-broken distinction already exists there.

Workaround for existing installs

find ~/.claude/hooks -maxdepth 1 -name '*.hook.ts' ! -perm -u+x -exec chmod +x {} \;

Found during a fresh /LifeOS setup on a clean machine.

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