diff --git a/packages/oaa-memory/package.json b/packages/oaa-memory/package.json new file mode 100644 index 0000000..f3fd220 --- /dev/null +++ b/packages/oaa-memory/package.json @@ -0,0 +1,40 @@ +{ + "name": "@civic/oaa-memory", + "version": "0.1.0", + "description": "Capsule codec for .gic memory seeds - verifiable, compressed semantic reconstruction", + "type": "module", + "main": "dist/capsule.js", + "types": "dist/capsule.d.ts", + "files": [ + "dist/**/*", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node -e \"console.log('oaa-memory ok')\"", + "dev": "tsc -p tsconfig.json --watch" + }, + "keywords": [ + "civic", + "capsule", + "memory", + "compression", + "verification", + "semantic" + ], + "author": "Civic OS", + "license": "MIT", + "dependencies": { + "js-sha256": "^0.9.0", + "pako": "^2.1.0", + "tweetnacl": "^1.0.3", + "yaml": "^2.5.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/packages/oaa-memory/src/capsule.ts b/packages/oaa-memory/src/capsule.ts new file mode 100644 index 0000000..3e2ffaf --- /dev/null +++ b/packages/oaa-memory/src/capsule.ts @@ -0,0 +1,210 @@ +/* .gic Capsule codec (v1) + * - encode: spec→capsule (compress + hash + merkle) + * - decode: capsule→spec (verify + decompress) + * - verify: schema-ish checks + signatures + GI threshold + * + * Peer deps: pako, js-sha256, tweetnacl, yaml + */ +import * as pako from "pako"; +import { sha256 } from "js-sha256"; +import nacl from "tweetnacl"; +import YAML from "yaml"; + +type Hex = string; + +export type Capsule = { + version: string; // "1.0" + kind: "memory"|"project"|"agent"|"site"|"game"; + id: string; // "sha256:" + owner: { handle: string; pubkey: string; uri?: string }; + provenance: { + created: string; // ISO + cycle: string; // e.g., "C-109" + sources: { type: "repo"|"doc"|"ledger"|"capsule"; ref: string; hash: string }[]; + }; + ethics: { gi_threshold: number; accords: string[]; notes?: string }; + payload: { level: number; format: "json"|"yaml"; data: string }; // hex of zlib-compressed text + integrity: { + merkle_root: string; // "sha256:" + chunks: { path: string; sha256: string }[]; + signatures: { by: "JADE"|"EVE"|"ZEUS"|"HERMES"|"OWNER"; alg: "ed25519"; sig: string }[]; + }; + policy?: { ruleset_uri?: string; blocked_ops?: string[]; allowed_tools?: string[] }; + restore: { recipe: string[]; env?: Record }; +}; + +// ---------- helpers +function utf8(s: string) { return new TextEncoder().encode(s); } +function hex(b: Uint8Array): Hex { return Array.from(b).map(x=>x.toString(16).padStart(2,"0")).join(""); } +function unhex(h: Hex): Uint8Array { + const bytes = new Uint8Array(h.length/2); + for (let i=0;i (o[k]=sortKeys(v[k]), o), {} as any); + } + return v; +} +function compress(text: string, level=9): string { + const deflated = pako.deflate(utf8(text), { level }); + return hex(deflated); +} +function decompress(hexData: string): string { + const inflated = pako.inflate(unhex(hexData)); + return new TextDecoder().decode(inflated); +} + +// Merkle over chunk hashes (sha256 hex WITHOUT "sha256:" prefix) +function merkleRoot(hashes: string[]): string { + if (hashes.length === 0) return "sha256:" + "0".repeat(64); + let layer = hashes.map(h => h.replace(/^sha256:/,"")); + while (layer.length > 1) { + const next: string[] = []; + for (let i=0;i ({ + path: c.path, + sha256: "sha256:" + shaHex(utf8(c.content)) + })); + const root = merkleRoot(chunkHashes.map(c => c.sha256)); + + // assemble header without id/signatures + const base: Capsule = { + version: "1.0", + kind: params.kind, + id: "", // fill below + owner: params.owner, + provenance: params.provenance, + ethics: params.ethics, + payload: { level: Math.min(Math.max(params.payloadLevel ?? 10,1),10), format: payloadFormat, data: dataHex }, + integrity: { merkle_root: root, chunks: chunkHashes, signatures: [] }, + restore: params.restore + }; + + const id = "sha256:" + shaHex(utf8(canonicalJSON({ ...base, id: undefined, integrity: { ...base.integrity, signatures: [] } }))+unhex(dataHex)); + base.id = id; + return base; +} + +export function decodeCapsule(capsule: Capsule): { payload: any; summaryText: string } { + const text = decompress(capsule.payload.data); + const obj = capsule.payload.format === "json" ? JSON.parse(text) : YAML.parse(text); + return { payload: obj, summaryText: text }; +} + +export function verifyCapsule(c: Capsule, opts?: { + minGI?: number; + requireSigners?: ("JADE"|"EVE"|"OWNER")[]; +}): { ok: boolean; errors: string[] } { + const errors: string[] = []; + + // version/kind/owner quick checks + if (c.version.split(".")[0] !== "1") errors.push("unsupported_version"); + if (!c.owner?.handle || !c.owner?.pubkey) errors.push("bad_owner"); + + // GI threshold + const minGI = opts?.minGI ?? 0.92; + if ((c.ethics?.gi_threshold ?? 0) < minGI) errors.push("gi_threshold_below_policy"); + + // payload integrity + try { + const text = decompress(c.payload.data); + if (c.payload.format === "json") JSON.parse(text); else YAML.parse(text); + } catch { + errors.push("payload_decompress_parse_failed"); + } + + // merkle root recompute + const root = merkleRoot(c.integrity.chunks.map(x => x.sha256)); + if (root !== c.integrity.merkle_root) errors.push("merkle_root_mismatch"); + + // id recompute (no signatures) + const idRe = "sha256:" + shaHex(utf8( + canonicalJSON({ ...c, id: undefined, integrity: { ...c.integrity, signatures: [] } }) + ) + c.payload.data); + if (idRe !== c.id) errors.push("id_mismatch"); + + // signatures (OPTIONAL but recommended) + const required = new Set(opts?.requireSigners ?? []); + const by = new Set(c.integrity.signatures.map(s => s.by)); + for (const r of required) if (!by.has(r)) errors.push(`missing_sig_${r}`); + + // Owner signature structural check (verification requires owner pubkey) + for (const sig of c.integrity.signatures) { + if (sig.alg !== "ed25519") { errors.push("unsupported_sig_alg"); continue; } + if (sig.by === "OWNER") { + try { + const msg = utf8(c.id); // sign the capsule id + const pub = base64urlToBytes(c.owner.pubkey); + const sigb = base64urlToBytes(sig.sig); + const ok = nacl.sign.detached.verify(msg, sigb, pub); + if (!ok) errors.push("owner_sig_invalid"); + } catch { + errors.push("owner_sig_invalid"); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +export function signOwner(c: Capsule, ownerSecretBase64Url: string): Capsule { + const sk = base64urlToBytes(ownerSecretBase64Url); + if (sk.length !== 64) throw new Error("ed25519 secret key must be 64 bytes (seed+pub)"); + const msg = utf8(c.id); + const sig = nacl.sign.detached(msg, sk); + const signed: Capsule = JSON.parse(JSON.stringify(c)); + signed.integrity.signatures = [ + ...signed.integrity.signatures, + { by: "OWNER", alg: "ed25519", sig: bytesToBase64url(sig) } + ]; + return signed; +} + +// ---- base64url utils (no padding) +function bytesToBase64url(b: Uint8Array): string { + const bin = Buffer.from(b).toString("base64").replace(/=+$/,"").replace(/\+/g,"-").replace(/\//g,"_"); + return bin; +} +function base64urlToBytes(s: string): Uint8Array { + const pad = s.length % 4 === 2 ? "==" : s.length % 4 === 3 ? "=" : ""; + const b64 = s.replace(/-/g,"+").replace(/_/g,"/") + pad; + return new Uint8Array(Buffer.from(b64, "base64")); +} \ No newline at end of file diff --git a/packages/oaa-memory/tsconfig.json b/packages/oaa-memory/tsconfig.json new file mode 100644 index 0000000..cfae075 --- /dev/null +++ b/packages/oaa-memory/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/schemas/gic-capsule.schema.json b/schemas/gic-capsule.schema.json new file mode 100644 index 0000000..46913f6 --- /dev/null +++ b/schemas/gic-capsule.schema.json @@ -0,0 +1,215 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://civic.os/schemas/gic-capsule.schema.json", + "title": ".gic Capsule", + "description": "A verifiable seed that can reconstruct meaning without retaining bulky raw logs", + "type": "object", + "required": ["version", "id", "owner", "provenance", "ethics", "payload", "integrity"], + "properties": { + "version": { + "type": "string", + "pattern": "^1\\.\\d+(-[a-z0-9]+)?$", + "description": "Capsule format version" + }, + "kind": { + "type": "string", + "enum": ["memory", "project", "agent", "site", "game"], + "description": "Type of capsule content" + }, + "id": { + "type": "string", + "description": "Deterministic capsule ID = sha256(header+payload) (hex)", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "owner": { + "type": "object", + "required": ["handle", "pubkey"], + "properties": { + "handle": { + "type": "string", + "description": "citizen handle or domain, e.g. michael.gic", + "pattern": "^[a-z0-9.-]+\\.gic$" + }, + "pubkey": { + "type": "string", + "description": "ed25519 public key (base64url)", + "pattern": "^[A-Za-z0-9_-]+$" + }, + "uri": { + "type": "string", + "format": "uri", + "description": "profile or DID URI" + } + } + }, + "provenance": { + "type": "object", + "required": ["created", "cycle", "sources"], + "properties": { + "created": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of creation" + }, + "cycle": { + "type": "string", + "description": "Kaizen cycle, e.g. C-109", + "pattern": "^C-\\d+$" + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "ref", "hash"], + "properties": { + "type": { + "type": "string", + "enum": ["repo", "doc", "ledger", "capsule"] + }, + "ref": { + "type": "string", + "description": "URL or logical URI (ledger://…)" + }, + "hash": { + "type": "string", + "description": "sha256 of referenced artifact (hex)", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + } + } + }, + "ethics": { + "type": "object", + "required": ["gi_threshold", "accords"], + "properties": { + "gi_threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Global Intelligence threshold for this capsule" + }, + "accords": { + "type": "array", + "items": { "type": "string" }, + "description": "Ethical frameworks this capsule adheres to" + }, + "notes": { + "type": "string", + "description": "Additional ethical context or reasoning" + } + } + }, + "payload": { + "type": "object", + "required": ["level", "format", "data"], + "properties": { + "level": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "compression depth" + }, + "format": { + "type": "string", + "enum": ["json", "yaml"], + "description": "Format of compressed data" + }, + "data": { + "type": "string", + "description": "zlib-compressed, then hex-encoded", + "pattern": "^[a-f0-9]+$" + } + } + }, + "integrity": { + "type": "object", + "required": ["merkle_root", "chunks", "signatures"], + "properties": { + "merkle_root": { + "type": "string", + "description": "sha256 merkle root (hex)", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "chunks": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "sha256"], + "properties": { + "path": { + "type": "string", + "description": "File path within the capsule" + }, + "sha256": { + "type": "string", + "description": "SHA256 hash of chunk content", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + }, + "signatures": { + "type": "array", + "items": { + "type": "object", + "required": ["by", "alg", "sig"], + "properties": { + "by": { + "type": "string", + "enum": ["JADE","EVE","ZEUS","HERMES","OWNER"], + "description": "Signing agent or owner" + }, + "alg": { + "type": "string", + "enum": ["ed25519","secp256k1","minisign"], + "description": "Signature algorithm" + }, + "sig": { + "type": "string", + "description": "detached signature (base64url)", + "pattern": "^[A-Za-z0-9_-]+$" + } + } + } + } + } + }, + "policy": { + "type": "object", + "properties": { + "ruleset_uri": { + "type": "string", + "description": "e.g. /.civic/rulesets/policy.yaml" + }, + "blocked_ops": { + "type": "array", + "items": { "type": "string" }, + "description": "Operations blocked during restore" + }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Tools allowed during restore" + } + } + }, + "restore": { + "type": "object", + "required": ["recipe"], + "properties": { + "recipe": { + "type": "array", + "items": { "type": "string" }, + "description": "ordered steps to rehydrate" + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Environment variables for restore" + } + } + } + } +} \ No newline at end of file