@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
948 lines (902 loc) • 46.1 kB
text/typescript
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import {
KxmConfigError,
loadKxmProject,
parseRestrictedYaml,
kxmCanonicalJson,
kxmPortablePath,
kxmResourceIdentifier,
type JsonObject,
type JsonValue,
type KxmConfigIssue,
type KxmConfigOptions,
type KxmProjectBundle,
type KxmResource,
type KxmResourceKind,
} from "./project-config.ts";
/* ------------------------------------------------------------------ *
* Structured authority projections
* ------------------------------------------------------------------ */
export type KxmPermissionField =
| "repository-access"
| "tools"
| "secrets"
| "executor"
| "harness"
| "network"
| "model"
| "sync-policy"
| "snapshot-policy"
| "evidence-quorum"
| "transition"
| "budget"
| "resource-shape"
| "environment"
| "gate"
| "delivery";
export type KxmPermissionDirection = "expansion" | "narrowing" | "neutral";
export interface KxmAuthorityEntry {
/** Resource-relative pointer, e.g. /repositories/api or /steps/3/tools/preset. */
path: string;
field: KxmPermissionField;
/** Canonical JSON of the comparable value (never secret material). */
value: string;
}
const PROSE_FIELDS: Readonly<Record<KxmResourceKind, ReadonlySet<string>>> = {
project: new Set(["name", "description"]),
repository: new Set(["description"]),
agent: new Set(["purpose", "instructions"]),
model: new Set([]),
role: new Set(["description"]),
environment: new Set([]),
workflow: new Set(["description"]),
"gate-registry": new Set([]),
};
const ACCESS_RANK: Readonly<Record<string, number>> = { none: 0, read: 1, write: 2 };
const NETWORK_RANK: Readonly<Record<string, number>> = { none: 0, "provider-only": 1, restricted: 2, host: 3 };
const UNTRACKED_RANK: Readonly<Record<string, number>> = { "tracked-only": 0, ask: 1, bounded: 2 };
function canonical(value: JsonValue | undefined): string {
return kxmCanonicalJson(value === undefined ? null : value);
}
function asObject(value: JsonValue | undefined): JsonObject | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined;
}
function valuesOf(value: JsonObject, key: string): JsonValue[] {
const candidate = value[key];
return Array.isArray(candidate) ? candidate : [];
}
function stringValue(value: JsonValue | undefined): string | undefined {
return typeof value === "string" ? value : undefined;
}
function pointerEscape(segment: string): string {
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
}
function secretGrantKeys(grants: JsonValue[]): Map<number, string> {
const counts = new Map<string, number>();
for (const grant of grants) {
const ref = stringValue(asObject(grant)?.ref);
if (ref) counts.set(ref, (counts.get(ref) ?? 0) + 1);
}
const keys = new Map<number, string>();
for (const [index, grant] of grants.entries()) {
const ref = stringValue(asObject(grant)?.ref);
keys.set(index, ref && counts.get(ref) === 1 ? `by-ref/${pointerEscape(ref)}` : String(index));
}
return keys;
}
/** Extract the authority-bearing fields of one resource as comparable entries. */
export function kxmAuthorityEntries(resource: { kind: KxmResourceKind; id?: string; value: JsonObject }): KxmAuthorityEntry[] {
const entries: KxmAuthorityEntry[] = [];
const push = (path: string, field: KxmPermissionField, value: JsonValue | undefined): void => {
entries.push({ path, field, value: canonical(value) });
};
const value = resource.value;
switch (resource.kind) {
case "project": {
push("/id", "resource-shape", value.id);
push("/defaultWorkflow", "delivery", value.defaultWorkflow);
push("/defaultExecutor", "executor", value.defaultExecutor);
push("/defaultHarness", "harness", value.defaultHarness ?? "pi");
const declarations = valuesOf(value, "repositories");
const declarationIds = new Map<string, number>();
for (const entry of declarations) {
const id = stringValue(asObject(entry)?.id);
if (id) declarationIds.set(id, (declarationIds.get(id) ?? 0) + 1);
}
for (const [index, repository] of declarations.entries()) {
const entry = asObject(repository);
if (!entry) continue;
const id = stringValue(entry.id);
const key = id && declarationIds.get(id) === 1 ? `by-id/${pointerEscape(id)}` : String(index);
push(`/repositories/${key}`, "resource-shape", entry);
}
const workspace = asObject(value.workspace);
if (workspace) push("/workspace", "snapshot-policy", workspace);
const sync = asObject(value.sync);
if (sync) push("/sync", "sync-policy", sync);
const limits = asObject(value.limits);
if (limits) push("/limits", "budget", limits);
break;
}
case "repository": {
push("/projectId", "resource-shape", value.projectId);
push("/repositoryId", "resource-shape", value.repositoryId);
push("/defaultAccess", "repository-access", value.defaultAccess);
push("/ecosystems", "resource-shape", value.ecosystems);
push("/classification", "resource-shape", value.classification);
break;
}
case "agent": {
push("/model", "model", value.model);
push("/executor", "executor", value.executor);
push("/harness", "harness", value.harness ?? "pi");
push("/tools", "tools", value.tools);
push("/defaultRepositoryAccess", "repository-access", value.defaultRepositoryAccess);
const repositories = asObject(value.repositories);
for (const repositoryId of Object.keys(repositories ?? {}).sort()) {
push(`/repositories/${pointerEscape(repositoryId)}`, "repository-access", repositories?.[repositoryId]);
}
const secretKeys = secretGrantKeys(valuesOf(value, "secrets"));
for (const [index, grant] of valuesOf(value, "secrets").entries()) {
push(`/secrets/${secretKeys.get(index)}`, "secrets", grant);
}
push("/network", "network", value.network);
push("/resultSchema", "delivery", value.resultSchema);
const session = asObject(value.session);
if (session) push("/session", "resource-shape", session);
break;
}
case "model": {
push("/provider", "model", value.provider);
push("/model", "model", value.model);
push("/thinking", "model", value.thinking);
// Tags drive selector resolution; retagging retargets consumers.
push("/tags", "model", value.tags);
push("/capabilities", "model", value.capabilities);
push("/priority", "model", value.priority);
push("/fallbacks", "model", value.fallbacks);
const limits = asObject(value.limits);
if (limits) push("/limits", "budget", limits);
break;
}
case "environment": {
push("/path", "environment", value.path);
const envValues = asObject(value.values);
for (const key of Object.keys(envValues ?? {}).sort()) {
const entry = asObject(envValues?.[key]) as JsonObject | undefined;
// Never compare or store secret values: hash only the reference shape.
const projected: JsonValue | undefined = entry
? ({ ...entry, value: entry.value === undefined ? undefined : "<redacted>" } as JsonObject)
: (envValues?.[key] as JsonValue | undefined);
push(`/values/${pointerEscape(key)}`, "environment", projected);
}
const envSecretKeys = secretGrantKeys(valuesOf(value, "secrets"));
for (const [index, grant] of valuesOf(value, "secrets").entries()) {
push(`/secrets/${envSecretKeys.get(index)}`, "secrets", grant);
}
break;
}
case "role": {
push("/schema", "resource-shape", value.schema);
push("/purpose", "resource-shape", value.purpose);
push("/permission", "resource-shape", value.permission);
push("/extends", "resource-shape", value.extends);
push("/roster", "resource-shape", value.roster);
push("/policy", "resource-shape", value.policy);
push("/tools", "tools", value.tools);
break;
}
case "gate-registry": {
push("/schema", "resource-shape", value.schema);
const gates = asObject(value.gates) ?? {};
for (const id of Object.keys(gates).sort()) {
const definition = asObject(gates[id]);
const path = `/gates/${pointerEscape(id)}`;
if (definition?.kind === "command") {
const { timeoutMs, ...authority } = definition;
push(path, "gate", authority);
push(`${path}/timeoutMs`, "budget", timeoutMs);
} else push(path, "gate", gates[id]);
}
break;
}
case "workflow": {
push("/coordinator", "resource-shape", value.coordinator);
const limits = asObject(value.limits);
if (limits) push("/limits", "budget", limits);
if (value.reproOracle !== undefined) push("/reproOracle", "evidence-quorum", value.reproOracle);
if (value.planHash !== undefined) push("/planHash", "evidence-quorum", value.planHash);
if (value.requirePlanHash !== undefined) push("/requirePlanHash", "evidence-quorum", value.requirePlanHash);
for (const [index, stepEntry] of valuesOf(value, "steps").entries()) {
const step = asObject(stepEntry);
if (!step) continue;
const stepPath = `/steps/${index}`;
push(`${stepPath}/id`, "resource-shape", step.id);
push(`${stepPath}/kind`, "resource-shape", step.kind);
push(`${stepPath}/agent`, "resource-shape", step.agent);
push(`${stepPath}/gate`, "gate", step.gate);
if (step.kind === "gate") push(`${stepPath}/expect`, "gate", step.expect ?? "pass");
push(`${stepPath}/signal`, "delivery", step.signal);
push(`${stepPath}/model`, "model", step.model);
push(`${stepPath}/maxAttempts`, "budget", step.maxAttempts);
push(`${stepPath}/timeoutMs`, "budget", step.timeoutMs);
const repositories = asObject(step.repositories);
for (const repositoryId of Object.keys(repositories ?? {}).sort()) {
push(`${stepPath}/repositories/${pointerEscape(repositoryId)}`, "repository-access", repositories?.[repositoryId]);
}
push(`${stepPath}/tools`, "tools", step.tools);
const stepSecretKeys = secretGrantKeys(valuesOf(step, "secrets"));
for (const [grantIndex, grant] of valuesOf(step, "secrets").entries()) {
push(`${stepPath}/secrets/${stepSecretKeys.get(grantIndex)}`, "secrets", grant);
}
push(`${stepPath}/assignments`, "resource-shape", step.assignments);
push(`${stepPath}/join`, "evidence-quorum", step.join);
const evidence = valuesOf(step, "requiredEvidence");
for (const [evidenceIndex, evidenceEntry] of evidence.entries()) {
push(`${stepPath}/requiredEvidence/${evidenceIndex}`, "evidence-quorum", evidenceEntry);
}
push(`${stepPath}/safeSpeculation`, "resource-shape", step.safeSpeculation);
push(`${stepPath}/on`, "transition", step.on);
}
break;
}
}
return entries;
}
/** Prose/non-authority fields, surfaced separately so reviewers see them as neutral. */
export function kxmProseEntries(resource: { kind: KxmResourceKind; value: JsonObject }): Map<string, string> {
const entries = new Map<string, string>();
for (const field of PROSE_FIELDS[resource.kind]) {
if (resource.value[field] !== undefined) entries.set(`/${field}`, canonical(resource.value[field]));
}
if (resource.kind === "workflow") {
for (const [index, stepEntry] of valuesOf(resource.value, "steps").entries()) {
const step = asObject(stepEntry);
if (!step) continue;
for (const field of ["description", "instructions"] as const) {
if (step[field] !== undefined) entries.set(`/steps/${index}/${field}`, canonical(step[field]));
}
}
}
return entries;
}
/* ------------------------------------------------------------------ *
* Diff
* ------------------------------------------------------------------ */
export interface KxmPermissionChange {
resource: string;
path: string;
field: KxmPermissionField;
direction: KxmPermissionDirection;
summary: string;
baseValueSha256?: string;
candidateValueSha256?: string;
}
export interface KxmPermissionDiff {
schema: "kxm.permission-diff.v1";
baseRevision: string;
candidateRevision: string;
changes: KxmPermissionChange[];
expansions: KxmPermissionChange[];
narrowings: KxmPermissionChange[];
neutralChanges: KxmPermissionChange[];
requiresReview: boolean;
}
function valueHash(canonicalValue: string): string {
return `sha256:${createHash("sha256").update(canonicalValue, "utf8").digest("hex")}`;
}
function rankDirection(rank: Readonly<Record<string, number>>, baseValue: string, candidateValue: string): KxmPermissionDirection {
const base = JSON.parse(baseValue) as unknown;
const candidate = JSON.parse(candidateValue) as unknown;
const baseRank = typeof base === "string" && base in rank ? rank[base] as number : undefined;
const candidateRank = typeof candidate === "string" && candidate in rank ? rank[candidate] as number : undefined;
if (baseRank === undefined || candidateRank === undefined) return "expansion";
if (candidateRank > baseRank) return "expansion";
if (candidateRank < baseRank) return "narrowing";
return "neutral";
}
/** Direction for a numeric bound: raising limits is expansion. */
function numericBoundDirection(baseValue: string, candidateValue: string): KxmPermissionDirection {
const base = JSON.parse(baseValue) as unknown;
const candidate = JSON.parse(candidateValue) as unknown;
if (typeof base !== "number" || typeof candidate !== "number") return "expansion";
if (candidate > base) return "expansion";
if (candidate < base) return "narrowing";
return "neutral";
}
function budgetDirection(baseValue: string, candidateValue: string): KxmPermissionDirection {
const base = asObject(JSON.parse(baseValue) as JsonValue);
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
if (!base || !candidate) return "expansion";
let sawExpansion = false;
let sawNarrowing = false;
for (const key of new Set([...Object.keys(base), ...Object.keys(candidate)])) {
const direction = numericBoundDirection(canonical(base[key] as JsonValue | undefined), canonical(candidate[key] as JsonValue | undefined));
if (direction === "expansion") sawExpansion = true;
if (direction === "narrowing") sawNarrowing = true;
}
// Mixed directions cannot be ordered conservatively.
if (sawExpansion) return "expansion";
return sawNarrowing ? "narrowing" : "neutral";
}
function quorumDirection(baseValue: string, candidateValue: string): KxmPermissionDirection {
const base = asObject(JSON.parse(baseValue) as JsonValue);
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
if (!base || !candidate) return "expansion";
// Join policies, oracles, and plan-hash retargets have no conservative
// order: any change can weaken the quorum path.
if (base.strategy !== undefined || candidate.strategy !== undefined) return "expansion";
if (base.stageId !== undefined || candidate.stageId !== undefined) return "expansion";
// Evidence requirement entries: key/kind must match for an ordered compare.
if (base.key === undefined || candidate.key === undefined || base.key !== candidate.key || base.kind !== candidate.kind) return "expansion";
const baseRequirementMinimum = typeof base.minimum === "number" ? base.minimum : 1;
const candidateRequirementMinimum = typeof candidate.minimum === "number" ? candidate.minimum : 1;
if (candidateRequirementMinimum < baseRequirementMinimum) return "expansion";
// A narrowing observed so far must never short-circuit the remaining
// quorum checks: a later expansion always wins the report.
let sawNarrowing = candidateRequirementMinimum > baseRequirementMinimum;
if (base.reusableAcrossAttempts === true && (candidate.reusableAcrossAttempts ?? false) === false) sawNarrowing = true;
if ((base.reusableAcrossAttempts ?? false) === false && candidate.reusableAcrossAttempts === true) return "expansion";
const CLASSIFIED = new Set(["key", "kind", "minimum", "reusableAcrossAttempts", "strategy", "stageId", "evidenceKey"]);
const CLASSIFIED_POLICY = new Set(["minimumProducers", "eligibleAgents", "degradation"]);
const residualOf = (value: string): JsonValue => {
const parsed = asObject(JSON.parse(value) as JsonValue);
if (!parsed) return null;
const residual: JsonObject = {};
for (const [key, entry] of Object.entries(parsed)) {
if (CLASSIFIED.has(key)) continue;
if (key === "producerPolicy") {
const policy = asObject(entry);
if (!policy) {
residual[key] = entry;
continue;
}
residual[key] = Object.fromEntries(Object.entries(policy).filter(([policyKey]) => !CLASSIFIED_POLICY.has(policyKey)));
continue;
}
residual[key] = entry;
}
return residual;
};
// Residual fail-closed check runs BEFORE any narrowing return: unknown or
// future fields always expand, even alongside an ordered narrowing.
if (kxmCanonicalJson(residualOf(baseValue)) !== kxmCanonicalJson(residualOf(candidateValue))) return "expansion";
const basePolicy = asObject(base.producerPolicy as JsonValue | undefined);
const candidatePolicy = asObject(candidate.producerPolicy as JsonValue | undefined);
if (!basePolicy && !candidatePolicy) return sawNarrowing ? "narrowing" : "neutral";
if (!basePolicy || !candidatePolicy) return "expansion";
const basePolicyMinimum = typeof basePolicy.minimumProducers === "number" ? basePolicy.minimumProducers : 1;
const candidatePolicyMinimum = typeof candidatePolicy.minimumProducers === "number" ? candidatePolicy.minimumProducers : 1;
if (candidatePolicyMinimum < basePolicyMinimum) return "expansion"; // weaker quorum
const baseEligible = new Set((Array.isArray(basePolicy.eligibleAgents) ? basePolicy.eligibleAgents : []).map(String));
const candidateEligible = new Set((Array.isArray(candidatePolicy.eligibleAgents) ? candidatePolicy.eligibleAgents : []).map(String));
for (const agent of baseEligible) if (!candidateEligible.has(agent)) return "expansion"; // producer removed
const baseDegradation = asObject(basePolicy.degradation as JsonValue | undefined);
const candidateDegradation = asObject(candidatePolicy.degradation as JsonValue | undefined);
if (!baseDegradation && candidateDegradation) return "expansion"; // degradation introduced
if (baseDegradation && candidateDegradation) {
const baseFloor = typeof baseDegradation.minimumProducers === "number" ? baseDegradation.minimumProducers : 1;
const candidateFloor = typeof candidateDegradation.minimumProducers === "number" ? candidateDegradation.minimumProducers : 1;
if (candidateFloor < baseFloor) return "expansion";
}
if (candidatePolicyMinimum > basePolicyMinimum) return "narrowing";
for (const agent of candidateEligible) if (!baseEligible.has(agent)) {
// A new producer enlarges the set of passing combinations at the same
// floor (a+c and b+c now pass where only a+b did): quorum weakening.
return "expansion";
}
return sawNarrowing ? "narrowing" : "neutral";
}
function snapshotDirection(baseValue: string, candidateValue: string): KxmPermissionDirection {
const base = asObject(JSON.parse(baseValue) as JsonValue);
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
if (!base || !candidate) return "expansion";
const baseSnapshot = asObject(base.dirtySnapshot as JsonValue | undefined);
const candidateSnapshot = asObject(candidate.dirtySnapshot as JsonValue | undefined);
if (!baseSnapshot || !candidateSnapshot) return "expansion";
const untracked = rankDirection(UNTRACKED_RANK, canonical(baseSnapshot.untracked as JsonValue | undefined), canonical(candidateSnapshot.untracked as JsonValue | undefined));
if (untracked === "expansion") return "expansion";
// Byte bounds matter only while the mode is bounded; downgrading the mode
// makes the bounds irrelevant rather than an expansion.
const candidateMode = stringValue(candidateSnapshot.untracked as JsonValue | undefined);
const bounds = candidateMode === "bounded"
? budgetDirection(
canonical({ maxUntrackedFileBytes: baseSnapshot.maxUntrackedFileBytes, maxUntrackedTotalBytes: baseSnapshot.maxUntrackedTotalBytes } as JsonValue),
canonical({ maxUntrackedFileBytes: candidateSnapshot.maxUntrackedFileBytes, maxUntrackedTotalBytes: candidateSnapshot.maxUntrackedTotalBytes } as JsonValue),
)
: "neutral";
if (bounds === "expansion") return "expansion";
// Residual fail-closed check: unknown future snapshot keys expand.
const CLASSIFIED_SNAPSHOT = new Set(["untracked", "maxUntrackedFileBytes", "maxUntrackedTotalBytes", "dirtySubmodules"]);
const residualOf = (snapshot: JsonObject): JsonValue => Object.fromEntries(Object.entries(snapshot).filter(([key]) => !CLASSIFIED_SNAPSHOT.has(key)));
if (kxmCanonicalJson(residualOf(baseSnapshot)) !== kxmCanonicalJson(residualOf(candidateSnapshot))) return "expansion";
return bounds === "narrowing" || untracked === "narrowing" ? "narrowing" : "neutral";
}
function classifyChange(
field: KxmPermissionField,
baseValue: string | undefined,
candidateValue: string | undefined,
context?: { fallbackRank?: number; baseFallbackRank?: number },
): KxmPermissionDirection {
if (baseValue === candidateValue) return "neutral";
if (baseValue === undefined || candidateValue === undefined) {
if (candidateValue === undefined) {
// Removal semantics depend on the effective fallback. A secret grant
// removal strictly narrows (no fallback exists). Everything else can
// widen the effective ceiling by falling back to a broader default, so
// it narrows only when the removed value is provably at or below the
// fallback rank; otherwise it expands.
if (field === "secrets") return "narrowing";
if (field === "repository-access" && context?.fallbackRank !== undefined) {
const removed = JSON.parse(baseValue as string) as unknown;
const removedRank = typeof removed === "string" && removed in ACCESS_RANK ? ACCESS_RANK[removed] as number : Number.POSITIVE_INFINITY;
// Removing an override lands on the fallback: strictly-weaker-than-
// fallback removals widen effective access; broader ones tighten it;
// equal-rank removals change nothing effective.
if (removedRank < context.fallbackRank) return "expansion";
return removedRank > context.fallbackRank ? "narrowing" : "neutral";
}
return "expansion";
}
// Addition: mirror of removal — an override stricter than the BASE
// fallback narrows, a broader one expands, an equal one is neutral.
if (field === "repository-access" && context?.baseFallbackRank !== undefined) {
const added = JSON.parse(candidateValue) as unknown;
const addedRank = typeof added === "string" && added in ACCESS_RANK ? ACCESS_RANK[added] as number : Number.POSITIVE_INFINITY;
if (addedRank > context.baseFallbackRank) return "expansion";
return addedRank < context.baseFallbackRank ? "narrowing" : "neutral";
}
return "expansion";
}
switch (field) {
case "repository-access": return rankDirection(ACCESS_RANK, baseValue, candidateValue);
case "network": return rankDirection(NETWORK_RANK, baseValue, candidateValue);
case "budget": {
// Scalar bounds (maxAttempts, timeoutMs) compare numerically; object
// bounds (limits/workspace) compare field-wise.
const baseParsed = JSON.parse(baseValue) as unknown;
const candidateParsed = JSON.parse(candidateValue) as unknown;
if (typeof baseParsed === "number" && typeof candidateParsed === "number") {
return numericBoundDirection(baseValue, candidateValue);
}
return budgetDirection(baseValue, candidateValue);
}
case "evidence-quorum": return quorumDirection(baseValue, candidateValue);
case "snapshot-policy": return snapshotDirection(baseValue, candidateValue);
case "secrets": {
const base = asObject(JSON.parse(baseValue) as JsonValue);
const candidate = asObject(JSON.parse(candidateValue) as JsonValue);
if (base && candidate && base.ref === candidate.ref) {
// Any non-required grant field (as, name, future keys) changing is a
// review-required expansion; then order by required (default true).
const { required: _b, ...baseRest } = base;
const { required: _c, ...candidateRest } = candidate;
if (kxmCanonicalJson(baseRest as JsonValue) !== kxmCanonicalJson(candidateRest as JsonValue)) return "expansion";
const baseRequired = (base.required ?? true) === true;
const candidateRequired = (candidate.required ?? true) === true;
if (baseRequired && !candidateRequired) return "narrowing";
if (!baseRequired && candidateRequired) return "expansion";
return "neutral";
}
return "expansion";
}
default:
// tools, executor, model, sync-policy, transition, resource-shape,
// environment, gate, delivery: no conservative order — any change is a
// review-required expansion.
return "expansion";
}
}
function summarize(resource: string, path: string, field: KxmPermissionField, direction: KxmPermissionDirection, baseValue: string | undefined, candidateValue: string | undefined): string {
const verb = baseValue === undefined ? "added" : candidateValue === undefined ? "removed" : "changed";
return `${resource} ${path} ${field} ${verb} (${direction})`;
}
/** Diff two complete bundles into a deterministic, classified permission report. */
export function computeKxmPermissionDiff(base: KxmProjectBundle, candidate: KxmProjectBundle): KxmPermissionDiff {
const changes: KxmPermissionChange[] = [];
const baseByPath = new Map(base.resources.map((resource) => [resource.logicalPath, resource]));
const candidateByPath = new Map(candidate.resources.map((resource) => [resource.logicalPath, resource]));
const allPaths = [...new Set([...baseByPath.keys(), ...candidateByPath.keys()])].sort();
for (const logicalPath of allPaths) {
const baseResource = baseByPath.get(logicalPath);
const candidateResource = candidateByPath.get(logicalPath);
if (baseResource && candidateResource) {
diffResource(logicalPath, baseResource, candidateResource, changes);
continue;
}
// Resource added or removed: shape change. Added resources expand
// authority; removed resources narrow it — except models: removing one
// can retarget tag-based consumers onto another profile (an expansion).
const resource = candidateResource ?? baseResource as KxmResource;
const direction: KxmPermissionDirection = candidateResource
? "expansion"
: ((resource.kind === "model" || resource.kind === "gate-registry") ? "expansion" : "narrowing");
changes.push({
resource: logicalPath,
path: "/",
field: "resource-shape",
direction,
summary: `${logicalPath} ${candidateResource ? "added" : "removed"} (${direction})`,
...(candidateResource && resource.kind !== "environment" ? { candidateValueSha256: valueHash(canonical(resource.value)) } : {}),
...(!candidateResource && resource.kind !== "environment" ? { baseValueSha256: valueHash(canonical(resource.value)) } : {}),
});
}
const sorted = changes.sort((left, right) => left.resource < right.resource ? -1 : left.resource > right.resource ? 1 : left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
const expansions = sorted.filter((change) => change.direction === "expansion");
const narrowings = sorted.filter((change) => change.direction === "narrowing");
const neutralChanges = sorted.filter((change) => change.direction === "neutral");
return {
schema: "kxm.permission-diff.v1",
baseRevision: base.configRevision,
candidateRevision: candidate.configRevision,
changes: sorted,
expansions,
narrowings,
neutralChanges,
requiresReview: expansions.length > 0,
};
}
/** Diff two values of one resource kind (e.g. template baseline vs target). */
export function computeKxmResourcePermissionDiff(
kind: KxmResourceKind,
resourcePath: string,
baseValue: JsonObject,
candidateValue: JsonObject,
): KxmPermissionChange[] {
const changes: KxmPermissionChange[] = [];
const base: KxmResource = { kind, file: resourcePath, logicalPath: resourcePath, value: baseValue };
const candidate: KxmResource = { kind, file: resourcePath, logicalPath: resourcePath, value: candidateValue };
diffResource(resourcePath, base, candidate, changes);
return changes.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
}
function diffResource(logicalPath: string, base: KxmResource, candidate: KxmResource, changes: KxmPermissionChange[]): void {
const baseEntries = new Map(kxmAuthorityEntries(base).map((entry) => [entry.path, entry]));
const candidateEntries = new Map(kxmAuthorityEntries(candidate).map((entry) => [entry.path, entry]));
const paths = [...new Set([...baseEntries.keys(), ...candidateEntries.keys()])].sort();
for (const path of paths) {
const baseEntry = baseEntries.get(path);
const candidateEntry = candidateEntries.get(path);
const field = (candidateEntry ?? baseEntry as KxmAuthorityEntry).field;
// For per-repository overrides, the effective fallback is the resource-
// level defaultRepositoryAccess (the loader's ceiling rule). Removal
// compares against the candidate's default; addition against the base's.
const context = field === "repository-access" && path.startsWith("/repositories/")
? {
fallbackRank: (() => {
const fallback = stringValue(candidate.value.defaultRepositoryAccess as JsonValue | undefined) ?? "none";
return fallback in ACCESS_RANK ? ACCESS_RANK[fallback] as number : 0;
})(),
baseFallbackRank: (() => {
const fallback = stringValue(base.value.defaultRepositoryAccess as JsonValue | undefined) ?? "none";
return fallback in ACCESS_RANK ? ACCESS_RANK[fallback] as number : 0;
})(),
}
: undefined;
const direction = classifyChange(field, baseEntry?.value, candidateEntry?.value, context);
if (direction === "neutral") continue;
changes.push({
resource: logicalPath,
path,
field,
direction,
summary: summarize(logicalPath, path, field, direction, baseEntry?.value, candidateEntry?.value),
...(baseEntry && field !== "environment" ? { baseValueSha256: valueHash(baseEntry.value) } : {}),
...(candidateEntry && field !== "environment" ? { candidateValueSha256: valueHash(candidateEntry.value) } : {}),
});
}
// Prose changes are surfaced as neutral for reviewer visibility.
const baseProse = kxmProseEntries(base);
const candidateProse = kxmProseEntries(candidate);
for (const path of [...new Set([...baseProse.keys(), ...candidateProse.keys()])].sort()) {
if (baseProse.get(path) === candidateProse.get(path)) continue;
changes.push({
resource: logicalPath,
path,
field: "resource-shape",
direction: "neutral",
summary: `${logicalPath} ${path} prose ${baseProse.has(path) ? (candidateProse.has(path) ? "changed" : "removed") : "added"} (neutral)`,
...(baseProse.has(path) ? { baseValueSha256: valueHash(baseProse.get(path) as string) } : {}),
...(candidateProse.has(path) ? { candidateValueSha256: valueHash(candidateProse.get(path) as string) } : {}),
});
}
}
/* ------------------------------------------------------------------ *
* Git base loading
* ------------------------------------------------------------------ */
function gitEnvironment(): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")));
}
function git(root: string, args: string[]): string {
const result = spawnSync("git", ["-C", root, ...args], {
encoding: "utf8",
env: gitEnvironment(),
timeout: 15_000,
windowsHide: true,
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0 || result.error) {
throw new KxmConfigError([{
phase: "discovery",
code: "git_base_unavailable",
file: args[0] ?? "<git>",
message: (result.stderr || result.stdout || String(result.error ?? "")).trim() || "git command failed",
}]);
}
return result.stdout;
}
function gitBuffer(root: string, args: string[]): Buffer {
const result = spawnSync("git", ["-C", root, ...args], {
env: gitEnvironment(),
timeout: 15_000,
windowsHide: true,
maxBuffer: 256 * 1024 * 1024,
});
if (result.status !== 0 || result.error) {
throw new KxmConfigError([{
phase: "discovery",
code: "git_base_unavailable",
file: args[0] ?? "<git>",
message: String(result.stderr || result.stdout || result.error || "git command failed").trim() || "git command failed",
}]);
}
return result.stdout as Buffer;
}
/** Resolve a revision to its tree SHA once so a moving ref cannot mix bases. */
function resolveTreeRevision(root: string, revision: string): string {
if (!/^[A-Za-z0-9._/^~-]+$/.test(revision) || revision.includes("..") || revision.startsWith("-")) {
throw new KxmConfigError([{
phase: "discovery",
code: "git_revision_invalid",
file: revision,
message: "base revision must be a plain Git revision name",
}]);
}
return git(root, ["rev-parse", "--verify", "--quiet", `${revision}^{tree}`]).trim();
}
/** Load the bundle as of a Git revision by materializing tracked files into a temporary shadow root. */
export function loadKxmProjectAtRevision(root: string, revision: string, options: KxmConfigOptions = {}): KxmProjectBundle {
const tree = resolveTreeRevision(root, revision);
const listing = gitBuffer(root, ["ls-tree", "-r", "-z", "--name-only", tree]).toString("utf8");
const paths = listing
.split("\u0000")
.filter((line) => line.length > 0 && (line.startsWith(".kxm/") || line.includes("/.kxm/")));
const shadow = mkdtempSync(join(tmpdir(), "kxm-permission-base-"));
const shadowResolved = resolve(shadow);
const shadowMembersDir = `.kxm-shadow-members-${createHash("sha256").update(shadowResolved, "utf8").digest("hex").slice(0, 8)}`;
try {
for (const path of paths) {
const segments = path.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || /[\\:]/.test(segment))) {
throw new KxmConfigError([{
phase: "path",
code: "git_tree_path_invalid",
file: path,
message: "Git tree entry contains a traversal or non-portable segment",
}]);
}
const absolute = join(shadowResolved, ...segments);
const resolved = resolve(absolute);
if (!resolved.startsWith(`${shadowResolved}${sep}`)) {
throw new KxmConfigError([{
phase: "path",
code: "git_tree_path_invalid",
file: path,
message: "Git tree entry escapes the shadow root",
}]);
}
const content = gitBuffer(root, ["cat-file", "blob", `${tree}:${path}`]);
mkdirSync(dirname(absolute), { recursive: true });
writeFileSync(absolute, content);
}
// Member repositories bind only at authoritative Git worktree roots.
// Initialize the shadow control root; member worktrees are gitlinks in
// the control tree and are materialized separately below.
initShadowGitRoot(shadowResolved);
// Nested member worktrees are gitlinks in the control tree: their content
// lives in each member's own history, and the control tree pins an exact
// commit for each. Materialize every BASE-declared member at its pinned
// gitlink commit. Members are enumerated from the base project.yaml so a
// candidate-added member reports as an expansion (base simply lacks it)
// and a removed member reports as a narrowing (base still has it).
const baseProjectFile = join(shadowResolved, ".kxm", "project.yaml");
let baseProject: BaseProjectMember[];
try {
baseProject = loadBaseProjectDeclarations(baseProjectFile);
} catch (error) {
if (error instanceof KxmConfigError) {
throw new KxmConfigError(error.issues.map((entry) => ({ ...entry, message: `base ${revision}: ${entry.message}` })));
}
throw error;
}
const shadowBindings: Record<string, string> = {};
for (const member of baseProject) {
if (member.role !== "member") continue;
if (!kxmResourceIdentifier(member.repositoryId)) {
throw new KxmConfigError([{
phase: "path",
code: "git_tree_path_invalid",
file: ".kxm/project.yaml",
message: `member repository id ${JSON.stringify(member.repositoryId)} at the base revision is not a valid identifier`,
}]);
}
if (member.pathHint !== undefined && !portableMemberPathHint(member.pathHint)) {
throw new KxmConfigError([{
phase: "path",
code: "git_tree_path_invalid",
file: ".kxm/project.yaml",
message: `member repository ${member.repositoryId} has a non-portable pathHint at the base revision`,
}]);
}
// The object source for reads: a host-local binding for this exact id,
// else the live portable worktree at the SAME pathHint.
const boundPath = options.repositoryBindings?.[member.repositoryId];
const memberWorktree = boundPath
? resolve(boundPath)
: member.pathHint
? resolve(root, ...member.pathHint.split("/"))
: undefined;
if (!memberWorktree || !existsSync(memberWorktree)) {
if (member.required === false) continue; // optional member absent on this host (loader tolerates it)
throw new KxmConfigError([{
phase: "discovery",
code: "trust_scope_unsupported",
file: ".kxm/project.yaml",
message: `member repository ${member.repositoryId} has no resolvable worktree at the base revision; trust diff cannot materialize its pinned content`,
}]);
}
// The control tree's gitlink for this member path pins the reviewed commit.
let pinnedCommit: string | undefined;
if (member.pathHint) {
const gitlink = git(root, ["ls-tree", tree, "--", member.pathHint]).trim();
if (gitlink.length > 0) {
const match = /^160000 commit ([0-9a-f]{40,64})\t/.exec(gitlink);
if (!match) {
throw new KxmConfigError([{
phase: "discovery",
code: "trust_scope_unsupported",
file: ".kxm/project.yaml",
message: `member ${member.repositoryId} pathHint ${member.pathHint} is not a pinned gitlink at the base revision`,
}]);
}
pinnedCommit = match[1];
} else if (boundPath) {
// Host-locally bound member with no gitlink in the control tree:
// the only reviewable base is the member's own HEAD commit.
pinnedCommit = git(memberWorktree, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]).trim();
} else {
throw new KxmConfigError([{
phase: "discovery",
code: "trust_scope_unsupported",
file: ".kxm/project.yaml",
message: `member repository ${member.repositoryId} pathHint ${member.pathHint} is not tracked as a gitlink at the base revision (gitignored nested worktrees cannot be diffed)`,
}]);
}
} else if (boundPath) {
// Host-locally bound member with no pathHint: the only reviewable
// base is the member's own HEAD commit.
pinnedCommit = git(memberWorktree, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]).trim();
}
if (!pinnedCommit) {
throw new KxmConfigError([{
phase: "discovery",
code: "trust_scope_unsupported",
file: ".kxm/project.yaml",
message: `member repository ${member.repositoryId} declares no pathHint and has no host-local binding at the base revision`,
}]);
}
const memberTree = git(memberWorktree, ["rev-parse", "--verify", "--quiet", `${pinnedCommit}^{tree}`]).trim();
const memberListing = gitBuffer(memberWorktree, ["ls-tree", "-r", "-z", "--name-only", memberTree]).toString("utf8");
const memberPaths = memberListing.split("\u0000").filter((line) => line.length > 0 && (line.startsWith(".kxm/") || line.includes("/.kxm/")));
const memberShadowRoot = join(shadowResolved, shadowMembersDir, member.repositoryId);
if (!resolve(memberShadowRoot).startsWith(`${resolve(shadowResolved, shadowMembersDir)}${sep}`)) {
throw new KxmConfigError([{
phase: "path",
code: "git_tree_path_invalid",
file: ".kxm/project.yaml",
message: `member repository ${member.repositoryId} shadow path escapes the member shadow root`,
}]);
}
for (const path of memberPaths) {
const segments = path.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || /[\\:]/.test(segment))) {
throw new KxmConfigError([{ phase: "path", code: "git_tree_path_invalid", file: path, message: "member Git tree entry contains a traversal or non-portable segment" }]);
}
const absolute = join(memberShadowRoot, ...segments);
const resolved = resolve(absolute);
if (!resolved.startsWith(`${resolve(memberShadowRoot)}${sep}`)) {
throw new KxmConfigError([{ phase: "path", code: "git_tree_path_invalid", file: path, message: "member Git tree entry escapes the shadow root" }]);
}
const content = gitBuffer(memberWorktree, ["cat-file", "blob", `${memberTree}:${path}`]);
mkdirSync(dirname(absolute), { recursive: true });
writeFileSync(absolute, content);
}
initShadowGitRoot(memberShadowRoot);
shadowBindings[member.repositoryId] = memberShadowRoot;
}
try {
return loadKxmProject(shadowResolved, { ...options, repositoryBindings: shadowBindings });
} catch (error) {
if (error instanceof KxmConfigError) {
throw new KxmConfigError(error.issues.map((entry) => ({ ...entry, message: `base ${revision}: ${entry.message}` })));
}
throw error;
}
} finally {
rmSync(shadow, { recursive: true, force: true });
}
}
interface BaseProjectMember {
repositoryId: string;
role?: string;
pathHint?: string;
required?: boolean;
}
/** Portable member pathHint predicate (mirrors the loader's portable path rules; "." is never a member). */
function portableMemberPathHint(pathHint: string): boolean {
return pathHint !== "." && kxmPortablePath(pathHint);
}
/** Read only the repository declarations from a materialized base project.yaml. */
function loadBaseProjectDeclarations(projectFile: string): BaseProjectMember[] {
if (!existsSync(projectFile)) return [];
const value = parseRestrictedYaml(readFileSync(projectFile, "utf8"), ".kxm/project.yaml");
const repositories = Array.isArray(value?.repositories) ? value.repositories : [];
const members: BaseProjectMember[] = [];
for (const entry of repositories) {
const record = entry && typeof entry === "object" && !Array.isArray(entry) ? entry as JsonObject : undefined;
const repositoryId = record && typeof record.id === "string" ? record.id : undefined;
if (!repositoryId) continue;
members.push({
repositoryId,
...(record && typeof record.role === "string" ? { role: record.role } : {}),
...(record && typeof record.pathHint === "string" ? { pathHint: record.pathHint } : {}),
...(record && typeof record.required === "boolean" ? { required: record.required } : {}),
});
}
return members;
}
function initShadowGitRoot(directory: string): void {
const result = spawnSync("git", ["-c", "init.defaultBranch=main", "init", "--quiet", directory], {
encoding: "utf8",
env: gitEnvironment(),
timeout: 10_000,
windowsHide: true,
});
if (result.status !== 0 || result.error) {
throw new KxmConfigError([{
phase: "discovery",
code: "git_base_unavailable",
file: directory,
message: (result.stderr || String(result.error ?? "")).trim() || "could not initialize the shadow Git root",
}]);
}
}
/** Diff the working tree against a Git revision. */
export function diffKxmProjectAgainstRevision(
root: string,
revision: string,
options: KxmConfigOptions = {},
): KxmPermissionDiff {
const base = loadKxmProjectAtRevision(root, revision, options);
const candidate = loadKxmProject(root, options);
return computeKxmPermissionDiff(base, candidate);
}
/** Human-readable report lines for the CLI. */
export function formatKxmPermissionDiff(diff: KxmPermissionDiff): string {
const lines: string[] = [];
lines.push(`permission diff: ${diff.baseRevision.slice(0, 19)}… -> ${diff.candidateRevision.slice(0, 19)}…`);
if (diff.changes.length === 0) {
lines.push("no authority-bearing or prose changes");
return lines.join("\n");
}
for (const change of diff.expansions) lines.push(`EXPANSION ${change.summary}`);
for (const change of diff.narrowings) lines.push(`narrowing ${change.summary}`);
for (const change of diff.neutralChanges) lines.push(`neutral ${change.summary}`);
lines.push(diff.requiresReview
? `${diff.expansions.length} expansion(s) require explicit reviewed trust action`
: "no expansions; no trust review required");
return lines.join("\n");
}