AMD1213-C: repair stale array consumer, fail closed on foreign link provenance, validate manifests before mutation, and exercise the fleet MCP preflight call path.
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
type JsonObject = { [key: string]: Json };
|
|
|
|
const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url));
|
|
const basePath = `${frameworkRoot}runtime/claude/settings.json`;
|
|
const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`;
|
|
const gatedFixturePath = fileURLToPath(
|
|
new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url),
|
|
);
|
|
|
|
function readJson(path: string): JsonObject {
|
|
return JSON.parse(readFileSync(path, 'utf8')) as JsonObject;
|
|
}
|
|
|
|
function isObject(value: unknown): value is JsonObject {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
// Production composition uses universal last-layer-wins array replacement. The
|
|
// lease overlay therefore carries complete affected event arrays, including the
|
|
// two QA carry-forward entries needed to avoid dropping non-lease hooks.
|
|
function deepMerge(base: Json, overlay: Json): Json {
|
|
if (Array.isArray(base) && Array.isArray(overlay)) return overlay;
|
|
if (isObject(base) && isObject(overlay)) {
|
|
const merged: JsonObject = { ...base };
|
|
for (const [key, value] of Object.entries(overlay)) {
|
|
merged[key] = key in merged ? deepMerge(merged[key]!, value) : value;
|
|
}
|
|
return merged;
|
|
}
|
|
return overlay;
|
|
}
|
|
|
|
function normalize(value: Json): Json {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map(normalize)
|
|
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
}
|
|
if (isObject(value)) {
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, nested]) => [key, normalize(nested)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function hookCommands(settings: JsonObject): string[] {
|
|
const hooks = settings['hooks'];
|
|
if (!isObject(hooks)) return [];
|
|
|
|
return Object.values(hooks).flatMap((event) => {
|
|
if (!Array.isArray(event)) return [];
|
|
return event.flatMap((entry) => {
|
|
if (!isObject(entry) || !Array.isArray(entry['hooks'])) return [];
|
|
return entry['hooks'].flatMap((hook) =>
|
|
isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [],
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
const sequentialThinking: JsonObject = {
|
|
command: 'npx',
|
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
};
|
|
|
|
describe('canonical Claude base and lease-promotion overlay', () => {
|
|
it('keeps every lease command out of the ungated base', () => {
|
|
const base = readJson(basePath);
|
|
const commands = hookCommands(base);
|
|
|
|
for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) {
|
|
expect(
|
|
commands.some((command) => command.includes(marker)),
|
|
marker,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => {
|
|
const base = readJson(basePath);
|
|
const overlay = readJson(overlayPath);
|
|
const preSplit = readJson(gatedFixturePath);
|
|
const expected: JsonObject = {
|
|
...preSplit,
|
|
hooks: {
|
|
...(preSplit['hooks'] as JsonObject),
|
|
Stop: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh',
|
|
timeout: 15,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command:
|
|
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status',
|
|
timeout: 15,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
mcpServers: { 'sequential-thinking': sequentialThinking },
|
|
};
|
|
|
|
expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected));
|
|
});
|
|
|
|
it('ships sequential-thinking in the base', () => {
|
|
const base = readJson(basePath);
|
|
expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking });
|
|
});
|
|
|
|
it('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => {
|
|
const overlay = readJson(overlayPath);
|
|
expect(Object.keys(overlay)).toEqual(['hooks']);
|
|
|
|
const commands = hookCommands(overlay);
|
|
const lease = commands.filter((command) =>
|
|
/mutator-gate|receipt-observer|promote-|revoke-lease/.test(command),
|
|
);
|
|
const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command));
|
|
expect(lease).toHaveLength(6);
|
|
expect(qa).toHaveLength(2);
|
|
expect(commands).toHaveLength(8);
|
|
});
|
|
|
|
it.each(['prevent-memory-write', 'reflect-stop'])(
|
|
'fails lossless reconstruction if QA carry-forward %s is removed',
|
|
(marker) => {
|
|
const base = readJson(basePath);
|
|
const overlay = readJson(overlayPath);
|
|
const expected = {
|
|
...readJson(gatedFixturePath),
|
|
mcpServers: { 'sequential-thinking': sequentialThinking },
|
|
};
|
|
const hooks = overlay['hooks'] as JsonObject;
|
|
const mutated: JsonObject = {
|
|
hooks: Object.fromEntries(
|
|
Object.entries(hooks).map(([event, entries]) => [
|
|
event,
|
|
Array.isArray(entries)
|
|
? entries.filter((entry) => !JSON.stringify(entry).includes(marker))
|
|
: entries,
|
|
]),
|
|
),
|
|
};
|
|
expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected));
|
|
},
|
|
);
|
|
});
|