Skip to content

Commit a4fa04f

Browse files
mcp-tool-shopclaude
andcommitted
fix(health-a): package defects across kernel/memories/rules (+25 tests)
memories: tighten inline-path parser so prose citations no longer become index entries (the memory-files/full-frame/see-also junk defect); surface unresolved refs; add ID_TOO_LONG validate rule; export nameToId; fix frontmatter summary-truncation asymmetry; regression tests (MEM-001/002/003/004/007/009). kernel: rewrite SECURITY.md threat model to match actual FS I/O; fix frontmatter indexOf line mis-resolution; fix usage --json dropping the modes Set; changelog date (KER-01/03/06/08; public API unchanged). rules: bounds-check --rules-dir arg parsing; structured INVALID_SIGNALS error; drift-detection tests; remove dead unsplittable plumbing (RUL-001/002/003/005/006). Tests: 203 -> 228 green (kernel 93->97, memories 35->42, rules 75->89). Exclusive file ownership verified, no cross-domain edits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab8aad8 commit a4fa04f

23 files changed

Lines changed: 837 additions & 69 deletions

packages/kernel/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
## 1.4.3 — unreleased
3+
## 1.4.3 — 2026-06-16
44

55
- **`validate` CLI command** (2026-03-25): validate index structure from the command line (`ai-loadout validate <index>`)
66
- **Fix**: `loadIndex()` now emits structured error on malformed JSON instead of raw stack trace (2026-03-25)

packages/kernel/SECURITY.md

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,45 @@
22

33
## Attack Surface
44

5-
`@mcptoolshop/ai-loadout` is a **pure data library**. It has:
6-
7-
- **No filesystem access** — does not read or write files
8-
- **No network access** — makes no HTTP requests, opens no sockets
9-
- **No code execution** — no `eval`, `Function()`, or dynamic imports
10-
- **No telemetry** — collects and transmits nothing
11-
- **No native dependencies** — pure TypeScript, zero production deps
12-
13-
All I/O is the consumer's responsibility. This package only transforms data structures in memory.
5+
`@mcptoolshop/ai-loadout` is a small, locally-scoped library. Its surface breaks into three parts:
6+
7+
- **Pure core** — matching, merging, validation, token estimation, and frontmatter
8+
parsing/serialization are pure functions with no side effects. They only transform
9+
data structures in memory.
10+
- **Resolver**`discoverLayers()` / `resolveLoadout()` / the `resolve` CLI command
11+
perform **local-only filesystem reads** to discover layer index files in fixed,
12+
canonical locations (`~/.ai-loadout/index.json`, `<project>/.claude/loadout/index.json`,
13+
and explicit org/session paths). It reads `index.json` files with `readFileSync` and
14+
probes for their existence with `existsSync`. It never writes, never walks arbitrary
15+
trees, and never follows network locations.
16+
- **Usage tracking**`recordUsage()` / `readUsage()` perform **append-only local
17+
JSONL writes** (`appendFileSync`) and reads (`readFileSync`) to a path supplied by the
18+
caller. Events are appended one line at a time; nothing is ever transmitted.
19+
20+
Across all three parts:
21+
22+
- **No network access** — makes no HTTP requests, opens no sockets.
23+
- **No code execution** — no `eval`, `Function()`, or dynamic imports.
24+
- **No telemetry / exfiltration** — usage data stays on the local disk path the caller
25+
chooses; nothing is collected centrally or sent anywhere.
26+
- **No secrets** — the library handles loadout metadata only; it reads no credentials,
27+
environment secrets, or tokens.
28+
- **No native dependencies** — pure TypeScript, zero production deps.
29+
30+
Filesystem access is confined to: reading the caller-specified index files the resolver
31+
discovers, and reading/appending the caller-specified usage JSONL log. The library never
32+
writes outside the path the caller hands it.
1433

1534
## Input Validation
1635

1736
The `parseFrontmatter()` function processes untrusted text input. It uses simple string splitting — no YAML parser, no regex-based evaluation, no prototype pollution vectors.
1837

1938
The `validateIndex()` function checks structural integrity of index objects. It does not execute or interpret any field values.
2039

40+
The resolver and usage reader treat on-disk files as untrusted: malformed `index.json`
41+
layers are skipped silently (the layer is reported as not found), and malformed JSONL
42+
usage lines are skipped without throwing. No file content is ever executed or evaluated.
43+
2144
## Supported Versions
2245

2346
| Version | Supported |

packages/kernel/src/cli.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { readFileSync, existsSync } from "node:fs";
1414
import { resolve, dirname, join } from "node:path";
1515
import { fileURLToPath } from "node:url";
1616
import type { LoadoutIndex } from "./types.js";
17-
import { readUsage, summarizeUsage } from "./usage.js";
17+
import { readUsage, summarizeUsage, summaryToJSON } from "./usage.js";
1818
import { findDeadEntries, findKeywordOverlaps, analyzeBudget } from "./analysis.js";
1919
import { resolveLoadout, explainEntry } from "./resolve.js";
2020
import type { ResolveOptions } from "./resolve.js";
@@ -147,7 +147,9 @@ function cmdUsage(args: string[]) {
147147
const summary = summarizeUsage(events);
148148

149149
if (json) {
150-
log(JSON.stringify(summary, null, 2));
150+
// summary.modes is a Set<string>; JSON.stringify would drop it to {}.
151+
// Project to a JSON-safe shape (modes → array) before serializing.
152+
log(JSON.stringify(summary.map(summaryToJSON), null, 2));
151153
return;
152154
}
153155

packages/kernel/src/frontmatter.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export function parseFrontmatter(
4141
let currentArray: string[] | null = null;
4242
let currentObject: Record<string, boolean> | null = null;
4343

44-
for (const line of fmLines) {
44+
for (let i = 0; i < fmLines.length; i++) {
45+
const line = fmLines[i];
4546
const trimmed = line.trim();
4647
if (trimmed === "") continue;
4748

@@ -81,13 +82,14 @@ export function parseFrontmatter(
8182
currentKey = key;
8283

8384
if (rawVal === "") {
84-
// Peek at next line to determine if block array or object
85-
const lineIdx = fmLines.indexOf(line);
86-
if (lineIdx + 1 < fmLines.length) {
87-
const nextTrimmed = fmLines[lineIdx + 1].trim();
85+
// Peek at next line to determine if block array or object.
86+
// Use the loop index `i` directly: indexOf(line) would return the
87+
// first byte-identical line, mis-resolving duplicate-value keys.
88+
if (i + 1 < fmLines.length) {
89+
const nextTrimmed = fmLines[i + 1].trim();
8890
if (nextTrimmed.startsWith("- ")) {
8991
currentArray = [];
90-
} else if (fmLines[lineIdx + 1].startsWith(" ")) {
92+
} else if (fmLines[i + 1].startsWith(" ")) {
9193
currentObject = {};
9294
}
9395
}

packages/kernel/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ export { validateIndex } from "./validate.js";
3333
export { mergeIndexes } from "./merge.js";
3434

3535
// ── Usage ─────────────────────────────────────────────────────
36-
export { recordUsage, readUsage, summarizeUsage } from "./usage.js";
37-
export type { UsageSummary } from "./usage.js";
36+
export { recordUsage, readUsage, summarizeUsage, summaryToJSON } from "./usage.js";
37+
export type { UsageSummary, UsageSummaryJSON } from "./usage.js";
3838

3939
// ── Analysis ──────────────────────────────────────────────────
4040
export { findDeadEntries, findKeywordOverlaps, analyzeBudget } from "./analysis.js";

packages/kernel/src/tests/frontmatter.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,44 @@ describe("parseFrontmatter", () => {
6262
assert.ok(frontmatter);
6363
assert.deepEqual(frontmatter.keywords, []);
6464
});
65+
66+
it("peeks the correct next line for byte-identical empty-value keys", () => {
67+
// Regression (KER-03): the empty-value block-key peek used
68+
// `fmLines.indexOf(line)`, which returns the FIRST byte-identical line.
69+
// When the same empty-value key line ("triggers:") appears twice, the
70+
// SECOND occurrence would peek the FIRST occurrence's next line and
71+
// mis-classify its block (array vs object).
72+
//
73+
// First `triggers:` is followed by a dash item (would open an array);
74+
// the last-wins `triggers:` is followed by an indented object. With the
75+
// buggy indexOf, the second `triggers:` peeks the first occurrence's next
76+
// line ("- task") and wrongly opens an array, so the real triggers object
77+
// is dropped and triggers fall back to DEFAULT_TRIGGERS. With numeric
78+
// indexing, the second occurrence peeks its OWN next line and parses the
79+
// object correctly.
80+
const content = [
81+
"---",
82+
"id: dup-key",
83+
"keywords: [x]",
84+
"priority: core",
85+
"triggers:", // first occurrence — dash item follows
86+
"- task", // (buggy indexOf peek target for the 2nd key)
87+
"triggers:", // byte-identical key line — indented object follows
88+
" task: false",
89+
" plan: false",
90+
" edit: true",
91+
"---",
92+
"Body",
93+
].join("\n");
94+
const { frontmatter } = parseFrontmatter(content);
95+
assert.ok(frontmatter);
96+
// The winning (second) triggers block must be parsed as an object.
97+
// On the buggy code it would be skipped, leaving DEFAULT_TRIGGERS
98+
// (task: true, edit: false) — distinct from the expected values below.
99+
assert.equal(frontmatter.triggers.task, false);
100+
assert.equal(frontmatter.triggers.plan, false);
101+
assert.equal(frontmatter.triggers.edit, true);
102+
});
65103
});
66104

67105
describe("serializeFrontmatter", () => {

packages/kernel/src/tests/usage.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
33
import { writeFileSync, unlinkSync, mkdtempSync } from "node:fs";
44
import { join } from "node:path";
55
import { tmpdir } from "node:os";
6-
import { recordUsage, readUsage, summarizeUsage } from "../usage.js";
6+
import { recordUsage, readUsage, summarizeUsage, summaryToJSON } from "../usage.js";
77
import type { UsageEvent } from "../types.js";
88

99
function makeTmp(): string {
@@ -109,4 +109,51 @@ describe("summarizeUsage", () => {
109109
const summary = summarizeUsage(events);
110110
assert.equal(summary[0].lastLoaded, "2026-03-06T12:00:00Z");
111111
});
112+
113+
it("tracks unique modes as a Set", () => {
114+
const events = [
115+
makeEvent({ entryId: "a", mode: "eager" }),
116+
makeEvent({ entryId: "a", mode: "lazy" }),
117+
makeEvent({ entryId: "a", mode: "eager" }),
118+
];
119+
const summary = summarizeUsage(events);
120+
assert.ok(summary[0].modes instanceof Set);
121+
assert.equal(summary[0].modes.size, 2);
122+
assert.ok(summary[0].modes.has("eager"));
123+
assert.ok(summary[0].modes.has("lazy"));
124+
});
125+
});
126+
127+
describe("summaryToJSON (KER-06)", () => {
128+
it("serializes modes as an array, not an empty object", () => {
129+
// Regression: JSON.stringify(summary) drops the modes Set to {}.
130+
// summaryToJSON projects modes to an array so --json output keeps them.
131+
const events = [
132+
makeEvent({ entryId: "a", mode: "eager" }),
133+
makeEvent({ entryId: "a", mode: "lazy" }),
134+
];
135+
const summary = summarizeUsage(events);
136+
137+
// The raw Set would serialize to {} — confirm the bug we are fixing.
138+
assert.equal(JSON.stringify(summary[0].modes), "{}");
139+
140+
const json = summary.map(summaryToJSON);
141+
assert.ok(Array.isArray(json[0].modes));
142+
assert.deepEqual([...json[0].modes].sort(), ["eager", "lazy"]);
143+
144+
// The serialized JSON string actually contains the modes.
145+
const serialized = JSON.stringify(json, null, 2);
146+
const parsed = JSON.parse(serialized) as Array<{ modes: string[] }>;
147+
assert.deepEqual(parsed[0].modes.sort(), ["eager", "lazy"]);
148+
});
149+
150+
it("preserves all other summary fields", () => {
151+
const events = [makeEvent({ entryId: "x", trigger: "kw-ci", tokensEst: 42 })];
152+
const json = summaryToJSON(summarizeUsage(events)[0]);
153+
assert.equal(json.entryId, "x");
154+
assert.equal(json.loadCount, 1);
155+
assert.equal(json.totalTokens, 42);
156+
assert.deepEqual(json.triggers, ["kw-ci"]);
157+
assert.deepEqual(json.modes, ["lazy"]);
158+
});
112159
});

packages/kernel/src/usage.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,36 @@ export function summarizeUsage(events: UsageEvent[]): UsageSummary[] {
9696
}))
9797
.sort((a, b) => b.loadCount - a.loadCount);
9898
}
99+
100+
/**
101+
* A JSON-safe projection of a UsageSummary.
102+
*
103+
* `UsageSummary.modes` is a `Set<string>`, which `JSON.stringify` serializes to
104+
* `{}` — silently dropping the data. This shape replaces the Set with an array
105+
* so `--json` output (and any other JSON consumer) round-trips the modes.
106+
*/
107+
export interface UsageSummaryJSON {
108+
entryId: string;
109+
loadCount: number;
110+
totalTokens: number;
111+
lastLoaded: string;
112+
triggers: string[];
113+
modes: string[];
114+
}
115+
116+
/**
117+
* Convert a UsageSummary to a JSON-safe shape (Set<string> modes → string[]).
118+
*
119+
* Use this at any JSON serialization boundary instead of stringifying the
120+
* summary directly, which would drop `modes`.
121+
*/
122+
export function summaryToJSON(summary: UsageSummary): UsageSummaryJSON {
123+
return {
124+
entryId: summary.entryId,
125+
loadCount: summary.loadCount,
126+
totalTokens: summary.totalTokens,
127+
lastLoaded: summary.lastLoaded,
128+
triggers: summary.triggers,
129+
modes: [...summary.modes],
130+
};
131+
}

packages/memories/src/index-gen.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,20 @@ export function generateIndex(
3939
for (const ref of analysis.refs) {
4040
// Try relative to MEMORY.md, then relative to parent
4141
const fullPath = resolveRefPath(ref.path, fileDir, parentDir);
42-
if (!fullPath) continue;
42+
if (!fullPath) {
43+
// MEM-002: don't drop unresolved refs silently. A ref whose path
44+
// doesn't resolve (a non-existent file, a glob like `memory/*.md`,
45+
// or an absolute path) used to vanish here with zero trace — that's
46+
// exactly how parser junk slipped through undetected. Surface it.
47+
if (!analysis.missingFiles.includes(ref.path)) {
48+
analysis.missingFiles.push(ref.path);
49+
}
50+
console.warn(
51+
`[claude-memories] unresolved ref "${ref.name || ref.path}" ` +
52+
`→ ${ref.path} (line ${ref.line + 1}); skipped from index`,
53+
);
54+
continue;
55+
}
4356

4457
const content = readFileSync(fullPath, "utf-8");
4558
const { frontmatter } = parseFrontmatter(content);
@@ -82,6 +95,14 @@ export function generateIndex(
8295
};
8396
}
8497

98+
/** Max summary length — keep entry summaries compact in the dispatch table. */
99+
const MAX_SUMMARY = 120;
100+
101+
/** Truncate a summary to MAX_SUMMARY chars. Shared by both entry builders. */
102+
function truncateSummary(summary: string): string {
103+
return summary.slice(0, MAX_SUMMARY);
104+
}
105+
85106
function entryFromFrontmatter(
86107
fm: Frontmatter,
87108
ref: MemoryRef,
@@ -94,7 +115,9 @@ function entryFromFrontmatter(
94115
keywords: fm.keywords,
95116
patterns: fm.patterns,
96117
priority: fm.priority,
97-
summary: ref.description || `Memory: ${ref.name}`,
118+
// MEM-007: truncate here too — entryFromContent already truncated to
119+
// 120, this branch did not, so a long summary survived asymmetrically.
120+
summary: truncateSummary(ref.description || `Memory: ${ref.name}`),
98121
triggers: fm.triggers,
99122
tokens_est: estimateTokens(content),
100123
lines,
@@ -113,7 +136,7 @@ function entryFromContent(ref: MemoryRef, content: string): LoadoutEntry {
113136
patterns: [],
114137
priority: "domain",
115138
summary: ref.description
116-
? ref.description.slice(0, 120)
139+
? truncateSummary(ref.description)
117140
: `Memory: ${ref.name}`,
118141
triggers: { ...DEFAULT_TRIGGERS },
119142
tokens_est: estimateTokens(content),
@@ -125,8 +148,13 @@ function entryFromContent(ref: MemoryRef, content: string): LoadoutEntry {
125148
* Convert a display name to kebab-case ID.
126149
* "AI Loadout" → "ai-loadout"
127150
* "Claude Guardian" → "claude-guardian"
151+
*
152+
* Exported (MEM-009) so the parser and validator can reuse the exact
153+
* same derivation — the kebab id is the contract shared between
154+
* ref-parsing (MEM-001 junk-id rejection) and validate (MEM-004
155+
* ID_TOO_LONG length check).
128156
*/
129-
function nameToId(name: string): string {
157+
export function nameToId(name: string): string {
130158
return name
131159
.toLowerCase()
132160
.replace(/[^a-z0-9\s-]/g, "")

0 commit comments

Comments
 (0)