fix(mosaic): quarantine unscanned brain state

This commit is contained in:
2026-08-05 14:24:27 -05:00
parent 2a9294f600
commit 6828e276e6
8 changed files with 154 additions and 27 deletions
+4 -4
View File
@@ -92,8 +92,8 @@ Agent and lane continuity currently accumulates as plain local files with no rep
3. `MB-REQ-03` (R3): Seat access is granted only through `mosaic cred`; callers must never resolve or read a token independently. Live grant verification is gated on MC-CRED-01 implementation.
4. `MB-REQ-04` (R4): The eventual live postcondition requires `~/.mosaic` to be a `main`-branch git repo with the expected remote and a seat-owned read/write round-trip. This live validation is gated on MC-CRED-01 implementation and cannot be replaced by a clone exit code.
5. `MB-REQ-05` (R5): The out-of-estate refusal control covers both Git and API resolver axes. Axis disagreement is `indeterminate` failure, never permission; contract tests bind to the broker's four terminal classes and stable reason codes.
6. `MB-REQ-06` (R6): The brain skeleton excludes `*.token`, `*.key`, `*.pem`, `.env`, and `credentials.json`; credentials remain broker-owned and no error path may print secret material.
7. `MB-REQ-07` (R7): Detect existing local lane directories and seat state files, migrate them into the durable layout without overwrite or deletion, and explicitly report every detected item that cannot be migrated. Lane findings are append-only; `board/` has a named single writer; writes push immediately rather than on a timer.
6. `MB-REQ-06` (R6): The brain skeleton excludes `*.token`, `*.key`, `*.pem`, `.env`, and `credentials.json`; credentials remain broker-owned and no error path may print secret material. Arbitrary legacy content is never auto-published from a heuristic denylist: an approved content scanner must bind approval to the exact source snapshot, otherwise the item is retained and reported.
7. `MB-REQ-07` (R7): Detect existing local lane directories and seat state files, publish approved snapshots into the durable layout without overwrite or deletion, and explicitly report every detected item that cannot be migrated. Automatic source deletion is parked until command-scoped identity propagation and the required clean audit; retained sources are always reported. Lane findings are append-only; `board/` has a named single writer; writes push immediately rather than on a timer.
8. `MB-REQ-08` (R8): `mosaic doctor` reports missing clone, wrong remote, incomplete write-access evidence, and uncommitted local state. `--fix` repairs the first three only through the approved installer/broker path; it never hand-rolls credential resolution.
9. `MB-REQ-09`: Retention is ownership-first and archive-only. Every retained artifact requires a named durable owner; absent or non-durable ownership leaves the gate open and blocking. Age and size never authorize deletion.
10. `MB-REQ-10`: Brain provisioning occupies canonical installer P7 only after the applicable P5 credential postcondition commits; canonical phase numbers are unchanged.
@@ -106,8 +106,8 @@ In scope now: estate derivation, secret exclusion, non-destructive migration, do
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result.
2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed.
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions, and seeded secret-shaped files remain ignored without their values appearing in output.
4. `AC-MB-04`: Migration moves lane-durable and seat-state content into collision-safe archive/ledger paths, preserves source on any incomplete move, never overwrites an existing finding, never deletes by age/size, and reports unresolved items explicitly.
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions; filename-, content-, binary-, and size-based secret controls remain outside Git without their values appearing in output. Without an approved scanner, even benign legacy content is retained and reported rather than auto-published.
4. `AC-MB-04`: Migration publishes approved lane-durable and seat-state snapshots into collision-safe archive/ledger paths, retains and reports every source, never overwrites an existing finding, and never deletes by age/size.
5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible.
6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`.
7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration.
@@ -61,8 +61,8 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi
- `error/20`: local contract/control failure; never reinterpret as denial.
- `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively.
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity.
- Migration success requires the durable object to contain the moved item and no overwrite; incomplete moves retain the source and are reported.
- Secret exclusion is tested through both exact ignore rules and seeded secret-shaped controls; output is scanned without printing secret values.
- Migration publication requires the durable object to contain the approved snapshot and no overwrite. Automatic path-based source deletion is parked; every source is retained and reported.
- Secret exclusion is tested through exact ignore rules, nested secret-shaped paths, bounded UTF-8 content controls, and an approved-scanner gate bound to the exact source snapshot. Without an approved scanner, even benign content is retained and reported rather than committed.
## Budget
@@ -83,10 +83,12 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati
- [x] C1 P5→P7 seam receipt read; no brain implementation is in C1.
- [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`.
- [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution.
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 76/76 (store 38, runtime 12, owner resolver 14, provision 4, provision command 3, installed doctor 5).
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 86/86 (secure config 3, store 43, runtime 12, owner resolver 16, provision 4, provision command 3, installed doctor 5).
- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()` at dependency head `6ca8758f`; current local copies are temporary until dependency integration and the 32-line permissive shim has been removed.
- [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified.
- [x] R6 migration now reports secret-shaped files without copying them; symlinked `.gitignore`, layout directories, and nested migration destinations fail closed.
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed.
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
- [x] Multi-host push-on-write retries non-fast-forward races via fetch/rebase rather than LWW, and unknown publication reachability retains both sources and local copies.
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
- [ ] Implementation green on merged dependency base.
@@ -196,6 +196,43 @@ describe('provider-backed durable owner resolver', (): void => {
expect(requests[0]?.signal).toBeInstanceOf(AbortSignal);
});
it('cancels a chunked provider body as soon as it exceeds the byte ceiling', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 bounded owner response stream');
let cancelled = false;
const oversized = new ReadableStream<Uint8Array>({
start(controller): void {
controller.enqueue(new Uint8Array(200_000));
controller.enqueue(new Uint8Array(100_000));
},
cancel(): void {
cancelled = true;
},
});
const result = await resolver.resolveProviderDurableOwner(
{
estateRegistrySource: estateRegistry(),
ownerPolicySource: ownerPolicy(),
host: 'git.example.invalid',
requestedOwner: 'user:durable-owner',
},
{
fetch: async (): Promise<Response> =>
new Response(oversized, {
status: 200,
headers: { 'content-type': 'application/json' },
}),
absentControlName: (): string => 'generated-absent-control',
},
);
expect(result).toMatchObject({
verdict: 'not-measured',
reasonCode: 'owner-unexpected-provider-shape',
});
expect(cancelled).toBe(true);
});
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
const resolver = await loadResolver('MB-REQ-09 standing process policy');
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
@@ -81,14 +81,37 @@ async function boundedJson(response: Response): Promise<unknown> {
throw new Error('owner-unexpected-content-type');
}
const declared = response.headers.get('content-length');
let declaredSize: number | null = null;
if (declared !== null) {
const size = Number.parseInt(declared, 10);
if (Number.isFinite(size) && size > MAX_BODY_BYTES) {
if (!/^\d+$/.test(declared)) throw new Error('owner-unexpected-provider-shape');
declaredSize = Number.parseInt(declared, 10);
if (!Number.isSafeInteger(declaredSize) || declaredSize > MAX_BODY_BYTES) {
throw new Error('owner-unexpected-provider-shape');
}
}
const body = new Uint8Array(await response.arrayBuffer());
if (body.byteLength > MAX_BODY_BYTES) throw new Error('owner-unexpected-provider-shape');
if (response.body === null) throw new Error('owner-unexpected-provider-shape');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const next = await reader.read();
if (next.done) break;
total += next.value.byteLength;
if (total > MAX_BODY_BYTES) {
await reader.cancel('owner response exceeds byte ceiling');
throw new Error('owner-unexpected-provider-shape');
}
chunks.push(next.value);
}
if (declaredSize !== null && declaredSize !== total) {
throw new Error('owner-unexpected-provider-shape');
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
} catch {
@@ -51,6 +51,7 @@ interface ProvisionModule {
readonly run: CommandRunner;
readonly fetch: FetchLike;
readonly absentControlName: () => string;
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
},
): Promise<ProvisionResult>;
}
@@ -359,6 +360,7 @@ describe('P7 brain provisioning orchestration', (): void => {
run: runner,
fetch: ownerFetch(),
absentControlName: (): string => 'generated-absent-control',
approveMigrationContent: (): boolean => true,
});
expect(result).toMatchObject({
@@ -88,6 +88,7 @@ export async function provisionBrain(
readonly run: CommandRunner;
readonly fetch: OwnerFetch;
readonly absentControlName: () => string;
readonly approveMigrationContent?: (path: string, content: Uint8Array) => boolean;
},
): Promise<ProvisionResult> {
const brainNamespace = parseRequestedOwner(input.requestedOwner);
@@ -213,6 +214,7 @@ export async function provisionBrain(
laneActive: input.laneActive,
},
(): MigrationOwnerResolution => owner,
dependencies.approveMigrationContent,
);
const migration = migrateBrainState(
plan,
@@ -133,6 +133,7 @@ interface BrainStoreModule {
readonly laneActive: boolean;
},
resolveOwner?: (lane: string) => MigrationOwnerResolution,
approveContent?: (path: string, content: Uint8Array) => boolean,
): MigrationPlan;
migrateBrainState(
plan: MigrationPlan,
@@ -557,6 +558,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
expect(plan.status).toBe('ready');
@@ -569,6 +571,36 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
);
});
it('retains and reports otherwise-benign legacy content when no approved scanner is available', async (): Promise<void> => {
const sut = await loadSut('MB-REQ-06 approved scanner required');
const root = tempRoot();
const sourceRoot = join(root, 'local-memory');
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md');
writeFileSync(source, 'ordinary finding\n');
const plan = sut.discoverBrainMigration(
{
sourceRoot,
brainRoot: join(root, 'brain'),
seat: 'seat-a',
lane: 'lane-a',
laneActive: true,
},
activeLaneOwner,
);
expect(plan.candidates).toHaveLength(0);
expect(plan.reported).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: source,
reason: expect.stringMatching(/scanner|approval/i),
}),
]),
);
});
it('ignores a caller-asserted owner string/validated flag and leaves the gate blocking', async (): Promise<void> => {
const sut = await loadSut('MB-REQ-09 caller owner assertion cannot satisfy gate');
const root = tempRoot();
@@ -672,20 +704,26 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
mkdirSync(join(laneRoot, path, '..'), { recursive: true });
writeFileSync(join(laneRoot, path), 'NESTED-SECRET-MARKER\n');
}
const embeddedSecret = join(laneRoot, 'benign-looking.md');
writeFileSync(
embeddedSecret,
'Authorization: Bearer NESTED-CONTENT-SECRET-MARKER-1234567890\n',
);
const embeddedSecrets = new Map([
['benign-auth.md', 'Authorization: Bearer NESTED-CONTENT-SECRET-MARKER-1234567890\n'],
['benign-token.md', 'token=fixture-value\n'],
['benign-aws.md', 'AWS_SECRET_ACCESS_KEY=fixture-value\n'],
['benign-header.md', 'X-Api-Key: fixture-value\n'],
['benign-jwt.md', 'session=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.signature\n'],
]);
for (const [name, content] of embeddedSecrets) writeFileSync(join(laneRoot, name), content);
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
expect(plan.status).toBe('ready');
expect(plan.candidates).toHaveLength(0);
expect(plan.reported).toHaveLength(secretNames.length + nestedSecretPaths.length + 1);
expect(plan.reported).toHaveLength(
secretNames.length + nestedSecretPaths.length + embeddedSecrets.size,
);
expect(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true);
expect(JSON.stringify(plan)).not.toMatch(/NESTED-(?:CONTENT-)?SECRET-MARKER/);
expect(existsSync(brainRoot)).toBe(false);
@@ -701,6 +739,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
const candidate = plan.candidates[0];
expect(candidate).toBeDefined();
@@ -756,6 +795,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
const result = sut.migrateBrainState(
@@ -796,6 +836,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
let swapped = false;
let publishCalls = 0;
@@ -838,6 +879,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
let publishCalls = 0;
@@ -867,6 +909,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
let cleanupHookCalled = false;
@@ -908,6 +951,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
const plan = sut.discoverBrainMigration(
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
activeLaneOwner,
(): boolean => true,
);
const result = sut.migrateBrainState(
+26 -9
View File
@@ -614,9 +614,8 @@ function migrationCandidate(
kind: 'lane' | 'seat',
seat: string,
lane: string,
source: StableSourceSnapshot,
): MigrationCandidate {
const source = stableSourceSnapshot(path);
if (sourceContainsSecretMaterial(source.content)) throw new Error('migration-secret-content');
const key = relative(sourceRoot, path).split(sep).join('/');
const digest = createHash('sha256')
.update(key)
@@ -672,7 +671,8 @@ function sourceContainsSecretMaterial(content: Buffer): boolean {
return [
/-----BEGIN [^-\r\n]*PRIVATE KEY-----/i,
/\bauthorization\s*:\s*(?:bearer|basic)\s+\S+/i,
/\b(?:password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*\S+/i,
/\b(?:[a-z0-9_-]*(?:token|secret|password|passwd)|(?:x-)?api[_-]?key|aws_secret_access_key)\s*[:=]\s*\S+/i,
/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/,
/https?:\/\/[^/\s:@]+:[^/\s@]+@/i,
/\b[A-Za-z0-9+/_=-]{40,}\b/,
].some((pattern): boolean => pattern.test(source));
@@ -691,6 +691,7 @@ export function discoverBrainMigration(
readonly laneActive: boolean;
},
resolveOwner?: (lane: string) => MigrationOwnerResolution,
approveContent?: (path: string, content: Uint8Array) => boolean,
): MigrationPlan {
const seat = safeName(input.seat, 'seat');
const lane = safeName(input.lane, 'lane');
@@ -746,16 +747,24 @@ export function discoverBrainMigration(
continue;
}
try {
const source = stableSourceSnapshot(path);
if (sourceContainsSecretMaterial(source.content)) throw new Error('migration-secret-content');
if (approveContent === undefined || !approveContent(path, Buffer.from(source.content))) {
throw new Error('migration-content-unapproved');
}
candidates.push(
migrationCandidate(input.sourceRoot, input.brainRoot, path, 'lane', seat, lane),
migrationCandidate(input.sourceRoot, input.brainRoot, path, 'lane', seat, lane, source),
);
} catch (error: unknown) {
const code = error instanceof Error ? error.message : '';
reported.push({
path,
reason:
error instanceof Error && error.message === 'migration-secret-content'
code === 'migration-secret-content'
? 'Content may contain secret material; retained and reported.'
: 'Lane state was not a regular file; retained and reported.',
: code === 'migration-content-unapproved'
? 'Approved secret scanner or content approval is unavailable; retained and reported.'
: 'Lane state was not a regular file; retained and reported.',
});
}
}
@@ -768,16 +777,24 @@ export function discoverBrainMigration(
continue;
}
try {
const source = stableSourceSnapshot(path);
if (sourceContainsSecretMaterial(source.content)) throw new Error('migration-secret-content');
if (approveContent === undefined || !approveContent(path, Buffer.from(source.content))) {
throw new Error('migration-content-unapproved');
}
candidates.push(
migrationCandidate(input.sourceRoot, input.brainRoot, path, 'seat', seat, lane),
migrationCandidate(input.sourceRoot, input.brainRoot, path, 'seat', seat, lane, source),
);
} catch (error: unknown) {
const code = error instanceof Error ? error.message : '';
reported.push({
path,
reason:
error instanceof Error && error.message === 'migration-secret-content'
code === 'migration-secret-content'
? 'Content may contain secret material; retained and reported.'
: 'Seat state was not a regular file; retained and reported.',
: code === 'migration-content-unapproved'
? 'Approved secret scanner or content approval is unavailable; retained and reported.'
: 'Seat state was not a regular file; retained and reported.',
});
}
}