Skip to content

identity proposals rejected for a path that IS allowed: normalizeProposalTargetFile only handles absolute and ~ paths, and one bad item fails the whole reviewer run #2047

Description

@aveneri

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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