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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- 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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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}
|
|
87
|
-
*
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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).
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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}\``,
|