peaks-loop 4.1.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/_register.js +2 -2
- package/dist/cli/commands/code-mode-gate-should-pause-command.js +1 -1
- package/dist/cli/commands/comments-commands.d.ts +14 -0
- package/dist/cli/commands/comments-commands.js +96 -0
- package/dist/cli/commands/core/memory-command.js +10 -0
- package/dist/cli/commands/core/skill-command.js +1 -1
- package/dist/cli/commands/core/standards-command.js +1 -1
- package/dist/cli/commands/ecc-commands.d.ts +17 -22
- package/dist/cli/commands/ecc-commands.js +38 -26
- package/dist/cli/commands/prd-commands.js +8 -2
- package/dist/cli/program.js +4 -9
- package/dist/services/code/mode-gate-types.d.ts +1 -1
- package/dist/services/code/mode-gate-types.js +0 -1
- package/dist/services/code/mode-gate.js +1 -9
- package/dist/services/code/user-touchpoint-classifier.js +0 -7
- package/dist/services/code-review/ecc-bridge.d.ts +6 -6
- package/dist/services/comments/citation-rules.d.ts +154 -0
- package/dist/services/comments/citation-rules.js +241 -0
- package/dist/services/comments/comment-audit.d.ts +57 -0
- package/dist/services/comments/comment-audit.js +100 -0
- package/dist/services/comments/comment-citations.d.ts +60 -0
- package/dist/services/comments/comment-citations.js +186 -0
- package/dist/services/comments/comment-hygiene.d.ts +79 -0
- package/dist/services/comments/comment-hygiene.js +133 -0
- package/dist/services/comments/comment-prune.d.ts +88 -0
- package/dist/services/comments/comment-prune.js +148 -0
- package/dist/services/comments/prune-apply.d.ts +60 -0
- package/dist/services/comments/prune-apply.js +150 -0
- package/dist/services/comments/repo-path-probe.d.ts +43 -0
- package/dist/services/comments/repo-path-probe.js +78 -0
- package/dist/services/log/retention.d.ts +0 -16
- package/dist/services/log/retention.js +0 -17
- 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 +19 -7
- package/dist/services/memory/project-memory-service/store/atomic-write.js +120 -26
- package/dist/services/prd/handoff-frontmatter.js +61 -0
- package/dist/services/prd/handoff-gate-evidence.js +14 -10
- package/dist/services/prd/handoff-service.d.ts +11 -1
- package/dist/services/prd/handoff-service.js +11 -1
- package/dist/services/prd/handoff-types.d.ts +33 -1
- package/dist/services/recommendations/installed-capability-detector.d.ts +5 -5
- package/dist/services/recommendations/installed-capability-detector.js +11 -10
- package/dist/services/scan/archetype-detection.d.ts +37 -0
- package/dist/services/scan/archetype-detection.js +175 -2
- package/dist/services/scan/archetype-service.js +36 -22
- package/dist/services/scan/scan-types.d.ts +9 -0
- package/dist/services/workspace/generated-artifacts-stamp.d.ts +2 -2
- package/dist/services/workspace/generated-artifacts-stamp.js +2 -2
- package/dist/services/workspace/workspace-service.js +1 -1
- package/package.json +5 -5
- package/scripts/install-skills.mjs +0 -177
- package/skills/bee/peaks-qa/references/reading-handoff-frontmatter.md +3 -1
- package/skills/bee/peaks-rd/references/parallel-review-fanout.md +1 -1
- package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +3 -2
- package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +29 -7
- package/skills/peaks-code/references/frontend-only-mode.md +2 -2
- package/skills/peaks-code/references/startup-sequence.md +0 -4
- package/dist/cli/commands/upgrade-commands.d.ts +0 -25
- package/dist/cli/commands/upgrade-commands.js +0 -154
- package/dist/services/upgrade/1x-detector-service.d.ts +0 -7
- package/dist/services/upgrade/1x-detector-service.js +0 -96
- package/dist/services/upgrade/gitignore-migrate-service.d.ts +0 -56
- package/dist/services/upgrade/gitignore-migrate-service.js +0 -170
- package/dist/services/upgrade/upgrade-service.d.ts +0 -81
- package/dist/services/upgrade/upgrade-service.js +0 -428
- package/skills/peaks-code/references/step-0-55-1x-detection.md +0 -83
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line mechanics of a comment prune, and the proof that gates every write.
|
|
3
|
+
*
|
|
4
|
+
* Split from `comment-prune.ts` because the two change for different reasons: that
|
|
5
|
+
* module decides WHAT is debt (and follows the classifier), this one decides how a
|
|
6
|
+
* line is removed from a file without damaging the file (and follows the language).
|
|
7
|
+
* Keeping them apart is also what lets the proof be tested on a text array, with no
|
|
8
|
+
* filesystem and no scan in the room.
|
|
9
|
+
*/
|
|
10
|
+
import type { PruneAction, PruneSkip } from './comment-prune.js';
|
|
11
|
+
/** What the write phase decided, per file. */
|
|
12
|
+
export type WriteOutcome = {
|
|
13
|
+
touched: string[];
|
|
14
|
+
notWritten: string[];
|
|
15
|
+
fileSkips: PruneSkip[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Read a file, or report that it is not there — a prune never creates one.
|
|
19
|
+
*
|
|
20
|
+
* `existsSync` first, and NO catch: a `catch { return null }` answers "present but
|
|
21
|
+
* unreadable" (permissions, a locked handle, a directory named as a file) with the same
|
|
22
|
+
* `null` as "absent", and that conflation is the defect this repository already
|
|
23
|
+
* ratchets under `silentWarningCatchReturnNull` — this very helper was counted by it.
|
|
24
|
+
* An absent path is a state the caller handles; an unreadable one should be a crash an
|
|
25
|
+
* operator sees.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readFileSafe(root: string, rel: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Split a source into lines, remembering what separated them.
|
|
30
|
+
*
|
|
31
|
+
* `mixed` is the signal to leave a file alone rather than a detail to paper over: a
|
|
32
|
+
* file with both CRLF and LF endings cannot be re-joined from one delimiter without
|
|
33
|
+
* rewriting every line, and a rewrite of every line is not a comment edit — it would
|
|
34
|
+
* also make the proof below vacuous, because all the lines would differ.
|
|
35
|
+
*/
|
|
36
|
+
export declare function splitLines(source: string): {
|
|
37
|
+
lines: string[];
|
|
38
|
+
eol: string;
|
|
39
|
+
mixed: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** Group a plan by file, in first-seen order. */
|
|
42
|
+
export declare function actionsByFile(actions: readonly PruneAction[]): Map<string, PruneAction[]>;
|
|
43
|
+
/**
|
|
44
|
+
* The proof, as data: compare kept lines against the original.
|
|
45
|
+
*
|
|
46
|
+
* One string per violation; empty means every difference between the two texts is a
|
|
47
|
+
* line this plan named, and every line it named that was kept is byte-identical to
|
|
48
|
+
* what was there before. This is computed whether or not the caller intends to write,
|
|
49
|
+
* so a dry run reports the refusals a real run would hit.
|
|
50
|
+
*/
|
|
51
|
+
export declare function proofViolations(before: readonly string[], after: readonly string[], actions: readonly PruneAction[]): string[];
|
|
52
|
+
/** Apply one file's actions to its lines, in the plan's own terms. */
|
|
53
|
+
export declare function pruneFileLines(lines: readonly string[], actions: readonly PruneAction[]): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Run the proof on every planned file, and write only the files that passed it.
|
|
56
|
+
*
|
|
57
|
+
* A file that fails is reported and left alone — never written "mostly", because a
|
|
58
|
+
* partially-pruned file is exactly the state the proof exists to make unreachable.
|
|
59
|
+
*/
|
|
60
|
+
export declare function executePlan(projectRoot: string, byFile: Map<string, PruneAction[]>, apply: boolean): WriteOutcome;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line mechanics of a comment prune, and the proof that gates every write.
|
|
3
|
+
*
|
|
4
|
+
* Split from `comment-prune.ts` because the two change for different reasons: that
|
|
5
|
+
* module decides WHAT is debt (and follows the classifier), this one decides how a
|
|
6
|
+
* line is removed from a file without damaging the file (and follows the language).
|
|
7
|
+
* Keeping them apart is also what lets the proof be tested on a text array, with no
|
|
8
|
+
* filesystem and no scan in the room.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
/**
|
|
13
|
+
* Read a file, or report that it is not there — a prune never creates one.
|
|
14
|
+
*
|
|
15
|
+
* `existsSync` first, and NO catch: a `catch { return null }` answers "present but
|
|
16
|
+
* unreadable" (permissions, a locked handle, a directory named as a file) with the same
|
|
17
|
+
* `null` as "absent", and that conflation is the defect this repository already
|
|
18
|
+
* ratchets under `silentWarningCatchReturnNull` — this very helper was counted by it.
|
|
19
|
+
* An absent path is a state the caller handles; an unreadable one should be a crash an
|
|
20
|
+
* operator sees.
|
|
21
|
+
*/
|
|
22
|
+
export function readFileSafe(root, rel) {
|
|
23
|
+
const abs = join(root, rel);
|
|
24
|
+
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Split a source into lines, remembering what separated them.
|
|
28
|
+
*
|
|
29
|
+
* `mixed` is the signal to leave a file alone rather than a detail to paper over: a
|
|
30
|
+
* file with both CRLF and LF endings cannot be re-joined from one delimiter without
|
|
31
|
+
* rewriting every line, and a rewrite of every line is not a comment edit — it would
|
|
32
|
+
* also make the proof below vacuous, because all the lines would differ.
|
|
33
|
+
*/
|
|
34
|
+
export function splitLines(source) {
|
|
35
|
+
const crlf = source.includes('\r\n');
|
|
36
|
+
const lfOnly = /\n(?!\r)/.test(source.replace(/\r\n/g, '\n')) || /\n[^\r]/.test(source);
|
|
37
|
+
return {
|
|
38
|
+
lines: source.split(/\r?\n/),
|
|
39
|
+
eol: crlf ? '\r\n' : '\n',
|
|
40
|
+
mixed: crlf && lfOnly
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Group a plan by file, in first-seen order. */
|
|
44
|
+
export function actionsByFile(actions) {
|
|
45
|
+
const byFile = new Map();
|
|
46
|
+
for (const action of actions) {
|
|
47
|
+
const list = byFile.get(action.file);
|
|
48
|
+
if (list === undefined)
|
|
49
|
+
byFile.set(action.file, [action]);
|
|
50
|
+
else
|
|
51
|
+
list.push(action);
|
|
52
|
+
}
|
|
53
|
+
return byFile;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The proof, as data: compare kept lines against the original.
|
|
57
|
+
*
|
|
58
|
+
* One string per violation; empty means every difference between the two texts is a
|
|
59
|
+
* line this plan named, and every line it named that was kept is byte-identical to
|
|
60
|
+
* what was there before. This is computed whether or not the caller intends to write,
|
|
61
|
+
* so a dry run reports the refusals a real run would hit.
|
|
62
|
+
*/
|
|
63
|
+
export function proofViolations(before, after, actions) {
|
|
64
|
+
const dropped = new Set(actions.filter((a) => a.mode === 'drop-line').map((a) => a.line));
|
|
65
|
+
const stripped = new Set(actions.filter((a) => a.mode === 'strip-trailing').map((a) => a.line));
|
|
66
|
+
const violations = [];
|
|
67
|
+
let afterAt = 0;
|
|
68
|
+
before.forEach((original, index) => {
|
|
69
|
+
const line = index + 1;
|
|
70
|
+
if (dropped.has(line))
|
|
71
|
+
return; // deleted on purpose
|
|
72
|
+
if (stripped.has(line)) {
|
|
73
|
+
// The only thing a strip may do is reveal the code that was already there, so
|
|
74
|
+
// what survives must be a PREFIX of the original line — that is what makes it a
|
|
75
|
+
// comment edit, and anything else is a code edit wearing its clothes.
|
|
76
|
+
const kept = after[afterAt] ?? '';
|
|
77
|
+
afterAt += 1;
|
|
78
|
+
if (!original.startsWith(kept))
|
|
79
|
+
violations.push(`line ${line}: code text changed`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (after[afterAt] !== original)
|
|
83
|
+
violations.push(`line ${line}: untouched line differs`);
|
|
84
|
+
afterAt += 1;
|
|
85
|
+
});
|
|
86
|
+
if (afterAt !== after.length)
|
|
87
|
+
violations.push(`produced ${after.length - afterAt} extra line(s)`);
|
|
88
|
+
return violations;
|
|
89
|
+
}
|
|
90
|
+
/** Apply one file's actions to its lines, in the plan's own terms. */
|
|
91
|
+
export function pruneFileLines(lines, actions) {
|
|
92
|
+
const drop = new Set(actions.filter((a) => a.mode === 'drop-line').map((a) => a.line));
|
|
93
|
+
const strip = new Map(actions.filter((a) => a.mode === 'strip-trailing').map((a) => [a.line, a.comment]));
|
|
94
|
+
const out = [];
|
|
95
|
+
lines.forEach((original, index) => {
|
|
96
|
+
const line = index + 1;
|
|
97
|
+
if (drop.has(line))
|
|
98
|
+
return;
|
|
99
|
+
if (strip.has(line)) {
|
|
100
|
+
// Cut where the scan's comment text starts, not at the first `//` in the line:
|
|
101
|
+
// `const url = "https://example.com"; // Slice 1 kept it` has its `//` inside a
|
|
102
|
+
// string literal, and slicing there would delete code.
|
|
103
|
+
const comment = strip.get(line) ?? '';
|
|
104
|
+
const at = original.indexOf(comment);
|
|
105
|
+
out.push(at > 0 ? original.slice(0, at).trimEnd() : comment.trimEnd());
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
out.push(original);
|
|
109
|
+
});
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run the proof on every planned file, and write only the files that passed it.
|
|
114
|
+
*
|
|
115
|
+
* A file that fails is reported and left alone — never written "mostly", because a
|
|
116
|
+
* partially-pruned file is exactly the state the proof exists to make unreachable.
|
|
117
|
+
*/
|
|
118
|
+
export function executePlan(projectRoot, byFile, apply) {
|
|
119
|
+
const touched = [];
|
|
120
|
+
const notWritten = [];
|
|
121
|
+
const fileSkips = [];
|
|
122
|
+
for (const [file, fileActions] of byFile) {
|
|
123
|
+
const source = readFileSafe(projectRoot, file);
|
|
124
|
+
if (source === null) {
|
|
125
|
+
notWritten.push(file);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const parts = splitLines(source);
|
|
129
|
+
if (parts.mixed) {
|
|
130
|
+
fileSkips.push({
|
|
131
|
+
file,
|
|
132
|
+
line: 0,
|
|
133
|
+
reason: 'mixed-line-endings',
|
|
134
|
+
text: `${parts.lines.length} line(s) left untouched`
|
|
135
|
+
});
|
|
136
|
+
notWritten.push(file);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const after = pruneFileLines(parts.lines, fileActions);
|
|
140
|
+
const violations = proofViolations(parts.lines, after, fileActions);
|
|
141
|
+
if (violations.length > 0) {
|
|
142
|
+
notWritten.push(`${file} (${violations[0]})`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
touched.push(file);
|
|
146
|
+
if (apply)
|
|
147
|
+
writeFileSync(join(projectRoot, file), after.join(parts.eol));
|
|
148
|
+
}
|
|
149
|
+
return { touched, notWritten, fileSkips };
|
|
150
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is a path required to exist in a checkout at all?
|
|
3
|
+
*
|
|
4
|
+
* The dominant false positive on `dead-reference` was never a stale comment: it
|
|
5
|
+
* was a correct reference to a file git is told not to track. Ranked over this
|
|
6
|
+
* repository, the top twelve "dead" citations were `.claude/settings.local.json`,
|
|
7
|
+
* `.peaks/_runtime/session.json`, `.peaks/.session.json`,
|
|
8
|
+
* `.peaks/_runtime/active-skill.json`, `.codegraph/config.json`,
|
|
9
|
+
* `.peaks/preferences.json` and friends — all generated state, all named
|
|
10
|
+
* correctly by the code that writes them.
|
|
11
|
+
*
|
|
12
|
+
* So the probe answers "exists OR ignored", and the ignore reading is deliberately
|
|
13
|
+
* bounded: trailing-slash prefixes, whole-path entries, and bare-basename entries.
|
|
14
|
+
* That limit has a direction, and the direction is the safe one — a rule
|
|
15
|
+
* `.gitignore` expresses fancily (negation, `**`, character classes) is NOT read
|
|
16
|
+
* here, so such a path stays reported. A missed exemption is a finding a human
|
|
17
|
+
* reads and dismisses; a phantom exemption is a silent false green in a gate that
|
|
18
|
+
* exists to catch claims that outlived their referent.
|
|
19
|
+
*/
|
|
20
|
+
type IgnoreRules = {
|
|
21
|
+
/** Directory rules: match the path and everything below it. */
|
|
22
|
+
readonly prefixes: readonly string[];
|
|
23
|
+
/** File rules written with a slash: match that exact repo-relative path. */
|
|
24
|
+
readonly exact: ReadonlySet<string>;
|
|
25
|
+
/** Basename rules: match that name in any directory. */
|
|
26
|
+
readonly basenames: ReadonlySet<string>;
|
|
27
|
+
};
|
|
28
|
+
/** True when `.gitignore` says this repo-relative path need not be on disk. */
|
|
29
|
+
export declare function isIgnoredPath(relPath: string, rules: IgnoreRules): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Build the existence probe a scan should use: a real filesystem lookup, widened
|
|
32
|
+
* by the repository's own ignore list read from its root.
|
|
33
|
+
*
|
|
34
|
+
* The second export below is the deliberate exception. "Is this an installed
|
|
35
|
+
* package name?" must NOT be widened by the ignore list, because `node_modules` is
|
|
36
|
+
* itself ignored — an ignore-aware probe answers yes for every first segment, and
|
|
37
|
+
* the whole `dead-reference` count collapses to zero while looking perfectly
|
|
38
|
+
* healthy.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createRepoProbe(repoRoot: string): (relPath: string) => boolean;
|
|
41
|
+
/** Filesystem-only existence, for questions where "git ignores it" is no answer. */
|
|
42
|
+
export declare function createFsProbe(repoRoot: string): (relPath: string) => boolean;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is a path required to exist in a checkout at all?
|
|
3
|
+
*
|
|
4
|
+
* The dominant false positive on `dead-reference` was never a stale comment: it
|
|
5
|
+
* was a correct reference to a file git is told not to track. Ranked over this
|
|
6
|
+
* repository, the top twelve "dead" citations were `.claude/settings.local.json`,
|
|
7
|
+
* `.peaks/_runtime/session.json`, `.peaks/.session.json`,
|
|
8
|
+
* `.peaks/_runtime/active-skill.json`, `.codegraph/config.json`,
|
|
9
|
+
* `.peaks/preferences.json` and friends — all generated state, all named
|
|
10
|
+
* correctly by the code that writes them.
|
|
11
|
+
*
|
|
12
|
+
* So the probe answers "exists OR ignored", and the ignore reading is deliberately
|
|
13
|
+
* bounded: trailing-slash prefixes, whole-path entries, and bare-basename entries.
|
|
14
|
+
* That limit has a direction, and the direction is the safe one — a rule
|
|
15
|
+
* `.gitignore` expresses fancily (negation, `**`, character classes) is NOT read
|
|
16
|
+
* here, so such a path stays reported. A missed exemption is a finding a human
|
|
17
|
+
* reads and dismisses; a phantom exemption is a silent false green in a gate that
|
|
18
|
+
* exists to catch claims that outlived their referent.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
function parseGitignore(source) {
|
|
23
|
+
const prefixes = [];
|
|
24
|
+
const exact = new Set();
|
|
25
|
+
const basenames = new Set();
|
|
26
|
+
for (const raw of source.split(/\r?\n/)) {
|
|
27
|
+
const rule = raw.trim();
|
|
28
|
+
if (rule.length === 0 || rule.startsWith('#') || rule.startsWith('!'))
|
|
29
|
+
continue;
|
|
30
|
+
const cleaned = rule.replace(/^\//, '').replace(/\/$/, '');
|
|
31
|
+
if (cleaned.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
if (!cleaned.includes('/')) {
|
|
34
|
+
basenames.add(cleaned);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// A rule with a slash is registered both ways rather than guessed: `.peaks/_runtime/`
|
|
38
|
+
// is a directory, `.claude/settings.local.json` is one file, and a rule that
|
|
39
|
+
// matched only `path/` silently failed every single-file ignore in the
|
|
40
|
+
// repository. Registering both costs nothing — an over-broad directory prefix
|
|
41
|
+
// here can only ever name the path it was written for and things under it.
|
|
42
|
+
prefixes.push(`${cleaned}/`);
|
|
43
|
+
exact.add(cleaned);
|
|
44
|
+
}
|
|
45
|
+
return { prefixes, exact, basenames };
|
|
46
|
+
}
|
|
47
|
+
/** True when `.gitignore` says this repo-relative path need not be on disk. */
|
|
48
|
+
export function isIgnoredPath(relPath, rules) {
|
|
49
|
+
const posix = relPath.replace(/\\/g, '/');
|
|
50
|
+
if (rules.exact.has(posix))
|
|
51
|
+
return true;
|
|
52
|
+
if (rules.prefixes.some((prefix) => posix.startsWith(prefix)))
|
|
53
|
+
return true;
|
|
54
|
+
const segments = posix.split('/');
|
|
55
|
+
return segments.some((segment) => rules.basenames.has(segment));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the existence probe a scan should use: a real filesystem lookup, widened
|
|
59
|
+
* by the repository's own ignore list read from its root.
|
|
60
|
+
*
|
|
61
|
+
* The second export below is the deliberate exception. "Is this an installed
|
|
62
|
+
* package name?" must NOT be widened by the ignore list, because `node_modules` is
|
|
63
|
+
* itself ignored — an ignore-aware probe answers yes for every first segment, and
|
|
64
|
+
* the whole `dead-reference` count collapses to zero while looking perfectly
|
|
65
|
+
* healthy.
|
|
66
|
+
*/
|
|
67
|
+
export function createRepoProbe(repoRoot) {
|
|
68
|
+
let rules = { prefixes: [], exact: new Set(), basenames: new Set() };
|
|
69
|
+
const ignorePath = join(repoRoot, '.gitignore');
|
|
70
|
+
if (existsSync(ignorePath)) {
|
|
71
|
+
rules = parseGitignore(readFileSync(ignorePath, 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
return (relPath) => existsSync(join(repoRoot, relPath)) || isIgnoredPath(relPath, rules);
|
|
74
|
+
}
|
|
75
|
+
/** Filesystem-only existence, for questions where "git ignores it" is no answer. */
|
|
76
|
+
export function createFsProbe(repoRoot) {
|
|
77
|
+
return (relPath) => existsSync(join(repoRoot, relPath));
|
|
78
|
+
}
|
|
@@ -32,19 +32,3 @@ export type ApplyRetentionOptions = {
|
|
|
32
32
|
* process between the `readdir` and the `unlink`).
|
|
33
33
|
*/
|
|
34
34
|
export declare function applyRetention(opts?: ApplyRetentionOptions): string[];
|
|
35
|
-
/**
|
|
36
|
-
* Slice 3 (on-demand-ecc) — 7-day TTL sweep over `~/.peaks/cache/ecc-<sha>/`
|
|
37
|
-
* directories. Delegates to `cleanupStaleCache` in the ECC cache
|
|
38
|
-
* service so the retention policy lives next to the cache it
|
|
39
|
-
* governs.
|
|
40
|
-
*
|
|
41
|
-
* Mirrors the `applyRetention` signature for symmetry: tests pass
|
|
42
|
-
* `nowMs` + `dirOverride`; production callers leave both unset.
|
|
43
|
-
*/
|
|
44
|
-
export declare function cleanupEccCache(options: {
|
|
45
|
-
retentionDays: number;
|
|
46
|
-
nowMs?: number;
|
|
47
|
-
dirOverride?: string;
|
|
48
|
-
}): {
|
|
49
|
-
removed: string[];
|
|
50
|
-
};
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { readdirSync, statSync, unlinkSync, existsSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { resolveLogDir } from './logger.js';
|
|
19
|
-
import { cleanupStaleCache } from 'peaks-loop-mut';
|
|
20
19
|
const LOG_FILE_NAME_PATTERN = /^peaks-loop-(\d{4}-\d{2}-\d{2})\.log$/;
|
|
21
20
|
function dayDiffUtc(nowUtcMidnightMs, fileDateUtcMs) {
|
|
22
21
|
const dayMs = 24 * 60 * 60 * 1000;
|
|
@@ -78,19 +77,3 @@ export function applyRetention(opts = {}) {
|
|
|
78
77
|
}
|
|
79
78
|
return removed;
|
|
80
79
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Slice 3 (on-demand-ecc) — 7-day TTL sweep over `~/.peaks/cache/ecc-<sha>/`
|
|
83
|
-
* directories. Delegates to `cleanupStaleCache` in the ECC cache
|
|
84
|
-
* service so the retention policy lives next to the cache it
|
|
85
|
-
* governs.
|
|
86
|
-
*
|
|
87
|
-
* Mirrors the `applyRetention` signature for symmetry: tests pass
|
|
88
|
-
* `nowMs` + `dirOverride`; production callers leave both unset.
|
|
89
|
-
*/
|
|
90
|
-
export function cleanupEccCache(options) {
|
|
91
|
-
return cleanupStaleCache({
|
|
92
|
-
retentionDays: options.retentionDays,
|
|
93
|
-
nowMs: options.nowMs ?? Date.now(),
|
|
94
|
-
...(options.dirOverride !== undefined ? { dirOverride: options.dirOverride } : {})
|
|
95
|
-
});
|
|
96
|
-
}
|
|
@@ -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, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
|
|
10
|
+
export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryContentRule, 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, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
|
|
22
|
+
export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryContentRule, 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';
|
|
@@ -24,6 +24,12 @@ export declare const SENSITIVE_MEMORY_CHECKS: {
|
|
|
24
24
|
* a value — so echoing it into the error envelope cannot leak anything.
|
|
25
25
|
*/
|
|
26
26
|
export declare function findSensitiveMemoryTitleTerm(title: string): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Which content rule refused this text — the id, never the match. A rule whose
|
|
29
|
+
* candidate fails `shaped` does not stop the scan: a later rule may still fire,
|
|
30
|
+
* exactly as a later alternative in the old `||` chain would have.
|
|
31
|
+
*/
|
|
32
|
+
export declare function findSensitiveMemoryContentRule(content: string): string | null;
|
|
27
33
|
export declare function hasSensitiveMemoryContent(content: string): boolean;
|
|
28
34
|
/**
|
|
29
35
|
* The refusal `assertSafeMemory` raises: the check that fired and the term it
|
|
@@ -42,19 +48,25 @@ export declare function hasSensitiveMemoryContent(content: string): boolean;
|
|
|
42
48
|
* `check` for the same reason — a substring test against a message whose words
|
|
43
49
|
* can be rewritten is not a routing rule, it is a guess.
|
|
44
50
|
*
|
|
45
|
-
* NOTHING HERE WEAKENS THE REDACTOR.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
51
|
+
* NOTHING HERE WEAKENS THE REDACTOR. Both scans put a NAME in this field, never
|
|
52
|
+
* a match: the title term comes from `SENSITIVE_PROSE_TERMS` and the content
|
|
53
|
+
* value from the closed id list in `SENSITIVE_CONTENT_RULES`, so no credential
|
|
54
|
+
* can reach an envelope through it. The redactor's guarantee over `message` is
|
|
55
|
+
* intact and is pinned by the cases in
|
|
48
56
|
* `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
|
|
49
57
|
*/
|
|
50
58
|
export declare class UnsafeMemoryError extends Error {
|
|
51
59
|
/** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
|
|
52
60
|
readonly check: string;
|
|
53
61
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
62
|
+
* WHAT matched, as a name rather than a value: the credential term for the
|
|
63
|
+
* title scan, the rule id for the content scan, `null` only for the metadata
|
|
64
|
+
* key scan (whose predicate answers by a config-key vocabulary the memory
|
|
65
|
+
* metadata never contains — see the note on `assertSafeMemory`).
|
|
66
|
+
*
|
|
67
|
+
* It is never the match itself: for most content patterns the match IS the
|
|
68
|
+
* credential (`ghp_…`, the PEM header, the JWT), which is why their messages
|
|
69
|
+
* describe the pattern family and never echo the text.
|
|
58
70
|
*/
|
|
59
71
|
readonly matchedTerm: string | null;
|
|
60
72
|
constructor(check: string, detail: string, matchedTerm?: string | null);
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// - `hasSensitiveMemoryContent` — pattern check for api keys, tokens,
|
|
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
|
-
// (`assertSafeMemoryFileContent`).
|
|
7
|
+
// (`assertSafeMemoryFileContent`). It is a wrapper over
|
|
8
|
+
// `findSensitiveMemoryContentRule`, which answers the same question and
|
|
9
|
+
// names the rule that answered it.
|
|
8
10
|
// - `findSensitiveMemoryTitleTerm` — the PROSE predicate for
|
|
9
11
|
// `memory.title`. It is deliberately NOT the config-key predicate: see
|
|
10
12
|
// the block comment above it (slice C0).
|
|
@@ -136,17 +138,89 @@ export function findSensitiveMemoryTitleTerm(title) {
|
|
|
136
138
|
}
|
|
137
139
|
return null;
|
|
138
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Is this string shaped like a credential VALUE rather than like prose?
|
|
143
|
+
*
|
|
144
|
+
* WHY THIS EXISTS. The value-carrying patterns used to fire on the SEPARATOR
|
|
145
|
+
* alone: `password:` was a match whether or not anything followed it, so a
|
|
146
|
+
* memory writing `password: the field label in the form` or `myApiKey: from
|
|
147
|
+
* env` was refused for describing an interface. Two documents were killed that
|
|
148
|
+
* way before anyone could say which of ten rules had fired — and the rules
|
|
149
|
+
* could not be tightened, because nothing in them described the VALUE.
|
|
150
|
+
*
|
|
151
|
+
* THE TEST, AND WHAT IT COSTS. A value is credential-shaped when it is at least
|
|
152
|
+
* 5 characters AND carries a digit or one of `_ + / =`. `hunter2`,
|
|
153
|
+
* `abcdef123456`, `supp3rSecret` and every base64-ish token pass;
|
|
154
|
+
* `Authorization`, `the`, `from`, `abc`, `well-designed` fail. Given up
|
|
155
|
+
* deliberately: an all-letter opaque run (`bearer abcdefghijklmnopq`) is no
|
|
156
|
+
* longer caught, and neither is a 4-character real password. The alternative was
|
|
157
|
+
* a rule that cannot tell an English word from a random string, and the
|
|
158
|
+
* complaint on record is about English words. Digits are the discriminator
|
|
159
|
+
* rather than `-` and `.` because hyphens and full stops belong to ordinary
|
|
160
|
+
* prose; provider tokens in the wild carry digits, and the prefix-shaped
|
|
161
|
+
* patterns (`ghp_`, `AKIA`, the PEM header, the JWT) keep their own length floors
|
|
162
|
+
* and are untouched by this predicate.
|
|
163
|
+
*/
|
|
164
|
+
const MIN_VALUE_CHARS = 5;
|
|
165
|
+
function isCredentialShapedValue(value) {
|
|
166
|
+
return value.length >= MIN_VALUE_CHARS && /[0-9_+/=]/.test(value);
|
|
167
|
+
}
|
|
168
|
+
/** A placeholder names where a value will go; it is not itself a secret. */
|
|
169
|
+
const PLACEHOLDER_VALUE = /^[<[{(]|^(?:your|the|an|some|my|example|placeholder|todo|fake)/i;
|
|
170
|
+
function isPlaceholderValue(value) {
|
|
171
|
+
return (PLACEHOLDER_VALUE.test(value) ||
|
|
172
|
+
/(?:replace|insert|provide|redact|<[a-z_]+>)/i.test(value) ||
|
|
173
|
+
/^[A-Za-z]+(?:_[A-Z*]+)+$/.test(value) // API_KEY_HERE-style constant
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const SENSITIVE_CONTENT_RULES = [
|
|
177
|
+
{
|
|
178
|
+
id: 'credential-assignment',
|
|
179
|
+
pattern: /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|password|passwd|credential|token|bearer)\s*[:=]\s*([^\s,;"'`]+)/gi,
|
|
180
|
+
shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'authorization-bearer-header',
|
|
184
|
+
pattern: /\bauthorization\s*:\s*bearer\s+([^\s,;"'`]+)/gi,
|
|
185
|
+
shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'bearer-value',
|
|
189
|
+
pattern: /\bbearer\s+([A-Za-z0-9._~+/=-]{8,})/gi,
|
|
190
|
+
shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'sk-prefixed-key',
|
|
194
|
+
// The SUFFIX is captured, not the whole `sk-…`: `sk-abcdef is the prefix`
|
|
195
|
+
// is a sentence about the scheme, and the shape test on the suffix is what
|
|
196
|
+
// tells it from `sk-abcdef1234567890`.
|
|
197
|
+
pattern: /\bsk-([A-Za-z0-9_-]{6,})\b/g,
|
|
198
|
+
shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
|
|
199
|
+
},
|
|
200
|
+
{ id: 'gh-prefixed-key', pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g },
|
|
201
|
+
{ id: 'github-pat', pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
|
|
202
|
+
{ id: 'glpat-prefixed-key', pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
203
|
+
{ id: 'akia-prefixed-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
204
|
+
{ id: 'pem-private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
205
|
+
{ id: 'jwt-shaped-value', pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g }
|
|
206
|
+
];
|
|
207
|
+
/**
|
|
208
|
+
* Which content rule refused this text — the id, never the match. A rule whose
|
|
209
|
+
* candidate fails `shaped` does not stop the scan: a later rule may still fire,
|
|
210
|
+
* exactly as a later alternative in the old `||` chain would have.
|
|
211
|
+
*/
|
|
212
|
+
export function findSensitiveMemoryContentRule(content) {
|
|
213
|
+
for (const rule of SENSITIVE_CONTENT_RULES) {
|
|
214
|
+
for (const match of content.matchAll(rule.pattern)) {
|
|
215
|
+
const candidate = match[1] ?? match[0];
|
|
216
|
+
if (rule.shaped === undefined || rule.shaped(candidate))
|
|
217
|
+
return rule.id;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
139
222
|
export function hasSensitiveMemoryContent(content) {
|
|
140
|
-
return (
|
|
141
|
-
/\bauthorization\s*:\s*bearer\s+\S+/i.test(content) ||
|
|
142
|
-
/\bbearer\s+[A-Za-z0-9._~+/=-]{12,}\b/i.test(content) ||
|
|
143
|
-
/\bsk-[A-Za-z0-9_-]{6,}\b/.test(content) ||
|
|
144
|
-
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/.test(content) ||
|
|
145
|
-
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/.test(content) ||
|
|
146
|
-
/\bglpat-[A-Za-z0-9_-]{20,}\b/.test(content) ||
|
|
147
|
-
/\bAKIA[0-9A-Z]{16}\b/.test(content) ||
|
|
148
|
-
/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content) ||
|
|
149
|
-
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/.test(content));
|
|
223
|
+
return findSensitiveMemoryContentRule(content) !== null;
|
|
150
224
|
}
|
|
151
225
|
/**
|
|
152
226
|
* The refusal `assertSafeMemory` raises: the check that fired and the term it
|
|
@@ -165,19 +239,25 @@ export function hasSensitiveMemoryContent(content) {
|
|
|
165
239
|
* `check` for the same reason — a substring test against a message whose words
|
|
166
240
|
* can be rewritten is not a routing rule, it is a guess.
|
|
167
241
|
*
|
|
168
|
-
* NOTHING HERE WEAKENS THE REDACTOR.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
242
|
+
* NOTHING HERE WEAKENS THE REDACTOR. Both scans put a NAME in this field, never
|
|
243
|
+
* a match: the title term comes from `SENSITIVE_PROSE_TERMS` and the content
|
|
244
|
+
* value from the closed id list in `SENSITIVE_CONTENT_RULES`, so no credential
|
|
245
|
+
* can reach an envelope through it. The redactor's guarantee over `message` is
|
|
246
|
+
* intact and is pinned by the cases in
|
|
171
247
|
* `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
|
|
172
248
|
*/
|
|
173
249
|
export class UnsafeMemoryError extends Error {
|
|
174
250
|
/** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
|
|
175
251
|
check;
|
|
176
252
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
253
|
+
* WHAT matched, as a name rather than a value: the credential term for the
|
|
254
|
+
* title scan, the rule id for the content scan, `null` only for the metadata
|
|
255
|
+
* key scan (whose predicate answers by a config-key vocabulary the memory
|
|
256
|
+
* metadata never contains — see the note on `assertSafeMemory`).
|
|
257
|
+
*
|
|
258
|
+
* It is never the match itself: for most content patterns the match IS the
|
|
259
|
+
* credential (`ghp_…`, the PEM header, the JWT), which is why their messages
|
|
260
|
+
* describe the pattern family and never echo the text.
|
|
181
261
|
*/
|
|
182
262
|
matchedTerm;
|
|
183
263
|
constructor(check, detail, matchedTerm = null) {
|
|
@@ -193,19 +273,28 @@ export function assertSafeMemory(memory) {
|
|
|
193
273
|
if (containsSensitiveConfigValue(metadata)) {
|
|
194
274
|
throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.metadataKey, 'matched a credential key in the memory metadata');
|
|
195
275
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
276
|
+
const contentRule = findSensitiveMemoryContentRule(content);
|
|
277
|
+
if (contentRule !== null) {
|
|
278
|
+
// The matched TEXT is deliberately NOT echoed, in the message or in the
|
|
279
|
+
// error's fields: for most of these patterns the match IS the credential, so
|
|
199
280
|
// copying it would write the value this scan exists to keep out.
|
|
200
281
|
//
|
|
282
|
+
// The RULE ID is echoed, and it is echoed in `matchedTerm` rather than only
|
|
283
|
+
// in prose. This used to be the one refusal that could not say what fired:
|
|
284
|
+
// the predicate returned a boolean, so the caller had no match to report and
|
|
285
|
+
// `matchedTerm` was `null` by construction while the title scan named its
|
|
286
|
+
// term. A reader then had to guess across documents which of ten shapes had
|
|
287
|
+
// refused them. An id from a closed vocabulary leaks nothing and answers the
|
|
288
|
+
// question, so the two paths are diagnosable the same way.
|
|
289
|
+
//
|
|
201
290
|
// The DETAIL is worded around the envelope redactor's vocabulary on
|
|
202
291
|
// purpose. Its catch-all (`/(secret|token|password|api[-_ ]?key)/gi`)
|
|
203
292
|
// rewrites those four words wherever they appear in a failure message, so
|
|
204
293
|
// naming the pattern families in their own words arrives on the CLI as
|
|
205
294
|
// "an [redacted] / [redacted] / [redacted] assignment" — a remedy sentence
|
|
206
|
-
// redacted into uselessness by the very policy it agrees with.
|
|
207
|
-
//
|
|
208
|
-
throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.content,
|
|
295
|
+
// redacted into uselessness by the very policy it agrees with. The rule ids
|
|
296
|
+
// above are spelled to survive that pass.
|
|
297
|
+
throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.content, `matched the credential pattern "${contentRule}" in the memory content`, contentRule);
|
|
209
298
|
}
|
|
210
299
|
const titleTerm = findSensitiveMemoryTitleTerm(memory.title);
|
|
211
300
|
if (titleTerm !== null) {
|
|
@@ -213,8 +302,13 @@ export function assertSafeMemory(memory) {
|
|
|
213
302
|
}
|
|
214
303
|
}
|
|
215
304
|
export function assertSafeMemoryFileContent(content) {
|
|
216
|
-
|
|
217
|
-
|
|
305
|
+
const rule = findSensitiveMemoryContentRule(content);
|
|
306
|
+
if (rule !== null) {
|
|
307
|
+
// The id travels here too. This path has no `UnsafeMemoryError` (it is not
|
|
308
|
+
// the extract gate, and its callers do not route on `check`), but a backup
|
|
309
|
+
// refusal that cannot say which shape fired is the same dead end as the one
|
|
310
|
+
// the extract path used to be.
|
|
311
|
+
throw new Error(`Refusing to back up sensitive memory content (rule: ${rule})`);
|
|
218
312
|
}
|
|
219
313
|
}
|
|
220
314
|
export function writeNewFile(path, content) {
|