ai-runtime-engine 2.9.0 → 3.0.1

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +30 -0
  3. package/dist/agents/admit.d.ts +9 -1
  4. package/dist/agents/admit.js +10 -2
  5. package/dist/agents/envelope.d.ts +21 -0
  6. package/dist/agents/envelope.js +39 -5
  7. package/dist/agents/finding.d.ts +9 -3
  8. package/dist/agents/finding.js +14 -3
  9. package/dist/agents/worker.d.ts +3 -0
  10. package/dist/agents/worker.js +4 -1
  11. package/dist/cli/cli.js +8 -1
  12. package/dist/cli/commands/cleanup.js +11 -3
  13. package/dist/cli/commands/doctor.js +1 -1
  14. package/dist/cli/commands/run.js +6 -0
  15. package/dist/cli/commands/skills.js +9 -2
  16. package/dist/cli/interactive/repl.js +12 -2
  17. package/dist/cli/interactive/session.d.ts +2 -0
  18. package/dist/cli/interactive/session.js +6 -2
  19. package/dist/config/schema.js +19 -1
  20. package/dist/conversations/conversations.d.ts +6 -1
  21. package/dist/conversations/conversations.js +15 -8
  22. package/dist/core/fallback/fallback.d.ts +7 -0
  23. package/dist/core/fallback/fallback.js +15 -2
  24. package/dist/core/health/monitor.d.ts +6 -0
  25. package/dist/core/health/monitor.js +15 -2
  26. package/dist/core/router/confidence.js +10 -5
  27. package/dist/core/router/dimensions.d.ts +3 -1
  28. package/dist/core/router/dimensions.js +15 -5
  29. package/dist/core/router/filter.js +25 -6
  30. package/dist/core/router/normalize.js +2 -0
  31. package/dist/core/router/router.js +16 -2
  32. package/dist/core/router/scorer.d.ts +3 -0
  33. package/dist/core/router/scorer.js +17 -2
  34. package/dist/discovery/openapi.js +3 -2
  35. package/dist/executions/agentTasks.d.ts +4 -4
  36. package/dist/generation/generateAdapter.js +3 -1
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.js +3 -2
  39. package/dist/mcp/protocol.js +4 -1
  40. package/dist/memory/bm25.d.ts +7 -0
  41. package/dist/memory/bm25.js +17 -1
  42. package/dist/memory/memory.d.ts +7 -1
  43. package/dist/memory/memory.js +18 -4
  44. package/dist/orchestration/orchestrator.d.ts +2 -1
  45. package/dist/orchestration/planner.d.ts +2 -1
  46. package/dist/plugin/ai.d.ts +6 -0
  47. package/dist/plugin/ai.js +17 -2
  48. package/dist/providers/estimate.d.ts +25 -0
  49. package/dist/providers/estimate.js +55 -0
  50. package/dist/providers/factory.d.ts +3 -0
  51. package/dist/providers/factory.js +26 -5
  52. package/dist/providers/httpClient.js +4 -0
  53. package/dist/providers/httpProvider.js +4 -3
  54. package/dist/providers/mock/mockProvider.js +4 -3
  55. package/dist/runtime/config.d.ts +4 -3
  56. package/dist/runtime/config.js +14 -23
  57. package/dist/runtime/events.d.ts +6 -0
  58. package/dist/runtime/runtime.d.ts +43 -5
  59. package/dist/runtime/runtime.js +133 -25
  60. package/dist/runtime/types.d.ts +8 -1
  61. package/dist/store/area.d.ts +1 -1
  62. package/dist/store/area.js +34 -10
  63. package/dist/store/crypto.d.ts +27 -13
  64. package/dist/store/crypto.js +101 -23
  65. package/dist/store/errors.d.ts +11 -0
  66. package/dist/store/errors.js +14 -0
  67. package/dist/store/store.d.ts +21 -1
  68. package/dist/store/store.js +74 -19
  69. package/dist/telemetry/sinks/file.js +4 -2
  70. package/dist/telemetry/sinks/otlp.d.ts +12 -2
  71. package/dist/telemetry/sinks/otlp.js +39 -24
  72. package/dist/telemetry/telemetry.d.ts +5 -0
  73. package/dist/telemetry/telemetry.js +4 -0
  74. package/dist/tools/builtins/shell.d.ts +30 -3
  75. package/dist/tools/builtins/shell.js +218 -7
  76. package/dist/tools/untrusted.d.ts +1 -1
  77. package/dist/tools/untrusted.js +5 -3
  78. package/dist/types.d.ts +14 -0
  79. package/dist/verification/verify.js +10 -3
  80. package/docs/GUIDE.md +66 -1
  81. package/docs/README.md +1 -1
  82. package/docs/architecture.md +5 -1
  83. package/docs/router.md +1 -1
  84. package/docs/security.md +26 -7
  85. package/package.json +4 -2
@@ -55,6 +55,9 @@ import { ExecutionStore } from '../executions/store.js';
55
55
  import { RESUMABLE, TERMINAL } from '../executions/execution.js';
56
56
  import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
57
57
  import { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from '../agents/task.js';
58
+ import { synthesizeAgents } from '../agents/synthesize.js';
59
+ import { isDerivedAgentId } from '../agents/roles.js';
60
+ import { resolveConflicts } from '../agents/finding.js';
58
61
  import { parseAgentTasks } from '../executions/agentTasks.js';
59
62
  import { stepIdentity } from '../agents/worker.js';
60
63
  import { hashOf } from '../util/hash.js';
@@ -91,7 +94,7 @@ import { loadModelProfile, resolveModelDirective, directiveToOverrides } from '.
91
94
  import { HashEmbedder } from '../memory/embedders/hash.js';
92
95
  import { HttpEmbedder } from '../memory/embedders/http.js';
93
96
  import { Credential } from '../security/credentials.js';
94
- import { makeCodec, deriveKey } from '../store/crypto.js';
97
+ import { makeCodec } from '../store/crypto.js';
95
98
  import { AIError } from '../core/fallback/errors.js';
96
99
  import { tokenize } from '../memory/bm25.js';
97
100
  let runCounter = 0;
@@ -211,7 +214,7 @@ export class Runtime {
211
214
  });
212
215
  this._modelProfile = loadModelProfile(root); // models.md (per-mode/task routing); undefined if absent
213
216
  this._memory = new MemoryStore(this._store, clock, this.buildEmbedder(options.ai?.fetchImpl));
214
- this._conversations = new ConversationStore(this._store.conversations(), clock);
217
+ this._conversations = new ConversationStore(this._store.conversations(), clock, { withLock: (fn) => this._store.withLock(fn) });
215
218
  this._executions = new ExecutionStore(this._store.executions(), { clock });
216
219
  this._artifacts = new ArtifactStore(this._store.artifacts(), clock);
217
220
  this._learning = new LearningStore(this._store.learning());
@@ -288,7 +291,9 @@ export class Runtime {
288
291
  const key = new Credential(cfg.keyEnv, this.env).use();
289
292
  if (!key)
290
293
  throw new AIError(`storage encryption is enabled but the key env var ${cfg.keyEnv} is not set`, { category: 'CONFIG' });
291
- return makeCodec(deriveKey(key));
294
+ // Pass the SECRET (not a pre-derived key) so the codec uses the v2 scrypt scheme; it still reads any
295
+ // legacy aienc1 records transparently and upgrades them to aienc2 as they are rewritten.
296
+ return makeCodec(key);
292
297
  }
293
298
  /** Build a Runtime from a workspace: load config (.ai-runtime/config.yaml > root fallback), detect workspace. */
294
299
  static async load(options = {}) {
@@ -460,6 +465,12 @@ export class Runtime {
460
465
  registerAgent(id, def) {
461
466
  if (!/^[a-z0-9][a-z0-9_-]{0,32}$/.test(id))
462
467
  throw new AIError(`invalid agent id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
468
+ // The same reservation the config schema enforces. Without it here, a host could register
469
+ // `auto_investigate` and — with decompose on — produce two catalog rows with one id, where the
470
+ // derived one wins both lookups and the SAME step resolves to a different envelope depending on a
471
+ // flag. Reserving a namespace in only one of its two doors reserves nothing.
472
+ if (isDerivedAgentId(id))
473
+ throw new AIError(`agent id '${id}' uses the reserved 'auto_' prefix — that namespace belongs to agents the runtime derives`, { category: 'CONFIG' });
463
474
  this.agentDefs.set(id, def);
464
475
  return this;
465
476
  }
@@ -471,13 +482,34 @@ export class Runtime {
471
482
  * This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
472
483
  * catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
473
484
  */
474
- agentEnvelopes(policy) {
475
- if (!this.agentsEnabled || this.agentDefs.size === 0)
485
+ /**
486
+ * Agents synthesized from the registry for this goal (Phase 3.7). Empty unless
487
+ * `runtime.agents.decompose` is on — so with the flag off nothing about planning changes.
488
+ *
489
+ * Deterministic and offline: no model call, no clock, no randomness. That is a requirement, not a
490
+ * preference — a derived definition is hashed into `agentDefHash`, and a resume that synthesized
491
+ * even slightly differently would discard every persisted inner plan as stale.
492
+ */
493
+ derivedAgents() {
494
+ if (!this.agentsEnabled || this.settingsValue.agents?.decompose !== true)
495
+ return [];
496
+ const capabilities = this._capabilities.list().map((c) => ({ id: c.id, effects: c.effects, providers: this._capabilities.providersOf(c.id).map((p) => p.providerId) }));
497
+ return synthesizeAgents({ capabilities, parentTools: this._tools.ids() });
498
+ }
499
+ agentEnvelopes(policy, derived = []) {
500
+ // Derived definitions are passed IN and never stored: `agentDefs` lives for the process, so writing
501
+ // a per-goal agent into it would leak that agent into every later run on this Runtime.
502
+ const entries = [
503
+ ...[...this.agentDefs].map(([id, d]) => [id, d, 'authored']),
504
+ ...derived.map((d) => [d.id, d.definition, 'derived']),
505
+ ];
506
+ if (!this.agentsEnabled || entries.length === 0)
476
507
  return [];
477
508
  const routing = this.effectiveRouting();
478
- return [...this.agentDefs].map(([id, definition]) => narrowEnvelope({
509
+ return entries.map(([id, definition, provenance]) => narrowEnvelope({
479
510
  agentId: id,
480
511
  definition,
512
+ provenance,
481
513
  parentTools: this._tools.ids(),
482
514
  parentSkills: this.skills().map((sk) => ({ id: sk.id, ...(sk.tools ? { tools: sk.tools } : {}) })),
483
515
  parentPermissions: policy.permissions,
@@ -594,11 +626,13 @@ export class Runtime {
594
626
  return [...this.mcpWarnings, ...this._mcp.warningsList()];
595
627
  }
596
628
  /**
597
- * Release long-lived resources — today: MCP stdio child processes. A one-shot CLI command and the REPL
598
- * both call this on completion/exit; without it a spawned server keeps the event loop alive.
629
+ * Release long-lived resources: MCP stdio child processes, and any batching telemetry sink (so a
630
+ * short-lived run does not drop OTLP events buffered below the batch threshold). A one-shot CLI command
631
+ * and the REPL both call this on completion/exit.
599
632
  */
600
633
  async close() {
601
634
  await this._mcp.close();
635
+ await this._ai.close();
602
636
  }
603
637
  /** Skills whose required tools are all registered. */
604
638
  skills() {
@@ -621,9 +655,9 @@ export class Runtime {
621
655
  if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(manifest.id))
622
656
  throw new AIError(`unsafe skill id: ${JSON.stringify(manifest.id)}`, { category: 'CONFIG' });
623
657
  const dir = join(this.workspaceRoot, '.ai-runtime', 'skills');
624
- mkdirSync(dir, { recursive: true });
658
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
625
659
  const path = join(dir, `${manifest.id}.skill.yaml`);
626
- writeFileSync(path, stringifyYaml(manifest));
660
+ writeFileSync(path, stringifyYaml(manifest), { mode: 0o600 });
627
661
  return path;
628
662
  }
629
663
  /** The ACTION-capability registry (Phase 3.1): what this runtime can DO, and who provides it. */
@@ -800,6 +834,12 @@ export class Runtime {
800
834
  streamedText += chunk;
801
835
  this.emitter.emit({ type: 'response.delta', runId, text: chunk });
802
836
  };
837
+ // A failed streamed attempt's partial text is discarded: reset the accumulator so the survivor's
838
+ // stream is what `streamed` is compared against, and tell the host to clear the abandoned segment.
839
+ runRequest.onStreamAbandoned = ({ providerId, model }) => {
840
+ streamedText = '';
841
+ this.emitter.emit({ type: 'response.stream_abandoned', runId, providerId, model });
842
+ };
803
843
  }
804
844
  const runResult = await this._ai.run(runRequest);
805
845
  this.calibrate(runRequest.system, text, runResult);
@@ -1028,6 +1068,10 @@ export class Runtime {
1028
1068
  exec.completedSteps = [...exec.completedSteps, record.stepId];
1029
1069
  checkpointIfMoved();
1030
1070
  }
1071
+ // A finished task is the only moment new findings can appear, so it is the only moment two of
1072
+ // them can start disagreeing.
1073
+ if (AGENT_TERMINAL.has(record.state))
1074
+ this.resolveFindingConflicts(exec);
1031
1075
  commit();
1032
1076
  },
1033
1077
  /**
@@ -1070,9 +1114,10 @@ export class Runtime {
1070
1114
  ...(routing ? { routing } : {}),
1071
1115
  ...(partial ? { partial: true } : {}),
1072
1116
  ...(this.approval ? { approval: this.approval } : {}),
1073
- // Phase 3.1: the capability snapshot is opt-in (`runtime.capabilities.catalog`); the gap resolver is
1074
- // always on but only fires on a validation failure, adding metadata to an unchanged error.
1075
- ...(this.settingsValue.capabilities?.catalog ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1117
+ // Phase 3.1, ON by default from 3.0.0 (`runtime.capabilities.catalog: false` removes it); the gap
1118
+ // resolver is always on but only fires on a validation failure, adding metadata to an unchanged error.
1119
+ // 3.0.0: ON by default. `catalog: false` removes it — the only way to get the 2.9.0 prompt back.
1120
+ ...(this.settingsValue.capabilities?.catalog !== false ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1076
1121
  // Phase 3.3: the derived-requirement block (opt-in, pre-rendered + clamped). Absent ⇒ the
1077
1122
  // OrchestrateInput/PlannerInput objects are key-identical to 2.5.1.
1078
1123
  ...(requiredCapabilities ? { requiredCapabilities } : {}),
@@ -1102,11 +1147,19 @@ export class Runtime {
1102
1147
  this._learning.record({ goalType: this.goalType(goal), mode, ok: outcome.status === 'completed', skills: this.planSkillRefs(outcome.plan) });
1103
1148
  }
1104
1149
  /**
1105
- * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1, opt-in). Untrusted
1106
- * sources (anything not an in-tree builtin) have their descriptions fenced, and the block is bounded so
1107
- * a large catalog can never dominate the prompt.
1150
+ * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1; ON by default from
1151
+ * 3.0.0 set `runtime.capabilities.catalog: false` to remove it).
1152
+ *
1153
+ * The fence is real as of 3.0.0 and was not before: this block carries ids that come from MCP servers
1154
+ * and third-party skills, and it renders them as trusted-looking prompt structure on every planning
1155
+ * iteration of every run. Flattening (`promptSafe`) bounds their shape but says nothing about their
1156
+ * provenance, so the whole block is wrapped as untrusted data. Three comments claimed "fenced" while
1157
+ * no fence existed; shipping that ON by default would have made a false safety claim load-bearing.
1158
+ *
1159
+ * Both halves are bounded. The blocked-skill list had no cap at all — measured at ~24k characters
1160
+ * with 300 blocked skills, silently, in every prompt.
1108
1161
  */
1109
- capabilityCatalogText(maxEntries = 40) {
1162
+ capabilityCatalogText(maxEntries = 40, maxBlocked = 15) {
1110
1163
  const caps = this._capabilities.list();
1111
1164
  if (caps.length === 0)
1112
1165
  return '';
@@ -1116,17 +1169,21 @@ export class Runtime {
1116
1169
  .providersOf(c.id)
1117
1170
  .map((p) => `${promptSafe(p.providerId)}${p.availability === 'available' ? '' : ` (${p.availability})`}`)
1118
1171
  .join(', ');
1119
- lines.push(` - capability "${promptSafe(c.id)}" [${c.effects.join('/')}] ${promptSafe(providers, 200)}`);
1172
+ // Every segment is clamped, `effects` included: it is an array off a declaration, so a hostile or
1173
+ // simply careless source can make one row arbitrarily long.
1174
+ lines.push(` - capability "${promptSafe(c.id)}" [${promptSafe(c.effects.join('/'), 40)}] → ${promptSafe(providers, 200)}`);
1120
1175
  }
1121
1176
  const more = caps.length > maxEntries ? `\n …and ${caps.length - maxEntries} more (see /capabilities)` : '';
1122
1177
  // Skills hidden by a missing tool — the "why can't you do this" answer the planner needs.
1123
1178
  const usable = new Set(this.skills().map((sk) => sk.id));
1124
- const blocked = this._skills
1125
- .list()
1126
- .filter((sk) => !usable.has(sk.id))
1179
+ const blockedAll = this._skills.list().filter((sk) => !usable.has(sk.id));
1180
+ const blocked = blockedAll
1181
+ .slice(0, maxBlocked)
1127
1182
  .map((sk) => ` - skill "${promptSafe(sk.id)}" needs tool(s) ${promptSafe((sk.tools ?? []).filter((t) => !this._tools.ids().includes(t)).join(', '), 200)} (not registered)`);
1128
- const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}` : '';
1129
- return `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`;
1183
+ const blockedMore = blockedAll.length > maxBlocked ? `\n …and ${blockedAll.length - maxBlocked} more` : '';
1184
+ const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}${blockedMore}` : '';
1185
+ // The ids inside come from MCP servers and third-party skills. Fenced as data, not structure.
1186
+ return wrapUntrusted('capability-catalog', `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`);
1130
1187
  }
1131
1188
  /** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
1132
1189
  configBudget() {
@@ -1503,8 +1560,12 @@ export class Runtime {
1503
1560
  */
1504
1561
  orchestrateRunners(policy, opts) {
1505
1562
  const { runId, signal, provenance, onRecord, agentResume } = opts;
1506
- const envelopes = policy ? this.agentEnvelopes(policy) : [];
1563
+ const derived = this.derivedAgents();
1564
+ const envelopes = policy ? this.agentEnvelopes(policy, derived) : [];
1507
1565
  const byId = new Map(envelopes.map((e) => [e.agentId, e]));
1566
+ // Definitions come from a LOCAL map, not the process-lifetime field: a derived agent exists for
1567
+ // this run only, and must not be findable by any later one.
1568
+ const defsById = new Map([...this.agentDefs, ...derived.map((d) => [d.id, d.definition])]);
1508
1569
  return {
1509
1570
  runSkill: (skillId, input) => this.runSkill(skillId, input).then((o) => ({ result: o.result, validation: o.validation })),
1510
1571
  runTool: (toolId, input) => this.runTool(toolId, input),
@@ -1514,13 +1575,14 @@ export class Runtime {
1514
1575
  reserve: (step) => (step.agent ? byId.get(step.agent)?.reservation ?? 1 : 0),
1515
1576
  runAgent: async (step, ctx) => {
1516
1577
  const envelope = byId.get(step.agent ?? '');
1517
- const definition = this.agentDefs.get(step.agent ?? '');
1578
+ const definition = defsById.get(step.agent ?? '');
1518
1579
  if (!envelope || !definition)
1519
1580
  return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
1520
1581
  const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
1521
1582
  // Phase 3.5: continue a persisted task for THIS step, if one exists.
1522
1583
  const prior = agentResume?.(step);
1523
1584
  const out = await runAgentTask(step, envelope, definition, {
1585
+ ...(derived.some((d) => d.id === step.agent) ? { derived: true } : {}),
1524
1586
  ...(prior?.record ? { resume: prior.record } : {}),
1525
1587
  ...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
1526
1588
  ai: this._ai,
@@ -1579,6 +1641,52 @@ export class Runtime {
1579
1641
  : {}),
1580
1642
  };
1581
1643
  }
1644
+ /**
1645
+ * Reconcile findings that contradict each other, across ALL of this execution's agent tasks
1646
+ * (Phase 3.7).
1647
+ *
1648
+ * `resolveConflicts` has existed since 3.4 with no caller, so two agents reaching opposite
1649
+ * conclusions about the same subject both stayed `active` — and both were rendered into the next
1650
+ * planning prompt, as if the runtime had no opinion about which was better supported. It does: it
1651
+ * weighs evidence-based `confidence`, with `executionCoverage` only as a tiebreak.
1652
+ *
1653
+ * Runs at the ONE point new findings can appear — a task reaching a terminal state — and writes the
1654
+ * outcome back onto the owning records, so a supersession survives a restart rather than being
1655
+ * recomputed (and possibly recomputed differently) on every read.
1656
+ *
1657
+ * EVERY finding is passed in, not just the active ones. Resolving over the active subset makes the
1658
+ * result depend on the order tasks happen to finish: a finding that beat a weak rival in wave 1 can
1659
+ * itself lose in wave 2, and the wave-1 loser is then left pointing at a superseded finding — a
1660
+ * broken chain nothing heals. Re-resolving the whole set each round is order-independent and gives
1661
+ * the same answer as one pass over the final set.
1662
+ */
1663
+ resolveFindingConflicts(exec) {
1664
+ const { tasks } = parseAgentTasks(exec);
1665
+ const all = tasks.flatMap((t) => t.findings);
1666
+ if (all.length < 2)
1667
+ return;
1668
+ const resolved = new Map(resolveConflicts(all).map((f) => [f.id, f]));
1669
+ let changed = false;
1670
+ const next = tasks.map((t) => {
1671
+ const findings = t.findings.map((f) => {
1672
+ const r = resolved.get(f.id);
1673
+ if (!r || (r.status === f.status && r.supersededBy === f.supersededBy))
1674
+ return f;
1675
+ changed = true;
1676
+ const next = { ...f, status: r.status };
1677
+ if (r.supersededBy)
1678
+ next.supersededBy = r.supersededBy;
1679
+ else
1680
+ delete next.supersededBy;
1681
+ return next;
1682
+ });
1683
+ return changed ? { ...t, findings } : t;
1684
+ });
1685
+ if (!changed)
1686
+ return;
1687
+ const byId = new Map(next.map((t) => [t.agentTaskId, t]));
1688
+ exec.agentTasks = (exec.agentTasks ?? []).map((entry) => byId.get(entry.agentTaskId ?? '') ?? entry);
1689
+ }
1582
1690
  /**
1583
1691
  * A bounded, fenced brief of what the agents have already established (Phase 3.5).
1584
1692
  *
@@ -90,7 +90,8 @@ export interface RuntimeSettings {
90
90
  encrypt: boolean;
91
91
  keyEnv: string;
92
92
  };
93
- /** Action capabilities (Phase 3.1/3.3). `catalog` enriches the planner catalog (default off);
93
+ /** Action capabilities (Phase 3.1/3.3). `catalog` enriches the planner catalog (default ON since 3.0.0;
94
+ * set `catalog: false` to remove the block);
94
95
  * `planning` (3.3, default OFF) derives the capabilities a goal needs BEFORE planning — offline BM25
95
96
  * first, one model call only when that finds nothing AND no call budget is set AND this is not a dry
96
97
  * run — resolves them against the run's permissions, and reports gaps. ADVISORY: it never blocks a
@@ -120,6 +121,12 @@ export interface RuntimeSettings {
120
121
  * the worker's hard inner-model-call ceiling. */
121
122
  agents?: {
122
123
  enabled?: boolean;
124
+ /** Phase 3.7, default OFF: offer the goal a bounded, read-shaped agent per shipped ROLE, with no
125
+ * operator-authored definition. The roles and their objectives are in-tree; nothing the model
126
+ * writes becomes an objective, a tool id or a permission.
127
+ *
128
+ * REQUIRES `enabled: true` — on its own this does nothing, because agent execution is off. */
129
+ decompose?: boolean;
123
130
  maxToolCalls?: number;
124
131
  maxDurationMs?: number;
125
132
  maxInnerCalls?: number;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export interface IntegrityIssue {
7
7
  file: string;
8
- problem: 'unreadable' | 'checksum-mismatch' | 'invalid-json';
8
+ problem: 'unreadable' | 'checksum-mismatch' | 'invalid-json' | 'decrypt-auth-failed' | 'unsupported-version';
9
9
  detail?: string;
10
10
  }
11
11
  /**
@@ -6,8 +6,19 @@
6
6
  import { createHash } from 'node:crypto';
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
+ import { StoreDecryptError } from './errors.js';
9
10
  const STORE_VERSION = 1;
10
11
  const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
12
+ /** Classify a caught read error into an IntegrityIssue problem code (decrypt errors are distinguished). */
13
+ function classifyProblem(err) {
14
+ if (err instanceof StoreDecryptError) {
15
+ if (err.code === 'AUTH_FAILED')
16
+ return 'decrypt-auth-failed';
17
+ if (err.code === 'UNSUPPORTED_VERSION')
18
+ return 'unsupported-version';
19
+ }
20
+ return 'invalid-json';
21
+ }
11
22
  /** Matches an orphaned atomic-write temp file: `<id>.json.tmp-<pid>-<n>` / `<id>.jsonl.tmp-…`. */
12
23
  const TEMP_RE = /\.(?:json|jsonl)\.tmp-/;
13
24
  function assertId(id) {
@@ -37,7 +48,9 @@ export class FileArea {
37
48
  }
38
49
  ensure() {
39
50
  if (!this.ensured) {
40
- mkdirSync(this.dir, { recursive: true });
51
+ // 0700: store dirs hold conversation/memory/execution/agent records — owner-only on shared hosts.
52
+ // (mode applies only to dirs this call creates; POSIX-only, a no-op on Windows.)
53
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
41
54
  this.ensured = true;
42
55
  }
43
56
  }
@@ -51,7 +64,8 @@ export class FileArea {
51
64
  this.ensure();
52
65
  this.counter += 1;
53
66
  const tmp = `${path}.tmp-${process.pid}-${this.counter}`;
54
- writeFileSync(tmp, this.encode(contents));
67
+ // 0600: rename preserves the tmp file's mode, so the final record is owner-only too.
68
+ writeFileSync(tmp, this.encode(contents), { mode: 0o600 });
55
69
  renameSync(tmp, path);
56
70
  }
57
71
  readJson(id) {
@@ -68,8 +82,14 @@ export class FileArea {
68
82
  try {
69
83
  return this.readJson(id);
70
84
  }
71
- catch {
72
- return undefined; // a corrupt record must not brick bulk listing/search
85
+ catch (err) {
86
+ // A wrong key makes EVERY encrypted record fail soft-skipping it would present the whole store as
87
+ // empty, a silent data-loss illusion. Rethrow AUTH_FAILED so the caller sees a real error; genuine
88
+ // per-record corruption (checksum/JSON/unsupported-version) still soft-skips so one bad record can't
89
+ // brick bulk listing/search.
90
+ if (err instanceof StoreDecryptError && err.code === 'AUTH_FAILED')
91
+ throw err;
92
+ return undefined;
73
93
  }
74
94
  }
75
95
  writeJson(id, data) {
@@ -96,7 +116,8 @@ export class FileArea {
96
116
  assertId(id);
97
117
  this.ensure();
98
118
  // Each JSONL line is encrypted independently (its own IV), so appends stay append-only.
99
- appendFileSync(this.logPath(id), this.encode(JSON.stringify(obj)) + '\n');
119
+ // 0600 applies when the file is first created; an existing file keeps its mode (append doesn't chmod).
120
+ appendFileSync(this.logPath(id), this.encode(JSON.stringify(obj)) + '\n', { mode: 0o600 });
100
121
  }
101
122
  readLines(id) {
102
123
  assertId(id);
@@ -110,8 +131,11 @@ export class FileArea {
110
131
  try {
111
132
  out.push(JSON.parse(this.decode(line)));
112
133
  }
113
- catch {
114
- /* skip a corrupt/undecryptable line rather than failing the whole log (surfaced by check()) */
134
+ catch (err) {
135
+ // Wrong key fails every line — surface it rather than returning a silently-empty log; a single
136
+ // corrupt line is still skipped (surfaced by check()).
137
+ if (err instanceof StoreDecryptError && err.code === 'AUTH_FAILED')
138
+ throw err;
115
139
  }
116
140
  }
117
141
  return out;
@@ -133,7 +157,7 @@ export class FileArea {
133
157
  issues.push({ file: path, problem: 'checksum-mismatch' });
134
158
  }
135
159
  catch (err) {
136
- issues.push({ file: path, problem: 'invalid-json', detail: err instanceof Error ? err.message : String(err) });
160
+ issues.push({ file: path, problem: classifyProblem(err), detail: err instanceof Error ? err.message : String(err) });
137
161
  }
138
162
  }
139
163
  else if (f.endsWith('.jsonl')) {
@@ -142,8 +166,8 @@ export class FileArea {
142
166
  try {
143
167
  JSON.parse(this.decode(line));
144
168
  }
145
- catch {
146
- issues.push({ file: path, problem: 'invalid-json', detail: `line ${i + 1}` });
169
+ catch (err) {
170
+ issues.push({ file: path, problem: classifyProblem(err), detail: `line ${i + 1}` });
147
171
  }
148
172
  });
149
173
  }
@@ -1,21 +1,35 @@
1
1
  /**
2
- * Transparent encryption at rest (Phase 18) — AES-256-GCM via `node:crypto` (zero new deps). A `ContentCodec`
3
- * transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope
4
- * `aienc1:<base64(iv|tag|ciphertext)>`; `decode` reverses it. `decode` is deliberately lenient on the way IN —
5
- * a string WITHOUT the `aienc1:` prefix is returned unchanged, so a store that predates encryption keeps
6
- * reading after it is enabled (new writes are always encrypted). A wrong key / tampered payload makes `decode`
7
- * THROW, which the store maps onto its existing corruption path (soft-skip in bulk reads, reported by check()).
2
+ * Transparent encryption at rest — AES-256-GCM via `node:crypto` (zero new deps). A `ContentCodec`
3
+ * transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope,
4
+ * `decode` reverses it. `decode` is lenient on the way IN — a string WITHOUT a known envelope prefix is
5
+ * returned unchanged, so a store that predates encryption keeps reading after it is enabled.
8
6
  *
9
- * SECURITY: the derived key is a Buffer held only inside the codec closure; it is never serialized, logged, or
10
- * placed in an error. The AES key is sha256(secret), so the raw secret length is irrelevant.
7
+ * TWO envelope versions coexist:
8
+ * - `aienc1:` (v1, legacy) key = sha256(secret). Still READ transparently; never written by 3.0.1+.
9
+ * - `aienc2:` (v2, current) — key = scrypt(secret, per-record salt). A proper KDF (memory-hard) so a
10
+ * weak/passphrase secret is not brute-forceable at raw-hash speed. Records upgrade to v2 lazily as
11
+ * they are rewritten (no bulk migration). The scrypt key is CACHED per salt inside the codec, so a
12
+ * bulk read does not pay a fresh derivation per record.
13
+ *
14
+ * A decrypt failure throws a typed `StoreDecryptError` (AUTH_FAILED / UNSUPPORTED_VERSION / CORRUPT) —
15
+ * honest about the fact that a GCM auth failure is wrong-key OR tampering OR corruption, not certainly a
16
+ * wrong key. SECURITY: derived keys are Buffers held only inside the codec closure; never serialized,
17
+ * logged, or placed in an error.
11
18
  */
12
19
  import type { ContentCodec } from './area.js';
13
20
  export declare const ENVELOPE_PREFIX = "aienc1:";
14
- /** Derive the 32-byte AES-256 key from an arbitrary secret (sha256 → fixed length, secret length irrelevant). */
21
+ export declare const ENVELOPE_PREFIX_V2 = "aienc2:";
22
+ /** Derive the 32-byte v1 AES key (sha256 → fixed length). Retained for reading legacy `aienc1:` records. */
15
23
  export declare function deriveKey(secret: string): Buffer;
16
- /** Encrypt a UTF-8 string into the self-identifying `aienc1:` envelope. A fresh random IV per call. */
24
+ /** v1 encrypt into the `aienc1:` envelope (legacy; retained for the exported API and Buffer-key codec). */
17
25
  export declare function encryptString(plain: string, key: Buffer): string;
18
- /** Decrypt an `aienc1:` envelope; a string without the prefix is returned unchanged (mixed/legacy store). */
26
+ /** v1 decrypt of an `aienc1:` envelope; a non-envelope string passes through (mixed/legacy store). */
19
27
  export declare function decryptString(stored: string, key: Buffer): string;
20
- /** Build the codec `FileArea` uses to encrypt/decrypt its file contents. */
21
- export declare function makeCodec(key: Buffer): ContentCodec;
28
+ /** v2 encrypt into the `aienc2:` envelope (salt‖iv‖tag‖ciphertext). */
29
+ export declare function encryptStringV2(plain: string, key: Buffer, salt: Buffer): string;
30
+ /**
31
+ * Build the codec `FileArea` uses. A **secret string** yields the current v2 codec (scrypt; writes
32
+ * `aienc2:`, reads every version). A **Buffer** yields the legacy v1 codec (sha256; writes `aienc1:`) —
33
+ * retained so existing `makeCodec(deriveKey(secret))` callers behave exactly as before.
34
+ */
35
+ export declare function makeCodec(keyOrSecret: Buffer | string): ContentCodec;
@@ -1,23 +1,53 @@
1
1
  /**
2
- * Transparent encryption at rest (Phase 18) — AES-256-GCM via `node:crypto` (zero new deps). A `ContentCodec`
3
- * transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope
4
- * `aienc1:<base64(iv|tag|ciphertext)>`; `decode` reverses it. `decode` is deliberately lenient on the way IN —
5
- * a string WITHOUT the `aienc1:` prefix is returned unchanged, so a store that predates encryption keeps
6
- * reading after it is enabled (new writes are always encrypted). A wrong key / tampered payload makes `decode`
7
- * THROW, which the store maps onto its existing corruption path (soft-skip in bulk reads, reported by check()).
2
+ * Transparent encryption at rest — AES-256-GCM via `node:crypto` (zero new deps). A `ContentCodec`
3
+ * transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope,
4
+ * `decode` reverses it. `decode` is lenient on the way IN — a string WITHOUT a known envelope prefix is
5
+ * returned unchanged, so a store that predates encryption keeps reading after it is enabled.
8
6
  *
9
- * SECURITY: the derived key is a Buffer held only inside the codec closure; it is never serialized, logged, or
10
- * placed in an error. The AES key is sha256(secret), so the raw secret length is irrelevant.
7
+ * TWO envelope versions coexist:
8
+ * - `aienc1:` (v1, legacy) key = sha256(secret). Still READ transparently; never written by 3.0.1+.
9
+ * - `aienc2:` (v2, current) — key = scrypt(secret, per-record salt). A proper KDF (memory-hard) so a
10
+ * weak/passphrase secret is not brute-forceable at raw-hash speed. Records upgrade to v2 lazily as
11
+ * they are rewritten (no bulk migration). The scrypt key is CACHED per salt inside the codec, so a
12
+ * bulk read does not pay a fresh derivation per record.
13
+ *
14
+ * A decrypt failure throws a typed `StoreDecryptError` (AUTH_FAILED / UNSUPPORTED_VERSION / CORRUPT) —
15
+ * honest about the fact that a GCM auth failure is wrong-key OR tampering OR corruption, not certainly a
16
+ * wrong key. SECURITY: derived keys are Buffers held only inside the codec closure; never serialized,
17
+ * logged, or placed in an error.
11
18
  */
12
- import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
13
- export const ENVELOPE_PREFIX = 'aienc1:';
19
+ import { createHash, randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto';
20
+ import { StoreDecryptError } from './errors.js';
21
+ export const ENVELOPE_PREFIX = 'aienc1:'; // v1 (legacy)
22
+ export const ENVELOPE_PREFIX_V2 = 'aienc2:'; // v2 (current)
14
23
  const IV_LEN = 12; // GCM standard nonce
15
24
  const TAG_LEN = 16; // GCM auth tag
16
- /** Derive the 32-byte AES-256 key from an arbitrary secret (sha256 → fixed length, secret length irrelevant). */
25
+ const SALT_LEN = 16; // per-record scrypt salt (v2)
26
+ const KEY_LEN = 32; // AES-256
27
+ // scrypt cost: N=2^14, r=8, p=1 → ~16 MiB working set (under node's 32 MiB default maxmem), ~tens of ms.
28
+ // A change to these parameters requires a new envelope version (aienc3), since the salt alone does not
29
+ // record them.
30
+ const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
31
+ /** Derive the 32-byte v1 AES key (sha256 → fixed length). Retained for reading legacy `aienc1:` records. */
17
32
  export function deriveKey(secret) {
18
33
  return createHash('sha256').update(secret).digest();
19
34
  }
20
- /** Encrypt a UTF-8 string into the self-identifying `aienc1:` envelope. A fresh random IV per call. */
35
+ /** Derive the 32-byte v2 AES key via scrypt over (secret, salt). Memory-hard resists brute force. */
36
+ function deriveKeyScrypt(secret, salt) {
37
+ return scryptSync(secret, salt, KEY_LEN, SCRYPT_PARAMS);
38
+ }
39
+ /** GCM-decrypt; a failed auth tag becomes a typed AUTH_FAILED (wrong key OR tampering OR corruption). */
40
+ function gcmDecrypt(key, iv, tag, ct) {
41
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
42
+ decipher.setAuthTag(tag);
43
+ try {
44
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
45
+ }
46
+ catch {
47
+ throw new StoreDecryptError('AUTH_FAILED', 'store record failed authentication (wrong key, tampering, or corruption)');
48
+ }
49
+ }
50
+ /** v1 encrypt into the `aienc1:` envelope (legacy; retained for the exported API and Buffer-key codec). */
21
51
  export function encryptString(plain, key) {
22
52
  const iv = randomBytes(IV_LEN);
23
53
  const cipher = createCipheriv('aes-256-gcm', key, iv);
@@ -25,25 +55,73 @@ export function encryptString(plain, key) {
25
55
  const tag = cipher.getAuthTag();
26
56
  return ENVELOPE_PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
27
57
  }
28
- /** Decrypt an `aienc1:` envelope; a string without the prefix is returned unchanged (mixed/legacy store). */
58
+ /** v1 decrypt of an `aienc1:` envelope; a non-envelope string passes through (mixed/legacy store). */
29
59
  export function decryptString(stored, key) {
30
60
  if (!stored.startsWith(ENVELOPE_PREFIX))
31
- return stored; // plaintext (pre-encryption) record — read as-is
61
+ return stored;
32
62
  const buf = Buffer.from(stored.slice(ENVELOPE_PREFIX.length), 'base64');
33
63
  if (buf.length < IV_LEN + TAG_LEN)
34
- throw new Error('encrypted record is truncated');
64
+ throw new StoreDecryptError('CORRUPT', 'encrypted record is truncated');
35
65
  const iv = buf.subarray(0, IV_LEN);
36
66
  const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
37
67
  const ct = buf.subarray(IV_LEN + TAG_LEN);
38
- const decipher = createDecipheriv('aes-256-gcm', key, iv);
39
- decipher.setAuthTag(tag);
40
- // GCM `final()` throws on a wrong key or tampered ciphertext — surfaces as store corruption to the caller.
41
- return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
68
+ return gcmDecrypt(key, iv, tag, ct);
69
+ }
70
+ /** v2 encrypt into the `aienc2:` envelope (salt‖iv‖tag‖ciphertext). */
71
+ export function encryptStringV2(plain, key, salt) {
72
+ const iv = randomBytes(IV_LEN);
73
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
74
+ const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
75
+ const tag = cipher.getAuthTag();
76
+ return ENVELOPE_PREFIX_V2 + Buffer.concat([salt, iv, tag, ct]).toString('base64');
42
77
  }
43
- /** Build the codec `FileArea` uses to encrypt/decrypt its file contents. */
44
- export function makeCodec(key) {
78
+ /**
79
+ * Decrypt ANY known envelope with the given secret: v2 (scrypt per embedded salt), v1 (sha256), or
80
+ * plaintext passthrough. An `aiencN:` prefix this build does not know is UNSUPPORTED_VERSION. `keyFor`
81
+ * supplies (and caches) the scrypt key for a v2 salt.
82
+ */
83
+ function decryptEnvelope(stored, secret, keyFor) {
84
+ if (stored.startsWith(ENVELOPE_PREFIX_V2)) {
85
+ const buf = Buffer.from(stored.slice(ENVELOPE_PREFIX_V2.length), 'base64');
86
+ if (buf.length < SALT_LEN + IV_LEN + TAG_LEN)
87
+ throw new StoreDecryptError('CORRUPT', 'encrypted record is truncated');
88
+ const salt = buf.subarray(0, SALT_LEN);
89
+ const iv = buf.subarray(SALT_LEN, SALT_LEN + IV_LEN);
90
+ const tag = buf.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
91
+ const ct = buf.subarray(SALT_LEN + IV_LEN + TAG_LEN);
92
+ return gcmDecrypt(keyFor(salt), iv, tag, ct);
93
+ }
94
+ if (stored.startsWith(ENVELOPE_PREFIX))
95
+ return decryptString(stored, deriveKey(secret)); // v1 legacy
96
+ const m = stored.match(/^aienc(\d+):/);
97
+ if (m)
98
+ throw new StoreDecryptError('UNSUPPORTED_VERSION', `store record uses an unsupported envelope version 'aienc${m[1]}'`);
99
+ return stored; // plaintext (pre-encryption) record — read as-is
100
+ }
101
+ /**
102
+ * Build the codec `FileArea` uses. A **secret string** yields the current v2 codec (scrypt; writes
103
+ * `aienc2:`, reads every version). A **Buffer** yields the legacy v1 codec (sha256; writes `aienc1:`) —
104
+ * retained so existing `makeCodec(deriveKey(secret))` callers behave exactly as before.
105
+ */
106
+ export function makeCodec(keyOrSecret) {
107
+ if (Buffer.isBuffer(keyOrSecret)) {
108
+ const key = keyOrSecret;
109
+ return { encode: (plain) => encryptString(plain, key), decode: (stored) => decryptString(stored, key) };
110
+ }
111
+ const secret = keyOrSecret;
112
+ const encodeSalt = randomBytes(SALT_LEN); // one salt per codec instance → writes share one derivation
113
+ const keyCache = new Map();
114
+ const keyFor = (salt) => {
115
+ const h = salt.toString('hex');
116
+ let k = keyCache.get(h);
117
+ if (!k) {
118
+ k = deriveKeyScrypt(secret, salt);
119
+ keyCache.set(h, k);
120
+ }
121
+ return k;
122
+ };
45
123
  return {
46
- encode: (plain) => encryptString(plain, key),
47
- decode: (stored) => decryptString(stored, key),
124
+ encode: (plain) => encryptStringV2(plain, keyFor(encodeSalt), encodeSalt),
125
+ decode: (stored) => decryptEnvelope(stored, secret, keyFor),
48
126
  };
49
127
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Typed store-layer errors. Decryption failures carry an HONEST code: an AEAD (GCM) authentication
3
+ * failure does NOT prove the key is wrong — it means wrong key OR tampering OR corruption, and the code
4
+ * says exactly that. Higher layers may infer "likely key mismatch" only from AGGREGATE evidence (most/
5
+ * all encrypted records failing at once), never from a single record.
6
+ */
7
+ export type StoreDecryptCode = 'AUTH_FAILED' | 'UNSUPPORTED_VERSION' | 'CORRUPT';
8
+ export declare class StoreDecryptError extends Error {
9
+ readonly code: StoreDecryptCode;
10
+ constructor(code: StoreDecryptCode, message: string);
11
+ }