rcf-lite 0.28.2 → 0.28.4
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 +211 -0
- package/bin/rcf.js +4 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
- package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
- package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
- package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
- package/rcf/code-nodes/cn-700.json +23 -0
- package/rcf/code-nodes/cn-701.json +14 -0
- package/rcf/code-nodes/cn-702.json +14 -0
- package/rcf/code-nodes/cn-703.json +16 -0
- package/rcf/code-nodes/cn-720.json +15 -0
- package/rcf/code-nodes/cn-721.json +15 -0
- package/rcf/code-nodes/cn-722.json +15 -0
- package/rcf/code-nodes/cn-723.json +18 -0
- package/rcf/code-nodes/cn-724.json +14 -0
- package/rcf/code-nodes/cn-725.json +21 -0
- package/rcf/code-nodes/cn-726.json +17 -0
- package/rcf/code-nodes/cn-727.json +14 -0
- package/rcf/code-nodes/cn-728.json +21 -0
- package/rcf/code-nodes/cn-729.json +18 -0
- package/rcf/code-nodes/cn-730.json +14 -0
- package/rcf/fbs/fbs-190.json +37 -0
- package/rcf/fbs/fbs-191.json +34 -0
- package/rcf/fbs/fbs-192.json +17 -0
- package/rcf/fbs/fbs-193.json +37 -0
- package/rcf/fbs/fbs-194.json +35 -0
- package/rcf/fbs/fbs-195.json +37 -0
- package/rcf/fbs/fbs-196.json +37 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +2 -1
- package/rcf/requirements/req-003.json +2 -1
- package/rcf/requirements/req-004.json +2 -1
- package/rcf/requirements/req-005.json +2 -1
- package/rcf/requirements/req-006.json +2 -1
- package/rcf/requirements/req-007.json +2 -1
- package/rcf/requirements/req-008.json +2 -1
- package/rcf/requirements/req-009.json +2 -1
- package/rcf/requirements/req-010.json +5 -2
- package/rcf/requirements/req-011.json +2 -1
- package/rcf/requirements/req-012.json +2 -1
- package/rcf/requirements/req-013.json +2 -1
- package/rcf/requirements/req-014.json +2 -1
- package/rcf/requirements/req-015.json +2 -1
- package/rcf/requirements/req-016.json +5 -2
- package/rcf/requirements/req-017.json +6 -2
- package/rcf/requirements/req-018.json +5 -2
- package/rcf/requirements/req-019.json +5 -2
- package/rcf/requirements/req-020.json +5 -2
- package/rcf/requirements/req-021.json +5 -2
- package/rcf/requirements/req-022.json +5 -2
- package/rcf/requirements/req-023.json +6 -4
- package/rcf/requirements/req-024.json +5 -2
- package/rcf/requirements/req-025.json +5 -2
- package/rcf/requirements/req-026.json +5 -2
- package/rcf/requirements/req-027.json +5 -2
- package/rcf/requirements/req-028.json +5 -2
- package/rcf/requirements/req-029.json +5 -2
- package/rcf/requirements/req-030.json +5 -2
- package/rcf/requirements/req-040.json +5 -2
- package/rcf/requirements/req-041.json +5 -2
- package/rcf/requirements/req-042.json +5 -2
- package/rcf/requirements/req-043.json +5 -2
- package/rcf/requirements/req-050.json +5 -2
- package/rcf/requirements/req-051.json +5 -2
- package/rcf/requirements/req-052.json +5 -2
- package/rcf/requirements/req-053.json +5 -2
- package/rcf/requirements/req-054.json +5 -2
- package/rcf/requirements/req-060.json +5 -2
- package/rcf/requirements/req-061.json +5 -2
- package/rcf/requirements/req-062.json +5 -2
- package/rcf/requirements/req-063.json +5 -2
- package/rcf/requirements/req-070.json +5 -2
- package/rcf/requirements/req-071.json +5 -2
- package/rcf/requirements/req-072.json +5 -2
- package/rcf/requirements/req-073.json +5 -2
- package/rcf/requirements/req-074.json +5 -2
- package/rcf/requirements/req-075.json +5 -2
- package/rcf/requirements/req-076.json +5 -2
- package/rcf/requirements/req-077.json +5 -2
- package/rcf/requirements/req-080.json +5 -2
- package/rcf/requirements/req-081.json +5 -2
- package/rcf/requirements/req-082.json +5 -2
- package/rcf/requirements/req-083.json +5 -2
- package/rcf/requirements/req-084.json +5 -2
- package/rcf/requirements/req-085.json +5 -2
- package/rcf/requirements/req-086.json +5 -2
- package/rcf/requirements/req-090.json +5 -2
- package/rcf/requirements/req-091.json +5 -2
- package/rcf/requirements/req-092.json +5 -2
- package/rcf/requirements/req-093.json +5 -2
- package/rcf/requirements/req-094.json +5 -2
- package/rcf/requirements/req-100.json +5 -2
- package/rcf/requirements/req-101.json +5 -2
- package/rcf/requirements/req-102.json +5 -2
- package/rcf/requirements/req-103.json +5 -2
- package/rcf/requirements/req-104.json +5 -2
- package/rcf/requirements/req-110.json +5 -2
- package/rcf/requirements/req-111.json +5 -2
- package/rcf/requirements/req-112.json +5 -2
- package/rcf/requirements/req-113.json +5 -2
- package/rcf/requirements/req-114.json +5 -2
- package/rcf/requirements/req-115.json +5 -2
- package/rcf/requirements/req-120.json +5 -2
- package/rcf/requirements/req-121.json +5 -2
- package/rcf/requirements/req-122.json +5 -2
- package/rcf/requirements/req-123.json +5 -2
- package/rcf/requirements/req-124.json +5 -2
- package/rcf/requirements/req-125.json +5 -2
- package/rcf/requirements/req-130.json +6 -2
- package/rcf/requirements/req-131.json +6 -2
- package/rcf/requirements/req-132.json +6 -2
- package/rcf/requirements/req-133.json +6 -2
- package/rcf/requirements/req-134.json +6 -2
- package/rcf/requirements/req-140.json +5 -2
- package/rcf/requirements/req-145.json +5 -2
- package/rcf/requirements/req-150.json +6 -2
- package/rcf/requirements/req-151.json +6 -2
- package/rcf/requirements/req-152.json +6 -2
- package/rcf/requirements/req-153.json +6 -2
- package/rcf/requirements/req-154.json +5 -2
- package/rcf/requirements/req-155.json +4 -1
- package/rcf/requirements/req-156.json +4 -1
- package/rcf/requirements/req-157.json +4 -1
- package/rcf/requirements/req-158.json +4 -1
- package/rcf/requirements/req-159.json +4 -1
- package/rcf/requirements/req-160.json +4 -1
- package/rcf/requirements/req-161.json +4 -1
- package/rcf/requirements/req-162.json +4 -1
- package/rcf/requirements/req-163.json +5 -2
- package/rcf/requirements/req-164.json +5 -2
- package/rcf/requirements/req-165.json +5 -2
- package/rcf/requirements/req-170.json +33 -0
- package/rcf/requirements/req-171.json +44 -0
- package/rcf/requirements/req-172.json +25 -0
- package/rcf/requirements/req-173.json +25 -0
- package/rcf/requirements/req-174.json +25 -0
- package/rcf/requirements/req-175.json +25 -0
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
- package/rcf/tacs/tac-4122-define-gates.json +25 -0
- package/rcf/tacs/tac-4123-define-readiness.json +25 -0
- package/rcf/test-suites/ts-204.json +30 -0
- package/rcf/test-suites/ts-205.json +22 -0
- package/rcf/test-suites/ts-206.json +22 -0
- package/rcf/test-suites/ts-207.json +22 -0
- package/rcf/test-suites/ts-208.json +22 -0
- package/rcf/test-suites/ts-209.json +22 -0
- package/rcf/test-suites/ts-210.json +22 -0
- package/rcf/test-suites/ts-211.json +22 -0
- package/rcf/test-suites/ts-212.json +22 -0
- package/rcf/test-suites/ts-213.json +50 -0
- package/rcf/test-suites/ts-214.json +74 -0
- package/rcf/test-suites/ts-215.json +67 -0
- package/rcf/test-suites/ts-216.json +78 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/user-stories/us-15601.json +4 -4
- package/rcf/user-stories/us-17001.json +27 -0
- package/rcf/user-stories/us-17002.json +22 -0
- package/rcf/user-stories/us-17003.json +22 -0
- package/rcf/user-stories/us-17004.json +22 -0
- package/rcf/user-stories/us-17005.json +22 -0
- package/rcf/user-stories/us-17006.json +21 -0
- package/rcf/user-stories/us-17007.json +21 -0
- package/rcf/user-stories/us-17008.json +22 -0
- package/rcf/user-stories/us-17101.json +22 -0
- package/rcf/user-stories/us-17102.json +22 -0
- package/rcf/user-stories/us-17201.json +38 -0
- package/rcf/user-stories/us-17301.json +38 -0
- package/rcf/user-stories/us-17401.json +58 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/releases/releases.yaml +21 -1
- package/src/cli/feedback.js +126 -10
- package/src/cli/help.js +11 -0
- package/src/cli/ledger.js +399 -0
- package/src/cli/readiness.js +280 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +232 -0
- package/src/define/ledgers.js +513 -0
- package/src/feedback/redact.js +105 -19
- package/src/query/delta.js +276 -0
- package/src/query/gates.js +832 -0
- package/src/query/index.js +36 -0
- package/src/query/readiness.js +333 -0
- package/src/server/index.js +37 -0
- package/src/server/routes.js +65 -3
- package/src/view/doc-renderers/helpers.js +2 -2
- package/src/view/doc-renderers/req.js +10 -2
- package/src/view/doc-renderers/user-story.js +10 -5
- package/src/view/html-page.js +347 -28
- package/src/view/index.js +14 -1
- package/src/view/live-client.js +22 -2
- package/src/view/product-map.js +751 -0
- package/src/view/style.css +378 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Sidecar ledgers under `rcf/define/` (REQ-173; proposal §2.5, §3.2,
|
|
2
|
+
// §8.2 v3).
|
|
3
|
+
//
|
|
4
|
+
// Four ledgers, each one JSON file, each owned by rcf-lite (local
|
|
5
|
+
// schema in this module until schemas 0.7, ADR-4121). Every entry is
|
|
6
|
+
// numbered in one sequence per ledger for the life of the project;
|
|
7
|
+
// numbers never reset, and are what the freeze record's
|
|
8
|
+
// `briefStatements` high-water mark counts against (proposal §2.5).
|
|
9
|
+
//
|
|
10
|
+
// rcf/define/brief-ledger.json D1 brief statements
|
|
11
|
+
// rcf/define/decisions-ledger.json D7 open / answered decisions
|
|
12
|
+
// rcf/define/concern-ledger.json D5 (concern, REQ) pair records
|
|
13
|
+
// rcf/define/probe-ledger.json D6 probe findings
|
|
14
|
+
//
|
|
15
|
+
// Kinds and fields:
|
|
16
|
+
//
|
|
17
|
+
// brief.statements[] { id, kind, text, source, addedAt, status,
|
|
18
|
+
// resolvedAt?, resolvedBy? }
|
|
19
|
+
// kind = capability | constraint | actor | entity | externalSystem
|
|
20
|
+
// | surface | outOfScope | openQuestion | amendment
|
|
21
|
+
//
|
|
22
|
+
// decisions.decisions[] { id, question, options: [{ letter, text }],
|
|
23
|
+
// default: <letter | null>, blocks, addedAt,
|
|
24
|
+
// status, answer?, answeredAt? }
|
|
25
|
+
// `decision list` prints the numbered format from #241: one
|
|
26
|
+
// decision per item; options with letters; the default; what it
|
|
27
|
+
// blocks. Slice 2 (Readiness) is the second consumer.
|
|
28
|
+
//
|
|
29
|
+
// concerns.concerns[] { id, concern, reqId, disposition, reason?,
|
|
30
|
+
// addedAt, status, resolvedAt? }
|
|
31
|
+
// disposition = applied | waived
|
|
32
|
+
//
|
|
33
|
+
// probes.probes[] { id, reqId, finding, severity, addedAt,
|
|
34
|
+
// status, resolvedAt? }
|
|
35
|
+
// severity = low | medium | high
|
|
36
|
+
//
|
|
37
|
+
// status on every entry is `open | resolved`. `resolve` flips it to
|
|
38
|
+
// resolved and stamps `resolvedAt`; `add` stamps `addedAt` and mints
|
|
39
|
+
// the next id (highest current + 1).
|
|
40
|
+
//
|
|
41
|
+
// Loader returns an empty ledger (`{ statements: [] }` etc.) when the
|
|
42
|
+
// file is missing so callers can compose without a per-ledger null
|
|
43
|
+
// check; a malformed file raises a clear error like the freeze
|
|
44
|
+
// record. Every write goes through the writer, which validates the
|
|
45
|
+
// full body first (a bad in-memory record never lands as JSON).
|
|
46
|
+
|
|
47
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
48
|
+
import { dirname, join } from 'node:path';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The four ledger identifiers. Ledger name -> filename convention.
|
|
52
|
+
* These are the values `rcf define ledger <name>` accepts.
|
|
53
|
+
*/
|
|
54
|
+
export const LEDGER_NAMES = /** @type {const} */ (['brief', 'decisions', 'concerns', 'probes']);
|
|
55
|
+
|
|
56
|
+
/** @typedef {(typeof LEDGER_NAMES)[number]} LedgerName */
|
|
57
|
+
|
|
58
|
+
const RELATIVE_DIR = 'rcf/define';
|
|
59
|
+
|
|
60
|
+
/** Per-ledger config: filename, array key on the body, entry schema. */
|
|
61
|
+
const LEDGER_CONFIG = /** @type {const} */ ({
|
|
62
|
+
brief: { file: 'brief-ledger.json', arrayKey: 'statements' },
|
|
63
|
+
decisions: { file: 'decisions-ledger.json', arrayKey: 'decisions' },
|
|
64
|
+
concerns: { file: 'concern-ledger.json', arrayKey: 'concerns' },
|
|
65
|
+
probes: { file: 'probe-ledger.json', arrayKey: 'probes' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const BRIEF_KINDS = /** @type {const} */ ([
|
|
69
|
+
'capability', 'constraint', 'actor', 'entity', 'externalSystem',
|
|
70
|
+
'surface', 'outOfScope', 'openQuestion', 'amendment',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const CONCERN_DISPOSITIONS = /** @type {const} */ (['applied', 'waived']);
|
|
74
|
+
const PROBE_SEVERITIES = /** @type {const} */ (['low', 'medium', 'high']);
|
|
75
|
+
const STATUS_VALUES = /** @type {const} */ (['open', 'resolved']);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Local relative path of a ledger file (project-root-relative).
|
|
79
|
+
*
|
|
80
|
+
* @param {LedgerName} name
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function ledgerRelPath(name) {
|
|
84
|
+
const cfg = LEDGER_CONFIG[name];
|
|
85
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
86
|
+
return `${RELATIVE_DIR}/${cfg.file}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Absolute path of a ledger file for a project root.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} projectRoot
|
|
93
|
+
* @param {LedgerName} name
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function ledgerPath(projectRoot, name) {
|
|
97
|
+
return join(projectRoot, ledgerRelPath(name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class LedgerError extends Error {
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} message
|
|
103
|
+
* @param {object} [opts]
|
|
104
|
+
* @param {string} [opts.filePath]
|
|
105
|
+
* @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage'} [opts.code]
|
|
106
|
+
* @param {string} [opts.field]
|
|
107
|
+
*/
|
|
108
|
+
constructor(message, opts = {}) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = 'LedgerError';
|
|
111
|
+
this.filePath = opts.filePath;
|
|
112
|
+
this.code = opts.code ?? 'schemaFailure';
|
|
113
|
+
this.field = opts.field;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Empty body for a named ledger (matches the shape a loader returns
|
|
121
|
+
* when the file is missing).
|
|
122
|
+
*
|
|
123
|
+
* @param {LedgerName} name
|
|
124
|
+
* @returns {Record<string, unknown>}
|
|
125
|
+
*/
|
|
126
|
+
export function emptyLedger(name) {
|
|
127
|
+
const cfg = LEDGER_CONFIG[name];
|
|
128
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
129
|
+
return { [cfg.arrayKey]: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate one entry against its ledger schema. Throws `LedgerError`
|
|
134
|
+
* on the first violation.
|
|
135
|
+
*
|
|
136
|
+
* @param {LedgerName} name
|
|
137
|
+
* @param {unknown} entry
|
|
138
|
+
* @param {string} [filePath]
|
|
139
|
+
* @param {string} [where] path segment for error messages, e.g.
|
|
140
|
+
* 'statements[0]'
|
|
141
|
+
*/
|
|
142
|
+
function validateEntry(name, entry, filePath, where = 'entry') {
|
|
143
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
144
|
+
throw new LedgerError(`${name} ${where} must be an object.`, {
|
|
145
|
+
filePath, field: where,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const e = /** @type {Record<string, unknown>} */ (entry);
|
|
149
|
+
|
|
150
|
+
if (typeof e.id !== 'number' || !Number.isInteger(e.id) || e.id < 1) {
|
|
151
|
+
throw new LedgerError(`${name} ${where}.id must be a positive integer.`, {
|
|
152
|
+
filePath, field: `${where}.id`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (typeof e.addedAt !== 'string' || !ISO_RE.test(e.addedAt)) {
|
|
156
|
+
throw new LedgerError(`${name} ${where}.addedAt must be an ISO-8601 string.`, {
|
|
157
|
+
filePath, field: `${where}.addedAt`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (typeof e.status !== 'string' || !STATUS_VALUES.includes(/** @type {any} */ (e.status))) {
|
|
161
|
+
throw new LedgerError(
|
|
162
|
+
`${name} ${where}.status must be one of: ${STATUS_VALUES.join(', ')}.`,
|
|
163
|
+
{ filePath, field: `${where}.status` },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (e.status === 'resolved') {
|
|
167
|
+
if (typeof e.resolvedAt !== 'string' || !ISO_RE.test(e.resolvedAt)) {
|
|
168
|
+
throw new LedgerError(
|
|
169
|
+
`${name} ${where}.resolvedAt must be an ISO-8601 string when status is resolved.`,
|
|
170
|
+
{ filePath, field: `${where}.resolvedAt` },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (name === 'brief') {
|
|
176
|
+
if (typeof e.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (e.kind))) {
|
|
177
|
+
throw new LedgerError(
|
|
178
|
+
`brief ${where}.kind must be one of: ${BRIEF_KINDS.join(', ')}.`,
|
|
179
|
+
{ filePath, field: `${where}.kind` },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (typeof e.text !== 'string' || e.text.length === 0) {
|
|
183
|
+
throw new LedgerError(`brief ${where}.text must be a non-empty string.`, {
|
|
184
|
+
filePath, field: `${where}.text`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (e.source !== undefined && typeof e.source !== 'string') {
|
|
188
|
+
throw new LedgerError(`brief ${where}.source must be a string when present.`, {
|
|
189
|
+
filePath, field: `${where}.source`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} else if (name === 'decisions') {
|
|
193
|
+
if (typeof e.question !== 'string' || e.question.length === 0) {
|
|
194
|
+
throw new LedgerError(`decisions ${where}.question must be non-empty.`, {
|
|
195
|
+
filePath, field: `${where}.question`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(e.options) || e.options.length === 0) {
|
|
199
|
+
throw new LedgerError(`decisions ${where}.options must be a non-empty array.`, {
|
|
200
|
+
filePath, field: `${where}.options`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
for (let i = 0; i < e.options.length; i += 1) {
|
|
204
|
+
const opt = e.options[i];
|
|
205
|
+
if (
|
|
206
|
+
opt === null || typeof opt !== 'object' || Array.isArray(opt)
|
|
207
|
+
|| typeof (/** @type {any} */ (opt)).letter !== 'string'
|
|
208
|
+
|| typeof (/** @type {any} */ (opt)).text !== 'string'
|
|
209
|
+
) {
|
|
210
|
+
throw new LedgerError(
|
|
211
|
+
`decisions ${where}.options[${i}] must be { letter, text }.`,
|
|
212
|
+
{ filePath, field: `${where}.options[${i}]` },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (e.default !== null && e.default !== undefined && typeof e.default !== 'string') {
|
|
217
|
+
throw new LedgerError(
|
|
218
|
+
`decisions ${where}.default must be a letter string or null.`,
|
|
219
|
+
{ filePath, field: `${where}.default` },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (e.blocks !== undefined && typeof e.blocks !== 'string') {
|
|
223
|
+
throw new LedgerError(`decisions ${where}.blocks must be a string when present.`, {
|
|
224
|
+
filePath, field: `${where}.blocks`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
} else if (name === 'concerns') {
|
|
228
|
+
if (typeof e.concern !== 'string' || e.concern.length === 0) {
|
|
229
|
+
throw new LedgerError(`concerns ${where}.concern must be non-empty.`, {
|
|
230
|
+
filePath, field: `${where}.concern`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
|
|
234
|
+
throw new LedgerError(`concerns ${where}.reqId must be non-empty.`, {
|
|
235
|
+
filePath, field: `${where}.reqId`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
typeof e.disposition !== 'string'
|
|
240
|
+
|| !CONCERN_DISPOSITIONS.includes(/** @type {any} */ (e.disposition))
|
|
241
|
+
) {
|
|
242
|
+
throw new LedgerError(
|
|
243
|
+
`concerns ${where}.disposition must be one of: ${CONCERN_DISPOSITIONS.join(', ')}.`,
|
|
244
|
+
{ filePath, field: `${where}.disposition` },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} else if (name === 'probes') {
|
|
248
|
+
if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
|
|
249
|
+
throw new LedgerError(`probes ${where}.reqId must be non-empty.`, {
|
|
250
|
+
filePath, field: `${where}.reqId`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (typeof e.finding !== 'string' || e.finding.length === 0) {
|
|
254
|
+
throw new LedgerError(`probes ${where}.finding must be non-empty.`, {
|
|
255
|
+
filePath, field: `${where}.finding`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
typeof e.severity !== 'string'
|
|
260
|
+
|| !PROBE_SEVERITIES.includes(/** @type {any} */ (e.severity))
|
|
261
|
+
) {
|
|
262
|
+
throw new LedgerError(
|
|
263
|
+
`probes ${where}.severity must be one of: ${PROBE_SEVERITIES.join(', ')}.`,
|
|
264
|
+
{ filePath, field: `${where}.severity` },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validate a full ledger body (the shape a file holds).
|
|
272
|
+
*
|
|
273
|
+
* @param {LedgerName} name
|
|
274
|
+
* @param {unknown} raw
|
|
275
|
+
* @param {string} [filePath]
|
|
276
|
+
* @returns {Record<string, unknown[]>}
|
|
277
|
+
*/
|
|
278
|
+
export function validateLedger(name, raw, filePath) {
|
|
279
|
+
const cfg = LEDGER_CONFIG[name];
|
|
280
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { filePath, code: 'usage' });
|
|
281
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
282
|
+
throw new LedgerError(`${name} ledger must be a JSON object.`, {
|
|
283
|
+
filePath, field: '(root)',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const body = /** @type {Record<string, unknown>} */ (raw);
|
|
287
|
+
const list = body[cfg.arrayKey];
|
|
288
|
+
if (!Array.isArray(list)) {
|
|
289
|
+
throw new LedgerError(
|
|
290
|
+
`${name} ledger '${cfg.arrayKey}' must be an array.`,
|
|
291
|
+
{ filePath, field: cfg.arrayKey },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const seen = new Set();
|
|
295
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
296
|
+
const entry = list[i];
|
|
297
|
+
validateEntry(name, entry, filePath, `${cfg.arrayKey}[${i}]`);
|
|
298
|
+
const id = /** @type {any} */ (entry).id;
|
|
299
|
+
if (seen.has(id)) {
|
|
300
|
+
throw new LedgerError(
|
|
301
|
+
`${name} ledger has duplicate id ${id}.`,
|
|
302
|
+
{ filePath, field: `${cfg.arrayKey}[${i}].id` },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
seen.add(id);
|
|
306
|
+
}
|
|
307
|
+
return /** @type {Record<string, unknown[]>} */ (body);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Load a ledger. Returns the empty ledger when the file is absent;
|
|
312
|
+
* throws `LedgerError` on parse / schema failure.
|
|
313
|
+
*
|
|
314
|
+
* @param {object} args
|
|
315
|
+
* @param {string} args.projectRoot
|
|
316
|
+
* @param {LedgerName} args.name
|
|
317
|
+
* @returns {Promise<Record<string, unknown[]>>}
|
|
318
|
+
*/
|
|
319
|
+
export async function loadLedger({ projectRoot, name }) {
|
|
320
|
+
const relPath = ledgerRelPath(name);
|
|
321
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
322
|
+
let raw;
|
|
323
|
+
try {
|
|
324
|
+
raw = await readFile(filePath, 'utf8');
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
|
|
327
|
+
return emptyLedger(name);
|
|
328
|
+
}
|
|
329
|
+
throw new LedgerError(
|
|
330
|
+
`Failed to read ${relPath}: ${/** @type {Error} */ (err).message}`,
|
|
331
|
+
{ filePath: relPath, code: 'ioFailure' },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(raw);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw new LedgerError(
|
|
339
|
+
`${relPath} parse failed: ${/** @type {Error} */ (err).message}`,
|
|
340
|
+
{ filePath: relPath, code: 'parseFailure' },
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return validateLedger(name, parsed, relPath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Save a ledger, validating first.
|
|
348
|
+
*
|
|
349
|
+
* @param {object} args
|
|
350
|
+
* @param {string} args.projectRoot
|
|
351
|
+
* @param {LedgerName} args.name
|
|
352
|
+
* @param {Record<string, unknown>} args.body
|
|
353
|
+
* @returns {Promise<{ filePath: string }>}
|
|
354
|
+
*/
|
|
355
|
+
export async function saveLedger({ projectRoot, name, body }) {
|
|
356
|
+
const validated = validateLedger(name, body, ledgerRelPath(name));
|
|
357
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
358
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
359
|
+
await writeFile(filePath, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
|
|
360
|
+
return { filePath };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Highest currently-used id on a ledger; 0 when empty.
|
|
365
|
+
*
|
|
366
|
+
* @param {LedgerName} name
|
|
367
|
+
* @param {Record<string, unknown[]>} body
|
|
368
|
+
* @returns {number}
|
|
369
|
+
*/
|
|
370
|
+
export function nextIdFor(name, body) {
|
|
371
|
+
const cfg = LEDGER_CONFIG[name];
|
|
372
|
+
if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
|
|
373
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
374
|
+
let max = 0;
|
|
375
|
+
for (const e of list) {
|
|
376
|
+
const id = Number(e?.id);
|
|
377
|
+
if (Number.isFinite(id) && id > max) max = id;
|
|
378
|
+
}
|
|
379
|
+
return max + 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Append an entry to a ledger. Mints the next id, stamps `addedAt`,
|
|
384
|
+
* defaults `status: 'open'`, and validates before returning the
|
|
385
|
+
* updated body. Pure over its inputs (does not write to disk); the
|
|
386
|
+
* CLI calls `saveLedger` with the result.
|
|
387
|
+
*
|
|
388
|
+
* @param {object} args
|
|
389
|
+
* @param {LedgerName} args.name
|
|
390
|
+
* @param {Record<string, unknown[]>} args.body
|
|
391
|
+
* @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
|
|
392
|
+
* @param {string} [args.now] - ISO timestamp; defaults to Date.now()
|
|
393
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
394
|
+
*/
|
|
395
|
+
export function addEntry({ name, body, entry, now }) {
|
|
396
|
+
const cfg = LEDGER_CONFIG[name];
|
|
397
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
398
|
+
const id = nextIdFor(name, body);
|
|
399
|
+
const full = {
|
|
400
|
+
id,
|
|
401
|
+
...entry,
|
|
402
|
+
addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
|
|
403
|
+
status: entry.status ?? 'open',
|
|
404
|
+
};
|
|
405
|
+
const nextBody = { ...body, [cfg.arrayKey]: [...(body[cfg.arrayKey] ?? []), full] };
|
|
406
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
407
|
+
return { body: nextBody, entry: full };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Mark an entry `resolved` and stamp `resolvedAt`. Throws `LedgerError`
|
|
412
|
+
* (code `usage`) when the id is unknown. Idempotent-ish: resolving an
|
|
413
|
+
* already-resolved entry refreshes `resolvedAt`.
|
|
414
|
+
*
|
|
415
|
+
* @param {object} args
|
|
416
|
+
* @param {LedgerName} args.name
|
|
417
|
+
* @param {Record<string, unknown[]>} args.body
|
|
418
|
+
* @param {number} args.id
|
|
419
|
+
* @param {Record<string, unknown>} [args.patch] - extra fields (e.g. answer, resolvedBy)
|
|
420
|
+
* @param {string} [args.now]
|
|
421
|
+
* @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
|
|
422
|
+
*/
|
|
423
|
+
export function resolveEntry({ name, body, id, patch = {}, now }) {
|
|
424
|
+
const cfg = LEDGER_CONFIG[name];
|
|
425
|
+
if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
|
|
426
|
+
const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
|
|
427
|
+
const idx = list.findIndex((e) => Number(e?.id) === Number(id));
|
|
428
|
+
if (idx < 0) {
|
|
429
|
+
throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
|
|
430
|
+
filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const updated = {
|
|
434
|
+
...list[idx],
|
|
435
|
+
...patch,
|
|
436
|
+
status: 'resolved',
|
|
437
|
+
resolvedAt: patch.resolvedAt ?? now ?? new Date().toISOString(),
|
|
438
|
+
};
|
|
439
|
+
const nextList = [...list];
|
|
440
|
+
nextList[idx] = updated;
|
|
441
|
+
const nextBody = { ...body, [cfg.arrayKey]: nextList };
|
|
442
|
+
validateLedger(name, nextBody, ledgerRelPath(name));
|
|
443
|
+
return { body: nextBody, entry: updated };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Parse `--from <file>` content into brief-statement drafts: one entry
|
|
448
|
+
* per non-empty line, stripping a leading `- `, `* ` or `<n>. ` bullet.
|
|
449
|
+
* The caller supplies the `kind` (or `capability` as the default). All
|
|
450
|
+
* statements land as `status: 'open'` at the same `addedAt`; the CLI
|
|
451
|
+
* assigns sequential ids.
|
|
452
|
+
*
|
|
453
|
+
* @param {string} content
|
|
454
|
+
* @param {object} [opts]
|
|
455
|
+
* @param {string} [opts.kind] default kind for every extracted line
|
|
456
|
+
* @param {string} [opts.source] optional source-span label per entry
|
|
457
|
+
* @returns {Array<{ text: string, kind: string, source?: string }>}
|
|
458
|
+
*/
|
|
459
|
+
export function parseBriefFromFile(content, opts = {}) {
|
|
460
|
+
const kind = opts.kind ?? 'capability';
|
|
461
|
+
/** @type {Array<{ text: string, kind: string, source?: string }>} */
|
|
462
|
+
const drafts = [];
|
|
463
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
464
|
+
const trimmed = raw.trim();
|
|
465
|
+
if (!trimmed) continue;
|
|
466
|
+
const stripped = trimmed
|
|
467
|
+
.replace(/^[-*]\s+/, '')
|
|
468
|
+
.replace(/^\d+[.)]\s+/, '');
|
|
469
|
+
if (!stripped) continue;
|
|
470
|
+
const entry = { text: stripped, kind };
|
|
471
|
+
if (opts.source) entry.source = opts.source;
|
|
472
|
+
drafts.push(entry);
|
|
473
|
+
}
|
|
474
|
+
return drafts;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Load every ledger the project has actually authored, keyed by name.
|
|
479
|
+
*
|
|
480
|
+
* A project that has never authored a given ledger contributes no
|
|
481
|
+
* `<name>` entry to the returned bundle, so `computeDelta` does not
|
|
482
|
+
* record a `ledger:<name>` docHash for absent ledgers (slice-2 P3
|
|
483
|
+
* resolution: this loader agrees with the `LedgerBundle` typedef on
|
|
484
|
+
* `computeDelta` that "if a project has not authored any decisions,
|
|
485
|
+
* the caller passes no `decisions` key and the delta records no
|
|
486
|
+
* `ledger:decisions` docHash"). Absent-file detection is a `stat` on
|
|
487
|
+
* the ledger path; an ENOENT skips the ledger without loading. A
|
|
488
|
+
* present-but-empty file is still loaded (an operator can `rcf
|
|
489
|
+
* define ledger <name> list --json > <path>` a stub in place; empty
|
|
490
|
+
* ledgers are legal and DO contribute a hash).
|
|
491
|
+
*
|
|
492
|
+
* @param {object} args
|
|
493
|
+
* @param {string} args.projectRoot
|
|
494
|
+
* @returns {Promise<import('../query/delta.js').LedgerBundle>}
|
|
495
|
+
*/
|
|
496
|
+
export async function loadAllLedgers({ projectRoot }) {
|
|
497
|
+
/** @type {import('../query/delta.js').LedgerBundle} */
|
|
498
|
+
const bundle = {};
|
|
499
|
+
for (const name of LEDGER_NAMES) {
|
|
500
|
+
const filePath = ledgerPath(projectRoot, name);
|
|
501
|
+
try {
|
|
502
|
+
// eslint-disable-next-line no-await-in-loop
|
|
503
|
+
await stat(filePath);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') continue;
|
|
506
|
+
// Any other stat error (permission etc.) surfaces via loadLedger.
|
|
507
|
+
}
|
|
508
|
+
// eslint-disable-next-line no-await-in-loop
|
|
509
|
+
const body = await loadLedger({ projectRoot, name });
|
|
510
|
+
bundle[name] = body;
|
|
511
|
+
}
|
|
512
|
+
return bundle;
|
|
513
|
+
}
|
package/src/feedback/redact.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// secret-shape check the last line of defence, which is what the
|
|
21
21
|
// residual-match refusal in rule 8 leans on.
|
|
22
22
|
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
23
24
|
import { readFileSync } from 'node:fs';
|
|
24
25
|
import { dirname, resolve } from 'node:path';
|
|
25
26
|
import { fileURLToPath } from 'node:url';
|
|
@@ -43,6 +44,14 @@ export const BODY_CAP_BYTES = 8 * 1024;
|
|
|
43
44
|
* conservative: anything a normal profile.md ## Name line might carry
|
|
44
45
|
* as a common English or engineering noun.
|
|
45
46
|
*/
|
|
47
|
+
|
|
48
|
+
// Codex P1-4: per-call collision-safe stash marker suffix. The suffix is
|
|
49
|
+
// 32 hex chars from randomUUID, which is astronomically unlikely to
|
|
50
|
+
// appear as a literal substring in prose the operator would submit.
|
|
51
|
+
function gitStashUuid() {
|
|
52
|
+
return randomUUID().replace(/-/g, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
export const RESERVED_IDENTITY_TOKENS = Object.freeze(new Set([
|
|
47
56
|
'agent', 'agents', 'user', 'users', 'operator', 'operators',
|
|
48
57
|
'admin', 'admins', 'root', 'client', 'clients', 'server', 'servers',
|
|
@@ -435,30 +444,98 @@ export function redact(input, context = {}) {
|
|
|
435
444
|
}
|
|
436
445
|
}
|
|
437
446
|
|
|
447
|
+
// 0.28.3 (Codex P1-1): move the rule-4 allowlist plumbing up so
|
|
448
|
+
// R5f's bare-remote stash can decide whether to preserve or fold the
|
|
449
|
+
// host before it hides the token. Duplicated later in the rule-4
|
|
450
|
+
// block was removed at the same time.
|
|
451
|
+
const allowHosts = new Set([
|
|
452
|
+
...BUNDLED_ALLOWLIST.hosts,
|
|
453
|
+
...(Array.isArray(context.allowHosts) ? context.allowHosts : []),
|
|
454
|
+
].map((h) => h.toLowerCase()));
|
|
455
|
+
const allowExtensions = new Set([
|
|
456
|
+
...(Array.isArray(BUNDLED_ALLOWLIST.extensions) ? BUNDLED_ALLOWLIST.extensions : []),
|
|
457
|
+
...(Array.isArray(context.allowExtensions) ? context.allowExtensions : []),
|
|
458
|
+
].map((e) => String(e).toLowerCase()));
|
|
459
|
+
|
|
460
|
+
// Rule 3-guard (design amendment R5f, issue #243, 0.28.3): protect the
|
|
461
|
+
// git protocol sentinel `git@<host>` before rule 3 runs. In a URL whose
|
|
462
|
+
// scheme is git+ssh/ssh/git, and in the bare git-remote shape
|
|
463
|
+
// `git@<host>:<owner>/<repo>`, `git@<host>` is URI userinfo, not an
|
|
464
|
+
// email. Folding it destroys evidence pointers copied from git remotes
|
|
465
|
+
// and clone URLs. We stash the full matched span before the email regex
|
|
466
|
+
// runs and restore it before rule 4 so the host inside the span is
|
|
467
|
+
// still eligible for the URL / bare-host fold.
|
|
468
|
+
const gitProtoRe = /(?:git\+ssh|ssh|git):\/\/git@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
469
|
+
const gitRemoteBareRe = /(?<![A-Za-z0-9._%+-])git@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?=:[^\s]+\/)/g;
|
|
470
|
+
const gitStash = [];
|
|
471
|
+
// Use a per-invocation random marker so a literal `GITPROTO0GITPROTO`
|
|
472
|
+
// in the operator's prose cannot be replayed as a restore token
|
|
473
|
+
// (Codex P1-4). The marker is 32 hex chars from randomUUID, wrapped
|
|
474
|
+
// in an unusual sigil (`\u0002...\u0003`) that would not survive
|
|
475
|
+
// the ctrl-char strip pass at rule 8a-late anyway, guaranteeing
|
|
476
|
+
// collision-free restoration for the duration of this call.
|
|
477
|
+
const GIT_STASH_TAG = `GITPROTO_${gitStashUuid()}`;
|
|
478
|
+
// Precompute a bare-remote host allowlist check per candidate:
|
|
479
|
+
// Codex P1-1 called out that a bare `git@<host>:...` remote used
|
|
480
|
+
// to have its host folded via the pre-fix email rule, and the R5f
|
|
481
|
+
// protection accidentally shielded the host too. Restore the host-
|
|
482
|
+
// level allowlist gate here: an allowlisted host stays verbatim,
|
|
483
|
+
// an unallowlisted host has the host portion folded to `<host>`
|
|
484
|
+
// before we stash, so the sentinel-plus-<host> shape survives
|
|
485
|
+
// through the pipeline.
|
|
486
|
+
function stashBareRemote(m) {
|
|
487
|
+
const at = m.indexOf('@');
|
|
488
|
+
const host = m.slice(at + 1);
|
|
489
|
+
if (isHostAllowed(host, allowHosts)) {
|
|
490
|
+
gitStash.push(m);
|
|
491
|
+
} else {
|
|
492
|
+
// Record a hostname ledger row so the operator disclosure names
|
|
493
|
+
// what was folded, matching the shape rule 4 would have used.
|
|
494
|
+
const before = host;
|
|
495
|
+
const after = '<host>';
|
|
496
|
+
// We push a synthetic ledger row for the hostname family (one
|
|
497
|
+
// per bare-remote fold). Deduplicated within the pass.
|
|
498
|
+
ledger.push({ rule: 'hostname', before, after, count: 1 });
|
|
499
|
+
gitStash.push(`git@${after}`);
|
|
500
|
+
}
|
|
501
|
+
return `${GIT_STASH_TAG}${gitStash.length - 1}${GIT_STASH_TAG}`;
|
|
502
|
+
}
|
|
503
|
+
text = text.replace(gitProtoRe, (m) => {
|
|
504
|
+
gitStash.push(m);
|
|
505
|
+
return `${GIT_STASH_TAG}${gitStash.length - 1}${GIT_STASH_TAG}`;
|
|
506
|
+
});
|
|
507
|
+
text = text.replace(gitRemoteBareRe, stashBareRemote);
|
|
508
|
+
|
|
438
509
|
// Rule 3: emails -> <email>. RFC-5322 lite; case-insensitive.
|
|
439
510
|
const emailRe = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
440
511
|
text = replaceRegex(text, emailRe, () => '<email>', 'email', ledger);
|
|
441
512
|
|
|
513
|
+
// R5f: restore git protocol sentinels. The stashed spans now re-enter
|
|
514
|
+
// the pipeline for rule 4 (URL / bare-host). Rule 4's URL regex is
|
|
515
|
+
// extended below to match git+ssh/ssh/git schemes with optional
|
|
516
|
+
// userinfo, so a non-allowlisted host inside a restored clone URL
|
|
517
|
+
// (e.g. `ssh://git@example.internal/owner/repo.git`) folds via that
|
|
518
|
+
// path while the `git@` userinfo is preserved. A bare
|
|
519
|
+
// `git@<host>:<owner>/<repo>` remote is left as-is by rule 4 because
|
|
520
|
+
// it has no scheme prefix and the bareHost lookbehind excludes `@`;
|
|
521
|
+
// the sentinel-protection posture is sufficient for that shape.
|
|
522
|
+
if (gitStash.length > 0) {
|
|
523
|
+
text = text.replace(new RegExp(`${GIT_STASH_TAG}(\\d+)${GIT_STASH_TAG}`, 'g'), (_m, i) => gitStash[Number(i)]);
|
|
524
|
+
}
|
|
525
|
+
|
|
442
526
|
// Rule 4: hostnames and URLs whose registrable domain is not on the
|
|
443
527
|
// allowlist. URLs keep their path suffix; bare hostnames collapse.
|
|
444
528
|
// Loopback (localhost, 127.0.0.1) and everything on the allowlist
|
|
445
529
|
// survive; every other public hostname or URL loses its host.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
// Rule 4 narrowing (0.28.2, issue #233, design amendment R5d): a
|
|
452
|
-
// token whose trailing dot-label is a known file extension is NOT
|
|
453
|
-
// a hostname. This kills the `Backstory-product-brief.md` and
|
|
530
|
+
// allowHosts / allowExtensions declared earlier (Codex P1-1) so the
|
|
531
|
+
// rule-3 bare-remote stash can gate on the host allowlist too. Rule
|
|
532
|
+
// 4 narrowing (0.28.2, issue #233, design amendment R5d): a token
|
|
533
|
+
// whose trailing dot-label is a known file extension is NOT a
|
|
534
|
+
// hostname, killing the `Backstory-product-brief.md` and
|
|
454
535
|
// `intake.json` false positives Barry hit in the WSD round-trip.
|
|
455
536
|
// The bundled shortlist lives in redact-allowlist.json under the
|
|
456
|
-
//
|
|
537
|
+
// `extensions` field; the operator overlay merges via
|
|
457
538
|
// context.allowExtensions.
|
|
458
|
-
const allowExtensions = new Set([
|
|
459
|
-
...(Array.isArray(BUNDLED_ALLOWLIST.extensions) ? BUNDLED_ALLOWLIST.extensions : []),
|
|
460
|
-
...(Array.isArray(context.allowExtensions) ? context.allowExtensions : []),
|
|
461
|
-
].map((e) => String(e).toLowerCase()));
|
|
462
539
|
|
|
463
540
|
// Rule 9: URL-embedded credentials (design amendment R3b, added
|
|
464
541
|
// round 4 for F-P0-B). Runs BEFORE the rule-4 hostname pass so a
|
|
@@ -471,11 +548,17 @@ export function redact(input, context = {}) {
|
|
|
471
548
|
// `url-credential`; false positives are visible in the disclosure.
|
|
472
549
|
text = redactUrlCredentials(text, ledger);
|
|
473
550
|
|
|
474
|
-
// URLs first (http/https), then bare hostnames.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
551
|
+
// URLs first (http/https/git+ssh/ssh/git), then bare hostnames.
|
|
552
|
+
// 0.28.3 (R5f, issue #243): the URL rule accepts git+ssh/ssh/git
|
|
553
|
+
// schemes with an optional userinfo prefix so the host inside a git
|
|
554
|
+
// clone URL can be folded by the same code path that folds an https
|
|
555
|
+
// URL host. The rule-3 guard above has already protected the `git@`
|
|
556
|
+
// sentinel from the email rule; here the sentinel is preserved and
|
|
557
|
+
// only the host portion is folded.
|
|
558
|
+
const urlRe = /(https?|git\+ssh|ssh|git):\/\/((?:[A-Za-z0-9._%+-]+@)?)([A-Za-z0-9.-]+)((?::[0-9]+)?(?:\/[^\s)]*)?)/g;
|
|
559
|
+
text = replaceRegex(text, urlRe, (_m, scheme, userinfo, host, tail) => {
|
|
560
|
+
if (isHostAllowed(host, allowHosts)) return `${scheme}://${userinfo}${host}${tail}`;
|
|
561
|
+
return `${scheme}://${userinfo}<host>${tail}`;
|
|
479
562
|
}, 'hostname', ledger);
|
|
480
563
|
// Bare hostnames: a word that looks like a domain (has a dot,
|
|
481
564
|
// ends with a 2+ letter TLD) and is not on the allowlist. Skip if
|
|
@@ -487,7 +570,10 @@ export function redact(input, context = {}) {
|
|
|
487
570
|
// lookbehind is the 0.28.2 (issue #233) fix that stops
|
|
488
571
|
// `Backstory-product-brief.md` folding on its internal
|
|
489
572
|
// `product-brief.md` slice; the extension-refusal step below is the
|
|
490
|
-
// second layer.
|
|
573
|
+
// second layer. Design amendment R5e (0.28.3, issue #247): the
|
|
574
|
+
// `{2,}` trailing class also functions as the single-letter-TLD
|
|
575
|
+
// exclusion - a token like `a.b.c` has a one-letter tail and is
|
|
576
|
+
// treated as a filename fragment, not a hostname.
|
|
491
577
|
const bareHostRe = /(?<![@/\\\w.\-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?![\w.-])/g;
|
|
492
578
|
text = replaceRegex(text, bareHostRe, (host) => {
|
|
493
579
|
// 0.28.2 (issue #233, design amendment R5d): a candidate whose
|