peaks-loop 4.0.53 → 4.0.54

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 (44) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/codegraph-commands.js +164 -5
  5. package/dist/cli/commands/core/memory-command.js +35 -2
  6. package/dist/cli/commands/dispatch-commands.js +4 -2
  7. package/dist/cli/commands/prd-commands.js +15 -0
  8. package/dist/cli/commands/sub-agent-shared.d.ts +23 -0
  9. package/dist/cli/commands/sub-agent-shared.js +39 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +34 -0
  11. package/dist/services/audit/enforcer-liveness.js +1 -1
  12. package/dist/services/codegraph/codegraph-config-repair-writer.d.ts +44 -0
  13. package/dist/services/codegraph/codegraph-config-repair-writer.js +112 -13
  14. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +23 -1
  15. package/dist/services/codegraph/codegraph-exclude-repair.js +6 -0
  16. package/dist/services/evidence/evidence-generator.js +11 -3
  17. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  18. package/dist/services/memory/project-memory-service/index.js +1 -1
  19. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +59 -0
  20. package/dist/services/memory/project-memory-service/store/atomic-write.js +167 -7
  21. package/dist/services/prd/gate-evidence-derivation.d.ts +160 -0
  22. package/dist/services/prd/gate-evidence-derivation.js +270 -0
  23. package/dist/services/prd/handoff-auto-regen.js +13 -1
  24. package/dist/services/prd/handoff-frontmatter.d.ts +1 -1
  25. package/dist/services/prd/handoff-frontmatter.js +62 -0
  26. package/dist/services/prd/handoff-gate-evidence.d.ts +95 -1
  27. package/dist/services/prd/handoff-gate-evidence.js +125 -57
  28. package/dist/services/prd/handoff-service.d.ts +21 -1
  29. package/dist/services/prd/handoff-service.js +45 -2
  30. package/dist/services/prd/handoff-types.d.ts +40 -0
  31. package/dist/services/prd/handoff-types.js +28 -1
  32. package/dist/services/prd/project-scan-reader.d.ts +7 -0
  33. package/dist/services/prd/project-scan-reader.js +7 -1
  34. package/dist/services/rd/reviewer-dispatch-policy.d.ts +36 -8
  35. package/dist/services/rd/reviewer-dispatch-policy.js +36 -8
  36. package/package.json +6 -6
  37. package/skills/bee/peaks-rd/SKILL.md +1 -1
  38. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +26 -10
  39. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +1 -1
  40. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -1
  41. package/skills/peaks-code/references/periodic-checkpoint.md +7 -5
  42. package/skills/peaks-code/references/startup-sequence.md +1 -1
  43. package/skills/peaks-final-review/SKILL.md +1 -1
  44. /package/{docs → contracts}/test-style-contract.md +0 -0
@@ -3,7 +3,10 @@
3
3
  // The pure repair plans (`repairCodegraphExclude` for the exclude axis,
4
4
  // `repairCodegraphInclude` for the include axis) and the writer that applies
5
5
  // them to `<projectRoot>/.codegraph/config.json` in ONE atomic rewrite, plus
6
- // the byte-exact `config.json.bak` copy it keeps for rollback.
6
+ // the byte-exact `config.json.bak` copy it keeps for rollback and
7
+ // `rollbackCodegraphConfig`, that copy's reader (slice A1 of
8
+ // `2026-09-17-session-607ead`: the reverse write, so the rollback the `.bak`
9
+ // has always promised is reachable).
7
10
  //
8
11
  // Extracted verbatim from `codegraph-exclude-repair.ts` (rid
9
12
  // 2026-09-17-oversize-followup, G1 — the 800-line file-size cap). Every moved
@@ -142,6 +145,40 @@ function writeConfigAtomic(filePath, content, mode) {
142
145
  throw error;
143
146
  }
144
147
  }
148
+ /**
149
+ * The shared shape of the two refusals this module makes at the FIXED,
150
+ * therefore guessable, `config.json.bak` path: a symbolic link (bytes land in
151
+ * whatever it points at), a directory (there is nowhere for them to land), or
152
+ * a hard link (`nlink > 1` — another name shares this inode). Returns `null`
153
+ * when the path is absent or is a plain regular file, the only two states both
154
+ * ends of the rollback contract may act on.
155
+ *
156
+ * ONE predicate, two callers, and that is the point: `writeConfigBackup`
157
+ * WRITES this copy and `rollbackCodegraphConfig` READS it back. A guard that
158
+ * drifted between the two would leave the rollback trusting a path the writer
159
+ * would have refused, so the refusal is the same at both ends because the
160
+ * trust premise is the same at both ends.
161
+ *
162
+ * `lstat`, never `stat`: `stat` follows a link and reports the victim's
163
+ * regular-file type, which is exactly the case both callers must refuse.
164
+ */
165
+ function linkOrDirectoryAt(path) {
166
+ const existing = lstatSync(path, { throwIfNoEntry: false });
167
+ if (existing === undefined ||
168
+ (!existing.isSymbolicLink() && !existing.isDirectory() && existing.nlink <= 1)) {
169
+ return null;
170
+ }
171
+ return {
172
+ // "through" for a link (the bytes land in whatever it points at), "at" for
173
+ // a directory (there is nowhere for them to land).
174
+ preposition: existing.isDirectory() ? 'at' : 'through',
175
+ kind: existing.isSymbolicLink()
176
+ ? 'a symbolic link'
177
+ : existing.isDirectory()
178
+ ? 'a directory'
179
+ : `a hard link (link count ${String(existing.nlink)})`
180
+ };
181
+ }
145
182
  /**
146
183
  * Copy the config's ORIGINAL bytes to `config.json.bak`, refusing to write
147
184
  * through a link that already occupies that path.
@@ -207,18 +244,9 @@ function writeConfigAtomic(filePath, content, mode) {
207
244
  */
208
245
  function writeConfigBackup(configPath, originalText) {
209
246
  const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
210
- const existing = lstatSync(backupPath, { throwIfNoEntry: false });
211
- if (existing !== undefined &&
212
- (existing.isSymbolicLink() || existing.isDirectory() || existing.nlink > 1)) {
213
- const kind = existing.isSymbolicLink()
214
- ? 'a symbolic link'
215
- : existing.isDirectory()
216
- ? 'a directory'
217
- : `a hard link (link count ${String(existing.nlink)})`;
218
- // "through" for a link (the bytes land in whatever it points at), "at" for
219
- // a directory (there is nowhere for them to land).
220
- const preposition = existing.isDirectory() ? 'at' : 'through';
221
- throw new Error(`codegraph config backup ${backupPath}: refusing to write ${preposition} ${kind} occupying ` +
247
+ const occupied = linkOrDirectoryAt(backupPath);
248
+ if (occupied !== null) {
249
+ throw new Error(`codegraph config backup ${backupPath}: refusing to write ${occupied.preposition} ${occupied.kind} occupying ` +
222
250
  'this path. Another file or directory shares it, so a backup written here would overwrite ' +
223
251
  'that. Remove it (or point `peaks` at a project root whose `.codegraph/` it owns) and re-run.');
224
252
  }
@@ -320,3 +348,74 @@ export function applyCodegraphConfigRepair(projectRoot, repair) {
320
348
  includeCountAfter: includePlan.include.length
321
349
  };
322
350
  }
351
+ // ─────────────────────────────────────────────────────────────────────
352
+ // Rollback — the read side of the `.bak`
353
+ // ─────────────────────────────────────────────────────────────────────
354
+ /** Local, because this module must not import `codegraph-exclude-repair.ts`. */
355
+ function errorMessage(error) {
356
+ return error instanceof Error ? error.message : String(error);
357
+ }
358
+ /**
359
+ * Put `configPath` back to the bytes `writeConfigBackup` saved beside it, and
360
+ * back to the mode those bytes were saved with.
361
+ *
362
+ * This is the reverse of `applyCodegraphConfigRepair`'s write half and the only
363
+ * reason the `.bak` exists: until this function, the backup had no reader
364
+ * anywhere in the repository, so "byte-exact rollback" described a file nobody
365
+ * ever restored.
366
+ *
367
+ * The guard is `linkOrDirectoryAt` — the SAME predicate that refuses to WRITE
368
+ * through a link at the backup path refuses to READ through one here. The
369
+ * attack it closes is the mirror image of the writer's: `config.json.bak` is
370
+ * fixed and committable, so a repository that ships it as a link would
371
+ * otherwise have the link's target's bytes published into `.codegraph/config.json`
372
+ * as if they were the pre-repair config — attacker-chosen content, written
373
+ * under the operator's own config path, from a file the operator never
374
+ * reviewed. A refusal is a refusal and not a repair, at both ends.
375
+ *
376
+ * The restore runs through `writeConfigAtomic` (same-directory CSPRNG temp +
377
+ * `renameSync`) for the same reason the backup write does: a crash mid-write
378
+ * must leave the repaired config intact rather than a prefix of the restored
379
+ * one, and the mode is applied to the TEMP before the rename so the published
380
+ * file never exists at the process umask. The mode comes from `statSync` of the
381
+ * backup, symmetrical with the write end: the copy carries the original
382
+ * config's mode, so handing that mode back to the config restores what the
383
+ * project had granted rather than this process's umask.
384
+ *
385
+ * A refusal, or a backup that cannot be read, is RETURNED rather than thrown:
386
+ * the caller reports it as a field and must keep going — the forced rebuild it
387
+ * was asked for still has to run. `writeConfigAtomic`'s own fs throw is left to
388
+ * propagate, and the caller catches that too.
389
+ */
390
+ export async function rollbackCodegraphConfig(configPath) {
391
+ const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
392
+ const occupied = linkOrDirectoryAt(backupPath);
393
+ if (occupied !== null) {
394
+ return {
395
+ rolledBack: false,
396
+ configPath,
397
+ backupPath,
398
+ error: `codegraph config rollback ${backupPath}: refusing to restore ${occupied.preposition} ${occupied.kind} occupying ` +
399
+ 'this path. A backup this writer did not create is not a rollback point, and restoring through it ' +
400
+ 'would publish bytes nobody reviewed. Restore it by hand and re-run.'
401
+ };
402
+ }
403
+ let originalText;
404
+ let originalMode;
405
+ try {
406
+ originalText = readFileSync(backupPath, 'utf8');
407
+ // `& 0o777` drops the file-type bits `statSync` packs above the permission
408
+ // bits — `chmod` takes permission bits only.
409
+ originalMode = statSync(backupPath).mode & 0o777;
410
+ }
411
+ catch (error) {
412
+ return {
413
+ rolledBack: false,
414
+ configPath,
415
+ backupPath,
416
+ error: `codegraph config rollback: cannot read ${backupPath}: ${errorMessage(error)}`
417
+ };
418
+ }
419
+ writeConfigAtomic(configPath, originalText, originalMode);
420
+ return { rolledBack: true, configPath, backupPath };
421
+ }
@@ -64,7 +64,15 @@ export type CodegraphExcludeRepairReport = {
64
64
  * AND the reads failed (there is no config to name).
65
65
  */
66
66
  readonly configPath: string;
67
- /** Byte-exact rollback copy (null when nothing was written). */
67
+ /**
68
+ * Byte-exact rollback copy (null when nothing was written).
69
+ *
70
+ * It is a rollback POINT, not a rollback: nothing in this seam reads it back.
71
+ * Putting the config back is the separate, explicit
72
+ * `peaks codegraph config-restore` verb, so this field is what that verb
73
+ * would read — and its presence is the one guarantee that a restore is
74
+ * possible at all.
75
+ */
68
76
  readonly backupPath: string | null;
69
77
  /** True when the post-repair `codegraph index` finished successfully. */
70
78
  readonly reindexed: boolean;
@@ -127,6 +135,20 @@ export type CodegraphExcludeRepairOptions = {
127
135
  * C) exists so that upgrading peaks-loop cannot change a downstream
128
136
  * project's cost profile, and purging dead rows is the caller's explicit
129
137
  * request instead.
138
+ *
139
+ * `'force'` changes ONLY the rebuild, never the config: it is the same
140
+ * two-axis repair as `'exclude'`, followed by `cg.clear()` + `indexAll()`
141
+ * instead of an incremental index. The config repair STAYS — that is what
142
+ * makes the force mode worth its cost, because `include` has to be widened
143
+ * before a rebuild can admit the files the config was dropping.
144
+ *
145
+ * Not a self-cancelling run, and that is a design decision rather than a
146
+ * detail: a mode that wrote the repair and then restored the pre-repair bytes
147
+ * would leave the config exactly as it found it, `peaks codegraph status`
148
+ * would still report the gap, and exit 75 would never clear. Putting the
149
+ * config BACK is the explicit `peaks codegraph config-restore` verb, which
150
+ * reads the `.bak` this run leaves — an operator decision, not a side effect
151
+ * of asking for a rebuild.
130
152
  */
131
153
  readonly reindex?: boolean | 'force';
132
154
  };
@@ -64,6 +64,12 @@
64
64
  // consumer project (`config.json.bak` matches neither the peaks-loop
65
65
  // snippet nor upstream's own `.codegraph/.gitignore`), so both files
66
66
  // are committable.
67
+ // - `rollbackCodegraphConfig` is that copy's READER, and it is reached by the
68
+ // EXPLICIT `peaks codegraph config-restore` verb — never from here. A
69
+ // repair must not undo itself: this seam's job is to close the gap, so a
70
+ // run that restored its own write would leave the config exactly as it
71
+ // found it and `peaks codegraph status` still reporting the gap (exit 75
72
+ // never clearing). Rolling back is an operator decision, not a step.
67
73
  // - The DIRECTORY both paths live in is contained: `assertCodegraphDirContained`
68
74
  // refuses when `<projectRoot>/.codegraph` resolves (junction or symlink)
69
75
  // outside the canonical project root, so neither the read nor either
@@ -31,6 +31,7 @@
31
31
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
32
32
  import { join } from 'node:path';
33
33
  import { serializeHandoffFrontmatter } from '../prd/handoff-frontmatter.js';
34
+ import { deriveGateEvidenceForRequest } from '../prd/gate-evidence-derivation.js';
34
35
  import { handoffRelativePath, sha256OfBody } from '../prd/handoff-service.js';
35
36
  import { getSessionDir } from '../session/getSessionDir.js';
36
37
  import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
@@ -260,7 +261,7 @@ function buildQaRequest(rid, sid, files) {
260
261
  * producer/consumer divergence rid `2026-09-14-handoff-writer-gate-divergence`
261
262
  * exists to remove, surviving in a function the same slice edited.
262
263
  */
263
- function buildHandoff(rid, sid, title, files, lineCounts) {
264
+ function buildHandoff(rid, sid, title, files, lineCounts, gateEvidence) {
264
265
  const body = `# PRD Handoff — ${rid}
265
266
 
266
267
  ${title}. Mechanical verbatim module split; behavior-preserving.
@@ -281,7 +282,13 @@ ${lineCountsMd(lineCounts)}
281
282
  goals: [],
282
283
  acceptanceCriteria: [],
283
284
  preservedBehavior: [],
284
- handoffPath: handoffRelativePath(sid, rid)
285
+ handoffPath: handoffRelativePath(sid, rid),
286
+ // B2 / F1: the THIRD frontmatter producer. B1 left this one untouched and
287
+ // QA found it, so a `peaks evidence generate` capsule and a
288
+ // `peaks prd handoff init` capsule for the same slice disagreed about the
289
+ // same contract. Passed in rather than derived here so this function stays
290
+ // synchronous and disk-free; the caller derives.
291
+ ...(gateEvidence === undefined ? {} : { gateEvidence })
285
292
  };
286
293
  return { content: `${serializeHandoffFrontmatter(frontmatter)}${body}`, hash: handoffHash };
287
294
  }
@@ -334,7 +341,8 @@ export async function generateEvidence(options) {
334
341
  createdDirectories.push(dir);
335
342
  }
336
343
  const qaRequestPath = await resolveQaRequestPath(qaDir, rid);
337
- const handoff = buildHandoff(rid, sessionId, title, files, lineCounts);
344
+ const gateEvidence = await deriveGateEvidenceForRequest({ projectRoot, sessionId, requestId: rid });
345
+ const handoff = buildHandoff(rid, sessionId, title, files, lineCounts, gateEvidence);
338
346
  // Four of these twelve paths carry the rid because the `rd:qa-handoff` gate
339
347
  // requires `<rid>` in them: every slice in a session shares `rd/` and
340
348
  // `audit/`, so writing the ridless name silently destroyed the previous
@@ -7,7 +7,7 @@ export type { ExtractedMemoryBlocks } from './parsers/markdown-pure.js';
7
7
  export { parseBlock, parseBlockResult, parseMemoryFrontmatter, parseStoredMemoryFile, renderMemoryFile, resolveMemoryKind, resolveMemoryName, slugify } from './parsers/frontmatter.js';
8
8
  export type { MemoryKindResolution, MemoryKindSource, MemoryNameResolution, MemoryNameSource, ParsedMemoryFrontmatter } from './parsers/frontmatter.js';
9
9
  export { assertInsideProject, assertSafeProjectMemoryDir, assertSafeSessionDir, normalizeRealRoot, normalizeRoot, realPathOrThrow, resolveProjectPath, safeRealpath } from './store/paths.js';
10
- export { assertSafeMemory, assertSafeMemoryFileContent, hasSensitiveMemoryContent, writeNewFile } from './store/atomic-write.js';
10
+ export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
11
11
  export { ensureMemoryBootstrap, emptyByKind, emptyIndex, listMarkdownFiles, readProjectMemories, readProjectMemoryBody } from './index/search.js';
12
12
  export { buildMemoryIndex, generateMemoryIndexFile, readExistingIndex, readMemoryFileMtime, readMemoryIndex, readStoredMemoryNames } from './index/ranking.js';
13
13
  export { executeMemoryReindex, renderMemoryMarkdown, KIND_ORDER, MEMORY_MD_BANNER, MEMORY_MD_FILENAME } from './index/reindex.js';
@@ -19,7 +19,7 @@ export { describeMemoryBlockDrops, describeSessionScanFailures, summarizeBackupR
19
19
  export { parseBlock, parseBlockResult, parseMemoryFrontmatter, parseStoredMemoryFile, renderMemoryFile, resolveMemoryKind, resolveMemoryName, slugify } from './parsers/frontmatter.js';
20
20
  // Store: path safety + sensitive content
21
21
  export { assertInsideProject, assertSafeProjectMemoryDir, assertSafeSessionDir, normalizeRealRoot, normalizeRoot, realPathOrThrow, resolveProjectPath, safeRealpath } from './store/paths.js';
22
- export { assertSafeMemory, assertSafeMemoryFileContent, hasSensitiveMemoryContent, writeNewFile } from './store/atomic-write.js';
22
+ export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
23
23
  // Index: search / ranking / dispatch
24
24
  export { ensureMemoryBootstrap, emptyByKind, emptyIndex, listMarkdownFiles, readProjectMemories, readProjectMemoryBody } from './index/search.js';
25
25
  export { buildMemoryIndex, generateMemoryIndexFile, readExistingIndex, readMemoryFileMtime, readMemoryIndex, readStoredMemoryNames } from './index/ranking.js';
@@ -1,5 +1,64 @@
1
1
  import type { ExtractedProjectMemory } from '../types.js';
2
+ /**
3
+ * The names of the three checks `assertSafeMemory` can fail on, written once.
4
+ *
5
+ * The name of the check that fired rides the refusal message, so the reader is
6
+ * told WHICH rule stopped the write instead of only that something was
7
+ * refused. Callers route their remedy on these same constants (see
8
+ * `memoryExtractNextActions` in `src/cli/commands/core/memory-command.ts`) —
9
+ * two strings kept equal by hand is how a message and its advice drift apart.
10
+ */
11
+ export declare const SENSITIVE_MEMORY_CHECKS: {
12
+ readonly metadataKey: "metadata key scan";
13
+ readonly content: "content scan";
14
+ readonly title: "title scan";
15
+ };
16
+ /**
17
+ * The credential term this title contains (as a run of words), or `null`.
18
+ *
19
+ * Returns the MATCHED TERM rather than a boolean because the refusal message
20
+ * names it: `title scan matched the credential term "apikey"` is actionable,
21
+ * while "Refusing to store sensitive memory content" is what sent slice C0's
22
+ * user looking for a secret that was not there. The returned string is a
23
+ * dictionary word from the list above — never the title's own text, and never
24
+ * a value — so echoing it into the error envelope cannot leak anything.
25
+ */
26
+ export declare function findSensitiveMemoryTitleTerm(title: string): string | null;
2
27
  export declare function hasSensitiveMemoryContent(content: string): boolean;
28
+ /**
29
+ * The refusal `assertSafeMemory` raises: the check that fired and the term it
30
+ * matched travel as DATA, not only as prose.
31
+ *
32
+ * WHY A TYPE RATHER THAN A MESSAGE. The envelope redactor behind every
33
+ * `fail()` (`packages/peaks-loop-shared/src/result.ts`) strips the words
34
+ * secret / token / password / api-key out of every failure MESSAGE — a blanket
35
+ * rule, applied to messages this module does not own. So a message that names
36
+ * the matched term reaches the CLI reading `… the credential term
37
+ * "[redacted]" …`: the term is named, and then removed, on the way out.
38
+ *
39
+ * The term is a word from `SENSITIVE_PROSE_TERMS` — a fixed vocabulary, never a
40
+ * value — so there is nothing about it to redact, and it rides a field
41
+ * instead: `fail()` redacts `message` only. The CLI routes its remedy on
42
+ * `check` for the same reason — a substring test against a message whose words
43
+ * can be rewritten is not a routing rule, it is a guess.
44
+ *
45
+ * NOTHING HERE WEAKENS THE REDACTOR. The content scan never puts its match in
46
+ * this object (see `matchedTerm` below), so no value can reach an envelope
47
+ * through it; the redactor's guarantee is intact and is pinned by the cases in
48
+ * `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
49
+ */
50
+ export declare class UnsafeMemoryError extends Error {
51
+ /** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
52
+ readonly check: string;
53
+ /**
54
+ * The credential term the TITLE scan matched, or `null` for the other two
55
+ * checks. `null` is not an omission: their match can BE the credential
56
+ * (`ghp_…`, the PEM header, the JWT), which is why their messages describe
57
+ * the pattern family and never echo the text.
58
+ */
59
+ readonly matchedTerm: string | null;
60
+ constructor(check: string, detail: string, matchedTerm?: string | null);
61
+ }
3
62
  export declare function assertSafeMemory(memory: ExtractedProjectMemory): void;
4
63
  export declare function assertSafeMemoryFileContent(content: string): void;
5
64
  export declare function writeNewFile(path: string, content: string): void;
@@ -5,9 +5,13 @@
5
5
  // PEM private keys, JWTs, GitHub / GitLab tokens, AWS access keys. Used
6
6
  // both by the extract path (`assertSafeMemory`) and the backup path
7
7
  // (`assertSafeMemoryFileContent`).
8
+ // - `findSensitiveMemoryTitleTerm` — the PROSE predicate for
9
+ // `memory.title`. It is deliberately NOT the config-key predicate: see
10
+ // the block comment above it (slice C0).
8
11
  // - `assertSafeMemory` — full safety gate applied during extraction.
9
- // Combines title/body content scan + config-service secret check +
10
- // sensitive-path check on the title.
12
+ // Combines the metadata key scan + the content pattern scan + the title
13
+ // scan. Each failure names the check that fired, and the title one names
14
+ // the term it matched.
11
15
  // - `assertSafeMemoryFileContent` — lighter version for backup: just
12
16
  // the content pattern scan, since the file already lives in
13
17
  // `.peaks/memory/` and was authored through the normal pipeline.
@@ -17,7 +21,109 @@
17
21
  // for index.json regeneration.
18
22
  // ---------------------------------------------------------------------------
19
23
  import { closeSync, constants, openSync, writeFileSync } from 'node:fs';
20
- import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../../../config/config-service.js';
24
+ import { containsSensitiveConfigValue } from '../../../config/config-service.js';
25
+ /**
26
+ * The names of the three checks `assertSafeMemory` can fail on, written once.
27
+ *
28
+ * The name of the check that fired rides the refusal message, so the reader is
29
+ * told WHICH rule stopped the write instead of only that something was
30
+ * refused. Callers route their remedy on these same constants (see
31
+ * `memoryExtractNextActions` in `src/cli/commands/core/memory-command.ts`) —
32
+ * two strings kept equal by hand is how a message and its advice drift apart.
33
+ */
34
+ export const SENSITIVE_MEMORY_CHECKS = {
35
+ metadataKey: 'metadata key scan',
36
+ content: 'content scan',
37
+ title: 'title scan'
38
+ };
39
+ /** The stable prefix of every refusal this module raises on the extract path. */
40
+ const SENSITIVE_MEMORY_REFUSAL = 'Refusing to store sensitive memory content';
41
+ /**
42
+ * Credential TERMS as they appear in prose — the word-level counterpart of the
43
+ * config-key predicate `config-service.isSensitiveConfigPath`.
44
+ *
45
+ * WHY THE TITLE CHECK EXISTS AT ALL (it is not redundant with the content
46
+ * scanner). `hasSensitiveMemoryContent` looks for a credential *value*: its
47
+ * first pattern needs a `:` or `=`, so a title that is nothing but `apiKey`
48
+ * carries no value and slips past it. The title scan is the only rule that can
49
+ * see "the title IS a credential name" — so this check was kept, and only its
50
+ * predicate was replaced.
51
+ *
52
+ * WHY IT DOES NOT REUSE `isSensitiveConfigPath`. That predicate answers "is
53
+ * this a CONFIG KEY", and it answers by SUBSTRING: `includes('auth')` is true
54
+ * for `authority`, `author`, `unauthorized`. On a config key that breadth is
55
+ * harmless — an auth-bearing key IS a credential key, and the config domain is
56
+ * untouched here. On a prose title it is a false refusal the author cannot see
57
+ * the cause of: slice C0, where `peaks memory extract` refused a memory titled
58
+ * "Derive from the authority, never re-declare it" and told the user to
59
+ * "remove secrets" from a memory that had none.
60
+ *
61
+ * HOW IT MATCHES. The title is cut into alphanumeric segments (camelCase
62
+ * boundaries included) and a match is a CONTIGUOUS RUN of segments whose
63
+ * concatenation is one of the terms below. Runs — not single words — are what
64
+ * keep the credential-name spellings a substring check caught and a naive
65
+ * word-per-word check would lose: `private_key`, `api key`, `myApiKey` and
66
+ * `access token` all still match, while `authority`, `author`, `tokenizer`,
67
+ * `secretary` and `credentialed` do not.
68
+ *
69
+ * The residual narrowing is named rather than hidden: an UNSEPARATED compound
70
+ * with a term buried mid-word (`mysecretstuff`) no longer matches. That is the
71
+ * same class as the false refusals above — a word containing `secret` is not a
72
+ * credential term — and the memory body is scanned for values either way.
73
+ *
74
+ * The plural forms are listed explicitly instead of deriving them by stripping
75
+ * a trailing `s` from each match: that rule would turn `secretaries` into
76
+ * `secretarie` → `secret`, which is precisely the substring confusion this
77
+ * predicate exists to end.
78
+ */
79
+ const SENSITIVE_PROSE_TERMS = new Set([
80
+ 'apikey', 'apikeys',
81
+ 'accesskey', 'accesskeys',
82
+ 'privatekey', 'privatekeys',
83
+ 'secretkey', 'secretkeys',
84
+ 'accesstoken', 'accesstokens',
85
+ 'authtoken', 'authtokens',
86
+ 'authkey', 'authkeys',
87
+ 'refreshtoken', 'refreshtokens',
88
+ 'token', 'tokens',
89
+ 'secret', 'secrets',
90
+ 'password', 'passwords',
91
+ 'passwd',
92
+ 'bearer',
93
+ 'credential', 'credentials'
94
+ ]);
95
+ /** A lower→upper transition: the boundary between the words of `apiKey`. */
96
+ const CAMEL_CASE_BOUNDARY = /([a-z0-9])([A-Z])/g;
97
+ /** `myApiKey` → `['my', 'api', 'key']`; `private_key` → `['private', 'key']`. */
98
+ function proseSegments(text) {
99
+ return text
100
+ .replace(CAMEL_CASE_BOUNDARY, '$1 $2')
101
+ .toLowerCase()
102
+ .split(/[^a-z0-9]+/)
103
+ .filter((segment) => segment.length > 0);
104
+ }
105
+ /**
106
+ * The credential term this title contains (as a run of words), or `null`.
107
+ *
108
+ * Returns the MATCHED TERM rather than a boolean because the refusal message
109
+ * names it: `title scan matched the credential term "apikey"` is actionable,
110
+ * while "Refusing to store sensitive memory content" is what sent slice C0's
111
+ * user looking for a secret that was not there. The returned string is a
112
+ * dictionary word from the list above — never the title's own text, and never
113
+ * a value — so echoing it into the error envelope cannot leak anything.
114
+ */
115
+ export function findSensitiveMemoryTitleTerm(title) {
116
+ const segments = proseSegments(title);
117
+ for (let start = 0; start < segments.length; start += 1) {
118
+ let run = '';
119
+ for (let end = start; end < segments.length; end += 1) {
120
+ run += segments[end];
121
+ if (SENSITIVE_PROSE_TERMS.has(run))
122
+ return run;
123
+ }
124
+ }
125
+ return null;
126
+ }
21
127
  export function hasSensitiveMemoryContent(content) {
22
128
  return /(?:api[_-]?key|token|secret|password|credential|bearer)\s*[:=]/i.test(content)
23
129
  || /\bauthorization\s*:\s*bearer\s+\S+/i.test(content)
@@ -30,14 +136,68 @@ export function hasSensitiveMemoryContent(content) {
30
136
  || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
31
137
  || /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/.test(content);
32
138
  }
139
+ /**
140
+ * The refusal `assertSafeMemory` raises: the check that fired and the term it
141
+ * matched travel as DATA, not only as prose.
142
+ *
143
+ * WHY A TYPE RATHER THAN A MESSAGE. The envelope redactor behind every
144
+ * `fail()` (`packages/peaks-loop-shared/src/result.ts`) strips the words
145
+ * secret / token / password / api-key out of every failure MESSAGE — a blanket
146
+ * rule, applied to messages this module does not own. So a message that names
147
+ * the matched term reaches the CLI reading `… the credential term
148
+ * "[redacted]" …`: the term is named, and then removed, on the way out.
149
+ *
150
+ * The term is a word from `SENSITIVE_PROSE_TERMS` — a fixed vocabulary, never a
151
+ * value — so there is nothing about it to redact, and it rides a field
152
+ * instead: `fail()` redacts `message` only. The CLI routes its remedy on
153
+ * `check` for the same reason — a substring test against a message whose words
154
+ * can be rewritten is not a routing rule, it is a guess.
155
+ *
156
+ * NOTHING HERE WEAKENS THE REDACTOR. The content scan never puts its match in
157
+ * this object (see `matchedTerm` below), so no value can reach an envelope
158
+ * through it; the redactor's guarantee is intact and is pinned by the cases in
159
+ * `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
160
+ */
161
+ export class UnsafeMemoryError extends Error {
162
+ /** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
163
+ check;
164
+ /**
165
+ * The credential term the TITLE scan matched, or `null` for the other two
166
+ * checks. `null` is not an omission: their match can BE the credential
167
+ * (`ghp_…`, the PEM header, the JWT), which is why their messages describe
168
+ * the pattern family and never echo the text.
169
+ */
170
+ matchedTerm;
171
+ constructor(check, detail, matchedTerm = null) {
172
+ super(`${SENSITIVE_MEMORY_REFUSAL}: ${check} ${detail}.`);
173
+ this.name = 'UnsafeMemoryError';
174
+ this.check = check;
175
+ this.matchedTerm = matchedTerm;
176
+ }
177
+ }
33
178
  export function assertSafeMemory(memory) {
34
179
  const content = `${memory.title}\n${memory.kind}\n${memory.body}`;
35
180
  const metadata = { title: memory.title, kind: memory.kind, body: memory.body };
36
- if (containsSensitiveConfigValue(metadata) || hasSensitiveMemoryContent(content)) {
37
- throw new Error('Refusing to store sensitive memory content');
181
+ if (containsSensitiveConfigValue(metadata)) {
182
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.metadataKey, 'matched a credential key in the memory metadata');
183
+ }
184
+ if (hasSensitiveMemoryContent(content)) {
185
+ // The match is deliberately NOT echoed, in the message or in the error's
186
+ // fields: for most of these patterns the match IS the credential, so
187
+ // copying it would write the value this scan exists to keep out.
188
+ //
189
+ // The DETAIL is worded around the envelope redactor's vocabulary on
190
+ // purpose. Its catch-all (`/(secret|token|password|api[-_ ]?key)/gi`)
191
+ // rewrites those four words wherever they appear in a failure message, so
192
+ // naming the pattern families in their own words arrives on the CLI as
193
+ // "an [redacted] / [redacted] / [redacted] assignment" — a remedy sentence
194
+ // redacted into uselessness by the very policy it agrees with. Measured on
195
+ // the real CLI, both wordings; this one survives intact.
196
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.content, 'matched a credential value in the memory content (a `key=value` credential assignment, a Bearer header, a PEM private key, a JWT, or a provider credential)');
38
197
  }
39
- if (isSensitiveConfigPath(memory.title)) {
40
- throw new Error('Refusing to store sensitive memory content');
198
+ const titleTerm = findSensitiveMemoryTitleTerm(memory.title);
199
+ if (titleTerm !== null) {
200
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.title, `matched the credential term "${titleTerm}" in memory.title`, titleTerm);
41
201
  }
42
202
  }
43
203
  export function assertSafeMemoryFileContent(content) {