Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/artifacts/TECHNICAL_DEBT-wave27.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

| # | 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-1 | ~~Attestation chain truncatable/TOFU~~ **done**: signed seq + head, key-id pinning, `verify-chain` requires a head | `src/sync/attestation.ts` | — |
| 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-3 | ~~Semantic merge poisoning~~ **done**: untrusted/self-asserted supersession and content inflation rejected; protected constraints never auto-resolve | `src/quality/branch_merge.ts` | — |
| 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 |
Expand Down
1 change: 1 addition & 0 deletions .agents/context/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ on any blocking failure, so CI runs it. Report:
---

## 🏁 Recently Landed Waves (Verified on `dev`)
- [x] **Attestation v2 + merge guard** (2026-11-09) — seq/head/key-id pinning, truncation rejection; semantic merge blocks untrusted lineage, inflation and protected constraints.
- [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`.
Expand Down
2 changes: 1 addition & 1 deletion .agents/context/ticket-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ Source: Strategic roadmaps, developer experience assets, R17–R20 tri-cognitive
## Wave 27: R19 Git-Branchable Memories & Multi-Tenant Agency Enclaves
- [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-438 · LANDED 2026-11-09 — PR #379: Signed Packet Attestation v2: Ed25519 over canonical hashes with seq/head/key-id 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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ Correctness release. Fixes five defects found by a cross-file invariant audit: a
- **Removed the legacy `web/` directory**: a second, hand-written copy of the site that the Astro build superseded, unreferenced and not deployed.

### Security
- **Attestation v2 and merge poisoning guard**: signed sequence + terminal head with key pinning; semantic merge refuses untrusted/self-asserted supersession, content inflation, and protected constraints.
- **The `CURRENT.md` injection channel is closed** — see *Fixed* above. This is the T-110 defence extended to the sink that was bypassing it.
- **Secret scanning is unchanged**: the Vibeguard rules still cover 8 credential families (AI API keys, GitHub and npm tokens, AWS keys, private key blocks, Slack tokens, credentialed connection strings and generic credential assignments). The published figure was wrong, not the scanner.

Expand Down
4 changes: 2 additions & 2 deletions claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"networkMillis": 300
},
"tests": {
"count": 1521,
"assertions": 7273,
"count": 1523,
"assertions": 7283,
"files": 240
}
}
29 changes: 22 additions & 7 deletions src/cli/attest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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, verifyAttestationChain, type Attestation } from "../sync/attestation.ts";
import { generateAttestationKeypair, keyIdFromPublicKey, signPacket, verifyAttestation, verifyAttestationChain, type Attestation } from "../sync/attestation.ts";
import { recordAuditEvent } from "../audit.ts";

export async function handleAttestCommand({ positional, flags }: ParsedArgs): Promise<number> {
Expand All @@ -30,15 +30,21 @@ export async function handleAttestCommand({ positional, flags }: ParsedArgs): Pr
reason: "attestation_keygen",
details: { path: keyPath, rotated: flags["force"] !== undefined },
});
console.log(`wrote ${keyPath} and attestation.pub`);
console.log(`wrote ${keyPath} and attestation.pub (key id ${keyIdFromPublicKey(pair.publicKey)})`);
return 0;
}
case "sign": {
const packetPath = positional[1];
const keyPath = flags["key"];
if (!packetPath || !keyPath) return usageError("attest sign <packet.json> --key <private.pem>");
const packet = JSON.parse(readFileSync(packetPath, "utf8"));
const attestation = signPacket(packet, readFileSync(keyPath, "utf8"), { signer: flags["signer"] });
const previous = flags["previous"] ? (JSON.parse(readFileSync(flags["previous"], "utf8")) as Attestation) : undefined;
const attestation = signPacket(packet, readFileSync(keyPath, "utf8"), {
signer: flags["signer"],
previous,
head: flags["head"] !== undefined,
audience: flags["audience"],
});
recordAuditEvent(ctx.memoryDir, {
operation: "attest_sign",
entry_id: "attestation",
Expand All @@ -56,17 +62,26 @@ export async function handleAttestCommand({ positional, flags }: ParsedArgs): Pr
if (!packetPath || !attestationPath || !pubPath) return usageError("attest verify <packet.json> <attestation.json> --pub <public.pem>");
const packet = JSON.parse(readFileSync(packetPath, "utf8"));
const attestation = JSON.parse(readFileSync(attestationPath, "utf8")) as Attestation;
const ok = verifyAttestation(packet, attestation, readFileSync(pubPath, "utf8"));
console.log(ok ? "valid" : "invalid");
const publicKey = readFileSync(pubPath, "utf8");
const ok = verifyAttestation(packet, attestation, publicKey, {
expectedKeyId: flags["key-id"] ?? keyIdFromPublicKey(publicKey),
expectedAudience: flags["audience"],
});
console.log(ok ? `valid (signer ${attestation.signer}, seq ${attestation.seq})` : "invalid");
return ok ? 0 : 1;
}
case "verify-chain": {
const chainPath = positional[1];
const pubPath = flags["pub"];
if (!chainPath || !pubPath) return usageError("attest verify-chain <chain.json> --pub <public.pem>");
const chain = JSON.parse(readFileSync(chainPath, "utf8")) as Attestation[];
const ok = verifyAttestationChain(chain, readFileSync(pubPath, "utf8"));
console.log(ok ? "valid" : "invalid");
const publicKey = readFileSync(pubPath, "utf8");
const ok = verifyAttestationChain(chain, publicKey, {
requireHead: flags["no-head"] === undefined,
expectedKeyId: flags["key-id"] ?? keyIdFromPublicKey(publicKey),
expectedAudience: flags["audience"],
});
console.log(ok ? `valid (${chain.length} record(s))` : "invalid");
return ok ? 0 : 1;
}
default:
Expand Down
42 changes: 36 additions & 6 deletions src/quality/branch_merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ function fingerprint(entry: MemoryEntry): string {
content: entry.content,
status: entry.status,
type: entry.type,
project: entry.project,
source: entry.source,
tags: entry.tags ?? [],
anchors: entry.anchors ?? [],
verification: entry.verification ?? null,
salience: entry.salience ?? null,
utility: entry.utility ?? null,
mem_path: entry.mem_path ?? null,
supersedes_id: entry.supersedes_id ?? null,
valid_from: entry.valid_from,
valid_to: entry.valid_to,
expires_at: entry.expires_at ?? null,
});
}

Expand All @@ -40,13 +48,35 @@ function same(a?: MemoryEntry, b?: MemoryEntry): boolean {
return fingerprint(a) === fingerprint(b);
}

function isProtected(entry: MemoryEntry): boolean {
if (entry.type === "constraint" || entry.type === "adr") return true;
const level = entry.verification?.level;
return level === "authoritative" || level === "independently-verified";
}

function sameAnchors(a: MemoryEntry, b: MemoryEntry): boolean {
return JSON.stringify(a.anchors ?? []) === JSON.stringify(b.anchors ?? []);
}

/** Strict extension: same type/anchors, losing content is a whole prefix. */
function strictExtension(winner: MemoryEntry, loser: MemoryEntry): boolean {
if (winner.type !== loser.type || !sameAnchors(winner, loser)) return false;
if (!winner.content.startsWith(loser.content)) return false;
const next = winner.content[loser.content.length];
return next === undefined || /[\s.,;:!?)\]}]/.test(next);
}

function semanticWinner(ours: MemoryEntry, theirs: MemoryEntry): MemoryEntry | "conflict" {
if (ours.supersedes_id === theirs.id) return ours;
if (theirs.supersedes_id === ours.id) return theirs;
if (ours.status === "archived" && theirs.status !== "archived") return theirs;
if (theirs.status === "archived" && ours.status !== "archived") return ours;
if (ours.content.includes(theirs.content)) return ours;
if (theirs.content.includes(ours.content)) return theirs;
// Untrusted/self-asserted lineage must never auto-win a merge.
if (ours.trust === "untrusted" || theirs.trust === "untrusted") return "conflict";
if (isProtected(ours) || isProtected(theirs)) return "conflict";

const live = new Set(["active", "confirmed"]);
if (ours.status === "archived" && live.has(theirs.status)) return theirs;
if (theirs.status === "archived" && live.has(ours.status)) return ours;

if (strictExtension(ours, theirs)) return ours;
if (strictExtension(theirs, ours)) return theirs;
return "conflict";
}

Expand Down
123 changes: 83 additions & 40 deletions src/sync/attestation.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { createHash, generateKeyPairSync, sign, verify } from "node:crypto";
import { createHash, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";

export interface Attestation {
version: 1;
version: 2;
algorithm: "ed25519";
signer: string;
key_id: string;
audience: string;
seq: number;
packet_hash: string;
prev_hash?: string;
chain_hash: string;
created_at: string;
/** Terminal marker: a chain is truncated unless its last record is a head. */
head: boolean;
signature: string;
}

Expand All @@ -16,6 +21,8 @@ export interface KeyPairPem {
privateKey: string;
}

const DOMAIN = "muse-attestation-v2\0";

export function canonicalJson(value: unknown): string {
if (value === null) return "null";
if (typeof value === "number") {
Expand All @@ -35,13 +42,15 @@ export function canonicalJson(value: unknown): string {
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");
}

export function keyIdFromPublicKey(publicKeyPem: string): string {
const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex").slice(0, 16);
}

export function generateAttestationKeypair(): KeyPairPem {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
return {
Expand All @@ -50,81 +59,115 @@ export function generateAttestationKeypair(): KeyPairPem {
};
}

/** Attest only the content hash; the packet body is not embedded. */
function payloadOf(attestation: Omit<Attestation, "signature">): string {
return DOMAIN + canonicalJson({
version: attestation.version,
algorithm: attestation.algorithm,
signer: attestation.signer,
key_id: attestation.key_id,
audience: attestation.audience,
seq: attestation.seq,
packet_hash: attestation.packet_hash,
prev_hash: attestation.prev_hash,
chain_hash: attestation.chain_hash,
created_at: attestation.created_at,
head: attestation.head,
});
}

/** Sign a packet hash; `previous` extends a chain and forbids extending a head. */
export function signPacket(
packet: unknown,
privateKeyPem: string,
options: { signer?: string; previous?: Attestation } = {},
options: { signer?: string; previous?: Attestation; audience?: string; head?: boolean; keyId?: string } = {},
): Attestation {
if (options.previous?.head) throw new Error("Cannot extend a chain after its head attestation");
const packet_hash = hashPacket(packet);
const prev_hash = options.previous?.chain_hash;
const chain_hash = createHash("sha256").update(`${prev_hash ?? "genesis"}:${packet_hash}`).digest("hex");
const seq = (options.previous?.seq ?? -1) + 1;
const chain_hash = createHash("sha256").update(`${prev_hash ?? "genesis"}:${seq}:${packet_hash}`).digest("hex");
const createdAt = new Date().toISOString();
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,
algorithm: "ed25519",
const publicKey = createPublicKey(privateKeyPem);
const key_id = options.keyId ?? createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex").slice(0, 16);
const unsigned = {
version: 2 as const,
algorithm: "ed25519" as const,
signer: options.signer ?? "musememory",
key_id,
audience: options.audience ?? "musememory",
seq,
packet_hash,
prev_hash,
chain_hash,
created_at: createdAt,
signature,
head: options.head === true,
};
const signature = sign(null, Buffer.from(payloadOf(unsigned)), privateKeyPem).toString("base64");
return { ...unsigned, signature };
}

function attestationPayload(attestation: Attestation): string {
return DOMAIN + canonicalJson({
version: attestation.version,
algorithm: attestation.algorithm,
packet_hash: attestation.packet_hash,
prev_hash: attestation.prev_hash,
chain_hash: attestation.chain_hash,
created_at: attestation.created_at,
signer: attestation.signer,
});
export interface VerifyOptions {
expectedKeyId?: string;
expectedAudience?: string;
}

export function verifyAttestation(
packet: unknown,
attestation: Attestation,
publicKeyPem: string,
options: VerifyOptions = {},
): boolean {
if (attestation.algorithm !== "ed25519" || attestation.version !== 1) return false;
if (attestation.algorithm !== "ed25519" || attestation.version !== 2) return false;
if (hashPacket(packet) !== attestation.packet_hash) return false;
if (attestation.key_id !== keyIdFromPublicKey(publicKeyPem)) return false;
if (options.expectedKeyId && attestation.key_id !== options.expectedKeyId) return false;
if (options.expectedAudience && attestation.audience !== options.expectedAudience) return false;
try {
return verify(null, Buffer.from(attestationPayload(attestation)), publicKeyPem, Buffer.from(attestation.signature, "base64"));
return verify(
null,
Buffer.from(payloadOf(attestation)),
publicKeyPem,
Buffer.from(attestation.signature, "base64"),
);
} catch {
return false;
}
}

/** Verify order and linkage of an attestation chain. */
export function verifyAttestationChain(chain: Attestation[], publicKeyPem: string): boolean {
export interface ChainVerifyOptions extends VerifyOptions {
/** Require the last record to be a signed head (default true). */
requireHead?: boolean;
}

/** Verify sequence, linkage, signatures and (by default) a signed terminal head. */
export function verifyAttestationChain(
chain: Attestation[],
publicKeyPem: string,
options: ChainVerifyOptions = {},
): boolean {
if (!Array.isArray(chain) || chain.length === 0) return false;
const expectedKeyId = options.expectedKeyId ?? keyIdFromPublicKey(publicKeyPem);
const requireHead = options.requireHead !== false;
let previous: Attestation | undefined;
for (const attestation of chain) {
if (attestation.version !== 1 || attestation.algorithm !== "ed25519") return false;
for (const [index, attestation] of chain.entries()) {
if (attestation.version !== 2 || attestation.algorithm !== "ed25519") return false;
if (attestation.seq !== index) return false;
if (attestation.key_id !== expectedKeyId) return false;
if (options.expectedAudience && attestation.audience !== options.expectedAudience) 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");
const expected = createHash("sha256").update(`${attestation.prev_hash ?? "genesis"}:${attestation.seq}:${attestation.packet_hash}`).digest("hex");
if (attestation.chain_hash !== expected) return false;
// Only the final record may be a head.
if (attestation.head && index !== chain.length - 1) return false;
try {
if (!verify(null, Buffer.from(attestationPayload(attestation)), publicKeyPem, Buffer.from(attestation.signature, "base64"))) {
if (!verify(null, Buffer.from(payloadOf(attestation)), publicKeyPem, Buffer.from(attestation.signature, "base64"))) {
return false;
}
} catch {
return false;
}
previous = attestation;
}
if (requireHead && !chain[chain.length - 1].head) return false;
return true;
}
Loading
Loading