specrails-core 6.6.5 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runtime/capabilities.d.ts +5 -2
- package/dist/agent-runtime/capabilities.js +8 -2
- package/dist/agent-runtime/change-scope.d.ts +2 -0
- package/dist/agent-runtime/change-scope.js +5 -0
- package/dist/agent-runtime/cli-executor.js +7 -0
- package/dist/agent-runtime/cli.d.ts +6 -1
- package/dist/agent-runtime/cli.js +15 -4
- package/dist/agent-runtime/compact/reviewer.js +3 -1
- package/dist/agent-runtime/config.js +16 -5
- package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/efficiency-summary.js +7 -1
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
- package/dist/agent-runtime/engine/cli.d.ts +12 -0
- package/dist/agent-runtime/engine/cli.js +15 -4
- package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-schema.js +1 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-types.js +1 -1
- package/dist/agent-runtime/engine/definition-validator.js +8 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
- package/dist/agent-runtime/engine/invocation-context.js +2 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
- package/dist/agent-runtime/engine/piece-registry.js +1 -1
- package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
- package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
- package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
- package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
- package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
- package/dist/agent-runtime/engine/pieces/index.js +2 -1
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
- package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
- package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
- package/dist/agent-runtime/engine/preflight.d.ts +2 -1
- package/dist/agent-runtime/engine/preflight.js +3 -1
- package/dist/agent-runtime/engine/regex.d.ts +12 -2
- package/dist/agent-runtime/engine/regex.js +25 -11
- package/dist/agent-runtime/engine/run-status.d.ts +34 -0
- package/dist/agent-runtime/engine/run-status.js +7 -1
- package/dist/agent-runtime/engine/runs.d.ts +56 -0
- package/dist/agent-runtime/engine/runs.js +88 -5
- package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
- package/dist/agent-runtime/engine/steering/inbox.js +21 -1
- package/dist/agent-runtime/engine/storage/private-path.js +7 -1
- package/dist/agent-runtime/evaluation.js +1 -1
- package/dist/agent-runtime/executor-types.d.ts +19 -0
- package/dist/agent-runtime/executor-types.js +10 -0
- package/dist/agent-runtime/failure-classification.d.ts +51 -0
- package/dist/agent-runtime/failure-classification.js +110 -0
- package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
- package/dist/agent-runtime/graph/artifacts.js +5 -1
- package/dist/agent-runtime/graph/baseline.d.ts +50 -0
- package/dist/agent-runtime/graph/baseline.js +145 -0
- package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
- package/dist/agent-runtime/graph/capability-map.js +65 -0
- package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
- package/dist/agent-runtime/graph/clarifications.js +158 -0
- package/dist/agent-runtime/graph/nodes.js +178 -19
- package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
- package/dist/agent-runtime/graph/review-brief-record.js +26 -0
- package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
- package/dist/agent-runtime/graph/review-brief.js +127 -0
- package/dist/agent-runtime/graph/roles.js +5 -3
- package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
- package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
- package/dist/agent-runtime/graph/state.d.ts +8 -0
- package/dist/agent-runtime/index.d.ts +7 -0
- package/dist/agent-runtime/index.js +7 -0
- package/dist/agent-runtime/openspec.d.ts +1 -0
- package/dist/agent-runtime/openspec.js +1 -1
- package/dist/agent-runtime/project-rules.d.ts +139 -0
- package/dist/agent-runtime/project-rules.js +683 -0
- package/dist/agent-runtime/prompts.d.ts +32 -1
- package/dist/agent-runtime/prompts.js +180 -7
- package/dist/agent-runtime/role-routing.d.ts +2 -1
- package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
- package/dist/agent-runtime/session/application/active-session.js +163 -7
- package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
- package/dist/agent-runtime/session/domain/descriptor.js +5 -0
- package/dist/agent-runtime/session/domain/interruption.js +1 -0
- package/dist/agent-runtime/session/domain/policy.js +2 -0
- package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
- package/dist/agent-runtime/session/domain/redaction.js +71 -0
- package/dist/agent-runtime/session/domain/types.d.ts +17 -1
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
- package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
- package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
- package/dist/agent-runtime/session/host/protocol.js +10 -2
- package/dist/agent-runtime/session/host/server.js +6 -5
- package/dist/agent-runtime/session/index.d.ts +1 -1
- package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
- package/dist/agent-runtime/session/ports.d.ts +6 -2
- package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
- package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
- package/dist/installer/cli.js +10 -1
- package/dist/installer/commands/init.js +14 -3
- package/dist/installer/phases/scaffold.js +56 -5
- package/dist/pipeline/pipeline-state.d.ts +15 -0
- package/dist/pipeline/pipeline-state.js +36 -3
- package/dist/pipeline/spec-trace.d.ts +134 -0
- package/dist/pipeline/spec-trace.js +392 -0
- package/dist/shared/secret-redaction.d.ts +42 -0
- package/dist/shared/secret-redaction.js +184 -0
- package/integration-contract.json +177 -7
- package/package.json +7 -7
- package/schemas/agent-runtime.schema.json +3 -1
- package/schemas/workflow-definition.schema.json +1 -0
- package/templates/settings/codex-config.toml +3 -2
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Requirement ↔ test traceability.
|
|
2
|
+
//
|
|
3
|
+
// Every OpenSpec requirement (`### Requirement: <name>` in
|
|
4
|
+
// `openspec/specs/<capability>/spec.md`) has a stable id
|
|
5
|
+
// `<capability>/<slug>`. A test claims a requirement with a tag in its title
|
|
6
|
+
// (`[spec:<capability>/<slug>]`) or a comment on the line before it
|
|
7
|
+
// (`// spec: <capability>/<slug>`, `# spec: …` for Python and shell; any
|
|
8
|
+
// language). The scanner crosses the tags with verification receipts: a
|
|
9
|
+
// requirement is demonstrated when a tagged test file was executed by a
|
|
10
|
+
// verification command that exited 0.
|
|
11
|
+
//
|
|
12
|
+
// This file is copied on its own into projects as
|
|
13
|
+
// `.specrails/runtime/spec-trace.mjs`, like pipeline-state.ts: Node built-ins
|
|
14
|
+
// only, no imports from the rest of Core.
|
|
15
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export const SPEC_TRACE_SCHEMA_VERSION = 1;
|
|
18
|
+
export const SPEC_TRACE_FILE = 'spec-trace.json';
|
|
19
|
+
const MAX_FILES = 20_000;
|
|
20
|
+
const MAX_FILE_BYTES = 1_048_576;
|
|
21
|
+
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', 'coverage', '.specrails', 'vendor', 'target', '.venv', 'venv', '__pycache__', '.next', '.nuxt', '.turbo', '.cache', 'openspec']);
|
|
22
|
+
/** A file that IS a test across ecosystems: `x.test.ts`, `x.spec.js`, `test_x.py`, `x_test.go`, `x_spec.rb`, `XTest.java`, `test_x.sh`, `x.bats`. */
|
|
23
|
+
export const TEST_FILE = /(?:^|\/)(?:test_[^/]+\.(?:py|sh)|[^/]+(?:\.(?:test|spec)\.[a-z0-9]+|_test\.(?:go|py|rs|rb|exs?|ts|js|sh)|_spec\.rb|Tests?\.(?:java|kt|cs|swift)|\.bats))$/i;
|
|
24
|
+
/** `capability/slug`: lowercase, accents removed (NFKD), every run of non-alphanumerics a single dash, no leading or trailing dash. */
|
|
25
|
+
export function requirementSlug(name) {
|
|
26
|
+
return name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
27
|
+
}
|
|
28
|
+
export function requirementId(capability, name) { return `${capability}/${requirementSlug(name)}`; }
|
|
29
|
+
const posix = (file) => file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
30
|
+
function readText(file) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = lstatSync(file);
|
|
33
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
|
|
34
|
+
return undefined;
|
|
35
|
+
return readFileSync(file, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function directories(root) {
|
|
42
|
+
try {
|
|
43
|
+
return readdirSync(root, { withFileTypes: true }).filter(entry => entry.isDirectory() && !entry.isSymbolicLink()).map(entry => entry.name).sort();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
50
|
+
/** Requirements of the canonical specs, in capability then file order. */
|
|
51
|
+
export function readSpecRequirements(specsRoot) {
|
|
52
|
+
const base = path.join(specsRoot, 'openspec', 'specs');
|
|
53
|
+
return directories(base).flatMap(capability => {
|
|
54
|
+
const file = path.join(base, capability, 'spec.md');
|
|
55
|
+
const text = readText(file);
|
|
56
|
+
if (text === undefined)
|
|
57
|
+
return [];
|
|
58
|
+
return text.split(/\r?\n/).flatMap((line, index) => {
|
|
59
|
+
const match = REQUIREMENT.exec(line);
|
|
60
|
+
return match ? [{ id: requirementId(capability, match[1]), capability, name: match[1], file: posix(path.relative(specsRoot, file)), line: index + 1 }] : [];
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** The change's directory: active, or its most recent archive (`archive/YYYY-MM-DD-<change>`). */
|
|
65
|
+
export function changeDirectory(specsRoot, change) {
|
|
66
|
+
const active = path.join(specsRoot, 'openspec', 'changes', change);
|
|
67
|
+
if (existsSync(active))
|
|
68
|
+
return active;
|
|
69
|
+
const archive = path.join(specsRoot, 'openspec', 'changes', 'archive');
|
|
70
|
+
const found = directories(archive).filter(name => name === change || (/^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === change)).at(-1);
|
|
71
|
+
return found ? path.join(archive, found) : undefined;
|
|
72
|
+
}
|
|
73
|
+
/** ADDED / MODIFIED / REMOVED / RENAMED requirements of a change's delta specs. */
|
|
74
|
+
export function readChangeDelta(specsRoot, change) {
|
|
75
|
+
const delta = { added: [], modified: [], removed: [], renamed: [] };
|
|
76
|
+
const directory = changeDirectory(specsRoot, change);
|
|
77
|
+
if (!directory)
|
|
78
|
+
return delta;
|
|
79
|
+
for (const capability of directories(path.join(directory, 'specs'))) {
|
|
80
|
+
const file = path.join(directory, 'specs', capability, 'spec.md');
|
|
81
|
+
const text = readText(file);
|
|
82
|
+
if (text === undefined)
|
|
83
|
+
continue;
|
|
84
|
+
let section = '';
|
|
85
|
+
let from;
|
|
86
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
87
|
+
const heading = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/i.exec(line);
|
|
88
|
+
if (heading) {
|
|
89
|
+
section = heading[1].toUpperCase();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (/^##\s/.test(line)) {
|
|
93
|
+
section = '';
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const requirement = (name) => ({ id: requirementId(capability, name), capability, name, file: posix(path.relative(specsRoot, file)), line: index + 1 });
|
|
97
|
+
if (section === 'RENAMED') {
|
|
98
|
+
const rename = /^\s*-\s*(FROM|TO):\s*`?\s*###\s+Requirement:\s*(.+?)\s*`?\s*$/i.exec(line);
|
|
99
|
+
if (!rename)
|
|
100
|
+
return;
|
|
101
|
+
if (rename[1].toUpperCase() === 'FROM')
|
|
102
|
+
from = requirementId(capability, rename[2]);
|
|
103
|
+
else if (from) {
|
|
104
|
+
delta.renamed.push({ from, to: requirement(rename[2]) });
|
|
105
|
+
from = undefined;
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const match = REQUIREMENT.exec(line);
|
|
110
|
+
if (!match)
|
|
111
|
+
return;
|
|
112
|
+
if (section === 'ADDED')
|
|
113
|
+
delta.added.push(requirement(match[1]));
|
|
114
|
+
else if (section === 'MODIFIED')
|
|
115
|
+
delta.modified.push(requirement(match[1]));
|
|
116
|
+
else if (section === 'REMOVED')
|
|
117
|
+
delta.removed.push(requirementId(capability, match[1]));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return delta;
|
|
121
|
+
}
|
|
122
|
+
/** Active (unarchived) changes, for a CI check that names none. */
|
|
123
|
+
export function activeChanges(specsRoot) {
|
|
124
|
+
return directories(path.join(specsRoot, 'openspec', 'changes')).filter(name => name !== 'archive' && existsSync(path.join(specsRoot, 'openspec', 'changes', name, 'specs')));
|
|
125
|
+
}
|
|
126
|
+
const TITLE_TAG = /\[spec:\s*([^\]\s]+)\s*\]/gi;
|
|
127
|
+
const COMMENT_TAG = /^\s*(?:\/\/+|#+|--+|;+|%+|\/\*+|\*+|<!--|\{-|\(\*)\s*spec:\s*([^\s*]+)/i;
|
|
128
|
+
/** Tags in a test file's text with their 1-based lines. */
|
|
129
|
+
export function scanTags(text) {
|
|
130
|
+
const tags = [];
|
|
131
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
132
|
+
const comment = COMMENT_TAG.exec(line);
|
|
133
|
+
if (comment) {
|
|
134
|
+
tags.push({ tag: comment[1].replace(/[,;.]+$/, '').toLowerCase(), line: index + 1, form: 'comment' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const match of line.matchAll(TITLE_TAG))
|
|
138
|
+
tags.push({ tag: match[1].toLowerCase(), line: index + 1, form: 'title' });
|
|
139
|
+
});
|
|
140
|
+
return tags;
|
|
141
|
+
}
|
|
142
|
+
function globToRegExp(glob) {
|
|
143
|
+
const source = posix(glob).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\//g, '\u0001').replace(/\*\*/g, '\u0002').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\u0001/g, '(?:.*/)?').replace(/\u0002/g, '.*');
|
|
144
|
+
return new RegExp(`^${source}$`);
|
|
145
|
+
}
|
|
146
|
+
function discoverTests(root, globs) {
|
|
147
|
+
const found = [];
|
|
148
|
+
const walk = (directory, relative) => {
|
|
149
|
+
if (found.length >= MAX_FILES)
|
|
150
|
+
return;
|
|
151
|
+
let entries;
|
|
152
|
+
try {
|
|
153
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
159
|
+
if (entry.isSymbolicLink())
|
|
160
|
+
continue;
|
|
161
|
+
const name = relative ? relative + '/' + entry.name : entry.name;
|
|
162
|
+
if (entry.isDirectory()) {
|
|
163
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name))
|
|
164
|
+
walk(path.join(directory, entry.name), name);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (entry.isFile() && (globs ? globs.some(glob => glob.test(name)) : TEST_FILE.test(name)))
|
|
168
|
+
found.push(name);
|
|
169
|
+
if (found.length >= MAX_FILES)
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
walk(root, '');
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
/** Literal reachability: a command naming test-shaped paths reaches only those; any other command is assumed to discover every test. */
|
|
177
|
+
export const literalReaches = (root, command, file) => {
|
|
178
|
+
const base = path.resolve(root, command.cwd ?? '.');
|
|
179
|
+
const target = posix(path.relative(base, path.resolve(root, file)));
|
|
180
|
+
const tokens = [command.command, ...command.args].filter(token => !token.startsWith('-')).map(posix);
|
|
181
|
+
// A bare word is a path only when it names an existing directory (`pytest tests`), never a script or verb (`npm test`).
|
|
182
|
+
const directory = (token) => { try {
|
|
183
|
+
return lstatSync(path.resolve(base, token)).isDirectory();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
} };
|
|
188
|
+
const paths = tokens.slice(1).filter(token => TEST_FILE.test(token) || token.includes('*') || ((token.includes('/') || directory(token)) && !/^(?:run|test|exec)$/.test(token)));
|
|
189
|
+
if (!paths.length)
|
|
190
|
+
return true;
|
|
191
|
+
return paths.some(token => token === target || target.startsWith(token.replace(/\/$/, '') + '/') || (token.includes('*') && globToRegExp(token).test(target)));
|
|
192
|
+
};
|
|
193
|
+
/** The traceability report for the specs and tests under the input roots. Never throws for unreadable files; they are skipped. */
|
|
194
|
+
export function traceSpecs(input) {
|
|
195
|
+
const changes = [...new Set(input.changes ?? [])];
|
|
196
|
+
const requirements = new Map();
|
|
197
|
+
for (const requirement of readSpecRequirements(input.specsRoot))
|
|
198
|
+
if (!requirements.has(requirement.id))
|
|
199
|
+
requirements.set(requirement.id, requirement);
|
|
200
|
+
const touched = new Set();
|
|
201
|
+
for (const change of changes) {
|
|
202
|
+
const delta = readChangeDelta(input.specsRoot, change);
|
|
203
|
+
for (const id of delta.removed)
|
|
204
|
+
requirements.delete(id);
|
|
205
|
+
for (const { from, to } of delta.renamed) {
|
|
206
|
+
requirements.delete(from);
|
|
207
|
+
requirements.set(to.id, to);
|
|
208
|
+
touched.add(to.id);
|
|
209
|
+
}
|
|
210
|
+
for (const requirement of [...delta.added, ...delta.modified]) {
|
|
211
|
+
if (!requirements.has(requirement.id))
|
|
212
|
+
requirements.set(requirement.id, requirement);
|
|
213
|
+
touched.add(requirement.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const globs = input.testGlobs?.length ? input.testGlobs.map(globToRegExp) : undefined;
|
|
217
|
+
const tags = [];
|
|
218
|
+
let scannedFiles = 0;
|
|
219
|
+
for (const repository of input.repositories) {
|
|
220
|
+
const files = new Set(discoverTests(repository.root, globs));
|
|
221
|
+
for (const explicit of input.testFiles ?? [])
|
|
222
|
+
if (explicit.repositoryId === repository.id)
|
|
223
|
+
files.add(posix(explicit.file));
|
|
224
|
+
for (const file of [...files].sort()) {
|
|
225
|
+
const absolute = path.resolve(repository.root, file);
|
|
226
|
+
const relative = posix(path.relative(repository.root, absolute));
|
|
227
|
+
if (!relative || relative.startsWith('..'))
|
|
228
|
+
continue;
|
|
229
|
+
const text = readText(absolute);
|
|
230
|
+
if (text === undefined)
|
|
231
|
+
continue;
|
|
232
|
+
scannedFiles += 1;
|
|
233
|
+
for (const tag of scanTags(text))
|
|
234
|
+
tags.push({ ...tag, repositoryId: repository.id, file: relative });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const receipt = input.receipt ?? null;
|
|
238
|
+
const evidence = receipt ? 'receipt' : input.testsPassed ? 'tests-passed' : 'none';
|
|
239
|
+
const reaches = input.reaches ?? literalReaches;
|
|
240
|
+
const roots = new Map(input.repositories.map(repository => [repository.id, repository.root]));
|
|
241
|
+
const execution = new Map();
|
|
242
|
+
const executed = (test) => {
|
|
243
|
+
const key = test.repositoryId + '\0' + test.file;
|
|
244
|
+
const cached = execution.get(key);
|
|
245
|
+
if (cached)
|
|
246
|
+
return cached;
|
|
247
|
+
let result = evidence === 'tests-passed' ? 'passed' : 'not-executed';
|
|
248
|
+
if (receipt) {
|
|
249
|
+
const root = roots.get(test.repositoryId);
|
|
250
|
+
const reaching = receipt.commands.filter(command => command.repositoryId === test.repositoryId && safeReach(reaches, root, command, test.file));
|
|
251
|
+
result = reaching.some(command => command.exitCode === 0) ? 'passed' : reaching.length ? 'failed' : 'not-executed';
|
|
252
|
+
}
|
|
253
|
+
execution.set(key, result);
|
|
254
|
+
return result;
|
|
255
|
+
};
|
|
256
|
+
const byRequirement = new Map();
|
|
257
|
+
const untagged = [];
|
|
258
|
+
for (const tag of tags) {
|
|
259
|
+
if (!requirements.has(tag.tag)) {
|
|
260
|
+
untagged.push(tag);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const list = byRequirement.get(tag.tag) ?? [];
|
|
264
|
+
if (!list.some(item => item.repositoryId === tag.repositoryId && item.file === tag.file && item.line === tag.line))
|
|
265
|
+
list.push({ repositoryId: tag.repositoryId, file: tag.file, line: tag.line });
|
|
266
|
+
byRequirement.set(tag.tag, list);
|
|
267
|
+
}
|
|
268
|
+
const demonstrated = [];
|
|
269
|
+
const undemonstrated = [];
|
|
270
|
+
for (const requirement of [...requirements.values()].sort((left, right) => left.id.localeCompare(right.id, 'en'))) {
|
|
271
|
+
const tests = byRequirement.get(requirement.id) ?? [];
|
|
272
|
+
const outcomes = tests.map(executed);
|
|
273
|
+
if (outcomes.includes('passed'))
|
|
274
|
+
demonstrated.push({ id: requirement.id, name: requirement.name, tests: tests.filter((_, index) => outcomes[index] === 'passed') });
|
|
275
|
+
else
|
|
276
|
+
undemonstrated.push({ id: requirement.id, name: requirement.name, reason: !tests.length ? 'no-test' : outcomes.includes('failed') ? 'failed' : 'not-executed', tests });
|
|
277
|
+
}
|
|
278
|
+
const missing = new Set(undemonstrated.map(item => item.id));
|
|
279
|
+
return {
|
|
280
|
+
schemaVersion: SPEC_TRACE_SCHEMA_VERSION, generatedAt: (input.now?.() ?? new Date()).toISOString(), changes, evidence, receiptId: receipt?.id ?? null,
|
|
281
|
+
requirements: requirements.size, demonstrated, undemonstrated, untagged, touched: [...touched].sort(), touchedUndemonstrated: [...touched].filter(id => missing.has(id)).sort(), scannedFiles,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function safeReach(reaches, root, command, file) {
|
|
285
|
+
try {
|
|
286
|
+
return reaches(root, { command: command.command, args: command.args, ...(command.cwd ? { cwd: command.cwd } : {}) }, file);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Requirement ids a change adds, modifies or renames: the ones its developer tags and `--check` enforces. */
|
|
293
|
+
export function touchedRequirements(specsRoot, change) {
|
|
294
|
+
const delta = readChangeDelta(specsRoot, change);
|
|
295
|
+
return [...new Set([...delta.added, ...delta.modified, ...delta.renamed.map(item => item.to)].map(item => item.id))].sort();
|
|
296
|
+
}
|
|
297
|
+
/** One-line summary shown to hosts and reviewers: `Requirements demonstrated X/Y`. */
|
|
298
|
+
export function traceSummary(report) {
|
|
299
|
+
return `Requirements demonstrated ${report.demonstrated.length}/${report.requirements}`;
|
|
300
|
+
}
|
|
301
|
+
export function renderTraceReport(report) {
|
|
302
|
+
const lines = [traceSummary(report) + (report.evidence === 'none' ? ' (no verification evidence: pass --tests-passed after running the tests, or --context for a run receipt)' : '')];
|
|
303
|
+
if (report.touched.length)
|
|
304
|
+
lines.push(`Touched by ${report.changes.join(', ')}: ${report.touched.length - report.touchedUndemonstrated.length}/${report.touched.length} demonstrated`);
|
|
305
|
+
for (const item of report.undemonstrated)
|
|
306
|
+
lines.push(` ${report.touched.includes(item.id) ? '!' : '-'} ${item.id} (${item.reason}${item.tests.length ? ': ' + item.tests.map(test => test.file + ':' + test.line).join(', ') : ''})`);
|
|
307
|
+
for (const tag of report.untagged)
|
|
308
|
+
lines.push(` ? ${tag.repositoryId}:${tag.file}:${tag.line} tags unknown requirement ${tag.tag}`);
|
|
309
|
+
return lines.join('\n') + '\n';
|
|
310
|
+
}
|
|
311
|
+
function parseTraceFlags(argv) {
|
|
312
|
+
const flags = { change: [], tests: [], testsPassed: false, check: false, json: false };
|
|
313
|
+
for (let index = 0; index < argv.length; index++) {
|
|
314
|
+
const arg = argv[index];
|
|
315
|
+
const [name, inline] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
|
|
316
|
+
const value = () => {
|
|
317
|
+
const next = inline ?? argv[++index];
|
|
318
|
+
if (next === undefined || next.startsWith('--'))
|
|
319
|
+
throw new Error(`${name} needs a value`);
|
|
320
|
+
return next;
|
|
321
|
+
};
|
|
322
|
+
if (name === 'trace')
|
|
323
|
+
continue;
|
|
324
|
+
else if (name === '--check')
|
|
325
|
+
flags.check = true;
|
|
326
|
+
else if (name === '--json')
|
|
327
|
+
flags.json = true;
|
|
328
|
+
else if (name === '--tests-passed')
|
|
329
|
+
flags.testsPassed = true;
|
|
330
|
+
else if (name === '--root')
|
|
331
|
+
flags.root = value();
|
|
332
|
+
else if (name === '--context')
|
|
333
|
+
flags.context = value();
|
|
334
|
+
else if (name === '--out')
|
|
335
|
+
flags.out = value();
|
|
336
|
+
else if (name === '--change')
|
|
337
|
+
flags.change.push(...value().split(',').map(item => item.trim()).filter(Boolean));
|
|
338
|
+
else if (name === '--tests')
|
|
339
|
+
flags.tests.push(...value().split(',').map(item => item.trim()).filter(Boolean));
|
|
340
|
+
else
|
|
341
|
+
throw new Error('Unknown trace option: ' + arg);
|
|
342
|
+
}
|
|
343
|
+
return flags;
|
|
344
|
+
}
|
|
345
|
+
function contextInput(file, flags) {
|
|
346
|
+
const context = JSON.parse(readFileSync(file, 'utf8'));
|
|
347
|
+
if (typeof context.artifactRoot !== 'string' || !Array.isArray(context.repositories))
|
|
348
|
+
throw new Error('Invalid pipeline context: artifactRoot and repositories are required');
|
|
349
|
+
const repositories = context.repositories.map(item => item).filter(item => typeof item.id === 'string' && typeof item.path === 'string').map(item => ({ id: item.id, root: item.path }));
|
|
350
|
+
let receipt = null;
|
|
351
|
+
let change;
|
|
352
|
+
if (typeof context.backlogRoot === 'string' && typeof context.runId === 'string' && /^[A-Za-z0-9._-]+$/.test(context.runId)) {
|
|
353
|
+
const stateFile = path.join(context.backlogRoot, '.specrails', 'pipeline', context.runId, 'state.json');
|
|
354
|
+
const state = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : undefined;
|
|
355
|
+
if (typeof state?.change === 'string')
|
|
356
|
+
change = state.change;
|
|
357
|
+
if (state?.verification && Array.isArray(state.verification.commands))
|
|
358
|
+
receipt = { ...(state.verification.id ? { id: state.verification.id } : {}), commands: state.verification.commands };
|
|
359
|
+
}
|
|
360
|
+
return { specsRoot: context.artifactRoot, repositories, changes: flags.change.length ? flags.change : change ? [change] : activeChanges(context.artifactRoot), receipt, testsPassed: flags.testsPassed, ...(flags.tests.length ? { testGlobs: flags.tests } : {}) };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* `pipeline trace [--root <dir>] [--context <context.json>] [--change <name>[,…]] [--tests <glob>[,…]] [--tests-passed] [--check] [--json] [--out <file>]`.
|
|
364
|
+
* Normal mode is fail-open: it reports and exits 0 even when it cannot read
|
|
365
|
+
* something. `--check` exits 1 when a requirement touched by the change is
|
|
366
|
+
* not demonstrated (or the scan itself fails).
|
|
367
|
+
*/
|
|
368
|
+
export async function runSpecTraceCli(argv, options = {}) {
|
|
369
|
+
const stdout = options.stdout ?? (text => process.stdout.write(text));
|
|
370
|
+
const stderr = options.stderr ?? (text => process.stderr.write(text));
|
|
371
|
+
let flags;
|
|
372
|
+
try {
|
|
373
|
+
flags = parseTraceFlags(argv);
|
|
374
|
+
const root = path.resolve(flags.root ?? process.cwd());
|
|
375
|
+
const input = flags.context
|
|
376
|
+
? contextInput(flags.context, flags)
|
|
377
|
+
: { specsRoot: root, repositories: [{ id: path.basename(root) || 'root', root }], changes: flags.change.length ? flags.change : activeChanges(root), testsPassed: flags.testsPassed, ...(flags.tests.length ? { testGlobs: flags.tests } : {}) };
|
|
378
|
+
const report = traceSpecs({ ...input, ...(options.reaches ? { reaches: options.reaches } : {}) });
|
|
379
|
+
if (flags.out)
|
|
380
|
+
writeFileSync(flags.out, JSON.stringify(report, null, 2) + '\n');
|
|
381
|
+
stdout(flags.json ? JSON.stringify(report) + '\n' : renderTraceReport(report));
|
|
382
|
+
if (flags.check && report.touchedUndemonstrated.length) {
|
|
383
|
+
stderr(`Spec trace check failed: ${report.touchedUndemonstrated.length} touched requirement${report.touchedUndemonstrated.length === 1 ? '' : 's'} not demonstrated: ${report.touchedUndemonstrated.join(', ')}\n`);
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
stderr('Spec trace: ' + (error instanceof Error ? error.message : String(error)) + '\n');
|
|
390
|
+
return flags?.check || argv.includes('--check') ? 1 : 0;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic secret redaction for text and structured values that Core
|
|
3
|
+
* persists for display (the agent session journal).
|
|
4
|
+
*
|
|
5
|
+
* Every pattern is bounded: quantifiers have explicit upper limits and no
|
|
6
|
+
* pattern nests unbounded repetition, so the work per input character is
|
|
7
|
+
* constant and a 1 MB string redacts in linear time. PEM blocks are found with
|
|
8
|
+
* `indexOf`, not a regex, for the same reason.
|
|
9
|
+
*
|
|
10
|
+
* The rules favour leaking less over keeping every byte: a value assigned to a
|
|
11
|
+
* secret-like name is replaced even when it is not a credential (for example
|
|
12
|
+
* `token = readToken()` in a file read). Words that merely contain a secret
|
|
13
|
+
* word are not names: `author`, `authority`, `authenticate`, `max_tokens` and
|
|
14
|
+
* `tokenizer` stay readable. Bare `session` / `sessionId` are protocol
|
|
15
|
+
* identifiers and are not redacted; `session_token`, `session_secret`,
|
|
16
|
+
* `session_key` and `session_cookie` are.
|
|
17
|
+
*
|
|
18
|
+
* Known limits (documented in docs/agent-sessions/journal.md):
|
|
19
|
+
* - A secret split across two separately redacted strings (for example two
|
|
20
|
+
* streamed output deltas) can leak the part after the split.
|
|
21
|
+
* - Short flags such as `mysql -pSECRET` and secrets with no recognisable name
|
|
22
|
+
* or format are not detected.
|
|
23
|
+
*
|
|
24
|
+
* `src/pipeline/pipeline-state.ts` keeps its own self-contained
|
|
25
|
+
* `redactRuntimeText` because it is copied into projects and may only use Node
|
|
26
|
+
* built-ins; it is not replaced by this module.
|
|
27
|
+
*/
|
|
28
|
+
export declare const REDACTED = "[REDACTED]";
|
|
29
|
+
/** True when a key, variable or flag name looks like it holds a secret. */
|
|
30
|
+
export declare function isSecretName(name: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Replace secrets inside free text. Idempotent: redacting a redacted string
|
|
33
|
+
* returns it unchanged. Non-secret text is returned byte for byte.
|
|
34
|
+
*/
|
|
35
|
+
export declare function redactSecrets(text: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Redact a JSON-like value: object entries whose key is secret-like have their
|
|
38
|
+
* string leaves replaced; every other string goes through `redactSecrets`.
|
|
39
|
+
* Numbers, booleans and null are kept (a numeric `max_tokens` stays readable).
|
|
40
|
+
* Returns a new value; the input is not mutated. Depth is bounded.
|
|
41
|
+
*/
|
|
42
|
+
export declare function redactSecretsDeep<T>(value: T): T;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic secret redaction for text and structured values that Core
|
|
3
|
+
* persists for display (the agent session journal).
|
|
4
|
+
*
|
|
5
|
+
* Every pattern is bounded: quantifiers have explicit upper limits and no
|
|
6
|
+
* pattern nests unbounded repetition, so the work per input character is
|
|
7
|
+
* constant and a 1 MB string redacts in linear time. PEM blocks are found with
|
|
8
|
+
* `indexOf`, not a regex, for the same reason.
|
|
9
|
+
*
|
|
10
|
+
* The rules favour leaking less over keeping every byte: a value assigned to a
|
|
11
|
+
* secret-like name is replaced even when it is not a credential (for example
|
|
12
|
+
* `token = readToken()` in a file read). Words that merely contain a secret
|
|
13
|
+
* word are not names: `author`, `authority`, `authenticate`, `max_tokens` and
|
|
14
|
+
* `tokenizer` stay readable. Bare `session` / `sessionId` are protocol
|
|
15
|
+
* identifiers and are not redacted; `session_token`, `session_secret`,
|
|
16
|
+
* `session_key` and `session_cookie` are.
|
|
17
|
+
*
|
|
18
|
+
* Known limits (documented in docs/agent-sessions/journal.md):
|
|
19
|
+
* - A secret split across two separately redacted strings (for example two
|
|
20
|
+
* streamed output deltas) can leak the part after the split.
|
|
21
|
+
* - Short flags such as `mysql -pSECRET` and secrets with no recognisable name
|
|
22
|
+
* or format are not detected.
|
|
23
|
+
*
|
|
24
|
+
* `src/pipeline/pipeline-state.ts` keeps its own self-contained
|
|
25
|
+
* `redactRuntimeText` because it is copied into projects and may only use Node
|
|
26
|
+
* built-ins; it is not replaced by this module.
|
|
27
|
+
*/
|
|
28
|
+
export const REDACTED = '[REDACTED]';
|
|
29
|
+
const MAX_PEM_BODY = 16_384;
|
|
30
|
+
/**
|
|
31
|
+
* Secret words inside a normalized (lower-case, `_`-separated) name.
|
|
32
|
+
* `auth` must end a word so `author`/`authority`/`authenticate` do not match.
|
|
33
|
+
* `token` must not be followed by `s`/`iz` so token counts and tokenizers stay.
|
|
34
|
+
*/
|
|
35
|
+
const SECRET_NAME = /(?:passw(?:or)?d|passphrase|secret|token(?!s|iz)|api_?key|access_?key|private_?key|signing_?key|encryption_?key|credentials?|(?:^|_)auth(?:orization)?(?:_|$)|bearer|cookie|session_(?:token|secret|key|cookie))/;
|
|
36
|
+
/** Lower-case and split camelCase / kebab-case / dotted names into `_` words. */
|
|
37
|
+
function normalizeName(name) {
|
|
38
|
+
return name
|
|
39
|
+
.slice(0, 128)
|
|
40
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
41
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.replace(/[^a-z0-9]+/g, '_');
|
|
44
|
+
}
|
|
45
|
+
/** True when a key, variable or flag name looks like it holds a secret. */
|
|
46
|
+
export function isSecretName(name) {
|
|
47
|
+
return SECRET_NAME.test(normalizeName(name));
|
|
48
|
+
}
|
|
49
|
+
// ── Inline patterns ─────────────────────────────────────────────────────────
|
|
50
|
+
/** Well-known credential formats, replaced wherever they appear. */
|
|
51
|
+
const KNOWN_FORMATS = [
|
|
52
|
+
// OpenAI / Anthropic style keys: sk-..., sk-proj-..., sk-ant-...; must contain a digit.
|
|
53
|
+
/\bsk-(?=[A-Za-z0-9_-]{0,200}\d)[A-Za-z0-9_-]{20,512}/g,
|
|
54
|
+
// GitHub tokens.
|
|
55
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g,
|
|
56
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g,
|
|
57
|
+
// GitLab and npm tokens.
|
|
58
|
+
/\bglpat-[A-Za-z0-9_-]{20,255}\b/g,
|
|
59
|
+
/\bnpm_[A-Za-z0-9]{36,255}\b/g,
|
|
60
|
+
// Slack tokens.
|
|
61
|
+
/\bxox[abeoprs]-[A-Za-z0-9-]{10,255}/g,
|
|
62
|
+
/\bxapp-[A-Za-z0-9-]{10,255}/g,
|
|
63
|
+
// AWS access key ids.
|
|
64
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
65
|
+
// Google API keys.
|
|
66
|
+
/\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g,
|
|
67
|
+
// JSON Web Tokens.
|
|
68
|
+
/\beyJ[A-Za-z0-9_-]{8,4096}\.eyJ[A-Za-z0-9_-]{8,4096}\.[A-Za-z0-9_-]{8,4096}/g,
|
|
69
|
+
];
|
|
70
|
+
/** `Authorization: Bearer x`, `"authorization": "Basic x"`, `Proxy-Authorization=...`; keeps the scheme. */
|
|
71
|
+
const AUTHORIZATION_HEADER = /\b((?:proxy-)?authorization["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)((?:bearer|basic|token|digest|negotiate)[ \t]{1,8})?([^\s"'`,;]{1,4096})/gi;
|
|
72
|
+
/** A standalone `Bearer <token>` (the token must be long enough not to be prose). */
|
|
73
|
+
const BEARER = /\b(bearer[ \t]{1,8})([A-Za-z0-9._~+/-]{16,4096}=*)/gi;
|
|
74
|
+
/** `scheme://user:password@host` → keeps the user and host. */
|
|
75
|
+
const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]{0,20}:\/\/[^\s:/@]{1,256}:)([^\s/@]{1,256})(@)/gi;
|
|
76
|
+
/**
|
|
77
|
+
* `--token v`, `--api-key=v`, `--password "v"`: the flag name must END with the
|
|
78
|
+
* secret word, so `--password-stdin` or `--token-file` are left alone; a value
|
|
79
|
+
* starting with `-` is the next flag, not a secret.
|
|
80
|
+
*/
|
|
81
|
+
const SECRET_FLAG = /(^|[\s"'=])(--?(?:[a-z0-9]{1,24}[-_]){0,4}(?:token|api[-_]?key|apikey|password|passwd|passphrase|secret|private[-_]?key|access[-_]?key|auth|credentials?))(=|[ \t]{1,8})("[^"\n]{0,4096}"|'[^'\n]{0,4096}'|[^\s"'-][^\s"']{0,4095})/gi;
|
|
82
|
+
/**
|
|
83
|
+
* `NAME=value`, `NAME: value`, `"name": "value"`, `export NAME='v'` where the
|
|
84
|
+
* name contains a secret word. The name starts at a non-name character so
|
|
85
|
+
* matching is attempted only at word starts; `isSecretName` then refines it.
|
|
86
|
+
*/
|
|
87
|
+
const ASSIGNMENT = /(?<![A-Za-z0-9_.-])(["']?)([A-Za-z_][A-Za-z0-9_.-]{0,127})\1([ \t]{0,4}[:=][ \t]{0,4})("[^"\n]{0,4096}"|'[^'\n]{0,4096}'|\[REDACTED\][^\s"',;&|<>(){}[\]]{1,4096}|(?![ \t]|\/\/)[^\s"',;&|<>(){}[\]]{1,4096})/g;
|
|
88
|
+
/** Values that are not secrets even after a secret-like name (types, flags, empty literals). */
|
|
89
|
+
const KEPT_VALUES = new Set(['', 'true', 'false', 'null', 'undefined', 'none', 'string', 'number', 'boolean', 'unknown', 'any', 'required', 'optional']);
|
|
90
|
+
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/g;
|
|
91
|
+
function replaceValue(quoted) {
|
|
92
|
+
if (quoted.startsWith('"') && quoted.endsWith('"') && quoted.length >= 2)
|
|
93
|
+
return `"${REDACTED}"`;
|
|
94
|
+
if (quoted.startsWith("'") && quoted.endsWith("'") && quoted.length >= 2)
|
|
95
|
+
return `'${REDACTED}'`;
|
|
96
|
+
return REDACTED;
|
|
97
|
+
}
|
|
98
|
+
/** PEM private key blocks: BEGIN..END, or BEGIN plus its base64 body when END is missing (truncated output). */
|
|
99
|
+
function redactPem(text) {
|
|
100
|
+
if (!text.includes('PRIVATE KEY-----'))
|
|
101
|
+
return text;
|
|
102
|
+
let out = '';
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
// The next END marker is searched once and reused, so a text full of BEGIN
|
|
105
|
+
// markers without END stays linear.
|
|
106
|
+
let nextEnd = -2;
|
|
107
|
+
PEM_BEGIN.lastIndex = 0;
|
|
108
|
+
for (let match = PEM_BEGIN.exec(text); match; match = PEM_BEGIN.exec(text)) {
|
|
109
|
+
const begin = match.index;
|
|
110
|
+
if (begin < cursor)
|
|
111
|
+
continue;
|
|
112
|
+
const bodyStart = begin + match[0].length;
|
|
113
|
+
if (nextEnd !== -1 && nextEnd < bodyStart)
|
|
114
|
+
nextEnd = text.indexOf('-----END ', bodyStart);
|
|
115
|
+
const endMarker = nextEnd;
|
|
116
|
+
let stop;
|
|
117
|
+
if (endMarker >= 0 && endMarker - bodyStart <= MAX_PEM_BODY) {
|
|
118
|
+
const close = text.indexOf('-----', endMarker + 9);
|
|
119
|
+
stop = close >= 0 && close - endMarker <= 60 ? close + 5 : endMarker + 9;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
stop = bodyStart;
|
|
123
|
+
const limit = Math.min(text.length, bodyStart + MAX_PEM_BODY);
|
|
124
|
+
while (stop < limit && /[A-Za-z0-9+/=\s]/.test(text[stop]))
|
|
125
|
+
stop++;
|
|
126
|
+
}
|
|
127
|
+
out += text.slice(cursor, begin) + REDACTED;
|
|
128
|
+
cursor = stop;
|
|
129
|
+
PEM_BEGIN.lastIndex = Math.max(stop, bodyStart);
|
|
130
|
+
}
|
|
131
|
+
return out + text.slice(cursor);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Replace secrets inside free text. Idempotent: redacting a redacted string
|
|
135
|
+
* returns it unchanged. Non-secret text is returned byte for byte.
|
|
136
|
+
*/
|
|
137
|
+
export function redactSecrets(text) {
|
|
138
|
+
if (text.length === 0)
|
|
139
|
+
return text;
|
|
140
|
+
let result = redactPem(text);
|
|
141
|
+
for (const pattern of KNOWN_FORMATS)
|
|
142
|
+
result = result.replace(pattern, REDACTED);
|
|
143
|
+
result = result.replace(AUTHORIZATION_HEADER, (_match, prefix, scheme) => `${prefix}${scheme ?? ''}${REDACTED}`);
|
|
144
|
+
result = result.replace(BEARER, (_match, prefix) => `${prefix}${REDACTED}`);
|
|
145
|
+
result = result.replace(URL_CREDENTIALS, (_match, prefix, _password, at) => `${prefix}${REDACTED}${at}`);
|
|
146
|
+
result = result.replace(SECRET_FLAG, (_match, lead, flag, separator, value) => `${lead}${flag}${separator}${replaceValue(value)}`);
|
|
147
|
+
result = result.replace(ASSIGNMENT, (match, quote, name, separator, value, offset, whole) => {
|
|
148
|
+
if (!isSecretName(name))
|
|
149
|
+
return match;
|
|
150
|
+
const bare = value.replace(/^["']|["']$/g, '');
|
|
151
|
+
if (bare === REDACTED || KEPT_VALUES.has(bare.toLowerCase()) || bare.startsWith('$'))
|
|
152
|
+
return match;
|
|
153
|
+
// `Authorization: Bearer [REDACTED]` was already handled: do not take the scheme for the value.
|
|
154
|
+
if (whole.slice(offset + match.length, offset + match.length + 24).trimStart().startsWith(REDACTED))
|
|
155
|
+
return match;
|
|
156
|
+
return `${quote}${name}${quote}${separator}${replaceValue(value)}`;
|
|
157
|
+
});
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Redact a JSON-like value: object entries whose key is secret-like have their
|
|
162
|
+
* string leaves replaced; every other string goes through `redactSecrets`.
|
|
163
|
+
* Numbers, booleans and null are kept (a numeric `max_tokens` stays readable).
|
|
164
|
+
* Returns a new value; the input is not mutated. Depth is bounded.
|
|
165
|
+
*/
|
|
166
|
+
export function redactSecretsDeep(value) {
|
|
167
|
+
return redactValue(value, false, 0);
|
|
168
|
+
}
|
|
169
|
+
const MAX_DEPTH = 32;
|
|
170
|
+
function redactValue(value, secret, depth) {
|
|
171
|
+
if (typeof value === 'string')
|
|
172
|
+
return secret ? (value.length === 0 ? value : REDACTED) : redactSecrets(value);
|
|
173
|
+
if (value === null || typeof value !== 'object')
|
|
174
|
+
return value;
|
|
175
|
+
if (depth >= MAX_DEPTH)
|
|
176
|
+
return secret ? REDACTED : value;
|
|
177
|
+
if (Array.isArray(value))
|
|
178
|
+
return value.map((item) => redactValue(item, secret, depth + 1));
|
|
179
|
+
const out = {};
|
|
180
|
+
for (const [key, item] of Object.entries(value)) {
|
|
181
|
+
out[key] = redactValue(item, secret || isSecretName(key), depth + 1);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|