@archstone/runtime
Advanced tools
+37
-2
| import { LoadIssue } from '@archstone/schema'; | ||
| import { Diagnostic } from '@archstone/compiler'; | ||
| import { Registry } from '@archstone/emitter-support'; | ||
| import { Registry, AuditSink } from '@archstone/emitter-support'; | ||
| export { AuditSink, AuditWritable, ExecutionConsumer, ExecutionDenialReason, ExecutionPhase, ExecutionRecord, ExecutionStatus, HealthStatus, LIFECYCLE_BLOCKED_REASON, LIFECYCLE_UNEVALUATABLE_REASON, MappingResult, MappingStatus, REDACTED, Registry, applyResponseMapping, inputJsonSchema, jsonLinesAuditSink, objectJsonSchema, toolName } from '@archstone/emitter-support'; | ||
@@ -44,2 +44,37 @@ import { InvokeOptions } from '@archstone/provider-rest'; | ||
| export { type BuildResult, HEALTH_SNAPSHOT_FILE, buildRegistry, serveStdio }; | ||
| interface RotatingFileAuditSinkOptions { | ||
| /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */ | ||
| path: string; | ||
| /** Rotate once the live file would exceed this. Default 64 MiB. */ | ||
| maxBytes?: number; | ||
| /** How many rotated generations to keep. The oldest is deleted on rotation. Default 10. */ | ||
| maxFiles?: number; | ||
| } | ||
| /** | ||
| * A JSON Lines audit sink that rotates by size and bounds its own disk use. | ||
| * | ||
| * **Size, not time.** An audit stream grows with invocations, not with the clock: hourly | ||
| * rotation on a quiet deployment produces a directory of empty files, and on a busy one produces | ||
| * a single file that outgrows the disk between rotations. Size-based rotation gives the one | ||
| * guarantee an operator actually needs — total footprint is at most | ||
| * `maxBytes × (maxFiles + 1)`, computable before deployment and independent of traffic. | ||
| * | ||
| * **Synchronous, on purpose.** `appendFileSync` per record costs a syscall; a buffered writer | ||
| * would be faster and would lose the last N records exactly when they matter most — a crash, | ||
| * an OOM kill, a `SIGKILL` during an incident. An audit record still in a buffer when the | ||
| * process dies is a record that never existed. Evidentiary logs trade throughput for durability; | ||
| * if that trade is wrong for a deployment, wrap a buffered writer yourself and own the loss. | ||
| * | ||
| * **Single writer.** The live file's size is tracked in memory (seeded from `statSync` at | ||
| * construction) so the common path is one `append` and no `stat`. Two processes appending to the | ||
| * same path therefore rotate on each other's estimates — give each instance its own path, which | ||
| * a shared volume makes trivial and which also keeps records attributable to an instance. | ||
| * | ||
| * Rotation is `rename`, so the live inode is replaced and no record is ever rewritten in place. | ||
| * A failure to rotate (permissions, a full disk) surfaces as an ordinary sink failure: | ||
| * `emitExecutionRecord` catches it, announces the loss on stderr, and the invocation itself is | ||
| * unaffected — an audit backend must never be able to take the capability down. | ||
| */ | ||
| declare function rotatingFileAuditSink(opts: RotatingFileAuditSinkOptions): AuditSink; | ||
| export { type BuildResult, HEALTH_SNAPSHOT_FILE, type RotatingFileAuditSinkOptions, buildRegistry, rotatingFileAuditSink, serveStdio }; |
+39
-0
@@ -93,2 +93,40 @@ import { | ||
| } | ||
| // src/audit-file.ts | ||
| import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from "fs"; | ||
| import { dirname } from "path"; | ||
| var DEFAULT_MAX_BYTES = 64 * 1024 * 1024; | ||
| var DEFAULT_MAX_FILES = 10; | ||
| function rotatingFileAuditSink(opts) { | ||
| const { path } = opts; | ||
| const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; | ||
| const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES; | ||
| if (!path) throw new Error("rotatingFileAuditSink: `path` is required."); | ||
| if (!Number.isInteger(maxBytes) || maxBytes <= 0) { | ||
| throw new Error(`rotatingFileAuditSink: maxBytes must be a positive integer, got ${String(maxBytes)}.`); | ||
| } | ||
| if (!Number.isInteger(maxFiles) || maxFiles < 1) { | ||
| throw new Error(`rotatingFileAuditSink: maxFiles must be a positive integer, got ${String(maxFiles)}.`); | ||
| } | ||
| mkdirSync(dirname(path), { recursive: true }); | ||
| let liveBytes = existsSync(path) ? statSync(path).size : 0; | ||
| function rotate() { | ||
| const oldest = `${path}.${maxFiles}`; | ||
| if (existsSync(oldest)) unlinkSync(oldest); | ||
| for (let i = maxFiles - 1; i >= 1; i--) { | ||
| const from = `${path}.${i}`; | ||
| if (existsSync(from)) renameSync(from, `${path}.${i + 1}`); | ||
| } | ||
| if (existsSync(path)) renameSync(path, `${path}.1`); | ||
| liveBytes = 0; | ||
| } | ||
| return (record) => { | ||
| const line = `${JSON.stringify(record)} | ||
| `; | ||
| const size = Buffer.byteLength(line); | ||
| if (liveBytes > 0 && liveBytes + size > maxBytes) rotate(); | ||
| appendFileSync(path, line); | ||
| liveBytes += size; | ||
| }; | ||
| } | ||
| export { | ||
@@ -112,2 +150,3 @@ CONTRACT_VIOLATION_META_KEY, | ||
| recordContract, | ||
| rotatingFileAuditSink, | ||
| runVerify, | ||
@@ -114,0 +153,0 @@ serveStdio, |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/registry.ts","../src/mcp.ts"],"sourcesContent":["// @archstone/runtime — Capability Registry (#5)\n//\n// The product kernel: capabilities queryable at runtime, indexed over the IR.\n// File-backed (no DB) — the IR is derived from manifests on disk. The MCP emitter\n// (#7) consumes this to list and resolve tools.\n//\n// `Registry` (index-only) moved to @archstone/emitter-support (ADD-0008 #27) — re-exported\n// here for back-compat so nothing downstream breaks. This file keeps the fs-touching\n// pipeline (`buildRegistry`), which is why the /http subpath (http.ts) never imports it.\n//\n// ADD-24 (#24): `buildRegistry` also optionally reads a conventional health-snapshot file\n// (`readHealthSnapshot`, below) — the ONE other fs-touching, network-free addition this ADD\n// makes. Binding health itself is never computed here (that's `archstone verify`'s own live\n// probe, ADD-18 D-5) — only its already-serialized `--json` output is read back.\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { load, type LoadResult, type LoadIssue } from \"@archstone/schema\";\nimport { validateSemantics, compile, type Diagnostic } from \"@archstone/compiler\";\nimport { Registry, type HealthStatus } from \"@archstone/emitter-support\";\n\nexport { Registry } from \"@archstone/emitter-support\";\n\n/** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the\n * operator/CI populates it by redirecting the ALREADY-shipped `archstone verify --json`\n * output here — no new serialization. `buildRegistry` reads it (fs, but no network — the\n * live probe stays exclusively `verify`'s, ADD-18 D-5) and hands the parsed map to\n * `Registry`, which composes it with each tool's lifecycle exposure (ADD-24 §7 step 5). */\nexport const HEALTH_SNAPSHOT_FILE = \".archstone-health.json\";\n\nconst HEALTH_STATUSES: ReadonlySet<string> = new Set([\"green\", \"yellow\", \"red\"]);\n\n/**\n * Parse the `{results: ToolVerification[]}` shape `archstone verify --json` already produces\n * (ADD-20) into a capabilityId -> HealthStatus map. Fail-open (ADD-24 D-9): a missing file, a\n * parse error, or a malformed/unexpected shape all return `undefined` — the caller then\n * proceeds with lifecycle-only exposure, never mistaking \"no snapshot\" for \"known bad\".\n *\n * #43 (ADD-43 D-14): an entry marked `policyDenied` is SKIPPED. A refusal that happened before\n * the call is not a health fact — no request was issued, so nothing about the backend's contract\n * was observed. Left in, it would reach `combineExposure` as a `red` and append\n * `\"binding health: red — the last contract verification failed\"` to the tool's agent-facing\n * description at the highest severity: a statement that never happened, shown to every caller\n * including permitted ones, making policy affect listing (which BR-36 forbids). Skipping leaves\n * the tool with NO health entry, which is exactly ADD-24 D-9's ratified posture — absent health\n * must never be manufactured into known-bad.\n */\nfunction readHealthSnapshot(dir: string): Map<string, HealthStatus> | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(join(dir, HEALTH_SNAPSHOT_FILE), \"utf8\"));\n } catch {\n return undefined; // absent, unreadable, or invalid JSON — fail-open\n }\n\n const results = (parsed as { results?: unknown } | null)?.results;\n if (!Array.isArray(results)) return undefined;\n\n const map = new Map<string, HealthStatus>();\n for (const r of results) {\n if (!r || typeof r !== \"object\") continue;\n // ADD-43 D-14: a policy denial is not a health reading — drop it rather than let it become\n // an agent-facing \"the last contract verification failed\" hint for a verification that\n // never ran. See this function's doc comment.\n if ((r as { policyDenied?: unknown }).policyDenied === true) continue;\n const capabilityId = (r as { capabilityId?: unknown }).capabilityId;\n const status = (r as { status?: unknown }).status;\n if (typeof capabilityId === \"string\" && typeof status === \"string\" && HEALTH_STATUSES.has(status)) {\n map.set(capabilityId, status as HealthStatus);\n }\n }\n return map;\n}\n\nexport interface BuildResult {\n ok: boolean;\n registry?: Registry;\n issues: LoadIssue[];\n diagnostics: Diagnostic[];\n}\n\n/**\n * File-backed pipeline: load (#2) → semantic-validate (#3) → compile (#4) → Registry (#5).\n * `registry` is present only when shapes are valid, there are no semantic errors, AND no\n * tool-name collision (ADD-30 D-2) — folded into this function's existing `diagnostics`/\n * `ok` contract (new `tool-name-collision` diagnostic code) rather than a new mechanism, so\n * `serveStdio`/`runServeHttp` (which already refuse to proceed on `!built.ok`) inherit the\n * gate for free.\n */\nexport function buildRegistry(dir: string): BuildResult {\n const model: LoadResult = load(dir);\n const diagnostics = validateSemantics(model);\n const hasErrors = diagnostics.some((d) => d.severity === \"error\");\n let ok = model.ok && !hasErrors;\n\n const registry = ok ? new Registry(compile(model), readHealthSnapshot(dir)) : undefined;\n if (registry) {\n for (const c of registry.toolNameCollisions) {\n ok = false;\n diagnostics.push({\n severity: \"error\",\n code: \"tool-name-collision\",\n message: `tool name '${c.name}' is ambiguous — capabilities ${c.ids.join(\", \")} all sanitize to it`,\n });\n }\n }\n\n return {\n ok,\n registry: ok ? registry : undefined,\n issues: model.issues,\n diagnostics,\n };\n}\n","// @archstone/runtime — MCP emitter (#7) — stdio entrypoint\n//\n// serveStdio builds the registry from disk (buildRegistry, registry.ts) and serves it over\n// stdio, the channel Claude Desktop uses. The fs-free MCP server construction\n// (toolDefinitions/callTool/createMcpServer) lives in ./server (ADD-0008 #27) — re-exported\n// here, alongside the semantic-lowering functions from @archstone/emitter-support, for\n// back-compat so nothing downstream breaks.\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { InvokeOptions } from \"@archstone/provider-rest\";\nimport { buildRegistry } from \"./registry\";\nimport { toolDefinitions, createMcpServer } from \"./server\";\n\nexport { toolName, inputJsonSchema, objectJsonSchema } from \"@archstone/emitter-support\";\n/** #44: the audit sink surface, re-exported so a deployer wiring `serveStdio`/`createMcpServer`\n * imports it from the package they already depend on. See `AuditSink`'s own doc comment for\n * the fire-and-forget contract and for the statement that the trail is best-effort and lossy. */\nexport {\n jsonLinesAuditSink,\n REDACTED,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n} from \"@archstone/emitter-support\";\nexport type {\n AuditSink,\n AuditWritable,\n ExecutionRecord,\n ExecutionStatus,\n ExecutionPhase,\n ExecutionConsumer,\n ExecutionDenialReason,\n} from \"@archstone/emitter-support\";\nexport * from \"./server\";\n\n/**\n * Build the registry from a manifest dir and serve it over stdio (blocks).\n *\n * `invoke` (ADD-32) is forwarded verbatim to `createMcpServer` — this closes a real gap: prior\n * to #32, `serveStdio` passed NO `InvokeOptions` at all, so nothing (not `env`, not `caller`)\n * could ever be injected here. A stdio server is one child process per conversation (Claude\n * Desktop's model) — single-process, single-user by construction — so a static per-process\n * `invoke.caller` is architecturally sound here, unlike the HTTP case (`createHttpHandler`'s\n * `resolveCaller`, which must vary per inbound request).\n */\nexport async function serveStdio(dir: string, invoke?: InvokeOptions): Promise<void> {\n const built = buildRegistry(dir);\n if (!built.ok || !built.registry) {\n // stdout is the MCP channel — all human output goes to stderr.\n console.error(`archstone: cannot serve '${dir}' — manifest invalid:`);\n for (const i of built.issues) console.error(` - ${i.file}: ${i.message}`);\n for (const d of built.diagnostics.filter((x) => x.severity === \"error\")) console.error(` - ${d.message}`);\n process.exit(1);\n }\n const tools = toolDefinitions(built.registry);\n console.error(`archstone: serving ${tools.length} tool(s) over stdio: ${tools.map((t) => t.name).join(\", \") || \"(none)\"}`);\n const server = createMcpServer(built.registry, invoke);\n await server.connect(new StdioServerTransport());\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAeA,SAAS,oBAAoB;AAC7B,SAAS,YAAY;AACrB,SAAS,YAA6C;AACtD,SAAS,mBAAmB,eAAgC;AAC5D,SAAS,gBAAmC;AAE5C,SAAS,YAAAA,iBAAgB;AAOlB,IAAM,uBAAuB;AAEpC,IAAM,kBAAuC,oBAAI,IAAI,CAAC,SAAS,UAAU,KAAK,CAAC;AAiB/E,SAAS,mBAAmB,KAAoD;AAC9E,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,aAAa,KAAK,KAAK,oBAAoB,GAAG,MAAM,CAAC;AAAA,EAC3E,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,QAAM,UAAW,QAAyC;AAC1D,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO;AAEpC,QAAM,MAAM,oBAAI,IAA0B;AAC1C,aAAW,KAAK,SAAS;AACvB,QAAI,CAAC,KAAK,OAAO,MAAM,SAAU;AAIjC,QAAK,EAAiC,iBAAiB,KAAM;AAC7D,UAAM,eAAgB,EAAiC;AACvD,UAAM,SAAU,EAA2B;AAC3C,QAAI,OAAO,iBAAiB,YAAY,OAAO,WAAW,YAAY,gBAAgB,IAAI,MAAM,GAAG;AACjG,UAAI,IAAI,cAAc,MAAsB;AAAA,IAC9C;AAAA,EACF;AACA,SAAO;AACT;AAiBO,SAAS,cAAc,KAA0B;AACtD,QAAM,QAAoB,KAAK,GAAG;AAClC,QAAM,cAAc,kBAAkB,KAAK;AAC3C,QAAM,YAAY,YAAY,KAAK,CAAC,MAAM,EAAE,aAAa,OAAO;AAChE,MAAI,KAAK,MAAM,MAAM,CAAC;AAEtB,QAAM,WAAW,KAAK,IAAI,SAAS,QAAQ,KAAK,GAAG,mBAAmB,GAAG,CAAC,IAAI;AAC9E,MAAI,UAAU;AACZ,eAAW,KAAK,SAAS,oBAAoB;AAC3C,WAAK;AACL,kBAAY,KAAK;AAAA,QACf,UAAU;AAAA,QACV,MAAM;AAAA,QACN,SAAS,cAAc,EAAE,IAAI,sCAAiC,EAAE,IAAI,KAAK,IAAI,CAAC;AAAA,MAChF,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,UAAU,KAAK,WAAW;AAAA,IAC1B,QAAQ,MAAM;AAAA,IACd;AAAA,EACF;AACF;;;ACzGA,SAAS,4BAA4B;AAKrC,SAAS,UAAU,iBAAiB,wBAAwB;AAI5D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsBP,eAAsB,WAAW,KAAa,QAAuC;AACnF,QAAM,QAAQ,cAAc,GAAG;AAC/B,MAAI,CAAC,MAAM,MAAM,CAAC,MAAM,UAAU;AAEhC,YAAQ,MAAM,4BAA4B,GAAG,4BAAuB;AACpE,eAAW,KAAK,MAAM,OAAQ,SAAQ,MAAM,OAAO,EAAE,IAAI,KAAK,EAAE,OAAO,EAAE;AACzE,eAAW,KAAK,MAAM,YAAY,OAAO,CAAC,MAAM,EAAE,aAAa,OAAO,EAAG,SAAQ,MAAM,OAAO,EAAE,OAAO,EAAE;AACzG,YAAQ,KAAK,CAAC;AAAA,EAChB;AACA,QAAM,QAAQ,gBAAgB,MAAM,QAAQ;AAC5C,UAAQ,MAAM,sBAAsB,MAAM,MAAM,wBAAwB,MAAM,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,IAAI,KAAK,QAAQ,EAAE;AACzH,QAAM,SAAS,gBAAgB,MAAM,UAAU,MAAM;AACrD,QAAM,OAAO,QAAQ,IAAI,qBAAqB,CAAC;AACjD;","names":["Registry"]} | ||
| {"version":3,"sources":["../src/registry.ts","../src/mcp.ts","../src/audit-file.ts"],"sourcesContent":["// @archstone/runtime — Capability Registry (#5)\n//\n// The product kernel: capabilities queryable at runtime, indexed over the IR.\n// File-backed (no DB) — the IR is derived from manifests on disk. The MCP emitter\n// (#7) consumes this to list and resolve tools.\n//\n// `Registry` (index-only) moved to @archstone/emitter-support (ADD-0008 #27) — re-exported\n// here for back-compat so nothing downstream breaks. This file keeps the fs-touching\n// pipeline (`buildRegistry`), which is why the /http subpath (http.ts) never imports it.\n//\n// ADD-24 (#24): `buildRegistry` also optionally reads a conventional health-snapshot file\n// (`readHealthSnapshot`, below) — the ONE other fs-touching, network-free addition this ADD\n// makes. Binding health itself is never computed here (that's `archstone verify`'s own live\n// probe, ADD-18 D-5) — only its already-serialized `--json` output is read back.\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { load, type LoadResult, type LoadIssue } from \"@archstone/schema\";\nimport { validateSemantics, compile, type Diagnostic } from \"@archstone/compiler\";\nimport { Registry, type HealthStatus } from \"@archstone/emitter-support\";\n\nexport { Registry } from \"@archstone/emitter-support\";\n\n/** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the\n * operator/CI populates it by redirecting the ALREADY-shipped `archstone verify --json`\n * output here — no new serialization. `buildRegistry` reads it (fs, but no network — the\n * live probe stays exclusively `verify`'s, ADD-18 D-5) and hands the parsed map to\n * `Registry`, which composes it with each tool's lifecycle exposure (ADD-24 §7 step 5). */\nexport const HEALTH_SNAPSHOT_FILE = \".archstone-health.json\";\n\nconst HEALTH_STATUSES: ReadonlySet<string> = new Set([\"green\", \"yellow\", \"red\"]);\n\n/**\n * Parse the `{results: ToolVerification[]}` shape `archstone verify --json` already produces\n * (ADD-20) into a capabilityId -> HealthStatus map. Fail-open (ADD-24 D-9): a missing file, a\n * parse error, or a malformed/unexpected shape all return `undefined` — the caller then\n * proceeds with lifecycle-only exposure, never mistaking \"no snapshot\" for \"known bad\".\n *\n * #43 (ADD-43 D-14): an entry marked `policyDenied` is SKIPPED. A refusal that happened before\n * the call is not a health fact — no request was issued, so nothing about the backend's contract\n * was observed. Left in, it would reach `combineExposure` as a `red` and append\n * `\"binding health: red — the last contract verification failed\"` to the tool's agent-facing\n * description at the highest severity: a statement that never happened, shown to every caller\n * including permitted ones, making policy affect listing (which BR-36 forbids). Skipping leaves\n * the tool with NO health entry, which is exactly ADD-24 D-9's ratified posture — absent health\n * must never be manufactured into known-bad.\n */\nfunction readHealthSnapshot(dir: string): Map<string, HealthStatus> | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(join(dir, HEALTH_SNAPSHOT_FILE), \"utf8\"));\n } catch {\n return undefined; // absent, unreadable, or invalid JSON — fail-open\n }\n\n const results = (parsed as { results?: unknown } | null)?.results;\n if (!Array.isArray(results)) return undefined;\n\n const map = new Map<string, HealthStatus>();\n for (const r of results) {\n if (!r || typeof r !== \"object\") continue;\n // ADD-43 D-14: a policy denial is not a health reading — drop it rather than let it become\n // an agent-facing \"the last contract verification failed\" hint for a verification that\n // never ran. See this function's doc comment.\n if ((r as { policyDenied?: unknown }).policyDenied === true) continue;\n const capabilityId = (r as { capabilityId?: unknown }).capabilityId;\n const status = (r as { status?: unknown }).status;\n if (typeof capabilityId === \"string\" && typeof status === \"string\" && HEALTH_STATUSES.has(status)) {\n map.set(capabilityId, status as HealthStatus);\n }\n }\n return map;\n}\n\nexport interface BuildResult {\n ok: boolean;\n registry?: Registry;\n issues: LoadIssue[];\n diagnostics: Diagnostic[];\n}\n\n/**\n * File-backed pipeline: load (#2) → semantic-validate (#3) → compile (#4) → Registry (#5).\n * `registry` is present only when shapes are valid, there are no semantic errors, AND no\n * tool-name collision (ADD-30 D-2) — folded into this function's existing `diagnostics`/\n * `ok` contract (new `tool-name-collision` diagnostic code) rather than a new mechanism, so\n * `serveStdio`/`runServeHttp` (which already refuse to proceed on `!built.ok`) inherit the\n * gate for free.\n */\nexport function buildRegistry(dir: string): BuildResult {\n const model: LoadResult = load(dir);\n const diagnostics = validateSemantics(model);\n const hasErrors = diagnostics.some((d) => d.severity === \"error\");\n let ok = model.ok && !hasErrors;\n\n const registry = ok ? new Registry(compile(model), readHealthSnapshot(dir)) : undefined;\n if (registry) {\n for (const c of registry.toolNameCollisions) {\n ok = false;\n diagnostics.push({\n severity: \"error\",\n code: \"tool-name-collision\",\n message: `tool name '${c.name}' is ambiguous — capabilities ${c.ids.join(\", \")} all sanitize to it`,\n });\n }\n }\n\n return {\n ok,\n registry: ok ? registry : undefined,\n issues: model.issues,\n diagnostics,\n };\n}\n","// @archstone/runtime — MCP emitter (#7) — stdio entrypoint\n//\n// serveStdio builds the registry from disk (buildRegistry, registry.ts) and serves it over\n// stdio, the channel Claude Desktop uses. The fs-free MCP server construction\n// (toolDefinitions/callTool/createMcpServer) lives in ./server (ADD-0008 #27) — re-exported\n// here, alongside the semantic-lowering functions from @archstone/emitter-support, for\n// back-compat so nothing downstream breaks.\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { InvokeOptions } from \"@archstone/provider-rest\";\nimport { buildRegistry } from \"./registry\";\nimport { toolDefinitions, createMcpServer } from \"./server\";\n\nexport { toolName, inputJsonSchema, objectJsonSchema } from \"@archstone/emitter-support\";\n/** #44: the audit sink surface, re-exported so a deployer wiring `serveStdio`/`createMcpServer`\n * imports it from the package they already depend on. See `AuditSink`'s own doc comment for\n * the fire-and-forget contract and for the statement that the trail is best-effort and lossy. */\nexport {\n jsonLinesAuditSink,\n REDACTED,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n} from \"@archstone/emitter-support\";\nexport type {\n AuditSink,\n AuditWritable,\n ExecutionRecord,\n ExecutionStatus,\n ExecutionPhase,\n ExecutionConsumer,\n ExecutionDenialReason,\n} from \"@archstone/emitter-support\";\nexport * from \"./server\";\n\n/**\n * Build the registry from a manifest dir and serve it over stdio (blocks).\n *\n * `invoke` (ADD-32) is forwarded verbatim to `createMcpServer` — this closes a real gap: prior\n * to #32, `serveStdio` passed NO `InvokeOptions` at all, so nothing (not `env`, not `caller`)\n * could ever be injected here. A stdio server is one child process per conversation (Claude\n * Desktop's model) — single-process, single-user by construction — so a static per-process\n * `invoke.caller` is architecturally sound here, unlike the HTTP case (`createHttpHandler`'s\n * `resolveCaller`, which must vary per inbound request).\n */\nexport async function serveStdio(dir: string, invoke?: InvokeOptions): Promise<void> {\n const built = buildRegistry(dir);\n if (!built.ok || !built.registry) {\n // stdout is the MCP channel — all human output goes to stderr.\n console.error(`archstone: cannot serve '${dir}' — manifest invalid:`);\n for (const i of built.issues) console.error(` - ${i.file}: ${i.message}`);\n for (const d of built.diagnostics.filter((x) => x.severity === \"error\")) console.error(` - ${d.message}`);\n process.exit(1);\n }\n const tools = toolDefinitions(built.registry);\n console.error(`archstone: serving ${tools.length} tool(s) over stdio: ${tools.map((t) => t.name).join(\", \") || \"(none)\"}`);\n const server = createMcpServer(built.registry, invoke);\n await server.connect(new StdioServerTransport());\n}\n","// @archstone/runtime — file-backed audit retention.\n//\n// `@archstone/emitter-support` ships `jsonLinesAuditSink`: one line, one write, no retention,\n// because that package imports no `node:` module and must stay usable on an edge runtime. A\n// self-hosted deployment that has to *keep* its audit trail — the whole point of an evidentiary\n// log — then has to solve rotation itself, which is where this lives: `runtime` already reads\n// the filesystem (`registry`), so fs belongs here and only here.\n//\n// Deliberately NOT a shipping/collector sink. Sending records to Splunk, an OTLP endpoint or an\n// S3 bucket is HTTP, and HTTP appears in exactly one package in this repository (`providers/rest`)\n// — a rule worth more than the convenience. Wrap this sink, or write your own; a sink is a\n// function.\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport type { AuditSink, ExecutionRecord } from \"@archstone/emitter-support\";\n\nexport interface RotatingFileAuditSinkOptions {\n /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */\n path: string;\n /** Rotate once the live file would exceed this. Default 64 MiB. */\n maxBytes?: number;\n /** How many rotated generations to keep. The oldest is deleted on rotation. Default 10. */\n maxFiles?: number;\n}\n\nconst DEFAULT_MAX_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_FILES = 10;\n\n/**\n * A JSON Lines audit sink that rotates by size and bounds its own disk use.\n *\n * **Size, not time.** An audit stream grows with invocations, not with the clock: hourly\n * rotation on a quiet deployment produces a directory of empty files, and on a busy one produces\n * a single file that outgrows the disk between rotations. Size-based rotation gives the one\n * guarantee an operator actually needs — total footprint is at most\n * `maxBytes × (maxFiles + 1)`, computable before deployment and independent of traffic.\n *\n * **Synchronous, on purpose.** `appendFileSync` per record costs a syscall; a buffered writer\n * would be faster and would lose the last N records exactly when they matter most — a crash,\n * an OOM kill, a `SIGKILL` during an incident. An audit record still in a buffer when the\n * process dies is a record that never existed. Evidentiary logs trade throughput for durability;\n * if that trade is wrong for a deployment, wrap a buffered writer yourself and own the loss.\n *\n * **Single writer.** The live file's size is tracked in memory (seeded from `statSync` at\n * construction) so the common path is one `append` and no `stat`. Two processes appending to the\n * same path therefore rotate on each other's estimates — give each instance its own path, which\n * a shared volume makes trivial and which also keeps records attributable to an instance.\n *\n * Rotation is `rename`, so the live inode is replaced and no record is ever rewritten in place.\n * A failure to rotate (permissions, a full disk) surfaces as an ordinary sink failure:\n * `emitExecutionRecord` catches it, announces the loss on stderr, and the invocation itself is\n * unaffected — an audit backend must never be able to take the capability down.\n */\nexport function rotatingFileAuditSink(opts: RotatingFileAuditSinkOptions): AuditSink {\n const { path } = opts;\n const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;\n const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;\n\n if (!path) throw new Error(\"rotatingFileAuditSink: `path` is required.\");\n if (!Number.isInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(`rotatingFileAuditSink: maxBytes must be a positive integer, got ${String(maxBytes)}.`);\n }\n if (!Number.isInteger(maxFiles) || maxFiles < 1) {\n throw new Error(`rotatingFileAuditSink: maxFiles must be a positive integer, got ${String(maxFiles)}.`);\n }\n\n // Fail at wiring time, not at the first denied invocation: a deployer who mistyped the path\n // should learn now, while they are looking at the config, and not from a stream of caught\n // sink failures under load.\n mkdirSync(dirname(path), { recursive: true });\n\n let liveBytes = existsSync(path) ? statSync(path).size : 0;\n\n function rotate(): void {\n // Oldest first, so nothing is overwritten before it has been moved along.\n const oldest = `${path}.${maxFiles}`;\n if (existsSync(oldest)) unlinkSync(oldest);\n for (let i = maxFiles - 1; i >= 1; i--) {\n const from = `${path}.${i}`;\n if (existsSync(from)) renameSync(from, `${path}.${i + 1}`);\n }\n if (existsSync(path)) renameSync(path, `${path}.1`);\n liveBytes = 0;\n }\n\n return (record: ExecutionRecord) => {\n const line = `${JSON.stringify(record)}\\n`;\n const size = Buffer.byteLength(line);\n // A single record larger than the whole budget still gets written, to its own generation,\n // rather than being silently dropped: losing an oversized record is losing evidence, and a\n // deployer who sees one file over budget can raise maxBytes. Dropping it teaches nothing.\n if (liveBytes > 0 && liveBytes + size > maxBytes) rotate();\n appendFileSync(path, line);\n liveBytes += size;\n };\n}\n"],"mappings":"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","names":["Registry"]} |
+5
-5
| { | ||
| "name": "@archstone/runtime", | ||
| "version": "0.11.7", | ||
| "version": "0.12.0", | ||
| "private": false, | ||
@@ -50,6 +50,6 @@ "type": "module", | ||
| "@modelcontextprotocol/sdk": "^1.12.0", | ||
| "@archstone/compiler": "0.11.7", | ||
| "@archstone/provider-rest": "0.11.7", | ||
| "@archstone/emitter-support": "0.11.7", | ||
| "@archstone/schema": "0.11.7" | ||
| "@archstone/emitter-support": "0.12.0", | ||
| "@archstone/compiler": "0.12.0", | ||
| "@archstone/provider-rest": "0.12.0", | ||
| "@archstone/schema": "0.12.0" | ||
| }, | ||
@@ -56,0 +56,0 @@ "devDependencies": { |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
129742
8.5%855
9.2%3
50%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated