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.
- package/CHANGELOG.md +49 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/codegraph-commands.js +164 -5
- package/dist/cli/commands/core/memory-command.js +35 -2
- package/dist/cli/commands/dispatch-commands.js +4 -2
- package/dist/cli/commands/prd-commands.js +15 -0
- package/dist/cli/commands/sub-agent-shared.d.ts +23 -0
- package/dist/cli/commands/sub-agent-shared.js +39 -0
- package/dist/services/artifacts/artifact-prerequisites.js +34 -0
- package/dist/services/audit/enforcer-liveness.js +1 -1
- package/dist/services/codegraph/codegraph-config-repair-writer.d.ts +44 -0
- package/dist/services/codegraph/codegraph-config-repair-writer.js +112 -13
- package/dist/services/codegraph/codegraph-exclude-repair.d.ts +23 -1
- package/dist/services/codegraph/codegraph-exclude-repair.js +6 -0
- package/dist/services/evidence/evidence-generator.js +11 -3
- package/dist/services/memory/project-memory-service/index.d.ts +1 -1
- package/dist/services/memory/project-memory-service/index.js +1 -1
- package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +59 -0
- package/dist/services/memory/project-memory-service/store/atomic-write.js +167 -7
- package/dist/services/prd/gate-evidence-derivation.d.ts +160 -0
- package/dist/services/prd/gate-evidence-derivation.js +270 -0
- package/dist/services/prd/handoff-auto-regen.js +13 -1
- package/dist/services/prd/handoff-frontmatter.d.ts +1 -1
- package/dist/services/prd/handoff-frontmatter.js +62 -0
- package/dist/services/prd/handoff-gate-evidence.d.ts +95 -1
- package/dist/services/prd/handoff-gate-evidence.js +125 -57
- package/dist/services/prd/handoff-service.d.ts +21 -1
- package/dist/services/prd/handoff-service.js +45 -2
- package/dist/services/prd/handoff-types.d.ts +40 -0
- package/dist/services/prd/handoff-types.js +28 -1
- package/dist/services/prd/project-scan-reader.d.ts +7 -0
- package/dist/services/prd/project-scan-reader.js +7 -1
- package/dist/services/rd/reviewer-dispatch-policy.d.ts +36 -8
- package/dist/services/rd/reviewer-dispatch-policy.js +36 -8
- package/package.json +6 -6
- package/skills/bee/peaks-rd/SKILL.md +1 -1
- package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +26 -10
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +1 -1
- package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -1
- package/skills/peaks-code/references/periodic-checkpoint.md +7 -5
- package/skills/peaks-code/references/startup-sequence.md +1 -1
- package/skills/peaks-final-review/SKILL.md +1 -1
- /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
|
|
211
|
-
if (
|
|
212
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
10
|
-
//
|
|
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
|
|
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)
|
|
37
|
-
throw new
|
|
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
|
-
|
|
40
|
-
|
|
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) {
|