1240 lines
39 KiB
TypeScript
1240 lines
39 KiB
TypeScript
import {
|
|
chmodSync,
|
|
closeSync,
|
|
constants as fsConstants,
|
|
existsSync,
|
|
fsyncSync,
|
|
fstatSync,
|
|
lstatSync,
|
|
linkSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { platform } from 'node:os';
|
|
import { z } from 'zod';
|
|
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
|
import { assertNoSymlinkAncestors } from '../fleet/secure-file.js';
|
|
|
|
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
const GITIGNORE_RULES = [
|
|
'*.token',
|
|
'*.key',
|
|
'*.pem',
|
|
'.env',
|
|
'credentials.json',
|
|
'*.TOKEN',
|
|
'*.KEY',
|
|
'*.PEM',
|
|
'*.p12',
|
|
'*.pfx',
|
|
'*.jks',
|
|
'*.keystore',
|
|
'.env.*',
|
|
'credentials.*',
|
|
'secret.*',
|
|
'secrets.*',
|
|
'id_rsa',
|
|
'id_dsa',
|
|
'id_ecdsa',
|
|
'id_ed25519',
|
|
] as const;
|
|
const BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const;
|
|
const MAX_MIGRATION_FILE_BYTES = 1024 * 1024;
|
|
const TERMINAL_EXITS = {
|
|
ok: 0,
|
|
refused: 10,
|
|
error: 20,
|
|
indeterminate: 30,
|
|
} as const;
|
|
const STABLE_REASON_CLASSES: Readonly<Record<string, CredentialOutcome>> = {
|
|
'identity-required': 'refused',
|
|
'estate-required': 'refused',
|
|
'estate-host-mismatch': 'refused',
|
|
'cross-estate-resolution': 'refused',
|
|
'no-token-for-identity': 'refused',
|
|
'tea-login-missing': 'refused',
|
|
'tea-login-host-mismatch': 'refused',
|
|
'provider-identity-mismatch': 'refused',
|
|
'credential-rejected': 'refused',
|
|
'permission-denied': 'refused',
|
|
'organization-membership-required': 'refused',
|
|
'team-membership-required': 'refused',
|
|
'invalid-input': 'error',
|
|
'estate-registry-invalid': 'error',
|
|
'insecure-credential-source': 'error',
|
|
'journal-unavailable': 'error',
|
|
'internal-invariant': 'error',
|
|
'provider-unavailable': 'indeterminate',
|
|
'identity-not-visible': 'indeterminate',
|
|
'identity-not-measured': 'indeterminate',
|
|
'identity-not-found': 'indeterminate',
|
|
'unexpected-content-type': 'indeterminate',
|
|
'unexpected-provider-shape': 'indeterminate',
|
|
'scope-not-evaluable': 'indeterminate',
|
|
'permission-evidence-disagrees': 'indeterminate',
|
|
'transport-principal-mismatch': 'indeterminate',
|
|
'read-only-control-invalid': 'indeterminate',
|
|
'readback-missing': 'indeterminate',
|
|
'mutation-state-unknown': 'indeterminate',
|
|
};
|
|
|
|
export interface BrainTarget {
|
|
readonly estate: string;
|
|
readonly host: string;
|
|
readonly owner: string;
|
|
readonly repo: string;
|
|
readonly cloneUrl: string;
|
|
}
|
|
|
|
export type CredentialOutcome = keyof typeof TERMINAL_EXITS;
|
|
|
|
export interface CredentialAssessment {
|
|
readonly outcome: CredentialOutcome;
|
|
readonly exitCode: 0 | 10 | 20 | 30;
|
|
readonly reasonCode: string;
|
|
readonly diagnostic: string;
|
|
}
|
|
|
|
export interface ResolverParityAssessment extends CredentialAssessment {
|
|
readonly gitReasonCode: string;
|
|
readonly apiReasonCode: string;
|
|
}
|
|
|
|
export interface MigrationOwnerResolution {
|
|
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
|
readonly reasonCode: string;
|
|
readonly principal: {
|
|
readonly name: string;
|
|
readonly kind:
|
|
| 'active-lane'
|
|
| 'durable-team'
|
|
| 'durable-human'
|
|
| 'durable-queue'
|
|
| 'mission-seat';
|
|
} | null;
|
|
readonly authority: {
|
|
readonly system: 'gitea' | 'glpi' | 'mosaic-mission-state';
|
|
readonly endpoint: string;
|
|
readonly contentType: 'application/json';
|
|
} | null;
|
|
}
|
|
|
|
export interface MigrationCandidate {
|
|
readonly source: string;
|
|
readonly destination: string;
|
|
readonly archive: string;
|
|
readonly kind: 'lane' | 'seat';
|
|
readonly sourceIdentity: {
|
|
readonly dev: number | bigint;
|
|
readonly ino: number | bigint;
|
|
readonly digest: string;
|
|
};
|
|
}
|
|
|
|
export interface MigrationReport {
|
|
readonly path: string;
|
|
readonly reason: string;
|
|
}
|
|
|
|
export interface MigrationPlan {
|
|
readonly status: 'ready' | 'blocked';
|
|
readonly candidates: readonly MigrationCandidate[];
|
|
readonly reported: readonly MigrationReport[];
|
|
readonly owner: MigrationOwnerResolution['principal'];
|
|
}
|
|
|
|
export interface MigrationPublishEntry {
|
|
readonly path: string;
|
|
readonly content: Uint8Array;
|
|
}
|
|
|
|
export interface MigrationPublishEvidence {
|
|
readonly commit: string;
|
|
readonly remoteHead: string;
|
|
readonly reachable: boolean;
|
|
}
|
|
|
|
export interface MigrationHooks {
|
|
readonly beforeDestinationWrite?: (destination: string) => void;
|
|
readonly beforeSourceCleanup?: (source: string) => void;
|
|
}
|
|
|
|
export interface MigrationResult {
|
|
readonly status: 'migrated' | 'reported' | 'failed';
|
|
readonly migrated: readonly MigrationCandidate[];
|
|
readonly reported: readonly MigrationReport[];
|
|
readonly publish: MigrationPublishEvidence | null;
|
|
}
|
|
|
|
export interface BrainDoctorObservation {
|
|
readonly rootExists: boolean;
|
|
readonly rootPrivate: boolean;
|
|
readonly gitRepository: boolean;
|
|
readonly remote: string | null;
|
|
readonly branch: string | null;
|
|
readonly worktreeState: 'clean' | 'dirty' | 'unmeasurable';
|
|
readonly access: CredentialAssessment | null;
|
|
}
|
|
|
|
export interface BrainDoctorFinding {
|
|
readonly code: string;
|
|
readonly repairable: boolean;
|
|
readonly reasonCode: string | null;
|
|
}
|
|
|
|
export interface BrainDoctorAction {
|
|
readonly program: 'git' | 'mosaic';
|
|
readonly args: readonly string[];
|
|
readonly findingCode: string;
|
|
}
|
|
|
|
export interface BrainWritePolicy {
|
|
readonly allowed: boolean;
|
|
readonly mode: 'append-only' | 'single-writer' | 'seat-writer' | 'refused';
|
|
readonly reason: string;
|
|
}
|
|
|
|
interface ParsedGitTarget {
|
|
readonly host: string;
|
|
readonly owner: string;
|
|
}
|
|
|
|
const providerIdentitySchema = z
|
|
.object({
|
|
login: z.string().min(1),
|
|
endpoint: z.literal('GET /api/v1/user'),
|
|
contentType: z.string().min(1),
|
|
})
|
|
.passthrough();
|
|
|
|
const repositoryPermissionSchema = z
|
|
.object({
|
|
requested: z.literal('write'),
|
|
effective: z.enum(['write', 'admin']),
|
|
endpoint: z.string().min(1),
|
|
contentType: z.string().min(1),
|
|
})
|
|
.passthrough();
|
|
|
|
const writeDifferentialSchema = z
|
|
.object({
|
|
state: z.literal('can-write'),
|
|
credentialBinding: z.literal('same-resolution'),
|
|
transportPrincipal: z.string().min(1),
|
|
authenticatedReceivePack: z.literal('advertised'),
|
|
readOnlyControl: z
|
|
.object({
|
|
identity: z.string().min(1),
|
|
providerPermission: z.literal('read'),
|
|
receivePack: z.literal('refused'),
|
|
})
|
|
.passthrough(),
|
|
unauthenticatedReceivePack: z.literal('refused'),
|
|
artifactCreated: z.literal(false),
|
|
proves: z.string().min(1),
|
|
doesNotProve: z.string().min(1),
|
|
})
|
|
.passthrough();
|
|
|
|
const credentialResultSchema = z
|
|
.object({
|
|
schemaVersion: z.literal(1),
|
|
operation: z.literal('validate'),
|
|
outcome: z.enum(['ok', 'refused', 'error', 'indeterminate']),
|
|
exitCode: z.number().int(),
|
|
subject: z
|
|
.object({
|
|
identity: z.string().min(1),
|
|
estate: z.string().min(1),
|
|
host: z.string().min(1),
|
|
repo: z.string().min(1),
|
|
})
|
|
.strict(),
|
|
mutation: z.enum(['none', 'not-started', 'applied', 'unknown']),
|
|
reason: z
|
|
.object({
|
|
code: z.string().min(1),
|
|
message: z.string(),
|
|
})
|
|
.passthrough(),
|
|
evidence: z
|
|
.object({
|
|
providerIdentity: providerIdentitySchema.nullable(),
|
|
repositoryPermission: repositoryPermissionSchema.nullable(),
|
|
writeDifferential: writeDifferentialSchema.nullable(),
|
|
})
|
|
.passthrough(),
|
|
audit: z
|
|
.object({
|
|
journalId: z.string().nullable(),
|
|
state: z.enum(['not-started', 'open', 'sealed']),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
function safeName(value: string, label: string): string {
|
|
if (!SAFE_NAME.test(value)) {
|
|
throw new Error(`invalid-${label}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseGitTarget(targetGitUrl: string): ParsedGitTarget {
|
|
let host = '';
|
|
let pathname = '';
|
|
|
|
if (/^[^@\s]+@[^:\s]+:.+$/.test(targetGitUrl)) {
|
|
const separator = targetGitUrl.indexOf(':');
|
|
const authority = targetGitUrl.slice(0, separator);
|
|
host = authority.slice(authority.lastIndexOf('@') + 1);
|
|
pathname = targetGitUrl.slice(separator + 1);
|
|
} else {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(targetGitUrl);
|
|
} catch {
|
|
throw new Error('target-git-url-invalid');
|
|
}
|
|
if (!['https:', 'ssh:'].includes(parsed.protocol) || parsed.password !== '') {
|
|
throw new Error('target-git-url-invalid');
|
|
}
|
|
if (parsed.protocol === 'https:' && parsed.username !== '') {
|
|
throw new Error('target-git-url-contains-credential');
|
|
}
|
|
host = parsed.hostname;
|
|
pathname = parsed.pathname;
|
|
}
|
|
|
|
const parts = pathname
|
|
.replace(/^\/+/, '')
|
|
.replace(/\.git$/, '')
|
|
.split('/')
|
|
.filter((part: string): boolean => part.length > 0);
|
|
if (host.length === 0 || parts.length !== 2) {
|
|
throw new Error('target-git-url-invalid');
|
|
}
|
|
return { host: host.toLowerCase(), owner: safeName(parts[0] ?? '', 'repository-owner') };
|
|
}
|
|
|
|
export function deriveBrainTarget(
|
|
registrySource: string,
|
|
targetGitUrl: string,
|
|
brainNamespace: string,
|
|
): BrainTarget {
|
|
const target = parseGitTarget(targetGitUrl);
|
|
const resolved = parseCredentialEstateRegistry(registrySource).resolveByHost(target.host);
|
|
if (resolved === undefined) {
|
|
throw new Error(`estate-host-unmapped: ${target.host}`);
|
|
}
|
|
const owner = safeName(brainNamespace, 'brain-namespace');
|
|
const repo = `${owner}/mosaic-brain`;
|
|
return {
|
|
estate: resolved.estate,
|
|
host: target.host,
|
|
owner,
|
|
repo,
|
|
cloneUrl: `${resolved.host.apiBaseUrl}/${repo}.git`,
|
|
};
|
|
}
|
|
|
|
function processUid(): number {
|
|
if (typeof process.getuid !== 'function') throw new Error('brain-owner-check-unsupported');
|
|
return process.getuid();
|
|
}
|
|
|
|
export function brainRootIsPrivate(root: string): boolean {
|
|
try {
|
|
const status = lstatSync(root);
|
|
return (
|
|
status.isDirectory() &&
|
|
!status.isSymbolicLink() &&
|
|
status.uid === processUid() &&
|
|
(status.mode & 0o077) === 0
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function ensureBrainRootPrivate(root: string): void {
|
|
assertNoSymlinkAncestors(root);
|
|
if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
const status = lstatSync(root);
|
|
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== processUid()) {
|
|
throw new Error('brain-layout-root-unsafe');
|
|
}
|
|
chmodSync(root, 0o700);
|
|
if (!brainRootIsPrivate(root)) throw new Error('brain-layout-root-permissions-unsafe');
|
|
}
|
|
|
|
function syncFile(path: string): void {
|
|
const descriptor = openSync(path, 'r');
|
|
try {
|
|
fsyncSync(descriptor);
|
|
} finally {
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
export function createBrainSkeleton(root: string): {
|
|
readonly created: readonly string[];
|
|
readonly publicationEntries: readonly MigrationPublishEntry[];
|
|
} {
|
|
const created: string[] = [];
|
|
const publicationEntries: MigrationPublishEntry[] = [];
|
|
ensureBrainRootPrivate(root);
|
|
for (const directory of BRAIN_DIRECTORIES) {
|
|
const path = join(root, directory);
|
|
if (existsSync(path)) {
|
|
const status = lstatSync(path);
|
|
if (!status.isDirectory() || status.isSymbolicLink() || status.uid !== processUid()) {
|
|
throw new Error('brain-layout-directory-unsafe');
|
|
}
|
|
chmodSync(path, 0o700);
|
|
}
|
|
}
|
|
|
|
for (const directory of BRAIN_DIRECTORIES) {
|
|
const path = join(root, directory);
|
|
if (!existsSync(path)) {
|
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
created.push(path);
|
|
}
|
|
const placeholder = join(path, '.gitkeep');
|
|
if (!existsSync(placeholder)) {
|
|
writeFileSync(placeholder, '', { encoding: 'utf8', mode: 0o644, flag: 'wx' });
|
|
syncFile(placeholder);
|
|
created.push(placeholder);
|
|
publicationEntries.push({ path: placeholder, content: new Uint8Array() });
|
|
} else if (!lstatSync(placeholder).isFile() || lstatSync(placeholder).isSymbolicLink()) {
|
|
throw new Error('brain-layout-placeholder-unsafe');
|
|
}
|
|
}
|
|
|
|
const ignorePath = join(root, '.gitignore');
|
|
if (existsSync(ignorePath)) {
|
|
const ignoreStatus = lstatSync(ignorePath);
|
|
if (!ignoreStatus.isFile() || ignoreStatus.isSymbolicLink()) {
|
|
throw new Error('brain-layout-ignore-unsafe');
|
|
}
|
|
}
|
|
const content = `${GITIGNORE_RULES.join('\n')}\n`;
|
|
if (existsSync(ignorePath)) {
|
|
const snapshot = stableSourceSnapshot(ignorePath);
|
|
if (!snapshot.content.equals(Buffer.from(content, 'utf8'))) {
|
|
throw new Error('brain-layout-ignore-content-unsafe');
|
|
}
|
|
} else {
|
|
const temporary = `${ignorePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
try {
|
|
writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o644, flag: 'wx' });
|
|
syncFile(temporary);
|
|
linkSync(temporary, ignorePath);
|
|
syncFile(ignorePath);
|
|
} finally {
|
|
rmSync(temporary, { force: true });
|
|
}
|
|
created.push(ignorePath);
|
|
publicationEntries.push({ path: ignorePath, content: new TextEncoder().encode(content) });
|
|
}
|
|
return { created, publicationEntries };
|
|
}
|
|
|
|
function indeterminate(reasonCode: string): CredentialAssessment {
|
|
return {
|
|
outcome: 'indeterminate',
|
|
exitCode: 30,
|
|
reasonCode,
|
|
diagnostic: `indeterminate: ${reasonCode}`,
|
|
};
|
|
}
|
|
|
|
export function assessCredentialResult(
|
|
source: string,
|
|
expectedSubject?: {
|
|
readonly identity: string;
|
|
readonly estate: string;
|
|
readonly host: string;
|
|
readonly repo: string;
|
|
},
|
|
): CredentialAssessment {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(source);
|
|
} catch {
|
|
return indeterminate('unexpected-provider-shape');
|
|
}
|
|
const parsed = credentialResultSchema.safeParse(raw);
|
|
if (!parsed.success) return indeterminate('unexpected-provider-shape');
|
|
|
|
if (
|
|
expectedSubject !== undefined &&
|
|
(parsed.data.subject.identity !== expectedSubject.identity ||
|
|
parsed.data.subject.estate !== expectedSubject.estate ||
|
|
parsed.data.subject.host !== expectedSubject.host ||
|
|
parsed.data.subject.repo !== expectedSubject.repo)
|
|
) {
|
|
return indeterminate('unexpected-provider-shape');
|
|
}
|
|
|
|
const expectedExit = TERMINAL_EXITS[parsed.data.outcome];
|
|
if (parsed.data.exitCode !== expectedExit) {
|
|
return indeterminate('unexpected-provider-shape');
|
|
}
|
|
|
|
const stableClass = STABLE_REASON_CLASSES[parsed.data.reason.code];
|
|
if (stableClass !== undefined && stableClass !== parsed.data.outcome) {
|
|
return indeterminate('unexpected-provider-shape');
|
|
}
|
|
|
|
const identity = parsed.data.evidence.providerIdentity;
|
|
if (identity !== null) {
|
|
if (!identity.contentType.toLowerCase().startsWith('application/json')) {
|
|
return indeterminate('unexpected-content-type');
|
|
}
|
|
if (identity.login !== parsed.data.subject.identity) {
|
|
return {
|
|
outcome: 'refused',
|
|
exitCode: 10,
|
|
reasonCode: 'provider-identity-mismatch',
|
|
diagnostic: 'refused: provider-identity-mismatch',
|
|
};
|
|
}
|
|
}
|
|
if (parsed.data.outcome === 'ok') {
|
|
const permission = parsed.data.evidence.repositoryPermission;
|
|
const differential = parsed.data.evidence.writeDifferential;
|
|
if (
|
|
identity === null ||
|
|
permission === null ||
|
|
differential === null ||
|
|
!permission.contentType.toLowerCase().startsWith('application/json') ||
|
|
differential.transportPrincipal !== parsed.data.subject.identity ||
|
|
parsed.data.mutation !== 'none' ||
|
|
parsed.data.audit.state !== 'sealed' ||
|
|
parsed.data.audit.journalId === null
|
|
) {
|
|
return indeterminate('readback-missing');
|
|
}
|
|
}
|
|
if (
|
|
parsed.data.reason.code === 'provider-identity-mismatch' &&
|
|
(identity === null || identity.login === parsed.data.subject.identity)
|
|
) {
|
|
return indeterminate('unexpected-provider-shape');
|
|
}
|
|
|
|
return {
|
|
outcome: parsed.data.outcome,
|
|
exitCode: expectedExit,
|
|
reasonCode: parsed.data.reason.code,
|
|
diagnostic: `${parsed.data.outcome}: ${parsed.data.reason.code}`,
|
|
};
|
|
}
|
|
|
|
export function assessResolverParity(
|
|
gitSource: string,
|
|
apiSource: string,
|
|
): ResolverParityAssessment {
|
|
const git = assessCredentialResult(gitSource);
|
|
const api = assessCredentialResult(apiSource);
|
|
if (git.outcome === 'refused' && api.outcome === 'refused' && git.reasonCode === api.reasonCode) {
|
|
return {
|
|
...git,
|
|
gitReasonCode: git.reasonCode,
|
|
apiReasonCode: api.reasonCode,
|
|
};
|
|
}
|
|
return {
|
|
...indeterminate('permission-evidence-disagrees'),
|
|
gitReasonCode: git.reasonCode,
|
|
apiReasonCode: api.reasonCode,
|
|
};
|
|
}
|
|
|
|
function filesBelow(root: string): string[] {
|
|
if (!existsSync(root)) return [];
|
|
const result: string[] = [];
|
|
const walk = (directory: string): void => {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) walk(path);
|
|
else if (entry.isFile()) result.push(path);
|
|
else result.push(path);
|
|
}
|
|
};
|
|
walk(root);
|
|
return result.sort((left: string, right: string): number => left.localeCompare(right));
|
|
}
|
|
|
|
function ownerIsValid(
|
|
resolution: MigrationOwnerResolution,
|
|
lane: string,
|
|
laneActive: boolean,
|
|
): boolean {
|
|
if (
|
|
resolution.verdict !== 'resolved' ||
|
|
resolution.principal === null ||
|
|
resolution.authority === null ||
|
|
resolution.authority.endpoint.length === 0 ||
|
|
resolution.authority.contentType !== 'application/json'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const principal = resolution.principal;
|
|
const normalized = principal.name.normalize('NFKC');
|
|
if (normalized !== principal.name) return false;
|
|
const grammars: Readonly<Record<string, RegExp>> = {
|
|
'active-lane': /^lane:[a-z0-9][a-z0-9-]*$/,
|
|
'durable-team': /^team:[a-z0-9][a-z0-9-]*$/,
|
|
'durable-human': /^user:[a-z0-9][a-z0-9-]*$/,
|
|
'durable-queue': /^queue:[a-z0-9][a-z0-9-]*$/,
|
|
};
|
|
const grammar = grammars[principal.kind];
|
|
if (grammar === undefined || !grammar.test(normalized)) return false;
|
|
if (principal.kind === 'active-lane') {
|
|
return laneActive && principal.name === `lane:${lane}`;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
interface StableSourceSnapshot {
|
|
readonly content: Buffer;
|
|
readonly dev: number | bigint;
|
|
readonly ino: number | bigint;
|
|
readonly digest: string;
|
|
}
|
|
|
|
function stableSourceSnapshot(path: string): StableSourceSnapshot {
|
|
const descriptor = openSync(
|
|
path,
|
|
fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW,
|
|
);
|
|
try {
|
|
const before = fstatSync(descriptor);
|
|
if (!before.isFile()) throw new Error('migration-source-not-regular');
|
|
const content = readFileSync(descriptor);
|
|
const after = fstatSync(descriptor);
|
|
if (
|
|
!after.isFile() ||
|
|
before.dev !== after.dev ||
|
|
before.ino !== after.ino ||
|
|
before.size !== after.size ||
|
|
before.mtimeMs !== after.mtimeMs ||
|
|
content.byteLength !== after.size
|
|
) {
|
|
throw new Error('migration-source-changed-during-read');
|
|
}
|
|
return {
|
|
content,
|
|
dev: after.dev,
|
|
ino: after.ino,
|
|
digest: createHash('sha256').update(content).digest('hex'),
|
|
};
|
|
} finally {
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function migrationCandidate(
|
|
sourceRoot: string,
|
|
brainRoot: string,
|
|
path: string,
|
|
kind: 'lane' | 'seat',
|
|
seat: string,
|
|
lane: string,
|
|
source: StableSourceSnapshot,
|
|
): MigrationCandidate {
|
|
const key = relative(sourceRoot, path).split(sep).join('/');
|
|
const digest = createHash('sha256')
|
|
.update(key)
|
|
.update('\0')
|
|
.update(source.content)
|
|
.digest('hex')
|
|
.slice(0, 16);
|
|
const name = `${digest}-${basename(path)}`;
|
|
const destination =
|
|
kind === 'lane'
|
|
? join(brainRoot, 'lanes', lane, 'findings', 'imports', name)
|
|
: join(brainRoot, 'agents', seat, 'state', 'imports', name);
|
|
return {
|
|
source: path,
|
|
destination,
|
|
archive: join(brainRoot, 'archives', 'imports', kind, name),
|
|
kind,
|
|
sourceIdentity: { dev: source.dev, ino: source.ino, digest: source.digest },
|
|
};
|
|
}
|
|
|
|
function secretShapedName(value: string): boolean {
|
|
const name = value.toLowerCase();
|
|
return (
|
|
['.npmrc', '.netrc', 'auth.json', '.dockerconfigjson'].includes(name) ||
|
|
name === '.env' ||
|
|
name.startsWith('.env.') ||
|
|
name === 'credentials.json' ||
|
|
name.startsWith('credentials.') ||
|
|
name === 'secret' ||
|
|
name.startsWith('secret.') ||
|
|
name === 'secrets' ||
|
|
name.startsWith('secrets.') ||
|
|
['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'].includes(name) ||
|
|
['.token', '.key', '.pem', '.p12', '.pfx', '.jks', '.keystore'].some((suffix): boolean =>
|
|
name.endsWith(suffix),
|
|
)
|
|
);
|
|
}
|
|
|
|
function secretShapedPath(sourceRoot: string, path: string): boolean {
|
|
return relative(sourceRoot, path).split(sep).filter(Boolean).some(secretShapedName);
|
|
}
|
|
|
|
function sourceContainsSecretMaterial(content: Buffer): boolean {
|
|
if (content.byteLength > MAX_MIGRATION_FILE_BYTES || content.includes(0)) return true;
|
|
let source: string;
|
|
try {
|
|
source = new TextDecoder('utf-8', { fatal: true }).decode(content);
|
|
} catch {
|
|
return true;
|
|
}
|
|
return [
|
|
/-----BEGIN [^-\r\n]*PRIVATE KEY-----/i,
|
|
/\bauthorization\s*:\s*(?:bearer|basic)\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));
|
|
}
|
|
|
|
function reportAll(paths: readonly string[], reason: string): MigrationReport[] {
|
|
return paths.map((path: string): MigrationReport => ({ path, reason }));
|
|
}
|
|
|
|
export function discoverBrainMigration(
|
|
input: {
|
|
readonly sourceRoot: string;
|
|
readonly brainRoot: string;
|
|
readonly seat: string;
|
|
readonly lane: string;
|
|
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');
|
|
const laneRoot = join(input.sourceRoot, 'lanes', lane);
|
|
const seatRoot = join(input.sourceRoot, 'agents', seat);
|
|
const laneFiles = filesBelow(laneRoot);
|
|
const seatFiles = filesBelow(seatRoot);
|
|
const selected = new Set([...laneFiles, ...seatFiles]);
|
|
const all = filesBelow(input.sourceRoot);
|
|
const unsupported = all.filter((path: string): boolean => !selected.has(path));
|
|
|
|
let ownerResolution: MigrationOwnerResolution = {
|
|
verdict: 'not-measured',
|
|
reasonCode: 'owner-resolver-unavailable',
|
|
principal: null,
|
|
authority: null,
|
|
};
|
|
if (resolveOwner !== undefined) {
|
|
try {
|
|
ownerResolution = resolveOwner(lane);
|
|
} catch {
|
|
ownerResolution = {
|
|
verdict: 'not-measured',
|
|
reasonCode: 'owner-resolver-failed',
|
|
principal: null,
|
|
authority: null,
|
|
};
|
|
}
|
|
}
|
|
if (!ownerIsValid(ownerResolution, lane, input.laneActive)) {
|
|
return {
|
|
status: 'blocked',
|
|
candidates: [],
|
|
reported: reportAll(
|
|
all,
|
|
'Migration requires a source-of-truth-resolved named durable owner; caller assertions are not evidence.',
|
|
),
|
|
owner: null,
|
|
};
|
|
}
|
|
|
|
const candidates: MigrationCandidate[] = [];
|
|
const reported: MigrationReport[] = reportAll(
|
|
unsupported,
|
|
'Ownership or supported migration shape was not established; retained and reported.',
|
|
);
|
|
for (const path of laneFiles) {
|
|
if (secretShapedPath(input.sourceRoot, path)) {
|
|
reported.push({
|
|
path,
|
|
reason: 'Secret-shaped state is forbidden in the brain; retained and reported.',
|
|
});
|
|
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, source),
|
|
);
|
|
} catch (error: unknown) {
|
|
const code = error instanceof Error ? error.message : '';
|
|
reported.push({
|
|
path,
|
|
reason:
|
|
code === 'migration-secret-content'
|
|
? 'Content may contain secret material; 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.',
|
|
});
|
|
}
|
|
}
|
|
for (const path of seatFiles) {
|
|
if (secretShapedPath(input.sourceRoot, path)) {
|
|
reported.push({
|
|
path,
|
|
reason: 'Secret-shaped state is forbidden in the brain; retained and reported.',
|
|
});
|
|
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, source),
|
|
);
|
|
} catch (error: unknown) {
|
|
const code = error instanceof Error ? error.message : '';
|
|
reported.push({
|
|
path,
|
|
reason:
|
|
code === 'migration-secret-content'
|
|
? 'Content may contain secret material; 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.',
|
|
});
|
|
}
|
|
}
|
|
return { status: 'ready', candidates, reported, owner: ownerResolution.principal };
|
|
}
|
|
|
|
function isContained(root: string, path: string): boolean {
|
|
const absoluteRoot = resolve(root);
|
|
const absolutePath = resolve(path);
|
|
return absolutePath === absoluteRoot || absolutePath.startsWith(`${absoluteRoot}${sep}`);
|
|
}
|
|
|
|
function assertSafeDestinationAncestors(root: string, destination: string): void {
|
|
if (!isContained(root, destination)) throw new Error('migration-destination-escaped-brain');
|
|
if (!existsSync(root)) mkdirSync(root, { recursive: true });
|
|
const rootStatus = lstatSync(root);
|
|
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) {
|
|
throw new Error('migration-destination-ancestor-unsafe');
|
|
}
|
|
const parts = relative(root, dirname(destination)).split(sep).filter(Boolean);
|
|
let cursor = root;
|
|
for (const part of parts) {
|
|
cursor = join(cursor, part);
|
|
if (!existsSync(cursor)) continue;
|
|
const status = lstatSync(cursor);
|
|
if (!status.isDirectory() || status.isSymbolicLink()) {
|
|
throw new Error('migration-destination-ancestor-unsafe');
|
|
}
|
|
}
|
|
}
|
|
|
|
function procDescriptorPath(descriptor: number, name: string): string {
|
|
return `/proc/self/fd/${descriptor}/${name}`;
|
|
}
|
|
|
|
function openDirectorySecure(directory: string): { readonly fd: number; readonly chain: number[] } {
|
|
if (platform() !== 'linux')
|
|
throw new Error('secure migration requires Linux descriptor traversal');
|
|
const chain: number[] = [];
|
|
try {
|
|
let descriptor = openSync(
|
|
sep,
|
|
fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW,
|
|
);
|
|
chain.push(descriptor);
|
|
for (const component of resolve(directory).split(sep).filter(Boolean)) {
|
|
descriptor = openSync(
|
|
procDescriptorPath(descriptor, component),
|
|
fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW,
|
|
);
|
|
if (!fstatSync(descriptor).isDirectory()) {
|
|
throw new Error('migration destination ancestor is not a directory');
|
|
}
|
|
chain.push(descriptor);
|
|
}
|
|
return { fd: descriptor, chain };
|
|
} catch (error: unknown) {
|
|
for (const descriptor of chain.reverse()) closeSync(descriptor);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function closeDirectorySecure(chain: readonly number[]): void {
|
|
for (const descriptor of [...chain].reverse()) closeSync(descriptor);
|
|
}
|
|
|
|
function verifyHeldDestinationVisible(
|
|
heldPath: string,
|
|
visiblePath: string,
|
|
brainRoot: string,
|
|
): void {
|
|
assertSafeDestinationAncestors(brainRoot, visiblePath);
|
|
const held = lstatSync(heldPath);
|
|
const visible = lstatSync(visiblePath);
|
|
if (
|
|
!held.isFile() ||
|
|
held.isSymbolicLink() ||
|
|
!visible.isFile() ||
|
|
visible.isSymbolicLink() ||
|
|
held.dev !== visible.dev ||
|
|
held.ino !== visible.ino
|
|
) {
|
|
throw new Error('migration-destination-visibility-changed');
|
|
}
|
|
}
|
|
|
|
function sourceSnapshotMatches(
|
|
snapshot: StableSourceSnapshot,
|
|
identity: MigrationCandidate['sourceIdentity'],
|
|
): boolean {
|
|
return (
|
|
snapshot.dev === identity.dev &&
|
|
snapshot.ino === identity.ino &&
|
|
snapshot.digest === identity.digest
|
|
);
|
|
}
|
|
|
|
function copyVerified(
|
|
source: StableSourceSnapshot,
|
|
destination: string,
|
|
brainRoot: string,
|
|
hooks: MigrationHooks,
|
|
): boolean {
|
|
assertSafeDestinationAncestors(brainRoot, destination);
|
|
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
assertSafeDestinationAncestors(brainRoot, destination);
|
|
const opened = openDirectorySecure(dirname(destination));
|
|
const name = basename(destination);
|
|
const heldDestination = procDescriptorPath(opened.fd, name);
|
|
const temporaryName = `.${name}.tmp-${process.pid}-${randomUUID()}`;
|
|
const heldTemporary = procDescriptorPath(opened.fd, temporaryName);
|
|
try {
|
|
hooks.beforeDestinationWrite?.(destination);
|
|
if (existsSync(heldDestination)) {
|
|
const status = lstatSync(heldDestination);
|
|
if (!status.isFile() || status.isSymbolicLink()) {
|
|
throw new Error('append-only-destination-unsafe');
|
|
}
|
|
const destinationDigest = createHash('sha256')
|
|
.update(readFileSync(heldDestination))
|
|
.digest('hex');
|
|
if (source.digest !== destinationDigest) throw new Error('append-only-collision');
|
|
verifyHeldDestinationVisible(heldDestination, destination, brainRoot);
|
|
return false;
|
|
}
|
|
writeFileSync(heldTemporary, source.content, { mode: 0o600, flag: 'wx' });
|
|
const temporaryStatus = lstatSync(heldTemporary);
|
|
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
|
|
throw new Error('migration-copy-target-unsafe');
|
|
}
|
|
syncFile(heldTemporary);
|
|
const copiedDigest = createHash('sha256').update(readFileSync(heldTemporary)).digest('hex');
|
|
if (source.digest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
|
linkSync(heldTemporary, heldDestination);
|
|
syncFile(heldDestination);
|
|
try {
|
|
verifyHeldDestinationVisible(heldDestination, destination, brainRoot);
|
|
} catch (error: unknown) {
|
|
unlinkSync(heldDestination);
|
|
throw error;
|
|
}
|
|
return true;
|
|
} finally {
|
|
rmSync(heldTemporary, { force: true });
|
|
closeDirectorySecure(opened.chain);
|
|
}
|
|
}
|
|
|
|
export function migrateBrainState(
|
|
plan: MigrationPlan,
|
|
publish: (
|
|
brainRoot: string,
|
|
entries: readonly MigrationPublishEntry[],
|
|
) => MigrationPublishEvidence,
|
|
brainRoot: string,
|
|
hooks: MigrationHooks = {},
|
|
): MigrationResult {
|
|
if (plan.status !== 'ready' || plan.candidates.length === 0) {
|
|
return {
|
|
status: 'reported',
|
|
migrated: [],
|
|
reported: plan.reported,
|
|
publish: null,
|
|
};
|
|
}
|
|
|
|
const created: string[] = [];
|
|
const published: MigrationPublishEntry[] = [];
|
|
let publicationAttempted = false;
|
|
let evidence: MigrationPublishEvidence;
|
|
try {
|
|
for (const candidate of plan.candidates) {
|
|
if (
|
|
!isContained(brainRoot, candidate.destination) ||
|
|
!isContained(brainRoot, candidate.archive)
|
|
) {
|
|
throw new Error('migration-destination-escaped-brain');
|
|
}
|
|
const source = stableSourceSnapshot(candidate.source);
|
|
if (!sourceSnapshotMatches(source, candidate.sourceIdentity)) {
|
|
throw new Error('migration-source-changed-before-copy');
|
|
}
|
|
if (copyVerified(source, candidate.destination, brainRoot, hooks)) {
|
|
created.push(candidate.destination);
|
|
}
|
|
if (copyVerified(source, candidate.archive, brainRoot, hooks)) {
|
|
created.push(candidate.archive);
|
|
}
|
|
published.push(
|
|
{ path: candidate.destination, content: Uint8Array.from(source.content) },
|
|
{ path: candidate.archive, content: Uint8Array.from(source.content) },
|
|
);
|
|
}
|
|
|
|
publicationAttempted = true;
|
|
evidence = publish(brainRoot, published);
|
|
if (!COMMIT.test(evidence.commit) || !COMMIT.test(evidence.remoteHead) || !evidence.reachable) {
|
|
throw new Error('remote reachability was not established');
|
|
}
|
|
} catch (error: unknown) {
|
|
// Once publication is attempted its remote mutation state may be unknown.
|
|
// Keep the local copies so the checkout does not silently diverge from a
|
|
// commit that may already be reachable; sources always remain intact.
|
|
if (!publicationAttempted) {
|
|
for (const path of created.reverse()) rmSync(path, { force: true });
|
|
}
|
|
const detail = error instanceof Error ? error.message : 'migration failed';
|
|
return {
|
|
status: 'failed',
|
|
migrated: [],
|
|
reported: [
|
|
...plan.reported,
|
|
...plan.candidates.map(
|
|
(candidate: MigrationCandidate): MigrationReport => ({
|
|
path: candidate.source,
|
|
reason: `Migration retained source: ${detail}`,
|
|
}),
|
|
),
|
|
],
|
|
publish: null,
|
|
};
|
|
}
|
|
|
|
const retentionReports = plan.candidates.map((candidate: MigrationCandidate): MigrationReport => {
|
|
hooks.beforeSourceCleanup?.(candidate.source);
|
|
return {
|
|
path: candidate.source,
|
|
reason:
|
|
'Published snapshot is remotely reachable; automatic path-based cleanup is unsafe, so Mosaic retained and reported the source.',
|
|
};
|
|
});
|
|
return {
|
|
status: 'reported',
|
|
migrated: [],
|
|
reported: [...plan.reported, ...retentionReports],
|
|
publish: evidence,
|
|
};
|
|
}
|
|
|
|
function accessFinding(access: CredentialAssessment | null): BrainDoctorFinding | null {
|
|
if (access === null) {
|
|
return {
|
|
code: 'brain-write-access-indeterminate',
|
|
repairable: false,
|
|
reasonCode: 'readback-missing',
|
|
};
|
|
}
|
|
if (access.outcome === 'ok') return null;
|
|
return {
|
|
code: `brain-write-access-${access.outcome}`,
|
|
repairable:
|
|
access.outcome === 'refused' &&
|
|
['permission-denied', 'no-token-for-identity'].includes(access.reasonCode),
|
|
reasonCode: access.reasonCode,
|
|
};
|
|
}
|
|
|
|
export function evaluateBrainDoctor(
|
|
observation: BrainDoctorObservation,
|
|
expectedRemote: string,
|
|
): readonly BrainDoctorFinding[] {
|
|
const access = accessFinding(observation.access);
|
|
if (!observation.rootExists) {
|
|
return [
|
|
{ code: 'brain-clone-missing', repairable: true, reasonCode: null },
|
|
...(access === null ? [] : [access]),
|
|
];
|
|
}
|
|
const findings: BrainDoctorFinding[] = [];
|
|
if (!observation.rootPrivate) {
|
|
findings.push({
|
|
code: 'brain-root-permissions-unsafe',
|
|
repairable: false,
|
|
reasonCode: 'owner-only-root-required',
|
|
});
|
|
}
|
|
if (!observation.gitRepository) {
|
|
findings.push({ code: 'brain-not-git-repository', repairable: true, reasonCode: null });
|
|
if (access !== null) findings.push(access);
|
|
return findings;
|
|
}
|
|
if (observation.remote === null) {
|
|
findings.push({
|
|
code: 'brain-git-state-indeterminate',
|
|
repairable: false,
|
|
reasonCode: 'remote-unmeasurable',
|
|
});
|
|
} else if (observation.remote !== expectedRemote) {
|
|
findings.push({ code: 'brain-remote-mismatch', repairable: true, reasonCode: null });
|
|
}
|
|
if (observation.branch === null) {
|
|
findings.push({
|
|
code: 'brain-git-state-indeterminate',
|
|
repairable: false,
|
|
reasonCode: 'branch-unmeasurable',
|
|
});
|
|
} else if (observation.branch !== 'main') {
|
|
findings.push({ code: 'brain-branch-mismatch', repairable: false, reasonCode: null });
|
|
}
|
|
if (observation.worktreeState === 'unmeasurable') {
|
|
findings.push({
|
|
code: 'brain-git-state-indeterminate',
|
|
repairable: false,
|
|
reasonCode: 'status-unmeasurable',
|
|
});
|
|
} else if (observation.worktreeState === 'dirty') {
|
|
findings.push({ code: 'brain-uncommitted-state', repairable: false, reasonCode: null });
|
|
}
|
|
if (access !== null) findings.push(access);
|
|
return findings;
|
|
}
|
|
|
|
export function planBrainDoctorFix(input: {
|
|
readonly findings: readonly BrainDoctorFinding[];
|
|
readonly target: BrainTarget;
|
|
readonly identity: string;
|
|
readonly root: string;
|
|
}): readonly BrainDoctorAction[] {
|
|
safeName(input.identity, 'identity');
|
|
const actions: BrainDoctorAction[] = [];
|
|
const priority: Readonly<Record<string, number>> = {
|
|
'brain-write-access-refused': 0,
|
|
'brain-clone-missing': 1,
|
|
'brain-remote-mismatch': 2,
|
|
};
|
|
const ordered = [...input.findings].sort(
|
|
(left: BrainDoctorFinding, right: BrainDoctorFinding): number =>
|
|
(priority[left.code] ?? 99) - (priority[right.code] ?? 99),
|
|
);
|
|
for (const finding of ordered) {
|
|
if (!finding.repairable) continue;
|
|
if (finding.code === 'brain-clone-missing') {
|
|
actions.push({
|
|
program: 'git',
|
|
args: ['clone', '--branch', 'main', '--single-branch', input.target.cloneUrl, input.root],
|
|
findingCode: finding.code,
|
|
});
|
|
} else if (finding.code === 'brain-remote-mismatch') {
|
|
actions.push({
|
|
program: 'git',
|
|
args: ['-C', input.root, 'remote', 'set-url', 'origin', input.target.cloneUrl],
|
|
findingCode: finding.code,
|
|
});
|
|
} else if (finding.code === 'brain-write-access-refused') {
|
|
actions.push({
|
|
program: 'mosaic',
|
|
args: [
|
|
'cred',
|
|
'grant',
|
|
input.identity,
|
|
'--estate',
|
|
input.target.estate,
|
|
'--host',
|
|
input.target.host,
|
|
'--repo',
|
|
input.target.repo,
|
|
'--permission',
|
|
'write',
|
|
'--json',
|
|
],
|
|
findingCode: finding.code,
|
|
});
|
|
}
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
function safeRelativePath(path: string): string[] | null {
|
|
if (isAbsolute(path) || path.includes('\\')) return null;
|
|
const parts = path.split('/').filter((part: string): boolean => part.length > 0);
|
|
if (parts.length === 0 || parts.some((part: string): boolean => part === '.' || part === '..')) {
|
|
return null;
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
export function classifyBrainWrite(input: {
|
|
readonly path: string;
|
|
readonly actor: string;
|
|
readonly seat: string;
|
|
readonly boardWriter?: string;
|
|
}): BrainWritePolicy {
|
|
const parts = safeRelativePath(input.path);
|
|
if (parts === null || !SAFE_NAME.test(input.actor) || !SAFE_NAME.test(input.seat)) {
|
|
return { allowed: false, mode: 'refused', reason: 'invalid-write-subject' };
|
|
}
|
|
if (parts[0] === 'lanes' && parts.length >= 3) {
|
|
return { allowed: true, mode: 'append-only', reason: 'lane-content-is-findings' };
|
|
}
|
|
if (parts[0] === 'board') {
|
|
if (input.boardWriter !== undefined && input.actor === input.boardWriter) {
|
|
return { allowed: true, mode: 'single-writer', reason: 'named-board-writer' };
|
|
}
|
|
return { allowed: false, mode: 'refused', reason: 'board-writer-mismatch' };
|
|
}
|
|
if (parts[0] === 'agents' && parts.length >= 3) {
|
|
if (parts[1] === input.seat && input.actor === input.seat) {
|
|
return { allowed: true, mode: 'seat-writer', reason: 'seat-owned-state' };
|
|
}
|
|
return { allowed: false, mode: 'refused', reason: 'seat-writer-mismatch' };
|
|
}
|
|
return { allowed: false, mode: 'refused', reason: 'unsupported-write-path' };
|
|
}
|