Version: 7.40.4 · Platform: Windows 10 (the separator half is Windows-specific; the relative-path half is not) · Bun: 1.3.10
identity proposals are rejected for naming a file that is on the allowlist, because normalizeProposalTargetFile only understands absolute paths and a leading ~. One rejected item then marks the whole reviewer run failed, which surfaces as a CRITICAL memory-health alert.
What happens
EINVAL_ITEM: target_file 'LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md'
is not an allowed 'identity' target
PRINCIPAL_IDENTITY.md is one of exactly two allowed identity targets. The path is correct; only its format is not.
Observed across runs: a proposal naming PRINCIPAL_IDENTITY.md applied successfully on one run and was rejected on another, because the model emits an absolute path sometimes and a repo-relative path other times. The failure is non-deterministic from the user's side.
Root cause
LIFEOS/TOOLS/MemoryTypes.ts:
export function normalizeProposalTargetFile(targetFile: string): string {
if (targetFile === "~" || targetFile.startsWith("~/")) {
return pathJoin(homedir(), targetFile.slice(1));
}
return targetFile; // relative paths pass through unchanged
}
It handles exactly one case. Anything relative is returned as-is and then compared against absolute allowlist entries, so it can never match.
This only bites identity because identity is the sole multi-file kind:
identity: Object.freeze([PRINCIPAL_IDENTITY_PATH, DA_IDENTITY_PATH]),
if (allowed.length === 1) return allowed[0]; // single-file kinds discard the supplied path
const normalized = normalizeProposalTargetFile(suppliedTargetFile);
return allowed.includes(normalized) ? normalized : null; // identity must match exactly
Every other kind ignores target_file entirely and substitutes the canonical path, so a malformed path is harmless there. identity is the one kind that must match, and the normalizer isn't strong enough to let a legitimate path through.
On Windows there is a second, independent mismatch: the allowlist is built with pathJoin (backslashes) while the reviewer emits forward slashes, so even a correct absolute path can fail on separator style alone.
Secondary issue: one bad item fails the entire run
A run that dispatched five items successfully and rejected one reports:
"proposals_auto_applied": 1,
"error": "dispatch failed for 1 item(s)"
MemoryHealthCheck reads only the latest run's status, so a single path-format mismatch produces overall: critical. Partial success is indistinguishable from total failure. Worth considering separately from the fix below: a run where most items landed is arguably warn, not critical.
Reproduce
import { pinProposalTargetFile } from "./LIFEOS/TOOLS/MemoryTypes";
pinProposalTargetFile("identity", "LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md");
// → null (expected: the canonical PRINCIPAL_IDENTITY_PATH)
Suggested fix
Expand the normalizer to accept the forms the reviewer actually emits, resolving only against the two real roots and returning a candidate only when it lands exactly on an allowlisted file. That keeps the anti-hallucination guarantee from #1563 intact: an invented path still fails to resolve and is still rejected.
-import { resolve as pathResolve, join as pathJoin } from "node:path";
+import { resolve as pathResolve, join as pathJoin, isAbsolute as pathIsAbsolute, normalize as pathNormalize } from "node:path";
export function normalizeProposalTargetFile(targetFile: string): string {
- if (targetFile === "~" || targetFile.startsWith("~/")) {
- return pathJoin(homedir(), targetFile.slice(1));
- }
- return targetFile;
+ // Accept the separator style the model happens to emit. A Windows install
+ // stores its allowlist with backslashes, while the reviewer routinely writes
+ // forward slashes, so comparing raw strings fails on formatting alone.
+ const raw = targetFile.trim().replace(/\\/g, "/");
+
+ if (raw === "~" || raw.startsWith("~/")) {
+ return pathNormalize(pathJoin(homedir(), raw.slice(1)));
+ }
+ if (pathIsAbsolute(raw)) return pathNormalize(raw);
+
+ // Relative paths. The reviewer emits target_file as free text and commonly
+ // produces a config-root-relative form such as
+ // "LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md", or occasionally a
+ // LIFEOS_DIR-relative "USER/PRINCIPAL/PRINCIPAL_IDENTITY.md".
+ //
+ // Resolving against the two real roots (never an arbitrary one) and returning
+ // a candidate only when it lands exactly on an allowlisted file keeps the
+ // anti-hallucination guarantee from #1563 intact: an invented path still
+ // fails to resolve and is still rejected by the caller.
+ const allowlist = Object.values(PROPOSAL_KIND_TO_FILES).flat() as string[];
+ for (const base of [CLAUDE_ROOT, LIFEOS_DIR]) {
+ const candidate = pathNormalize(pathJoin(base, raw));
+ if (allowlist.includes(candidate)) return candidate;
+ }
+ return pathNormalize(raw);
}
Verification
Ten cases against the patched build, all passing:
| Case |
Input |
Result |
| config-root-relative (the bug) |
LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md |
allowed |
| Windows separators |
LIFEOS\USER\PRINCIPAL\PRINCIPAL_IDENTITY.md |
allowed |
| mixed separators |
LIFEOS\USER/PRINCIPAL\PRINCIPAL_IDENTITY.md |
allowed |
| LIFEOS_DIR-relative |
USER/DIGITAL_ASSISTANT/DA_IDENTITY.md |
allowed |
| tilde (regression) |
~/.claude/LIFEOS/.../DA_IDENTITY.md |
allowed |
| absolute (regression) |
canonical absolute path |
allowed |
| surrounding whitespace |
␣␣LIFEOS/USER/.../PRINCIPAL_IDENTITY.md␣␣ |
allowed |
| traversal escape |
LIFEOS/USER/PRINCIPAL/../../../../../etc/passwd |
rejected |
| out-of-allowlist |
LIFEOS/USER/SOMETHING_ELSE.md |
rejected |
| hallucinated filename |
LIFEOS/USER/PRINCIPAL/IDENTITY.md |
rejected |
Single-file kinds still discard the supplied path and pin to canonical, unchanged.
The shipped bun MemoryTypes.ts test suite reports 56 passed, 4 failed both before and after the patch, so this is net-neutral on it.
Unrelated observation from that suite
Those same 4 pre-existing failures are worth a look on their own. They are knowledge/idea routing assertions that compare against POSIX-style paths and fail on Windows purely on separators:
✗ knowledge(person) → KNOWLEDGE/People/ — C:\Users\<user>\...\KNOWLEDGE\People\some-person.md
✗ idea → KNOWLEDGE/Ideas/ — C:\Users\<user>\...\KNOWLEDGE\Ideas\...
Reproduced on an unmodified 7.40.4 checkout. Same root cause class as the separator half above. Happy to file that separately if useful.
Happy to open a PR for any of this.
Version: 7.40.4 · Platform: Windows 10 (the separator half is Windows-specific; the relative-path half is not) · Bun: 1.3.10
identityproposals are rejected for naming a file that is on the allowlist, becausenormalizeProposalTargetFileonly understands absolute paths and a leading~. One rejected item then marks the whole reviewer runfailed, which surfaces as a CRITICAL memory-health alert.What happens
PRINCIPAL_IDENTITY.mdis one of exactly two allowed identity targets. The path is correct; only its format is not.Observed across runs: a proposal naming
PRINCIPAL_IDENTITY.mdapplied successfully on one run and was rejected on another, because the model emits an absolute path sometimes and a repo-relative path other times. The failure is non-deterministic from the user's side.Root cause
LIFEOS/TOOLS/MemoryTypes.ts:It handles exactly one case. Anything relative is returned as-is and then compared against absolute allowlist entries, so it can never match.
This only bites
identitybecauseidentityis the sole multi-file kind:Every other kind ignores
target_fileentirely and substitutes the canonical path, so a malformed path is harmless there.identityis the one kind that must match, and the normalizer isn't strong enough to let a legitimate path through.On Windows there is a second, independent mismatch: the allowlist is built with
pathJoin(backslashes) while the reviewer emits forward slashes, so even a correct absolute path can fail on separator style alone.Secondary issue: one bad item fails the entire run
A run that dispatched five items successfully and rejected one reports:
MemoryHealthCheckreads only the latest run's status, so a single path-format mismatch producesoverall: critical. Partial success is indistinguishable from total failure. Worth considering separately from the fix below: a run where most items landed is arguablywarn, notcritical.Reproduce
Suggested fix
Expand the normalizer to accept the forms the reviewer actually emits, resolving only against the two real roots and returning a candidate only when it lands exactly on an allowlisted file. That keeps the anti-hallucination guarantee from #1563 intact: an invented path still fails to resolve and is still rejected.
export function normalizeProposalTargetFile(targetFile: string): string { - if (targetFile === "~" || targetFile.startsWith("~/")) { - return pathJoin(homedir(), targetFile.slice(1)); - } - return targetFile; + // Accept the separator style the model happens to emit. A Windows install + // stores its allowlist with backslashes, while the reviewer routinely writes + // forward slashes, so comparing raw strings fails on formatting alone. + const raw = targetFile.trim().replace(/\\/g, "/"); + + if (raw === "~" || raw.startsWith("~/")) { + return pathNormalize(pathJoin(homedir(), raw.slice(1))); + } + if (pathIsAbsolute(raw)) return pathNormalize(raw); + + // Relative paths. The reviewer emits target_file as free text and commonly + // produces a config-root-relative form such as + // "LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.md", or occasionally a + // LIFEOS_DIR-relative "USER/PRINCIPAL/PRINCIPAL_IDENTITY.md". + // + // Resolving against the two real roots (never an arbitrary one) and returning + // a candidate only when it lands exactly on an allowlisted file keeps the + // anti-hallucination guarantee from #1563 intact: an invented path still + // fails to resolve and is still rejected by the caller. + const allowlist = Object.values(PROPOSAL_KIND_TO_FILES).flat() as string[]; + for (const base of [CLAUDE_ROOT, LIFEOS_DIR]) { + const candidate = pathNormalize(pathJoin(base, raw)); + if (allowlist.includes(candidate)) return candidate; + } + return pathNormalize(raw); }Verification
Ten cases against the patched build, all passing:
LIFEOS/USER/PRINCIPAL/PRINCIPAL_IDENTITY.mdLIFEOS\USER\PRINCIPAL\PRINCIPAL_IDENTITY.mdLIFEOS\USER/PRINCIPAL\PRINCIPAL_IDENTITY.mdUSER/DIGITAL_ASSISTANT/DA_IDENTITY.md~/.claude/LIFEOS/.../DA_IDENTITY.md␣␣LIFEOS/USER/.../PRINCIPAL_IDENTITY.md␣␣LIFEOS/USER/PRINCIPAL/../../../../../etc/passwdLIFEOS/USER/SOMETHING_ELSE.mdLIFEOS/USER/PRINCIPAL/IDENTITY.mdSingle-file kinds still discard the supplied path and pin to canonical, unchanged.
The shipped
bun MemoryTypes.ts testsuite reports 56 passed, 4 failed both before and after the patch, so this is net-neutral on it.Unrelated observation from that suite
Those same 4 pre-existing failures are worth a look on their own. They are knowledge/idea routing assertions that compare against POSIX-style paths and fail on Windows purely on separators:
Reproduced on an unmodified 7.40.4 checkout. Same root cause class as the separator half above. Happy to file that separately if useful.
Happy to open a PR for any of this.