diff --git a/.agents/artifacts/TECHNICAL_DEBT-wave27.md b/.agents/artifacts/TECHNICAL_DEBT-wave27.md new file mode 100644 index 0000000..dfe7b45 --- /dev/null +++ b/.agents/artifacts/TECHNICAL_DEBT-wave27.md @@ -0,0 +1,13 @@ +# TECHNICAL_DEBT — Wave 27 audit (2026-11-09) + +| # | Finding | Location | Fix | +|---|---|---|---| +| W27-1 | Attestation chain is truncatable; no signed head/seq; verify is TOFU | `src/sync/attestation.ts`, `src/cli/attest.ts` | Signed sequence + terminal head, trusted key pinning | +| W27-2 | No enclave key rotation/migration; single store-wide key | `src/store/enclaves.ts` | `key_id` rotation with lazy re-encrypt (key_id already in AAD) | +| W27-3 | Semantic merge can be poisoned by self-asserted supersession/content inclusion | `src/quality/branch_merge.ts` | Corroborated lineage; block auto-resolve for constraints/reproducible verification | +| W27-4 | T-437 `scope: client` is not wired into MemoryScope/schema | `src/types.ts`, `schema.json` | Add client scope or reword the ticket | +| W27-5 | Overlay `.merged`/`.dropped` archives accumulate; no GC/listing | `src/store/branch.ts` | Retention sweep + `overlay archives` | +| W27-6 | Branch identity can fall back to `store.dir` when CLI omits `--branch` | `src/store/branch.ts` | Thread `ctx.root` through store-layer creation | +| W27-7 | No MCP surfaces for enclave/attest/overlay | `src/mcp.ts` | Read-only MCP tools with explicit project/client scope | +| W27-8 | Enclave put is read-modify-write without a lock; O(n²) | `src/store/enclaves.ts` | `withStoreLock` + batched put | +| W27-9 | Overlay plaintext archives at 0600 but no size caps | `src/store/branch.ts` | Entry/size caps | diff --git a/.agents/artifacts/gauntlet-wave27/ACCEPTANCE_PACKET.md b/.agents/artifacts/gauntlet-wave27/ACCEPTANCE_PACKET.md new file mode 100644 index 0000000..ca22ecb --- /dev/null +++ b/.agents/artifacts/gauntlet-wave27/ACCEPTANCE_PACKET.md @@ -0,0 +1,7 @@ +# ACCEPTANCE PACKET — Wave 27 hardening + +**Status: CAPPED.** Code review and muse-security both rejected the first cut; blockers were fixed and re-gated. No second critic round. + +Receipts: 1521/0 tests; tsc clean; build 434 modules; secret scan clean. Crypto: GCM AAD + key id, Ed25519 domain-separated signatures, strict tenant/branch names, 0600/0700 files, overlay merge through the three-way resolver with audit. + +Residual: `TECHNICAL_DEBT-wave27.md` (chain truncation/TOFU, key rotation, semantic merge poisoning, scope:client not wired, overlay GC, branch identity from store.dir). diff --git a/.agents/artifacts/gauntlet-wave27/ITERATION_LEDGER.md b/.agents/artifacts/gauntlet-wave27/ITERATION_LEDGER.md new file mode 100644 index 0000000..3efdb1f --- /dev/null +++ b/.agents/artifacts/gauntlet-wave27/ITERATION_LEDGER.md @@ -0,0 +1,36 @@ +# ITERATION LEDGER — Wave 27 (2026-11-09) + +## Critics + +- **code-review audit / gauntlet**: REJECT, 4.5/10 (Correctness 4, Diff 6, Edge 4, Architecture 4). +- **muse-security audit**: 8 High, 9 Medium, 0 Critical; headline claims overstated. + +## Fixed in this round + +| Finding | Fix | +|---|---| +| Enclave `safeName` collisions | Strict `[A-Za-z0-9._-]` validation; reject instead of rewrite | +| Envelope metadata not authenticated | GCM AAD over version/key_id/client/division; request metadata asserted | +| Master-key TOCTOU | `wx` creation + EEXIST re-read; 64-hex env only; `key_id` in envelope/AAD | +| Enclave path traversal (`..`) | Name validation + containment by construction | +| Enclave permissions | 0700 dirs, 0600 envelopes; key 0600 | +| Branch name collisions | Reversible base64url filenames; `meta.branch` identity check | +| Overlay path collision with memory versioning | Moved to `.memory/overlays/` | +| Overlay merge lost updates | Captured `base_entries`; merge now calls `mergeMemories3Way` (T-439 wiring) | +| Overlay merge non-atomic / unaudited | Whole-batch validation + `withStoreLock` + per-entry `overlay_merge` audit + `archiveMemory` for deletions | +| Overlay CLI had no write path | `overlay set` / `overlay rm` + audit | +| Detached HEAD treated as branch "HEAD" | `git symbolic-ref`; null on detached, requires `--branch` | +| `overlay list` ignored `--branch` | Uses resolved branch | +| Attest keygen unsafe default/overwrite | Defaults to `~/.config/musememory/keys` (0700), refuses overwrite unless `--force`, audit event | +| Canonical JSON collisions | Rejects unsafe numbers, non-plain objects; omits undefined keys; domain separator | +| Chain semantics | Empty chain rejected; version/algorithm signed and checked; `verify-chain` CLI | +| merge3 fail-open/manual contract | Exit 1 on conflicts unless `--allow-conflicts`; TTY guard; stderr prompts; 0600 output; secret scan | +| Enclave CLI type/traversal/content | Enum validation, name validation, `--file`/stdin | + +## Gate + +- `bun test` 1521 pass / 0 fail (240 files) +- `tsc --noEmit` clean; build 434 modules; secret scan clean +- New regression tests: name collisions, AAD relabel, empty/tampered chains, overlay 3-way conflict, CLI fail-closed, overlay write surface. + +Status: **CAPPED** — one critic round; residual items in `TECHNICAL_DEBT-wave27.md`. diff --git a/.agents/context/current.md b/.agents/context/current.md index 7a7b638..443ab4c 100644 --- a/.agents/context/current.md +++ b/.agents/context/current.md @@ -180,6 +180,7 @@ on any blocking failure, so CI runs it. Report: --- ## 🏁 Recently Landed Waves (Verified on `dev`) +- [x] **Wave 27 hardening** (2026-11-09) — audit-REJECT fixes: strict names, GCM AAD/key id, audited overlay merge via T-439, fail-closed merge3, corrected claims. Gate 1521/0; CAPPED. - [x] **Wave 27 (`T-436`–`T-439`)** (2026-11-09) — branch overlays (#377), encrypted enclaves (#378), packet attestations (#379), three-way merge (#380). Tests 1514/0. - [x] **Sensitive-read audit + project identity** (2026-11-09) — `read` audit events for `scope: "all"`/untrusted; configured project via env/config/`memory project`. - [x] **Code-context security boundary** (2026-11-09) — project-scoped by default, untrusted opt-in, read-time redaction/sanitization, bounded inputs; `muse_memory_for_code` accepts project/include_untrusted. diff --git a/.agents/context/ticket-plan.md b/.agents/context/ticket-plan.md index fbb9fa2..565f7f7 100644 --- a/.agents/context/ticket-plan.md +++ b/.agents/context/ticket-plan.md @@ -618,9 +618,9 @@ Source: Strategic roadmaps, developer experience assets, R17–R20 tri-cognitive - [x] T-435 · LANDED 2026-11-09 — PR #372: Anchor-Matched Invariant Auto-Recall: Bind exported symbols and route interfaces directly to governing ADRs and architectural invariants · src/retrieval/anchors.ts · F ## Wave 27: R19 Git-Branchable Memories & Multi-Tenant Agency Enclaves -- [x] T-436 · LANDED 2026-11-09 — PR #377: Branchable Memory Overlays (`memory overlay` / `merge3`): Branch-isolated memory layers tracking git feature branch lifecycles · src/store/branch.ts · F -- [x] T-437 · LANDED 2026-11-09 — PR #378: Multi-Tenant Agency Enclaves (`scope: client`): Cryptographically segregated client vaults across Council divisions (Sol, Jasper, Crew, Nexus) · src/store/enclaves.ts · F -- [x] T-438 · LANDED 2026-11-09 — PR #379: Zero-Knowledge Memory Attestation: Cryptographic SHA-256 signing of SyncPackets with tamper-evident audit chains for client handoffs · src/sync/attestation.ts · F +- [x] T-436 · LANDED 2026-11-09 — PR #377: Branchable Memory Overlays (`memory overlay` with `set`/`rm`; merge via T-439): Branch-isolated memory layers tracking git feature branch lifecycles · src/store/branch.ts · F +- [x] T-437 · LANDED 2026-11-09 — PR #378: Multi-Tenant Agency Enclaves: authenticated encrypted client vaults (store-key, at-rest); `scope: client` deferred · src/store/enclaves.ts · F +- [x] T-438 · LANDED 2026-11-09 — PR #379: Signed Packet Attestation: detached Ed25519 over canonical packet hashes with tamper-evident audit chains for client handoffs · src/sync/attestation.ts · F - [x] T-439 · LANDED 2026-11-09 — PR #380: Semantic Contradiction Merge Resolver: Interactive 3-way semantic conflict resolution on branch merge into `main` · src/quality/branch_merge.ts · F ## Wave 28: R20 Test-Driven Reinforcement & Regression Sentry diff --git a/CHANGELOG.md b/CHANGELOG.md index e640b80..bdc2921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Wave 27 multi-tenant memory**: branch-scoped overlays (`memory overlay`), AES-256-GCM client enclaves, Ed25519 packet attestations with tamper-evident chains, and a three-way semantic memory merge (`merge3`). +- **Wave 27 multi-tenant memory**: branch-scoped overlays (`memory overlay`, three-way merge with base snapshots), authenticated AES-256-GCM client vaults, domain-separated Ed25519 packet attestations, and a three-way semantic memory merge (`merge3`). - **Unified file→memory resolver**: pre-flight anchored invariants and PR context now share one project/trust/freshness-aware matcher, ranking and reason tokens. - **Pre-flight surfaces**: a matcher-scoped `PreToolUse` hook and read-only `muse_preflight` MCP tool expose anchored invariants, blast-radius risk and a token-budgeted pack before edits. - **Wave 26 associative retrieval**: recursive-CTE associative expansion (`memory recall --associative`), speculative pre-flight context (`memory preflight`) with blast-radius risk scoring, and anchor-matched invariant auto-recall. diff --git a/claims.json b/claims.json index 8b5f230..4e032b4 100644 --- a/claims.json +++ b/claims.json @@ -14,8 +14,8 @@ "networkMillis": 300 }, "tests": { - "count": 1514, - "assertions": 7245, - "files": 239 + "count": 1521, + "assertions": 7273, + "files": 240 } } diff --git a/src/cli/attest.ts b/src/cli/attest.ts index f84b3f3..4405a6a 100644 --- a/src/cli/attest.ts +++ b/src/cli/attest.ts @@ -1,8 +1,10 @@ -import { chmodSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { requireRoot, usageError } from "./shared.ts"; import type { ParsedArgs } from "./shared.ts"; -import { generateAttestationKeypair, signPacket, verifyAttestation, type Attestation } from "../sync/attestation.ts"; +import { generateAttestationKeypair, signPacket, verifyAttestation, verifyAttestationChain, type Attestation } from "../sync/attestation.ts"; +import { recordAuditEvent } from "../audit.ts"; export async function handleAttestCommand({ positional, flags }: ParsedArgs): Promise { const ctx = requireRoot(flags); @@ -11,12 +13,24 @@ export async function handleAttestCommand({ positional, flags }: ParsedArgs): Pr switch (sub) { case "keygen": { - const out = flags["out"] ?? ctx.memoryDir; + const out = flags["out"] ?? join(homedir(), ".config", "musememory", "keys"); + mkdirSync(out, { recursive: true, mode: 0o700 }); + const keyPath = join(out, "attestation.key"); + if (existsSync(keyPath) && flags["force"] === undefined) { + return usageError(`${keyPath} exists; pass --force to rotate (old attestations will not verify)`); + } const pair = generateAttestationKeypair(); - writeFileSync(join(out, "attestation.key"), pair.privateKey, { encoding: "utf8", mode: 0o600 }); + writeFileSync(keyPath, pair.privateKey, { encoding: "utf8", mode: 0o600, flag: "w" }); writeFileSync(join(out, "attestation.pub"), pair.publicKey, { encoding: "utf8", mode: 0o644 }); - try { chmodSync(join(out, "attestation.key"), 0o600); } catch {} - console.log(`wrote ${join(out, "attestation.key")} and attestation.pub`); + try { chmodSync(keyPath, 0o600); } catch {} + recordAuditEvent(ctx.memoryDir, { + operation: "attest_keygen", + entry_id: "attestation", + actor: "cli", + reason: "attestation_keygen", + details: { path: keyPath, rotated: flags["force"] !== undefined }, + }); + console.log(`wrote ${keyPath} and attestation.pub`); return 0; } case "sign": { @@ -25,6 +39,13 @@ export async function handleAttestCommand({ positional, flags }: ParsedArgs): Pr if (!packetPath || !keyPath) return usageError("attest sign --key "); const packet = JSON.parse(readFileSync(packetPath, "utf8")); const attestation = signPacket(packet, readFileSync(keyPath, "utf8"), { signer: flags["signer"] }); + recordAuditEvent(ctx.memoryDir, { + operation: "attest_sign", + entry_id: "attestation", + actor: "cli", + reason: "packet_signed", + details: { packet_hash: attestation.packet_hash, signer: attestation.signer }, + }); console.log(JSON.stringify(attestation, null, 2)); return 0; } @@ -39,7 +60,16 @@ export async function handleAttestCommand({ positional, flags }: ParsedArgs): Pr console.log(ok ? "valid" : "invalid"); return ok ? 0 : 1; } + case "verify-chain": { + const chainPath = positional[1]; + const pubPath = flags["pub"]; + if (!chainPath || !pubPath) return usageError("attest verify-chain --pub "); + const chain = JSON.parse(readFileSync(chainPath, "utf8")) as Attestation[]; + const ok = verifyAttestationChain(chain, readFileSync(pubPath, "utf8")); + console.log(ok ? "valid" : "invalid"); + return ok ? 0 : 1; + } default: - return usageError(`unknown attest subcommand '${sub}' (expected keygen | sign | verify)`); + return usageError(`unknown attest subcommand '${sub}' (expected keygen | sign | verify | verify-chain)`); } } diff --git a/src/cli/branch.ts b/src/cli/branch.ts index bb40032..376d806 100644 --- a/src/cli/branch.ts +++ b/src/cli/branch.ts @@ -7,7 +7,10 @@ import { listBranchMemories, loadBranchOverlay, mergeBranchOverlay, + writeBranchEntry, + deleteBranchEntry, } from "../store/branch.ts"; +import { get } from "../store.ts"; export async function handleOverlayCommand({ positional, flags }: ParsedArgs): Promise { const ctx = requireRoot(flags); @@ -16,12 +19,14 @@ export async function handleOverlayCommand({ positional, flags }: ParsedArgs): P const branch = positional[1] ?? flags["branch"] ?? currentGitBranch(ctx.root) ?? undefined; if (!branch && sub !== "list") return usageError("branch requires a branch name or a git checkout"); + try { switch (sub) { case "list": { - const branchName = currentGitBranch(ctx.root); + const branchName = branch ?? currentGitBranch(ctx.root); const overlay = branchName ? loadBranchOverlay(ctx.memoryDir, branchName) : null; console.log(branchName ?? "(detached)"); if (overlay) console.log(`overlay: ${overlay.entries.length} entry(ies), ${overlay.deleted_ids.length} deletion(s)`); + else console.log("overlay: none"); return 0; } case "create": { @@ -36,9 +41,40 @@ export async function handleOverlayCommand({ positional, flags }: ParsedArgs): P for (const entry of overlay.entries) console.log(` ${entry.id} ${entry.status} ${entry.title}`); return 0; } + case "set": { + const id = positional[2]; + const content = flags["content"]; + if (!id || !content) return usageError("overlay set --content "); + const base = get(ctx.store, id); + const overlay = loadBranchOverlay(ctx.memoryDir, branch!); + const existing = overlay?.entries.find((entry) => entry.id === id) ?? base; + if (!existing) return usageError(`no memory '${id}'`); + writeBranchEntry(ctx.store, branch!, { + ...existing, + content, + title: flags["title"] ?? existing.title, + status: (flags["status"] as typeof existing.status) ?? existing.status, + updated_at: new Date().toISOString(), + }); + console.log(`overlay ${branch}: set ${id}`); + return 0; + } + case "rm": { + const id = positional[2]; + if (!id) return usageError("overlay rm "); + deleteBranchEntry(ctx.store, branch!, id); + console.log(`overlay ${branch}: removed ${id}`); + return 0; + } case "merge": { - const report = mergeBranchOverlay(ctx.store, branch!); - console.log(`merged ${report.branch}: ${report.applied.length} applied, ${report.deleted.length} archived`); + const strategy = (flags["strategy"] ?? "semantic") as "ours" | "theirs" | "semantic"; + const report = mergeBranchOverlay(ctx.store, branch!, { strategy }); + if (report.conflicts.length > 0) { + console.error(`merge blocked: ${report.conflicts.length} conflict(s)`); + for (const conflict of report.conflicts) console.error(` ${conflict.id} (${conflict.reason})`); + return 1; + } + console.log(`merged ${report.branch}: ${report.applied.length} applied, ${report.archived.length} archived`); return 0; } case "drop": { @@ -51,6 +87,9 @@ export async function handleOverlayCommand({ positional, flags }: ParsedArgs): P return 0; } default: - return usageError(`unknown overlay subcommand '${sub}' (expected list | create | show | entries | merge | drop)`); + return usageError(`unknown overlay subcommand '${sub}' (expected list | create | show | entries | set | rm | merge | drop)`); + } + } catch (err) { + return usageError(err instanceof Error ? err.message : String(err)); } } diff --git a/src/cli/enclave.ts b/src/cli/enclave.ts index 76ab5f4..9e2baa0 100644 --- a/src/cli/enclave.ts +++ b/src/cli/enclave.ts @@ -1,17 +1,30 @@ import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; import { requireRoot, usageError } from "./shared.ts"; import type { ParsedArgs } from "./shared.ts"; -import { createEnclave, listEnclaveEntries, putEnclaveEntry } from "../store/enclaves.ts"; +import { createEnclave, listEnclaveEntries, putEnclaveEntry, validateEnclaveName } from "../store/enclaves.ts"; +import { recordAuditEvent } from "../audit.ts"; import type { MemoryEntry, MemoryType } from "../types.ts"; +const MEMORY_TYPES: MemoryType[] = [ + "session", "decision", "fix", "failure", "architecture", "operation", + "constraint", "preference", "discovery", "negative", "adr", +]; + export async function handleEnclaveCommand({ positional, flags }: ParsedArgs): Promise { const ctx = requireRoot(flags); if (!ctx) return 1; const sub = positional[0]; const client = positional[1]; if (!sub || !client) return usageError("enclave requires "); - const division = flags["division"] ?? "default"; + let division: string; + try { + division = validateEnclaveName(flags["division"] ?? "default", "division"); + } catch (err) { + return usageError(err instanceof Error ? err.message : String(err)); + } + try { switch (sub) { case "create": createEnclave(ctx.memoryDir, client, division); @@ -19,13 +32,25 @@ export async function handleEnclaveCommand({ positional, flags }: ParsedArgs): P return 0; case "list": { const entries = listEnclaveEntries(ctx.memoryDir, client, division); + recordAuditEvent(ctx.memoryDir, { + operation: "enclave_read", + entry_id: "enclave", + actor: "cli", + reason: "enclave_list", + details: { client, division, count: entries.length }, + }); console.log(`${client}/${division}: ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`); for (const entry of entries) console.log(` ${entry.id} ${entry.type} ${entry.title}`); return 0; } case "put": { - const content = positional[2]; - if (!content) return usageError("enclave put requires "); + const file = flags["file"]; + let content = positional[2]; + if (file) content = readFileSync(file, "utf8"); + else if (content === "-") content = readFileSync(0, "utf8"); + if (!content) return usageError("enclave put requires , --file or '-' for stdin"); + const type = (flags["type"] ?? "discovery") as MemoryType; + if (!MEMORY_TYPES.includes(type)) return usageError(`invalid --type '${type}'`); const id = `m_${Date.now()}_${randomUUID().slice(0, 8)}`; const now = new Date().toISOString(); const entry: MemoryEntry = { @@ -33,17 +58,29 @@ export async function handleEnclaveCommand({ positional, flags }: ParsedArgs): P title: content.slice(0, 80), content, project: client, - type: (flags["type"] as MemoryType) ?? "discovery", + source: "enclave", + type, status: "confirmed", created_at: now, updated_at: now, tags: [], } as MemoryEntry; putEnclaveEntry(ctx.memoryDir, client, entry, division); + recordAuditEvent(ctx.memoryDir, { + operation: "enclave_put", + entry_id: id, + project: client, + actor: "cli", + reason: "enclave_put", + details: { client, division, type }, + }); console.log(`stored ${id} in ${client}/${division}`); return 0; } default: return usageError(`unknown enclave subcommand '${sub}' (expected create | list | put)`); } + } catch (err) { + return usageError(err instanceof Error ? err.message : String(err)); + } } diff --git a/src/cli/merge3.ts b/src/cli/merge3.ts index 292fd8e..0b55df0 100644 --- a/src/cli/merge3.ts +++ b/src/cli/merge3.ts @@ -1,53 +1,73 @@ import { readFileSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline/promises"; -import { stdin, stdout } from "node:process"; +import { stdin, stderr } from "node:process"; import { requireRoot, usageError } from "./shared.ts"; import type { ParsedArgs } from "./shared.ts"; -import { mergeMemories3Way, type MergeConflict, type MergeDecision, type MergeStrategy } from "../quality/branch_merge.ts"; +import { mergeMemories3Way, type MergeConflict, type MergeStrategy } from "../quality/branch_merge.ts"; +import { scanSecrets } from "../secrets.ts"; import type { MemoryEntry } from "../types.ts"; function readEntries(path: string): MemoryEntry[] { - const parsed = JSON.parse(readFileSync(path, "utf8")); - return Array.isArray(parsed) ? (parsed as MemoryEntry[]) : ((parsed.entries ?? []) as MemoryEntry[]); + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const entries = Array.isArray(parsed) ? parsed : (parsed as { entries?: unknown })?.entries; + if (!Array.isArray(entries)) throw new Error(`${path} is not a memory snapshot`); + for (const entry of entries) { + if (!entry || typeof entry !== "object" || typeof (entry as MemoryEntry).id !== "string" || typeof (entry as MemoryEntry).content !== "string") { + throw new Error(`${path} contains an invalid memory entry`); + } + } + return entries as MemoryEntry[]; } export async function handleMerge3Command({ positional, flags }: ParsedArgs): Promise { const ctx = requireRoot(flags); if (!ctx) return 1; - const [basePath, oursPath, theirsPath] = positional; - if (!basePath || !oursPath || !theirsPath) { - return usageError("merge3 requires [--strategy ours|theirs|semantic|manual]"); - } - const strategy = (flags["strategy"] ?? "semantic") as MergeStrategy; - if (!["ours", "theirs", "semantic", "manual"].includes(strategy)) { - return usageError("--strategy must be ours, theirs, semantic or manual"); - } + try { + const [basePath, oursPath, theirsPath] = positional; + if (!basePath || !oursPath || !theirsPath) { + return usageError("merge3 requires [--strategy ours|theirs|semantic|manual]"); + } + const strategy = (flags["strategy"] ?? "semantic") as MergeStrategy; + if (!["ours", "theirs", "semantic", "manual"].includes(strategy)) { + return usageError("--strategy must be ours, theirs, semantic or manual"); + } - const rl = strategy === "manual" ? createInterface({ input: stdin, output: stdout }) : null; - const decide = async (conflict: MergeConflict): Promise => { - if (!rl) return undefined; - const answer = (await rl.question(`conflict ${conflict.id} (${conflict.reason}) [ours/theirs/skip]: `)).trim(); - return answer === "ours" || answer === "theirs" ? answer : undefined; - }; + const result = mergeMemories3Way(readEntries(basePath), readEntries(oursPath), readEntries(theirsPath), { strategy }); + let conflicts = result.conflicts; - let result = mergeMemories3Way(readEntries(basePath), readEntries(oursPath), readEntries(theirsPath), { strategy }); - if (strategy === "manual" && rl) { - const resolved = []; - for (const conflict of result.conflicts) { - const picked = await decide(conflict); - if (picked === "ours" && conflict.ours) resolved.push(conflict.ours); - else if (picked === "theirs" && conflict.theirs) resolved.push(conflict.theirs); + if (strategy === "manual" && conflicts.length > 0) { + if (!stdin.isTTY) return usageError("--strategy manual requires an interactive terminal"); + const rl = createInterface({ input: stdin, output: stderr }); + try { + for (const conflict of conflicts) { + const choices = [conflict.ours ? "ours" : null, conflict.theirs ? "theirs" : null].filter(Boolean).join("/"); + const answer = (await rl.question(`conflict ${conflict.id} (${conflict.reason}) [${choices}/skip]: `)).trim(); + if (answer === "ours" && conflict.ours) { + result.merged.push(conflict.ours); + result.resolutions[conflict.id] = "ours"; + } else if (answer === "theirs" && conflict.theirs) { + result.merged.push(conflict.theirs); + result.resolutions[conflict.id] = "theirs"; + } + } + } finally { + rl.close(); + } + conflicts = conflicts.filter((conflict) => !(conflict.id in result.resolutions)); } - result = { ...result, merged: [...result.merged, ...resolved] }; - rl.close(); - } - const output = { merged: result.merged, conflicts: result.conflicts, resolutions: result.resolutions }; - if (flags["out"]) { - writeFileSync(flags["out"], JSON.stringify(output, null, 2) + "\n", "utf8"); - console.log(`wrote ${result.merged.length} merged, ${result.conflicts.length} conflict(s) to ${flags["out"]}`); - } else { - console.log(JSON.stringify(output, null, 2)); + const output = { merged: result.merged, conflicts, resolutions: result.resolutions }; + const serialized = JSON.stringify(output, null, 2) + "\n"; + const leaked = scanSecrets(serialized); + if (leaked.length > 0) return usageError(`refusing to emit a credential: ${leaked[0]}`); + if (flags["out"]) { + writeFileSync(flags["out"], serialized, { encoding: "utf8", mode: 0o600 }); + console.log(`wrote ${result.merged.length} merged, ${conflicts.length} conflict(s) to ${flags["out"]}`); + } else { + console.log(serialized); + } + return conflicts.length > 0 && flags["allow-conflicts"] === undefined ? 1 : 0; + } catch (err) { + return usageError(err instanceof Error ? err.message : String(err)); } - return 0; } diff --git a/src/store/branch.ts b/src/store/branch.ts index 6c5c139..e318fa0 100644 --- a/src/store/branch.ts +++ b/src/store/branch.ts @@ -4,6 +4,12 @@ import { execFileSync } from "node:child_process"; import type { Store } from "../store.ts"; import { list, get, save } from "../store.ts"; import type { MemoryEntry } from "../types.ts"; +import { validateEntry } from "../schema.ts"; +import { scanSecrets } from "../secrets.ts"; +import { withStoreLock } from "../lock.ts"; +import { recordAuditEvent } from "../audit.ts"; +import { archiveMemory } from "../promotion/archival.ts"; +import { mergeMemories3Way, type MergeStrategy, type MergeConflict } from "../quality/branch_merge.ts"; export interface BranchOverlayMeta { branch: string; @@ -14,28 +20,54 @@ export interface BranchOverlayMeta { export interface BranchOverlay { meta: BranchOverlayMeta; + /** Base revisions captured at creation, for three-way merge. */ + base_entries: MemoryEntry[]; entries: MemoryEntry[]; deleted_ids: string[]; } -function safeName(branch: string): string { - return branch.replace(/[^A-Za-z0-9._-]/g, "_"); +const MAX_BRANCH_LENGTH = 200; + +function validateBranch(branch: string): string { + if (!branch.trim() || branch.length > MAX_BRANCH_LENGTH || /[\u0000-\u001f]/.test(branch)) { + throw new Error(`Invalid branch name '${branch}'`); + } + return branch; +} + +function encodedBranch(branch: string): string { + return Buffer.from(validateBranch(branch), "utf8").toString("base64url"); } export function branchOverlayPath(memoryDir: string, branch: string): string { - return join(memoryDir, "branches", `${safeName(branch)}.json`); + return join(memoryDir, "overlays", `${encodedBranch(branch)}.json`); } export function currentGitBranch(cwd: string = process.cwd()): string | null { try { - return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + const symbolic = execFileSync("git", ["symbolic-ref", "--short", "-q", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }).trim() || null; + }).trim(); + if (symbolic) return symbolic; } catch { return null; } + return null; +} + +function assertValidEntries(entries: unknown, label: string): MemoryEntry[] { + if (!Array.isArray(entries)) throw new Error(`${label} is not an array`); + const validated: MemoryEntry[] = []; + for (const entry of entries) { + const result = validateEntry(entry); + if (!result.valid) throw new Error(`${label} contains an invalid memory: ${result.errors?.join("; ")}`); + const secrets = scanSecrets(`${(entry as MemoryEntry).title}\n${(entry as MemoryEntry).content}`); + if (secrets.length > 0) throw new Error(`${label} contains a credential: ${secrets[0]}`); + validated.push(entry as MemoryEntry); + } + return validated; } export function loadBranchOverlay(memoryDir: string, branch: string): BranchOverlay | null { @@ -45,24 +77,29 @@ export function loadBranchOverlay(memoryDir: string, branch: string): BranchOver if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) { throw new Error(`Invalid branch overlay at ${path}`); } + if (parsed.meta?.branch !== branch) { + throw new Error(`Overlay identity mismatch at ${path}: expected '${branch}', found '${parsed.meta?.branch}'`); + } return { meta: { - branch: parsed.meta?.branch ?? branch, + branch, base_branch: parsed.meta?.base_branch, created_at: parsed.meta?.created_at ?? new Date().toISOString(), updated_at: parsed.meta?.updated_at ?? new Date().toISOString(), }, + base_entries: Array.isArray(parsed.base_entries) ? (parsed.base_entries as MemoryEntry[]) : [], entries: parsed.entries as MemoryEntry[], - deleted_ids: Array.isArray(parsed.deleted_ids) ? parsed.deleted_ids : [], + deleted_ids: Array.isArray(parsed.deleted_ids) ? parsed.deleted_ids.map(String) : [], }; } function writeOverlay(memoryDir: string, overlay: BranchOverlay): void { - const dir = join(memoryDir, "branches"); - mkdirSync(dir, { recursive: true }); + assertValidEntries(overlay.entries, "branch overlay"); + const dir = join(memoryDir, "overlays"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); const path = branchOverlayPath(memoryDir, overlay.meta.branch); const tmp = `${path}.${process.pid}.tmp`; - writeFileSync(tmp, JSON.stringify(overlay, null, 2) + "\n", "utf8"); + writeFileSync(tmp, JSON.stringify(overlay, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); renameSync(tmp, path); } @@ -71,16 +108,19 @@ export function createBranchOverlay( options: { branch?: string; baseBranch?: string } = {}, ): BranchOverlay { const branch = options.branch ?? currentGitBranch(store.dir); - if (!branch) throw new Error("Cannot determine branch: pass --branch"); - const existing = loadBranchOverlay(store.memoryDir ?? store.dir, branch); + if (!branch) throw new Error("Cannot determine branch: pass --branch (detached HEAD)"); + validateBranch(branch); + const memoryDir = store.memoryDir ?? store.dir; + const existing = loadBranchOverlay(memoryDir, branch); if (existing) return existing; const now = new Date().toISOString(); const overlay: BranchOverlay = { meta: { branch, base_branch: options.baseBranch, created_at: now, updated_at: now }, + base_entries: list(store), entries: [], deleted_ids: [], }; - writeOverlay(store.memoryDir ?? store.dir, overlay); + writeOverlay(memoryDir, overlay); return overlay; } @@ -97,11 +137,20 @@ export function listBranchMemories(store: Store, branch: string): MemoryEntry[] export function writeBranchEntry(store: Store, branch: string, entry: MemoryEntry): BranchOverlay { const memoryDir = store.memoryDir ?? store.dir; const overlay = loadBranchOverlay(memoryDir, branch) ?? createBranchOverlay(store, { branch }); + const validated = assertValidEntries([entry], "branch overlay entry")[0]; overlay.entries = overlay.entries.filter((candidate) => candidate.id !== entry.id); - overlay.entries.push(entry); + overlay.entries.push(validated); overlay.deleted_ids = overlay.deleted_ids.filter((id) => id !== entry.id); overlay.meta.updated_at = new Date().toISOString(); writeOverlay(memoryDir, overlay); + recordAuditEvent(memoryDir, { + operation: "overlay_set", + entry_id: entry.id, + project: entry.project, + actor: `overlay:${branch}`, + reason: "branch_overlay_write", + details: { branch }, + }); return overlay; } @@ -112,38 +161,98 @@ export function deleteBranchEntry(store: Store, branch: string, id: string): Bra if (!overlay.deleted_ids.includes(id)) overlay.deleted_ids.push(id); overlay.meta.updated_at = new Date().toISOString(); writeOverlay(memoryDir, overlay); + recordAuditEvent(memoryDir, { + operation: "overlay_set", + entry_id: id, + actor: `overlay:${branch}`, + reason: "branch_overlay_delete", + details: { branch, deleted: true }, + }); return overlay; } +function sameMergeFields(a: MemoryEntry, b: MemoryEntry): boolean { + const fields = (entry: MemoryEntry) => JSON.stringify({ + title: entry.title, + content: entry.content, + status: entry.status, + type: entry.type, + tags: entry.tags ?? [], + anchors: entry.anchors ?? [], + valid_from: entry.valid_from, + valid_to: entry.valid_to, + }); + return fields(a) === fields(b); +} + export interface MergeReport { branch: string; applied: string[]; - deleted: string[]; + archived: string[]; + conflicts: MergeConflict[]; } -/** Apply an overlay to the base store and clear it. */ -export function mergeBranchOverlay(store: Store, branch: string): MergeReport { +/** Apply a branch overlay to the base store through the T-439 three-way resolver. */ +export function mergeBranchOverlay( + store: Store, + branch: string, + options: { strategy?: MergeStrategy } = {}, +): MergeReport { const memoryDir = store.memoryDir ?? store.dir; const overlay = loadBranchOverlay(memoryDir, branch); - if (!overlay) return { branch, applied: [], deleted: [] }; - const applied: string[] = []; - for (const entry of overlay.entries) { - const current = get(store, entry.id); - save(store, current ? { ...current, ...entry } : entry); - applied.push(entry.id); - } - for (const id of overlay.deleted_ids) { - const current = get(store, id); - if (current) save(store, { ...current, status: "archived" }); + if (!overlay) return { branch, applied: [], archived: [], conflicts: [] }; + + const base = overlay.base_entries.length > 0 ? overlay.base_entries : list(store); + const ours = listBranchMemories(store, branch); + const theirs = list(store); + const result = mergeMemories3Way(base, ours, theirs, { strategy: options.strategy ?? "semantic" }); + if (result.conflicts.length > 0) { + return { branch, applied: [], archived: [], conflicts: result.conflicts }; } + + const mergedIds = new Set(result.merged.map((entry) => entry.id)); + const archiveIds = theirs.filter((entry) => !mergedIds.has(entry.id)).map((entry) => entry.id); + assertValidEntries(result.merged, "merged overlay entries"); + + const applied: string[] = []; + const archived: string[] = []; + withStoreLock(memoryDir, () => { + for (const entry of result.merged) { + const current = get(store, entry.id); + if (current && sameMergeFields(current, entry)) continue; + save(store, entry); + applied.push(entry.id); + recordAuditEvent(memoryDir, { + operation: "overlay_merge", + entry_id: entry.id, + project: entry.project, + actor: `overlay:${branch}`, + reason: "branch_overlay_merge", + details: { strategy: options.strategy ?? "semantic", branch }, + }); + } + for (const id of archiveIds) { + if (archiveMemory(store, id, "archived", `overlay merge: ${branch}`, `overlay:${branch}`)) { + archived.push(id); + } + } + }); + const path = branchOverlayPath(memoryDir, branch); - if (existsSync(path)) renameSync(path, `${path}.merged`); - return { branch, applied, deleted: overlay.deleted_ids }; + if (existsSync(path)) renameSync(path, `${path}.merged.${Date.now()}`); + return { branch, applied, archived, conflicts: [] }; } export function dropBranchOverlay(store: Store, branch: string): boolean { const path = branchOverlayPath(store.memoryDir ?? store.dir, branch); if (!existsSync(path)) return false; - renameSync(path, `${path}.dropped`); + recordAuditEvent(store.memoryDir ?? store.dir, { + operation: "overlay_drop", + entry_id: "overlay", + actor: "cli", + reason: "branch_overlay_drop", + details: { branch }, + }); + renameSync(path, `${path}.dropped.${Date.now()}`); return true; } diff --git a/src/store/enclaves.ts b/src/store/enclaves.ts index 406b91b..26f3104 100644 --- a/src/store/enclaves.ts +++ b/src/store/enclaves.ts @@ -4,9 +4,11 @@ import { join } from "node:path"; import type { MemoryEntry } from "../types.ts"; const VERSION = 1; +const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export interface EnclaveEnvelope { version: number; + key_id: string; client: string; division: string; salt: string; @@ -15,19 +17,27 @@ export interface EnclaveEnvelope { ciphertext: string; } -function safeName(value: string): string { - return value.replace(/[^A-Za-z0-9._-]/g, "_"); +/** Validate a tenant component instead of lossy-rewriting it. */ +export function validateEnclaveName(value: string, label: string): string { + if (!NAME_PATTERN.test(value) || value === "." || value === "..") { + throw new Error(`Invalid ${label} '${value}': use [A-Za-z0-9._-], 1-64 chars`); + } + return value; } export function enclavePath(memoryDir: string, client: string, division: string = "default"): string { - return join(memoryDir, "enclaves", safeName(client), `${safeName(division)}.enc.json`); + validateEnclaveName(client, "client"); + validateEnclaveName(division, "division"); + return join(memoryDir, "enclaves", client, `${division}.enc.json`); } function loadMasterKey(memoryDir: string): Buffer { const env = process.env.MUSE_ENCLAVE_KEY; if (env) { - const fromEnv = /^[0-9a-fA-F]{64}$/.test(env) ? Buffer.from(env, "hex") : createHash("sha256").update(env).digest(); - return fromEnv; + if (!/^[0-9a-fA-F]{64}$/.test(env)) { + throw new Error("MUSE_ENCLAVE_KEY must be exactly 64 hex characters (raw 32-byte key)"); + } + return Buffer.from(env, "hex"); } const keyPath = join(memoryDir, "enclave.key"); if (existsSync(keyPath)) { @@ -35,9 +45,18 @@ function loadMasterKey(memoryDir: string): Buffer { if (!/^[0-9a-fA-F]{64}$/.test(stored)) throw new Error(`Invalid enclave key at ${keyPath}`); return Buffer.from(stored, "hex"); } - mkdirSync(memoryDir, { recursive: true }); + mkdirSync(memoryDir, { recursive: true, mode: 0o700 }); const key = randomBytes(32); - writeFileSync(keyPath, key.toString("hex") + "\n", { encoding: "utf8", mode: 0o600 }); + try { + writeFileSync(keyPath, key.toString("hex") + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") { + const stored = readFileSync(keyPath, "utf8").trim(); + if (!/^[0-9a-fA-F]{64}$/.test(stored)) throw new Error(`Invalid enclave key at ${keyPath}`); + return Buffer.from(stored, "hex"); + } + throw err; + } try { chmodSync(keyPath, 0o600); } catch {} return key; } @@ -46,14 +65,26 @@ function deriveKey(master: Buffer, salt: Buffer): Buffer { return scryptSync(master, salt, 32); } +function keyId(master: Buffer): string { + return createHash("sha256").update(master).digest("hex").slice(0, 16); +} + +function aad(client: string, division: string, id: string): Buffer { + return Buffer.from(JSON.stringify({ version: VERSION, key_id: id, client, division })); +} + function encrypt(memoryDir: string, client: string, division: string, entries: MemoryEntry[]): EnclaveEnvelope { + const master = loadMasterKey(memoryDir); + const id = keyId(master); const salt = randomBytes(16); const iv = randomBytes(12); - const key = deriveKey(loadMasterKey(memoryDir), salt); + const key = deriveKey(master, salt); const cipher = createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(aad(client, division, id)); const ciphertext = Buffer.concat([cipher.update(JSON.stringify(entries), "utf8"), cipher.final()]); return { version: VERSION, + key_id: id, client, division, salt: salt.toString("base64"), @@ -63,19 +94,27 @@ function encrypt(memoryDir: string, client: string, division: string, entries: M }; } -function decrypt(memoryDir: string, envelope: EnclaveEnvelope): MemoryEntry[] { +function decrypt(memoryDir: string, client: string, division: string, envelope: EnclaveEnvelope): MemoryEntry[] { if (envelope.version !== VERSION) throw new Error(`Unsupported enclave version ${envelope.version}`); - const key = deriveKey(loadMasterKey(memoryDir), Buffer.from(envelope.salt, "base64")); + if (envelope.client !== client || envelope.division !== division) { + throw new Error("Enclave metadata does not match the requested client/division"); + } + const master = loadMasterKey(memoryDir); + const id = keyId(master); + if (envelope.key_id !== id) throw new Error("Enclave was encrypted with a different key"); + const key = deriveKey(master, Buffer.from(envelope.salt, "base64")); const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64")); + decipher.setAAD(aad(client, division, id)); decipher.setAuthTag(Buffer.from(envelope.tag, "base64")); const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]); return JSON.parse(plaintext.toString("utf8")) as MemoryEntry[]; } function writeEnvelope(path: string, envelope: EnclaveEnvelope): void { - mkdirSync(join(path, ".."), { recursive: true }); + const dir = path.slice(0, path.lastIndexOf("/")); + mkdirSync(dir, { recursive: true, mode: 0o700 }); const tmp = `${path}.${process.pid}.tmp`; - writeFileSync(tmp, JSON.stringify(envelope, null, 2) + "\n", "utf8"); + writeFileSync(tmp, JSON.stringify(envelope, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); renameSync(tmp, path); } @@ -89,7 +128,7 @@ export function listEnclaveEntries(memoryDir: string, client: string, division: const path = enclavePath(memoryDir, client, division); if (!existsSync(path)) return []; const envelope = JSON.parse(readFileSync(path, "utf8")) as EnclaveEnvelope; - return decrypt(memoryDir, envelope); + return decrypt(memoryDir, client, division, envelope); } export function putEnclaveEntry( diff --git a/src/sync/attestation.ts b/src/sync/attestation.ts index 1b9e1f7..5e8e36e 100644 --- a/src/sync/attestation.ts +++ b/src/sync/attestation.ts @@ -17,12 +17,26 @@ export interface KeyPairPem { } export function canonicalJson(value: unknown): string { - if (value === null || typeof value !== "object") return JSON.stringify(value); + if (value === null) return "null"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("canonicalJson: non-finite number"); + if (!Number.isSafeInteger(value) && Number.isInteger(value)) throw new Error("canonicalJson: unsafe integer"); + return JSON.stringify(value); + } + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value !== "object") throw new Error(`canonicalJson: unsupported ${typeof value}`); if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("canonicalJson: only plain objects are supported"); + } const record = value as Record; - return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; + const keys = Object.keys(record).filter((key) => record[key] !== undefined).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; } +const DOMAIN = "muse-attestation-v1\0"; + /** Content address of a packet; the attestation never needs the body again. */ export function hashPacket(packet: unknown): string { return createHash("sha256").update(canonicalJson(packet)).digest("hex"); @@ -46,7 +60,15 @@ export function signPacket( const prev_hash = options.previous?.chain_hash; const chain_hash = createHash("sha256").update(`${prev_hash ?? "genesis"}:${packet_hash}`).digest("hex"); const createdAt = new Date().toISOString(); - const payload = canonicalJson({ packet_hash, prev_hash, chain_hash, created_at: createdAt, signer: options.signer ?? "musememory" }); + const payload = DOMAIN + canonicalJson({ + version: 1, + algorithm: "ed25519", + packet_hash, + prev_hash, + chain_hash, + created_at: createdAt, + signer: options.signer ?? "musememory", + }); const signature = sign(null, Buffer.from(payload), privateKeyPem).toString("base64"); return { version: 1, @@ -61,7 +83,9 @@ export function signPacket( } function attestationPayload(attestation: Attestation): string { - return canonicalJson({ + return DOMAIN + canonicalJson({ + version: attestation.version, + algorithm: attestation.algorithm, packet_hash: attestation.packet_hash, prev_hash: attestation.prev_hash, chain_hash: attestation.chain_hash, @@ -86,8 +110,10 @@ export function verifyAttestation( /** Verify order and linkage of an attestation chain. */ export function verifyAttestationChain(chain: Attestation[], publicKeyPem: string): boolean { + if (!Array.isArray(chain) || chain.length === 0) return false; let previous: Attestation | undefined; for (const attestation of chain) { + if (attestation.version !== 1 || attestation.algorithm !== "ed25519") return false; if (attestation.prev_hash !== previous?.chain_hash) return false; const expected = createHash("sha256").update(`${attestation.prev_hash ?? "genesis"}:${attestation.packet_hash}`).digest("hex"); if (attestation.chain_hash !== expected) return false; diff --git a/src/types.ts b/src/types.ts index 9445e2f..56abb99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -388,6 +388,13 @@ export type AuditOperation = | "verify_refused" | "restore" | "read" + | "overlay_merge" + | "overlay_drop" + | "overlay_set" + | "enclave_put" + | "enclave_read" + | "attest_keygen" + | "attest_sign" | "conflict_detected" | "conflict_detected_dry_run" | "conflict_resolved" diff --git a/test/attestation.test.ts b/test/attestation.test.ts index 27bad6d..740bed0 100644 --- a/test/attestation.test.ts +++ b/test/attestation.test.ts @@ -31,7 +31,17 @@ describe("T-438: zero-knowledge packet attestation", () => { expect(verifyAttestationChain([second, first, third], keys.publicKey)).toBe(false); }); - test("canonical JSON is key-order independent", () => { + test("canonical JSON is key-order independent and rejects non-I-JSON", () => { expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe(canonicalJson({ a: { c: 3, d: 2 }, b: 1 })); + expect(canonicalJson({ a: undefined, b: 1 })).toBe('{"b":1}'); + expect(() => canonicalJson(new Date(0))).toThrow(); + expect(() => canonicalJson(Number.NaN)).toThrow(); + }); + + test("rejects empty chains and tampered algorithm", () => { + const keys = generateAttestationKeypair(); + expect(verifyAttestationChain([], keys.publicKey)).toBe(false); + const attestation = signPacket({ n: 1 }, keys.privateKey); + expect(verifyAttestationChain([{ ...attestation, algorithm: "rsa" as any }], keys.publicKey)).toBe(false); }); }); diff --git a/test/branch_overlays.test.ts b/test/branch_overlays.test.ts index e476496..c9aaa5e 100644 --- a/test/branch_overlays.test.ts +++ b/test/branch_overlays.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { openStore, propose, list, get } from "../src/store.ts"; +import { openStore, propose, list, get, save } from "../src/store.ts"; import { createBranchOverlay, writeBranchEntry, @@ -10,7 +10,9 @@ import { mergeBranchOverlay, dropBranchOverlay, loadBranchOverlay, + branchOverlayPath, } from "../src/store/branch.ts"; +import { getAuditTrail } from "../src/audit.ts"; describe("T-436: branchable memory overlays", () => { test("isolates branch writes from the base store and merges them back", () => { @@ -39,6 +41,45 @@ describe("T-436: branchable memory overlays", () => { } }); + test("keeps colliding branch names separate and rejects identity mismatch", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-branch-names-")); + try { + const store = openStore(memoryDir); + createBranchOverlay(store, { branch: "feat/x" }); + expect(loadBranchOverlay(memoryDir, "feat_x")).toBeNull(); + expect(branchOverlayPath(memoryDir, "feat/x")).not.toBe(branchOverlayPath(memoryDir, "feat_x")); + const path = branchOverlayPath(memoryDir, "feat/x"); + const parsed = JSON.parse(readFileSync(path, "utf8")); + parsed.meta.branch = "feat/y"; + writeFileSync(path, JSON.stringify(parsed)); + expect(() => loadBranchOverlay(memoryDir, "feat/x")).toThrow(/identity mismatch/); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); + + test("merges through the three-way resolver and surfaces conflicts", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-branch-merge3-")); + try { + const store = openStore(memoryDir); + const base = propose(store, { content: "base rule", project: "p", type: "constraint", confirmed: true }); + createBranchOverlay(store, { branch: "feat/merge" }); + writeBranchEntry(store, "feat/merge", { ...get(store, base.id)!, content: "ours" }); + save(store, { ...get(store, base.id)!, content: "theirs" }); + + const blocked = mergeBranchOverlay(store, "feat/merge", { strategy: "semantic" }); + expect(blocked.conflicts.map((c) => c.reason)).toEqual(["modify/modify"]); + expect(loadBranchOverlay(memoryDir, "feat/merge")).not.toBeNull(); + + const merged = mergeBranchOverlay(store, "feat/merge", { strategy: "ours" }); + expect(merged.applied).toContain(base.id); + expect(get(store, base.id)?.content).toBe("ours"); + expect(getAuditTrail(memoryDir, { operation: "overlay_merge" }).length).toBeGreaterThan(0); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); + test("drops an overlay without touching the base store", () => { const memoryDir = mkdtempSync(join(tmpdir(), "muse-branch-drop-")); try { diff --git a/test/cli_wave27.test.ts b/test/cli_wave27.test.ts new file mode 100644 index 0000000..80ccbbf --- /dev/null +++ b/test/cli_wave27.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleMerge3Command } from "../src/cli/merge3.ts"; +import { handleEnclaveCommand } from "../src/cli/enclave.ts"; +import { handleOverlayCommand } from "../src/cli/branch.ts"; +import { openStore, propose } from "../src/store.ts"; +import { loadBranchOverlay } from "../src/store/branch.ts"; + +function entry(id: string, content: string) { + return { id, title: id, content, project: "p", type: "discovery", status: "confirmed", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", tags: [] }; +} + +function snapshots(root: string) { + const base = join(root, "base.json"); + const ours = join(root, "ours.json"); + const theirs = join(root, "theirs.json"); + writeFileSync(base, JSON.stringify([entry("m_1_a", "base")])); + writeFileSync(ours, JSON.stringify([entry("m_1_a", "ours")])); + writeFileSync(theirs, JSON.stringify([entry("m_1_a", "theirs")])); + return [base, ours, theirs]; +} + +describe("Wave 27 CLI contracts", () => { + test("merge3 fails closed on unresolved conflicts unless allowed", async () => { + const root = mkdtempSync(join(tmpdir(), "muse-cli-merge3-")); + mkdirSync(join(root, ".memory"), { recursive: true }); + try { + const [base, ours, theirs] = snapshots(root); + const blocked = await handleMerge3Command({ positional: [base, ours, theirs], flags: { dir: root, strategy: "semantic" } } as any); + expect(blocked).toBe(1); + const allowed = await handleMerge3Command({ positional: [base, ours, theirs], flags: { dir: root, strategy: "semantic", "allow-conflicts": "true" } } as any); + expect(allowed).toBe(0); + const manual = await handleMerge3Command({ positional: [base, ours, theirs], flags: { dir: root, strategy: "manual" } } as any); + expect(manual).toBe(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("overlay set and rm give the CLI a write surface", async () => { + const root = mkdtempSync(join(tmpdir(), "muse-cli-overlay-")); + mkdirSync(join(root, ".memory"), { recursive: true }); + const store = openStore(join(root, ".memory")); + const entry = propose(store, { content: "base", project: "p", confirmed: true }); + try { + expect(await handleOverlayCommand({ positional: ["create", "feat/cli"], flags: { dir: root } } as any)).toBe(0); + expect(await handleOverlayCommand({ positional: ["set", "feat/cli", entry.id], flags: { dir: root, content: "overlay content" } } as any)).toBe(0); + expect(loadBranchOverlay(join(root, ".memory"), "feat/cli")?.entries[0]?.content).toBe("overlay content"); + expect(await handleOverlayCommand({ positional: ["rm", "feat/cli", entry.id], flags: { dir: root } } as any)).toBe(0); + expect(loadBranchOverlay(join(root, ".memory"), "feat/cli")?.entries.length).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("enclave rejects invalid types and traversal names", async () => { + const root = mkdtempSync(join(tmpdir(), "muse-cli-enclave-")); + mkdirSync(join(root, ".memory"), { recursive: true }); + try { + const badType = await handleEnclaveCommand({ positional: ["put", "acme", "content"], flags: { dir: root, type: "bogus" } } as any); + expect(badType).toBe(2); + const traversal = await handleEnclaveCommand({ positional: ["put", "..", "content"], flags: { dir: root } } as any); + expect(traversal).toBe(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/enclaves.test.ts b/test/enclaves.test.ts index 4b670f3..c9adb22 100644 --- a/test/enclaves.test.ts +++ b/test/enclaves.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createEnclave, listEnclaveEntries, putEnclaveEntry, getEnclaveEntry } from "../src/store/enclaves.ts"; +import { createEnclave, listEnclaveEntries, putEnclaveEntry, getEnclaveEntry, enclavePath, validateEnclaveName } from "../src/store/enclaves.ts"; import type { MemoryEntry } from "../src/types.ts"; function entry(id: string, content: string): MemoryEntry { @@ -31,6 +31,27 @@ describe("T-437: encrypted client enclaves", () => { } }); + test("rejects lossy names and detects vault relabeling", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-enclave-names-")); + try { + expect(() => validateEnclaveName("../acme", "client")).toThrow(); + expect(() => validateEnclaveName("acme/alpha", "client")).toThrow(); + expect(() => validateEnclaveName("acme alpha", "client")).toThrow(); + expect(() => enclavePath(memoryDir, "..")).toThrow(); + + createEnclave(memoryDir, "client-a"); + putEnclaveEntry(memoryDir, "client-a", entry("m_1_secret", "alpha only")); + const source = enclavePath(memoryDir, "client-a"); + const target = enclavePath(memoryDir, "client-b"); + const { mkdirSync } = require("node:fs") as typeof import("node:fs"); + mkdirSync(join(memoryDir, "enclaves", "client-b"), { recursive: true }); + writeFileSync(target, readFileSync(source)); + expect(() => listEnclaveEntries(memoryDir, "client-b")).toThrow(/metadata/); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); + test("tampered ciphertext fails authentication", () => { const memoryDir = mkdtempSync(join(tmpdir(), "muse-enclave-tamper-")); try {