session-orchestrator 3.19.0 → 3.20.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.
Files changed (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -30,6 +30,7 @@
30
30
 
31
31
  import { dirname } from 'node:path';
32
32
 
33
+ import { learningKeyOf } from '../learnings/kebab.mjs';
33
34
  import { deriveExpiresAt } from '../learnings/schema.mjs';
34
35
 
35
36
  const DAY_MS = 86400 * 1000;
@@ -44,26 +45,64 @@ const DESCRIPTION_MAX = 120;
44
45
  // shortens a healthy future one.
45
46
  const MIN_RULE_DAYS_DEFAULT = 7;
46
47
 
48
+ // ── #1015: field-shape gates (VALIDATION, never transformation) ──────────────
49
+ // This emitter REJECTS agent-authored values whose SHAPE cannot be serialised
50
+ // into frontmatter safely. It deliberately does NOT escape or neutralise
51
+ // content: neutralisation of agent text happens exactly once, in the renderer's
52
+ // render path. Escaping here as well would double-escape the output — a defect
53
+ // invisible to a file-level diff, because each file still looks locally correct.
54
+ // Rejection is idempotent, so it composes safely with the renderer's sanitiser.
55
+
56
+ /**
57
+ * Accepted shape for `learning.host_class`.
58
+ *
59
+ * `host-class` is by far the highest-escalation field this emitter copies: the
60
+ * renderer serialises it UNQUOTED as `host-class: <value>`, so a newline in it
61
+ * injects sibling TOP-LEVEL frontmatter keys. An injected `alwaysApply: true`
62
+ * plus `expires-at: 2099-01-01` converts a scoped, expiring auto-generated rule
63
+ * into a permanent always-on one — defeating BOTH the never-always-on
64
+ * brandmauer below AND the expiry sweep, because each of those inspects the
65
+ * emitter's in-memory values rather than the serialised document.
66
+ *
67
+ * `validateLearning` (scripts/lib/learnings/schema.mjs) only asserts "string or
68
+ * null" for this field — ANY string, newlines included — and
69
+ * `validateProposalRecord` performs no unknown-key rejection, so every key on a
70
+ * proposal record is spread verbatim into the learning
71
+ * (scripts/lib/memory-proposals/sink.mjs). That is the mechanism that makes an
72
+ * attacker-shaped `host_class` reachable at all; this gate is where it stops.
73
+ *
74
+ * The pattern admits every value `classifyHost()` in scripts/lib/host-identity.mjs
75
+ * can produce (`macos-arm64-m4pro`, `linux-x86_64`, `windows-arm64`,
76
+ * `<osName>-<arch>`) with generous headroom, and rejects everything that could
77
+ * alter frontmatter structure: control chars, whitespace, `:`, `#`, quotes, and
78
+ * a leading `-` (a YAML sequence marker). The 64-char ceiling bounds the emitted
79
+ * line; the longest real value today is 17 chars, so the ceiling is inert in
80
+ * practice. Revisit if a future host taxonomy needs richer tokens.
81
+ */
82
+ const HOST_CLASS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
83
+
84
+ // Characters that make a `file_paths[]` entry unsafe to serialise as a glob.
85
+ // The renderer emits each glob as ` - "<value>"`, so a control char (a newline
86
+ // above all) breaks out of the block sequence into new top-level frontmatter
87
+ // keys, and a quote character breaks out of the quoted scalar. Entries carrying
88
+ // either are SKIPPED — the same defense-in-depth posture as the
89
+ // glob-metacharacter guard below, and for the same reason: this emitter also
90
+ // processes learnings.jsonl records that predate the #900 argv/schema guards.
91
+ const UNSAFE_PATH_QUOTE_RE = /["']/;
92
+
47
93
  // Control chars: C0 range (U+0000–U+001F, includes \n \r \t) plus DEL (U+007F).
48
94
  // A newline in frontmatter would corrupt the YAML parse — stripping these is
49
95
  // critical. Written with \u escapes so the source stays pure ASCII.
50
96
  // eslint-disable-next-line no-control-regex
51
97
  const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g;
52
98
 
53
- /**
54
- * Slugify a string into a stable kebab-case token.
55
- * Lowercases, collapses every run of non-`[a-z0-9]` chars into a single `-`,
56
- * and trims leading/trailing `-`. UUID subjects produce ugly-but-stable keys.
57
- *
58
- * @param {string} s
59
- * @returns {string}
60
- */
61
- function kebab(s) {
62
- return String(s)
63
- .toLowerCase()
64
- .replace(/[^a-z0-9]+/g, '-')
65
- .replace(/^-+|-+$/g, '');
66
- }
99
+ // Non-global twin of CONTROL_CHARS_RE for stateless `.test()` calls. A /g regex
100
+ // carries `lastIndex` across calls, so testing with CONTROL_CHARS_RE directly
101
+ // would alternate true/false on identical input. Derived from the same `.source`
102
+ // so the two can never drift — and so this line stays pure ASCII (a literal
103
+ // control char here would be a NUL-class corruption; see
104
+ // .claude/rules/proven-pattern-nul-byte-corruption...).
105
+ const CONTROL_CHARS_TEST_RE = new RegExp(CONTROL_CHARS_RE.source);
67
106
 
68
107
  // Glob metacharacters (issue #900-follow-up, Q3-MED). A top-level
69
108
  // (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF below — so a
@@ -83,8 +122,11 @@ const GLOB_METACHAR_RE = /[*?[\]{}]/;
83
122
  * the file sits at the repo top level (`dirname` === '.'), emit the bare
84
123
  * basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
85
124
  * glob metacharacter (`* ? [ ] { }`) are skipped entirely — see
86
- * {@link GLOB_METACHAR_RE}. Results are deduped, order-preserving on first
87
- * occurrence.
125
+ * {@link GLOB_METACHAR_RE} as are entries carrying a control char or a quote
126
+ * character, which would break out of the renderer's ` - "<glob>"` sequence
127
+ * item into new top-level frontmatter keys (#1015; see
128
+ * {@link CONTROL_CHARS_TEST_RE} / {@link UNSAFE_PATH_QUOTE_RE}). Results are
129
+ * deduped, order-preserving on first occurrence.
88
130
  *
89
131
  * @param {string[]} filePaths
90
132
  * @returns {string[]}
@@ -95,6 +137,9 @@ function globsFromFilePaths(filePaths) {
95
137
  for (const raw of filePaths) {
96
138
  if (typeof raw !== 'string' || raw === '') continue;
97
139
  if (GLOB_METACHAR_RE.test(raw)) continue;
140
+ // #1015: frontmatter-structure guard. Skipping (never escaping) keeps this
141
+ // idempotent and non-overlapping with the renderer's sanitiser.
142
+ if (CONTROL_CHARS_TEST_RE.test(raw) || UNSAFE_PATH_QUOTE_RE.test(raw)) continue;
98
143
  const normalized = raw.replace(/\\/g, '/');
99
144
  const dir = dirname(normalized);
100
145
  const pattern = dir === '.' ? normalized : `${dir}/**`;
@@ -218,6 +263,9 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
218
263
  * }}
219
264
  * @throws {Error} when no activation axis can be produced (empty globs AND no
220
265
  * hostClass) — the never-always-on invariant.
266
+ * @throws {Error} when the learning has no derivable `learning_key` (no usable
267
+ * `type`, or no `title`/`subject` that slugs to a non-empty token) — a rule
268
+ * whose key resolves to no learning is worse than no rule.
221
269
  */
222
270
  export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDays } = {}) {
223
271
  if (learning === null || typeof learning !== 'object' || Array.isArray(learning)) {
@@ -227,10 +275,25 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
227
275
  const filePaths = Array.isArray(learning.file_paths) ? learning.file_paths : [];
228
276
  const globs = globsFromFilePaths(filePaths);
229
277
 
230
- const hostClass =
231
- typeof learning.host_class === 'string' && learning.host_class !== ''
232
- ? learning.host_class
233
- : undefined;
278
+ // #1015: `host_class` is copied straight into an UNQUOTED `host-class:`
279
+ // frontmatter line by the renderer, so its SHAPE is load-bearing. Reject a
280
+ // malformed value loudly rather than degrade to "no host axis" — a
281
+ // structurally-invalid host_class is a corruption/injection signal, and
282
+ // silently emitting the rest of the rule from the same record would hide it.
283
+ // The engine wraps each learning in its own try/catch, so this throw degrades
284
+ // to ONE recorded rejection with an auditable reason, never a crashed run.
285
+ let hostClass;
286
+ if (typeof learning.host_class === 'string' && learning.host_class !== '') {
287
+ if (!HOST_CLASS_RE.test(learning.host_class)) {
288
+ throw new Error(
289
+ `emitter: host_class has an unsafe shape (${JSON.stringify(learning.host_class)}) — refusing to emit; ` +
290
+ 'the renderer serialises it as an unquoted `host-class:` line, so a newline / colon / quote there ' +
291
+ 'injects sibling top-level frontmatter keys (e.g. alwaysApply: true + a far-future expires-at), ' +
292
+ `defeating the never-always-on brandmauer and the expiry sweep. Expected ${HOST_CLASS_RE.source}.`,
293
+ );
294
+ }
295
+ hostClass = learning.host_class;
296
+ }
234
297
 
235
298
  // The brandmauer: an auto-generated rule must carry ≥1 activation axis. If we
236
299
  // could derive neither a glob nor a host-class, refuse — never emit always-on.
@@ -240,8 +303,30 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
240
303
  );
241
304
  }
242
305
 
243
- const subjectOrTitle = learning.title || learning.subject || '';
244
- const learningKey = `${learning.type}/${kebab(subjectOrTitle)}`;
306
+ // THE key. Derived through the shared `learningKeyOf` so the writer and every
307
+ // reader (engine's rejection path, validate/check-learning-provenance,
308
+ // learnings/candidates dedupe, claude-md-drift-check) compute one identity
309
+ // from one rule. This line previously kebab'd the TYPE half, which no reader
310
+ // does: harmless while every live type is kebab-identical, and silently
311
+ // divergent in both directions the moment one is not — the writer stamps one
312
+ // key into `.claude/rules/*.md` + `reconcile-candidates.jsonl` while every
313
+ // reader computes another, so dedupe stops matching with nothing failing.
314
+ //
315
+ // The injection hazard that motivated the kebab is closed downstream and
316
+ // LOUDLY: the key becomes an unquoted `learning-key:` frontmatter scalar, and
317
+ // `renderer.mjs` asserts `LEARNING_KEY_RE` (`/^[a-z0-9/-]+$/`) on it before
318
+ // rendering. A type carrying a newline/colon/quote is therefore rejected with
319
+ // an auditable reason instead of being silently re-keyed here — the same
320
+ // reject-don't-degrade posture as the `host_class` guard above.
321
+ const learningKey = learningKeyOf(learning);
322
+ if (learningKey === null) {
323
+ throw new Error(
324
+ 'emitter: unkeyable learning (no usable type, or no title/subject that slugs to a non-empty token) — ' +
325
+ 'refusing to emit; `learning_key` is the dedupe identity, and a record without one would either ' +
326
+ 'stamp a rule no reader can resolve back to a learning or collide with every other unkeyable record ' +
327
+ 'of its type.',
328
+ );
329
+ }
245
330
 
246
331
  const confidence = typeof learning.confidence === 'number' ? learning.confidence : 0;
247
332
 
@@ -91,6 +91,7 @@
91
91
  import { readFileSync } from 'node:fs';
92
92
  import { isAbsolute, join } from 'node:path';
93
93
 
94
+ import { learningKeyOf } from '../learnings/kebab.mjs';
94
95
  import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
95
96
  import { filterEligible } from './eligibility.mjs';
96
97
  import { toActivationMetadata } from './emitter.mjs';
@@ -180,26 +181,19 @@ function defaultLoadLearnings(repoRoot) {
180
181
 
181
182
  /**
182
183
  * Best-effort logical key for a REJECTED learning (rejections never run the
183
- * emitter, so there is no metadata.learningKey). Mirrors the emitter's key
184
- * shape `${type}/${kebab(subject||title)}` when both halves are present; falls
185
- * back to `null` when the type or subject/title is unusable. Never throws.
184
+ * emitter, so there is no `metadata.learningKey`). Delegates to the shared
185
+ * `learningKeyOf` it must NOT merely "mirror" the emitter's shape, it has to
186
+ * BE it: this key is written to `reconcile-candidates.jsonl` and folded into
187
+ * `makeCandidateId`, so a learning that succeeds one run and is rejected the
188
+ * next would otherwise appear in the sidecar under two identities and defeat
189
+ * `isProcessed`/`mergeCandidates` dedupe. Returns `null` for an unkeyable
190
+ * record. Never throws.
186
191
  *
187
192
  * @param {unknown} learning
188
193
  * @returns {string|null}
189
194
  */
190
195
  function rejectedLearningKey(learning) {
191
- if (learning === null || typeof learning !== 'object' || Array.isArray(learning)) return null;
192
- const rec = /** @type {Record<string, unknown>} */ (learning);
193
- const type = typeof rec.type === 'string' && rec.type !== '' ? rec.type : '';
194
- const subjectOrTitle =
195
- (typeof rec.title === 'string' && rec.title !== '' ? rec.title : '') ||
196
- (typeof rec.subject === 'string' && rec.subject !== '' ? rec.subject : '');
197
- if (type === '' || subjectOrTitle === '') return null;
198
- const kebab = subjectOrTitle
199
- .toLowerCase()
200
- .replace(/[^a-z0-9]+/g, '-')
201
- .replace(/^-+|-+$/g, '');
202
- return `${type}/${kebab}`;
196
+ return learningKeyOf(learning);
203
197
  }
204
198
 
205
199
  /**
@@ -23,24 +23,43 @@
23
23
  * `confidence`, `expires-at`. Glob values are always double-quoted (the loader
24
24
  * strips surrounding quotes), which keeps `*`/`[`/`{` safe.
25
25
  *
26
+ * ── Untrusted-input containment (issue #1015) ────────────────────────────────
27
+ * Every field this renderer interpolates is AGENT-AUTHORED, and the file it
28
+ * produces becomes a project instruction delivered to every agent in every
29
+ * session — permanently, with no revocation. {@link renderRule} therefore
30
+ * applies `sanitize.mjs` at the render point, in two modes:
31
+ * - MACHINE values (`description`, `globs[]`, `host-class`, `learning-key`,
32
+ * `confidence`, `expires-at`, `id`, `source_session`) are ASSERTED and the
33
+ * record REJECTED (throw) on any violation — never silently repaired.
34
+ * - PROSE (`title`/`subject`, `insight`, `evidence`) is stripped of
35
+ * unambiguous non-content, hard-capped in bytes, and FRAMED in the
36
+ * `untrusted-content` envelope that states it is data, not instruction.
37
+ * The guard lives HERE and not only in the emitter for the same reason the
38
+ * brandmauer is re-checked below: `renderRule` is exported and callable with
39
+ * hand-built metadata, so the emitter's guards do not bind on it.
40
+ *
26
41
  * @module reconcile/renderer
27
42
  */
28
43
 
29
44
  import { createHash } from 'node:crypto';
30
45
 
31
- /**
32
- * Slugify a string into a stable kebab-case token: lowercases, collapses every
33
- * run of non-`[a-z0-9]` chars into a single `-`, and trims leading/trailing `-`.
34
- *
35
- * @param {string} s
36
- * @returns {string}
37
- */
38
- function kebab(s) {
39
- return String(s)
40
- .toLowerCase()
41
- .replace(/[^a-z0-9]+/g, '-')
42
- .replace(/^-+|-+$/g, '');
43
- }
46
+ import { kebab } from '../learnings/kebab.mjs';
47
+ import {
48
+ EVIDENCE_ITEM_MAX_BYTES,
49
+ EVIDENCE_MAX_BYTES,
50
+ EXPIRES_AT_RE,
51
+ HOST_CLASS_RE,
52
+ INSIGHT_MAX_BYTES,
53
+ LEARNING_KEY_RE,
54
+ PROVENANCE_TOKEN_RE,
55
+ TITLE_MAX_BYTES,
56
+ UNTRUSTED_BEGIN,
57
+ UNTRUSTED_END,
58
+ assertMachineToken,
59
+ assertSafeDescription,
60
+ assertSafeGlob,
61
+ sanitizeProse,
62
+ } from './sanitize.mjs';
44
63
 
45
64
  /**
46
65
  * Short, stable SHA-1 prefix (first 7 hex chars) of an input string.
@@ -93,20 +112,47 @@ export function deriveSlug(learning) {
93
112
  * Array → one `- ` bullet per item; string → as-is; absent/empty →
94
113
  * `(no evidence recorded)`.
95
114
  *
115
+ * Untrusted (#1015): every item passes through {@link sanitizeProse} with a
116
+ * per-bullet byte cap, and the whole region is bounded by
117
+ * {@link EVIDENCE_MAX_BYTES}. Items that would cross the region budget are
118
+ * dropped with a machine-emitted note — visibly, never silently. Because
119
+ * `EVIDENCE_ITEM_MAX_BYTES < EVIDENCE_MAX_BYTES` by construction, at least the
120
+ * first item always fits.
121
+ *
96
122
  * @param {unknown} evidence
97
123
  * @returns {string}
124
+ * @throws {Error} when an item carries a delivery-wrapper forgery literal
98
125
  */
99
126
  function renderEvidence(evidence) {
100
- if (Array.isArray(evidence)) {
101
- const lines = evidence
102
- .filter((e) => e !== null && e !== undefined && String(e) !== '')
103
- .map((e) => `- ${String(e)}`);
104
- return lines.length > 0 ? lines.join('\n') : '(no evidence recorded)';
127
+ const isArray = Array.isArray(evidence);
128
+ const items = isArray
129
+ ? evidence.filter((e) => e !== null && e !== undefined && String(e) !== '')
130
+ : typeof evidence === 'string' && evidence !== ''
131
+ ? [evidence]
132
+ : [];
133
+
134
+ if (items.length === 0) return '(no evidence recorded)';
135
+
136
+ /** @type {string[]} */
137
+ const lines = [];
138
+ let usedBytes = 0;
139
+ for (const item of items) {
140
+ const safe = sanitizeProse(item, { field: 'evidence', maxBytes: EVIDENCE_ITEM_MAX_BYTES });
141
+ const line = isArray ? `- ${safe}` : safe;
142
+ const width = Buffer.byteLength(line, 'utf8');
143
+ if (lines.length > 0 && usedBytes + width > EVIDENCE_MAX_BYTES) break;
144
+ lines.push(line);
145
+ usedBytes += width;
105
146
  }
106
- if (typeof evidence === 'string' && evidence !== '') {
107
- return evidence;
147
+
148
+ const omitted = items.length - lines.length;
149
+ if (omitted > 0) {
150
+ lines.push(
151
+ `- […${omitted} further evidence item(s) omitted by the reconciliation engine: ${EVIDENCE_MAX_BYTES}-byte region cap]`,
152
+ );
108
153
  }
109
- return '(no evidence recorded)';
154
+
155
+ return lines.join('\n');
110
156
  }
111
157
 
112
158
  /**
@@ -120,6 +166,14 @@ function renderEvidence(evidence) {
120
166
  * @returns {{ slug: string, path: string, content: string }}
121
167
  * @throws {Error} when `metadata.globs` is empty AND `metadata.hostClass` is
122
168
  * falsy — the never-always-on invariant (defends the emitter's guard).
169
+ * @throws {Error} (`reconcile-sanitize: rejecting record — …`) when a machine
170
+ * value is malformed (a `description` with a control char, a dangerous
171
+ * invisible, a wrapper-forgery literal or over its byte budget; a `globs[]`
172
+ * element with a quote/control char/invisible; a non-token
173
+ * `host-class`/`learning-key`/`id`/`source_session`; a non-finite
174
+ * `confidence`; a non-`YYYY-MM-DD` `expires-at`) or when prose forges the
175
+ * delivery wrapper's framing — issue #1015. The record is REJECTED, never
176
+ * repaired.
123
177
  */
124
178
  export function renderRule(learning, metadata) {
125
179
  if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
@@ -139,18 +193,59 @@ export function renderRule(learning, metadata) {
139
193
  );
140
194
  }
141
195
 
196
+ // ── Machine-value gate (#1015) — assert, never repair ─────────────────────
197
+ // Each of these is serialised into a frontmatter scalar (or, for the last
198
+ // two, into an inline-code span). A violation REJECTS the whole record: the
199
+ // engine catches the throw and records an audited `emit/render error: …`
200
+ // rejection, so a hostile record costs one rejected proposal, never a rule
201
+ // file. Repairing instead of rejecting would invent information — see
202
+ // `sanitize.mjs` for the per-field reasoning.
203
+ // `description` is the ONE agent-authored value emitted outside the untrusted
204
+ // envelope (a frontmatter scalar cannot carry the envelope's HTML comment —
205
+ // the loader would read the comment AS the description). It therefore gets
206
+ // equivalent neutralisation instead of framing: no frontmatter escape, no
207
+ // smuggled invisibles, no delivery-wrapper forgery, bounded length. See
208
+ // `sanitize.mjs` § assertSafeDescription.
209
+ assertSafeDescription(metadata.description);
210
+ for (const glob of globs) assertSafeGlob(glob);
211
+ if (hostClass !== undefined) {
212
+ assertMachineToken(hostClass, { field: 'host-class', pattern: HOST_CLASS_RE });
213
+ }
214
+ assertMachineToken(metadata.learningKey, { field: 'learning-key', pattern: LEARNING_KEY_RE });
215
+ if (!Number.isFinite(metadata.confidence)) {
216
+ // A non-finite confidence is SILENTLY DROPPED by the loader's `Number(...)`
217
+ // coercion, so the rule would load with no confidence at all rather than
218
+ // fail loudly.
219
+ throw new Error(
220
+ `reconcile-sanitize: rejecting record — confidence must be a finite number (got ${JSON.stringify(metadata.confidence)})`,
221
+ );
222
+ }
223
+ assertMachineToken(metadata.expiresAt, { field: 'expires-at', pattern: EXPIRES_AT_RE });
224
+
142
225
  const slug = deriveSlug(learning);
143
226
  const path = `.claude/rules/${slug}.md`;
144
227
 
145
- const humanTitle =
228
+ // Prose sources. `deriveSlug` is safe by construction (its `kebab` collapses
229
+ // every non-`[a-z0-9]` run, so no title can steer the output path), but the
230
+ // rendered H1 carries the raw value — `title` outranks `subject` and has no
231
+ // schema constraint at all, so it is capped and framed like any other prose.
232
+ const rawTitle =
146
233
  (typeof learning.title === 'string' && learning.title !== '' ? learning.title : '') ||
147
234
  (typeof learning.subject === 'string' && learning.subject !== '' ? learning.subject : '') ||
148
235
  metadata.learningKey ||
149
236
  slug;
237
+ const safeTitle = sanitizeProse(rawTitle, { field: 'title', maxBytes: TITLE_MAX_BYTES })
238
+ // The H1 is a single line: a newline in the title would end the heading and
239
+ // let the remainder render as top-level markdown of the attacker's choosing.
240
+ .replace(/[\t\n]+/g, ' ')
241
+ .trim();
242
+ // A title made only of stripped invisibles sanitises to '' — fall back to the
243
+ // slug so the H1 is never empty.
244
+ const humanTitle = safeTitle !== '' ? safeTitle : slug;
150
245
 
151
246
  const insight =
152
247
  typeof learning.insight === 'string' && learning.insight !== ''
153
- ? learning.insight
248
+ ? sanitizeProse(learning.insight, { field: 'insight', maxBytes: INSIGHT_MAX_BYTES })
154
249
  : '(no insight recorded)';
155
250
 
156
251
  // ── Frontmatter (fixed key order; loader-parseable) ──────────────────────
@@ -182,11 +277,30 @@ export function renderRule(learning, metadata) {
182
277
  fm.push('---');
183
278
 
184
279
  // ── Body (free markdown; does not affect frontmatter parsing) ────────────
185
- const learningId = learning.id ? String(learning.id) : 'n/a';
186
- const sourceSession = learning.source_session ? String(learning.source_session) : 'n/a';
280
+ // Both provenance values are rendered INSIDE an inline-code span below, which
281
+ // an interior backtick would close so they are asserted as machine tokens
282
+ // rather than escaped. The 'n/a' fallbacks are machine-emitted, not asserted.
283
+ const learningId = learning.id
284
+ ? assertMachineToken(String(learning.id), {
285
+ field: 'learning-id',
286
+ pattern: PROVENANCE_TOKEN_RE,
287
+ })
288
+ : 'n/a';
289
+ const sourceSession = learning.source_session
290
+ ? assertMachineToken(String(learning.source_session), {
291
+ field: 'source-session',
292
+ pattern: PROVENANCE_TOKEN_RE,
293
+ })
294
+ : 'n/a';
187
295
 
296
+ // The untrusted region (H1 + insight + Evidence) is FRAMED: the envelope
297
+ // states, in machine-generated text the record cannot forge, that everything
298
+ // inside is agent-authored data rather than an instruction to the reading
299
+ // agent. Provenance stays OUTSIDE the envelope — it is machine-derived and
300
+ // asserted above.
188
301
  const body = [
189
302
  '',
303
+ UNTRUSTED_BEGIN,
190
304
  `# Auto-generated rule: ${humanTitle}`,
191
305
  '',
192
306
  insight,
@@ -194,6 +308,8 @@ export function renderRule(learning, metadata) {
194
308
  '## Evidence',
195
309
  renderEvidence(learning.evidence),
196
310
  '',
311
+ UNTRUSTED_END,
312
+ '',
197
313
  '<!-- provenance (auto-generated by the reconciliation engine — do not hand-edit) -->',
198
314
  '## Provenance',
199
315
  `- learning-key: \`${metadata.learningKey}\``,