@rulvar/core 1.36.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -6469,9 +6469,16 @@ declare class JsonlFileStore implements MetaLookupStore {
6469
6469
  /**
6470
6470
  * File-backed TranscriptStore (M6-T02): blobs (transcripts, checkpoints,
6471
6471
  * persisted CompiledWorkflow sources) as one file per ref under `dir`,
6472
- * so compiled runs resume across processes. Refs follow
6473
- * the `<runId>/<name>` convention; each path segment is checked
6474
- * filesystem-safe and nested segments become directories.
6472
+ * so compiled runs resume across processes. Refs follow the
6473
+ * `<runId>/<name>` convention; nested segments become directories.
6474
+ *
6475
+ * Every ref is contained under `dir` (v1.36.0 review SEC-P1): each
6476
+ * segment must match `[A-Za-z0-9._-]` and be neither empty, '.', nor
6477
+ * '..', and the resolved path must stay under the resolved root. A '..'
6478
+ * segment used to pass the per-segment alphabet (dots are in it) and, via
6479
+ * `join`, escape the root; a caller passing an untrusted ref (or an
6480
+ * untrusted runId, which prefixes checkpoint and workflow-source refs)
6481
+ * could read, write, or delete `.bin` files outside `dir`.
6475
6482
  */
6476
6483
  declare class FileTranscriptStore implements TranscriptStore {
6477
6484
  private readonly dir;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, getRandomValues, randomUUID } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
4
  import { Client } from "@modelcontextprotocol/sdk/client";
5
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
6
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -2302,6 +2302,79 @@ function applyClaimOps(claims, ops) {
2302
2302
  }
2303
2303
  return next;
2304
2304
  }
2305
+ /** A lowercase sha256 digest: 64 hex characters. */
2306
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
2307
+ const CLAIM_STATUSES = /* @__PURE__ */ new Set([
2308
+ "active",
2309
+ "stale",
2310
+ "superseded",
2311
+ "archived"
2312
+ ]);
2313
+ /**
2314
+ * Structural issues of one PERSISTED claim (empty = sound). Distinct from
2315
+ * the editorial commit validator (claims.ts): a persisted snapshot
2316
+ * legitimately holds non-active statuses (stale, superseded, archived) and
2317
+ * carries no gate, so only shape and vocabulary are checked here. This is
2318
+ * the boundary that keeps a null or partial claim from reaching the card
2319
+ * render, where `claim.status` would throw an untyped TypeError (v1.36.0
2320
+ * review P2-6).
2321
+ */
2322
+ function persistedClaimIssues(value, path) {
2323
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return [`${path}: expected a claim object`];
2324
+ const claim = value;
2325
+ const issues = [];
2326
+ if (typeof claim.id !== "string" || claim.id.length === 0) issues.push(`${path}.id: expected a non-empty string`);
2327
+ const subject = claim.subject;
2328
+ if (subject === null || typeof subject !== "object") issues.push(`${path}.subject: expected an object`);
2329
+ else if (typeof subject.model !== "string" || !subject.model.includes(":")) issues.push(`${path}.subject.model: expected a 'provider:model' string`);
2330
+ if (typeof claim.taskClass !== "string" || claim.taskClass.length === 0) issues.push(`${path}.taskClass: expected a non-empty string`);
2331
+ if (claim.polarity !== "strength" && claim.polarity !== "weakness") issues.push(`${path}.polarity: expected 'strength' or 'weakness'`);
2332
+ if (typeof claim.statement !== "string" || claim.statement.length === 0) issues.push(`${path}.statement: expected a non-empty string`);
2333
+ if (claim.class !== "eval-measured" && claim.class !== "human-editorial") issues.push(`${path}.class: expected 'eval-measured' or 'human-editorial'`);
2334
+ if (typeof claim.status !== "string" || !CLAIM_STATUSES.has(claim.status)) issues.push(`${path}.status: expected active, stale, superseded, or archived`);
2335
+ if (!Array.isArray(claim.evidence) || claim.evidence.length === 0) issues.push(`${path}.evidence: expected a non-empty array`);
2336
+ if (claim.confidence !== "high" && claim.confidence !== "medium" && claim.confidence !== "low") issues.push(`${path}.confidence: expected 'high', 'medium', or 'low'`);
2337
+ if (typeof claim.observedAt !== "string" || Number.isNaN(Date.parse(claim.observedAt))) issues.push(`${path}.observedAt: expected an ISO date`);
2338
+ if (typeof claim.expiresAt !== "string" || Number.isNaN(Date.parse(claim.expiresAt))) issues.push(`${path}.expiresAt: expected an ISO date`);
2339
+ const author = claim.author;
2340
+ if (author === null || typeof author !== "object") issues.push(`${path}.author: expected an object`);
2341
+ else {
2342
+ if (author.kind !== "eval-pipeline" && author.kind !== "human") issues.push(`${path}.author.kind: expected 'eval-pipeline' or 'human'`);
2343
+ if (typeof author.id !== "string" || author.id.length === 0) issues.push(`${path}.author.id: expected a non-empty string`);
2344
+ }
2345
+ return issues;
2346
+ }
2347
+ /**
2348
+ * The single read boundary of the store (v1.36.0 review P2-6). A persisted
2349
+ * snapshot must hold a nonnegative integer version, a lowercase sha256
2350
+ * hash, structurally sound claims, and a hash that MATCHES its claims: the
2351
+ * KnowledgeSnapshot contract promises the hash is the deterministic
2352
+ * content hash of the claims, so a file edited without rehashing (a forged
2353
+ * version or hash, a torn write) is refused with a typed ConfigError
2354
+ * instead of flowing on to forge the audit trail or crash the render.
2355
+ */
2356
+ function validateKnowledgeSnapshot(parsed, path) {
2357
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`knowledge store file does not hold a KnowledgeSnapshot: ${path}`);
2358
+ const snapshot = parsed;
2359
+ const issues = [];
2360
+ const version = snapshot.version;
2361
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 0) issues.push(`version: expected a nonnegative integer; got ${String(version)}`);
2362
+ const hash = snapshot.hash;
2363
+ if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) issues.push("hash: expected a lowercase sha256 digest of 64 hex characters");
2364
+ if (!Array.isArray(snapshot.claims)) issues.push("claims: expected an array");
2365
+ else snapshot.claims.forEach((claim, index) => {
2366
+ issues.push(...persistedClaimIssues(claim, `claims[${String(index)}]`));
2367
+ });
2368
+ if (issues.length > 0) throw new ConfigError(`knowledge store file is not a valid KnowledgeSnapshot (${path}):\n- ${issues.join("\n- ")}`);
2369
+ const claims = snapshot.claims;
2370
+ const recomputed = knowledgeHash(claims);
2371
+ if (hash !== recomputed) throw new ConfigError(`knowledge store hash does not match its claims (${path}): stored ${String(hash)}, computed ${recomputed}; the file was edited without rehashing`);
2372
+ return {
2373
+ version,
2374
+ hash,
2375
+ claims
2376
+ };
2377
+ }
2305
2378
  var FileModelKnowledgeStore = class {
2306
2379
  path;
2307
2380
  activeClaimsCap;
@@ -2330,13 +2403,7 @@ var FileModelKnowledgeStore = class {
2330
2403
  } catch (cause) {
2331
2404
  throw new ConfigError(`knowledge store file is not valid JSON: ${this.path}`, { cause });
2332
2405
  }
2333
- const snapshot = parsed;
2334
- if (snapshot === null || typeof snapshot.version !== "number" || typeof snapshot.hash !== "string" || !Array.isArray(snapshot.claims)) throw new ConfigError(`knowledge store file does not hold a KnowledgeSnapshot: ${this.path}`);
2335
- return {
2336
- version: snapshot.version,
2337
- hash: snapshot.hash,
2338
- claims: snapshot.claims
2339
- };
2406
+ return validateKnowledgeSnapshot(parsed, this.path);
2340
2407
  }
2341
2408
  async current() {
2342
2409
  return this.read();
@@ -6578,9 +6645,16 @@ const TRANSCRIPT_SUFFIX = ".bin";
6578
6645
  /**
6579
6646
  * File-backed TranscriptStore (M6-T02): blobs (transcripts, checkpoints,
6580
6647
  * persisted CompiledWorkflow sources) as one file per ref under `dir`,
6581
- * so compiled runs resume across processes. Refs follow
6582
- * the `<runId>/<name>` convention; each path segment is checked
6583
- * filesystem-safe and nested segments become directories.
6648
+ * so compiled runs resume across processes. Refs follow the
6649
+ * `<runId>/<name>` convention; nested segments become directories.
6650
+ *
6651
+ * Every ref is contained under `dir` (v1.36.0 review SEC-P1): each
6652
+ * segment must match `[A-Za-z0-9._-]` and be neither empty, '.', nor
6653
+ * '..', and the resolved path must stay under the resolved root. A '..'
6654
+ * segment used to pass the per-segment alphabet (dots are in it) and, via
6655
+ * `join`, escape the root; a caller passing an untrusted ref (or an
6656
+ * untrusted runId, which prefixes checkpoint and workflow-source refs)
6657
+ * could read, write, or delete `.bin` files outside `dir`.
6584
6658
  */
6585
6659
  var FileTranscriptStore = class {
6586
6660
  dir;
@@ -6590,9 +6664,13 @@ var FileTranscriptStore = class {
6590
6664
  }
6591
6665
  blobPath(ref) {
6592
6666
  const segments = ref.split("/");
6593
- for (const segment of segments) if (!/^[A-Za-z0-9._-]+$/.test(segment)) throw new JournalOrderViolation(`FileTranscriptStore: ref segment '${segment}' is not filesystem-safe`);
6667
+ for (const segment of segments) if (segment === "" || segment === "." || segment === ".." || !/^[A-Za-z0-9._-]+$/.test(segment)) throw new JournalOrderViolation(`FileTranscriptStore: ref segment '${segment}' is not filesystem-safe`);
6594
6668
  const name = segments.pop() ?? "";
6595
- return join(this.dir, ...segments, `${name}${TRANSCRIPT_SUFFIX}`);
6669
+ const path = join(this.dir, ...segments, `${name}${TRANSCRIPT_SUFFIX}`);
6670
+ const root = resolve(this.dir);
6671
+ const resolved = resolve(path);
6672
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new JournalOrderViolation(`FileTranscriptStore: ref '${ref}' resolves outside the configured root`);
6673
+ return path;
6596
6674
  }
6597
6675
  async put(ref, blob) {
6598
6676
  const path = this.blobPath(ref);
@@ -6610,6 +6688,7 @@ var FileTranscriptStore = class {
6610
6688
  }
6611
6689
  }
6612
6690
  async list(runId) {
6691
+ if (runId === "." || runId === "..") throw new JournalOrderViolation(`FileTranscriptStore: runId '${runId}' is not filesystem-safe`);
6613
6692
  const root = join(this.dir, safeName(runId));
6614
6693
  const refs = [];
6615
6694
  const walk = (dir, prefix) => {
@@ -13525,6 +13604,29 @@ var EventBus = class {
13525
13604
  }
13526
13605
  };
13527
13606
  //#endregion
13607
+ //#region src/l0/run-id.ts
13608
+ /**
13609
+ * Run id containment (v1.36.0 review SEC-P1). A runId becomes both a
13610
+ * journal path component (JsonlFileStore.safeName) and the PREFIX of every
13611
+ * transcript ref (checkpointRefFor, workflowSourceRef append `/...`). The
13612
+ * journal's whole-token regex rejects a separator, but a bare '.' or '..'
13613
+ * slips through as a single component there and, once a '/suffix' is
13614
+ * appended, becomes a real traversal segment at the transcript store. The
13615
+ * engine validates the runId at its boundary, before the first transcript
13616
+ * write, so an untrusted runId is refused with a typed ConfigError instead
13617
+ * of escaping the configured transcript root.
13618
+ */
13619
+ /** Filesystem-safe token: the journal store's own alphabet. */
13620
+ const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
13621
+ /**
13622
+ * Throws a ConfigError unless runId is a filesystem-safe token: a
13623
+ * non-empty string over [A-Za-z0-9._-] that is neither '.' nor '..'. The
13624
+ * dot pair passes the alphabet on its own, so it is refused explicitly.
13625
+ */
13626
+ function assertSafeRunId(runId, context) {
13627
+ if (typeof runId !== "string" || runId === "" || runId === "." || runId === ".." || !SAFE_RUN_ID.test(runId)) throw new ConfigError(`${context}: runId ${JSON.stringify(runId)} is not filesystem-safe (allowed: [A-Za-z0-9._-], and neither "." nor "..")`);
13628
+ }
13629
+ //#endregion
13528
13630
  //#region src/runner/inprocess.ts
13529
13631
  /**
13530
13632
  * ScriptRunner SPI and InProcessRunner (M1-T11).
@@ -13751,6 +13853,7 @@ function createEngine(options) {
13751
13853
  const compiled = wf.kind === "compiled-workflow" ? wf : void 0;
13752
13854
  if (compiled !== void 0 && options.runners?.sandbox === void 0) throw new ConfigError("running a CompiledWorkflow requires a sandbox runner: pass createEngine({ runners: { sandbox: new WorkerSandboxRunner() } }) from @rulvar/planner ");
13753
13855
  const runId = resumeCtx?.runId ?? opts?.runId ?? mintRunId();
13856
+ assertSafeRunId(runId, "engine.run");
13754
13857
  const registry = buildDeriverRegistry(options.extraDerivers);
13755
13858
  const segmentsBefore = resumeCtx?.segmentsBefore ?? 0;
13756
13859
  const telemetryBase = segmentsBefore * EVENT_SEGMENT_STRIDE;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "description": "Rulvar core: L0 contracts, journal kernel, ctx primitives, agent runtime, model router, tool system, dynamic orchestrator, InMemory and JSONL stores, event stream.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",