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.ts → substituteTree()
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:
- 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.
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.
substituteTreestrips the exec bit, silently disabling 10 wired hooks on every installAffected: v7.28.3 (current release; the code path is long-standing)
Component:
LifeOS/Tools/InstallEngine.ts→substituteTree()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 theoriginal.
writeFileSynccreates the temp file at the default umask mode (0644), andrenameSyncreplaces the original inode — so the original's mode is discarded. Everyfile the identity-substitution pass touches loses its executable bit.
Workflows/Setup.mdstep 9(d) instructs the installer to runsubstituteTree(<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.tsin thev7.28.3tarball are100755,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: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.tsFormatGate.hook.ts,ISAFoldGate.hook.ts,ModelRungGuard.hook.ts,WritingGate.hook.tsLost 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.
denydecision, noexit(2), in any of them.Safety.hook.tsis an auto-approver, not a denylist. It emitsdecision: allowwhena 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.
no stdout, native engine falls back to its default behavior."
permissions.denyentries insettings.jsonare enforced by Claude Code itself, independent of file mode.The one genuine security-adjacent loss is defence in depth, not a bypass: the
PostToolUsepath 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 systemprompt still carries the rule.
Why it stays silent
Doctor.tsdoes not check hook executability, and step 9(b)'s "probe session shows thebanner fire" passes, because the injection hooks that still run are the ones without
placeholders. The first symptom is an incidental
Permission deniedline from an unrelatedhook — in our case
TabState.hook.tsonStop.Reproduction (isolated, 6 lines)
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:Two related hardening suggestions:
checkSurvivingPlaceholders. An equivalent post-substitution check that every hookcommand wired in
settings.jsonis executable would have caught this at install time.Doctor.tsshould test executability of each wired hook command, since this failuremode is silent and a declined-vs-broken distinction already exists there.
Workaround for existing installs
Found during a fresh
/LifeOS setupon a clean machine.