devflow-kit 2.4.0 → 2.5.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/CHANGELOG.md +156 -0
- package/README.md +86 -18
- package/dist/agents/git.md +824 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +23 -1
- package/dist/cli/commands/init-seed.js +24 -26
- package/dist/cli/commands/init.js +502 -71
- package/dist/cli/commands/install-report.js +205 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +27 -37
- package/dist/cli/commands/learning.js +37 -30
- package/dist/cli/commands/memory.js +79 -69
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/security.js +76 -16
- package/dist/cli/commands/skills.js +53 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +405 -0
- package/dist/cli/commands/uninstall.js +211 -65
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +22 -4
- package/dist/commands/code-review.md +44 -15
- package/dist/commands/debug.md +20 -6
- package/dist/commands/dynamic-build.md +289 -67
- package/dist/commands/dynamic-plan.md +60 -21
- package/dist/commands/dynamic-profile.md +1 -1
- package/dist/commands/dynamic-tickets.md +58 -8
- package/dist/commands/explore.md +2 -2
- package/dist/commands/implement.md +241 -53
- package/dist/commands/plan.md +88 -17
- package/dist/commands/release.md +64 -17
- package/dist/commands/resolve.md +138 -58
- package/dist/commands/self-review.md +2 -2
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/evidence-policy.js +147 -0
- package/dist/core/feature-config.js +130 -64
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +4 -4
- package/dist/core/manifest.js +33 -7
- package/dist/core/mds-variants.js +861 -0
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +357 -9
- package/dist/core/project-paths.js +1 -1
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/tracker.js +407 -0
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/installer.js +1213 -31
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +196 -74
- package/dist/targets/claude-code/tracker-install.js +161 -0
- package/package.json +4 -3
- package/src/assets/agents/code.md +42 -4
- package/src/assets/agents/design.md +1 -1
- package/src/assets/agents/git.mds +827 -0
- package/src/assets/agents/knowledge.md +1 -1
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +467 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_engine.mds +11 -9
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_knowledge.mds +2 -2
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +1 -1
- package/src/assets/commands/_partials/_publication.mds +3 -1
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +15 -5
- package/src/assets/commands/code-review.mds +34 -14
- package/src/assets/commands/debug.mds +11 -4
- package/src/assets/commands/dynamic-build.mds +227 -41
- package/src/assets/commands/dynamic-plan.mds +35 -13
- package/src/assets/commands/dynamic-tickets.mds +47 -5
- package/src/assets/commands/implement.mds +206 -52
- package/src/assets/commands/plan.mds +70 -17
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/resolve.mds +126 -56
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +299 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +14 -9
- package/src/assets/scripts/hooks/capture-prompt +6 -2
- package/src/assets/scripts/hooks/capture-question +6 -2
- package/src/assets/scripts/hooks/capture-turn +6 -2
- package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
- package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +223 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
- package/src/assets/scripts/hooks/memory-worker +15 -8
- package/src/assets/scripts/hooks/pre-compact-memory +12 -8
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +68 -24
- package/src/assets/scripts/hooks/session-start-context +355 -8
- package/src/assets/scripts/hooks/session-start-memory +12 -8
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +2 -0
- package/src/assets/skills/docs-framework/SKILL.md +5 -3
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- package/src/assets/agents/git.md +0 -938
|
@@ -6,17 +6,44 @@
|
|
|
6
6
|
// Installed as a top-level sibling of hud.sh under ~/.devflow/scripts/.
|
|
7
7
|
//
|
|
8
8
|
// Usage: node redact-secrets.cjs <input-file> <output-file>
|
|
9
|
+
// node redact-secrets.cjs --emit <input-file>
|
|
10
|
+
//
|
|
11
|
+
// The two modes exist because their sinks differ, not for convenience.
|
|
12
|
+
// <in> <out> FILE sink. The caller gates the post with a shell `&&` chain and
|
|
13
|
+
// passes the scrubbed FILE to `--body-file`.
|
|
14
|
+
// --emit TOOL-CALL sink (GAP-04). A tracker reached through a tool call has
|
|
15
|
+
// no `--body-file` and no shell operator between the scrub and the
|
|
16
|
+
// post, so the `&&` gate cannot exist. Instead the scrubbed bytes
|
|
17
|
+
// are printed behind a framing line only this script can produce,
|
|
18
|
+
// and LINE 1 is always that line:
|
|
19
|
+
// D11-OK <nonce> <sha256> <bytes> <n> [type:count,…]
|
|
20
|
+
// <the scrubbed body>
|
|
21
|
+
// Stdout whose line 1 is not the framing is a body that was never
|
|
22
|
+
// scrubbed, and a body that would itself have carried a framing
|
|
23
|
+
// line is refused rather than emitted — so "the bytes after line 1"
|
|
24
|
+
// and "the bytes after the framing" can never name different bytes.
|
|
25
|
+
// No failure ever writes body bytes: a failure the mode
|
|
26
|
+
// owns is EXACTLY `D11-FAIL <reason>` and nothing else, and the two
|
|
27
|
+
// that precede or escape mode selection — a usage error and an
|
|
28
|
+
// internal error — leave stdout entirely EMPTY. The consumer gates
|
|
29
|
+
// on the presence of `D11-OK`, so all three are one case to it.
|
|
9
30
|
//
|
|
10
31
|
// Exit codes:
|
|
11
32
|
// 0 success (zero or more redactions made)
|
|
12
|
-
// 1 usage error (wrong
|
|
33
|
+
// 1 usage error (wrong arity, or an unrecognised flag)
|
|
13
34
|
// 2 input file unreadable or larger than 1 MiB
|
|
14
|
-
// 3 output file write failed
|
|
35
|
+
// 3 output file write failed — the FILE sink only; `--emit` writes no file
|
|
15
36
|
// 4 internal / unexpected error
|
|
37
|
+
// 5 --emit only: a gate refused — the second scrub pass was non-zero, the
|
|
38
|
+
// scrubbed body carried a framing line of its own, or a nonce could not be
|
|
39
|
+
// generated. Distinct from 4 so a caller can tell "the body must not be
|
|
40
|
+
// posted" from "the script broke": the first is final, the second is retried.
|
|
16
41
|
//
|
|
17
42
|
// Design constraints (binding):
|
|
18
|
-
// PF-011
|
|
19
|
-
// old-or-new,
|
|
43
|
+
// PF-011 the file sink — the one file this script writes — goes via
|
|
44
|
+
// temp-sibling + rename (atomic same-fs write; readers see old-or-new,
|
|
45
|
+
// never a momentarily absent file). `--emit` prints to stdout and
|
|
46
|
+
// touches no file, so it has nothing to protect
|
|
20
47
|
// PF-014 never call process.exit() inside any scope with pending cleanup or
|
|
21
48
|
// buffered output; main() returns an exit code; the single top-level
|
|
22
49
|
// boundary writes stdout SYNCHRONOUSLY then sets process.exitCode so
|
|
@@ -30,6 +57,9 @@
|
|
|
30
57
|
'use strict';
|
|
31
58
|
|
|
32
59
|
const fs = require('fs');
|
|
60
|
+
// Genuinely new: no hashing or randomness helper exists anywhere else
|
|
61
|
+
// under src/assets/scripts. frameEmit is the ONLY consumer.
|
|
62
|
+
const crypto = require('crypto');
|
|
33
63
|
|
|
34
64
|
// ---------------------------------------------------------------------------
|
|
35
65
|
// Constants
|
|
@@ -38,6 +68,83 @@ const fs = require('fs');
|
|
|
38
68
|
/** Maximum allowed input size in bytes (1 MiB). */
|
|
39
69
|
const MAX_INPUT_BYTES = 1048576;
|
|
40
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Nonce width, in hex characters (16 random bytes).
|
|
73
|
+
*
|
|
74
|
+
* The nonce is per-invocation and REQUIRED (§14.9-3), and it is the SECOND of two
|
|
75
|
+
* independent controls over the same forgery. `FRAMING_IN_BODY_RE` below is the
|
|
76
|
+
* first: no emitted body can hold a framing line at all. The nonce is what a
|
|
77
|
+
* consumer still has if it reads the framing from somewhere other than line 1 —
|
|
78
|
+
* a fixed `D11-OK` literal would be reproducible by anyone who can write an issue
|
|
79
|
+
* comment, and an unpredictable one is not.
|
|
80
|
+
*
|
|
81
|
+
* Exported so the framing grammar's guard pins its width from here rather than
|
|
82
|
+
* from a retyped number.
|
|
83
|
+
*/
|
|
84
|
+
const NONCE_HEX_CHARS = 32;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A BODY line that would read as framing.
|
|
88
|
+
*
|
|
89
|
+
* The framing's one job is to say where the scrubbed bytes begin, and it can only
|
|
90
|
+
* do that if line 1 is the only line shaped like it. Composed bodies carry
|
|
91
|
+
* untrusted issue and comment text, so a body is one comment away from holding a
|
|
92
|
+
* `D11-OK`-shaped line of its own — and a consumer that looked for "a D11-OK
|
|
93
|
+
* line" instead of "line 1" would take the planted one, post the attacker's half
|
|
94
|
+
* under a devflow-authored marker, and suppress the real summary along with its
|
|
95
|
+
* SECRET-EXPOSED rotation warning.
|
|
96
|
+
*
|
|
97
|
+
* Refusing such a body here is what makes the line-1 rule MECHANICAL: the prose
|
|
98
|
+
* rule then describes a property of every body this script can emit, instead of
|
|
99
|
+
* an obligation nine documents have to restate correctly.
|
|
100
|
+
*
|
|
101
|
+
* PF-018: bounded — a fixed alternation over two literals, anchored per line by
|
|
102
|
+
* the `m` flag, with no quantifier to backtrack through. The trailing space is
|
|
103
|
+
* load-bearing: it is what keeps prose such as `D11-FAILURE` out of the refusal.
|
|
104
|
+
*/
|
|
105
|
+
const FRAMING_IN_BODY_RE = /^D11-(OK|FAIL) /m;
|
|
106
|
+
|
|
107
|
+
/** The `SCRUB: ` prefix — one spelling, shared by formatScrubLine and frameEmit. */
|
|
108
|
+
const SCRUB_LINE_PREFIX = 'SCRUB: ';
|
|
109
|
+
|
|
110
|
+
/** The exact text a clean pass produces. The second pass returning THIS is the gate. */
|
|
111
|
+
const ZERO_SCRUB_LINE = SCRUB_LINE_PREFIX + '0 []';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every reason that may follow `D11-FAIL `.
|
|
115
|
+
*
|
|
116
|
+
* Bare lowercase tokens, never prose and never a path: stdout is read back by an
|
|
117
|
+
* agent and pasted into reports, so a reason carrying a tmpdir path or input
|
|
118
|
+
* bytes would travel with it. The human-readable diagnosis goes to stderr, which
|
|
119
|
+
* no recipe forwards.
|
|
120
|
+
*
|
|
121
|
+
* A closed registry rather than inline strings, for the reason
|
|
122
|
+
* compliance-compose.ts states about its token tables: a guard asserts the shape
|
|
123
|
+
* of every entry, and an entry added inline would not be covered by it.
|
|
124
|
+
*/
|
|
125
|
+
const D11_FAIL_REASONS = Object.freeze({
|
|
126
|
+
INPUT_UNREADABLE: 'input-unreadable',
|
|
127
|
+
INPUT_TOO_LARGE: 'input-too-large',
|
|
128
|
+
SECOND_PASS_NONZERO: 'second-pass-nonzero',
|
|
129
|
+
BODY_CONTAINS_FRAMING: 'body-contains-framing',
|
|
130
|
+
NONCE_UNAVAILABLE: 'nonce-unavailable',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// EVERY reason here is emitted by an arm of the emit mode, and a test drives the
|
|
134
|
+
// arms and compares what they produce against this registry. Two tokens a reader
|
|
135
|
+
// might expect are deliberately absent:
|
|
136
|
+
//
|
|
137
|
+
// internal-error the top-level catch fires BEFORE the boundary has written
|
|
138
|
+
// anything and knows no mode, so it leaves stdout empty
|
|
139
|
+
// rather than framing a reason.
|
|
140
|
+
// output-unwritable the emit mode's sink is stdout and it writes no file, so
|
|
141
|
+
// no write of its own can fail. Exit 3 belongs to the file
|
|
142
|
+
// mode, which returns a bare code and frames nothing.
|
|
143
|
+
//
|
|
144
|
+
// A token with no arm that can emit it is a value in a closed vocabulary that a
|
|
145
|
+
// consumer can never see, and it reads as a live refusal to anyone auditing the
|
|
146
|
+
// set.
|
|
147
|
+
|
|
41
148
|
// ---------------------------------------------------------------------------
|
|
42
149
|
// Shannon entropy
|
|
43
150
|
// Bounded by string length; O(n) time, O(distinct-chars) space.
|
|
@@ -69,23 +176,47 @@ function shannonEntropy(s) {
|
|
|
69
176
|
// Applied before every replacement to guard against false positives.
|
|
70
177
|
// ---------------------------------------------------------------------------
|
|
71
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A marker this script's own passes write. The slug vocabulary is lowercase and
|
|
181
|
+
* hyphenated, so the class is bounded and the pattern cannot span two markers.
|
|
182
|
+
*/
|
|
183
|
+
const REDACTION_MARKER_RE = /\[REDACTED:[a-z][a-z-]{1,38}\]/g;
|
|
184
|
+
|
|
72
185
|
/**
|
|
73
186
|
* @param {string} candidate The matched text (or value portion) to test.
|
|
74
187
|
* @returns {boolean}
|
|
75
188
|
*/
|
|
76
189
|
function shouldSkip(candidate) {
|
|
77
|
-
// Idempotency guard
|
|
78
|
-
|
|
190
|
+
// Idempotency guard, marker-STRIPPED rather than contains-based (GAP-54).
|
|
191
|
+
//
|
|
192
|
+
// A value this script already produced is markers and whitespace and nothing
|
|
193
|
+
// else, so removing them leaves nothing and the value is skipped — which is
|
|
194
|
+
// what keeps the second pass at zero and the `--emit` gate open. Bytes that
|
|
195
|
+
// SURVIVE the strip are not this script's output: `[REDACTED:` is a literal
|
|
196
|
+
// anyone can type into an issue comment, and a contains-check let one disarm
|
|
197
|
+
// rule 8 — the only generic `key = value` rule — for the whole line. An
|
|
198
|
+
// anchored check cannot serve here either: a value holding two markers would
|
|
199
|
+
// fail it, the second pass could never return zero, and the gate would refuse
|
|
200
|
+
// every body.
|
|
201
|
+
if (candidate.replace(REDACTION_MARKER_RE, '').trim() === '') return true;
|
|
79
202
|
|
|
80
203
|
// Environment variable references (value is not the secret itself)
|
|
81
204
|
if (candidate.includes('process.env.')) return true;
|
|
82
205
|
if (candidate.includes('os.environ')) return true;
|
|
83
206
|
|
|
84
|
-
// Template / shell variable references (bounded alternation, no ReDoS risk)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
207
|
+
// Template / shell variable references (bounded alternation, no ReDoS risk).
|
|
208
|
+
//
|
|
209
|
+
// ANCHORED, both ends (GAP-54). These four skips exist to keep AUTHOR fixtures
|
|
210
|
+
// readable — `api_key = "${DEPLOY_KEY}"` is documentation, not a credential. A
|
|
211
|
+
// value that merely CONTAINS a placeholder is a different thing: `api_key =
|
|
212
|
+
// "<ref> a8Kd91jZx0Qw7Lp2Vn"` would disarm rule 8 — the only generic
|
|
213
|
+
// `key = value` rule — and provider-rendered bodies and remote issue text are
|
|
214
|
+
// exactly what flows into a composed comment sink. So the skip fires only when
|
|
215
|
+
// the value IS the placeholder and nothing else.
|
|
216
|
+
if (/^\$\{[^}]{0,300}\}$/.test(candidate)) return true; // ${VAR}
|
|
217
|
+
if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(candidate)) return true; // $VAR
|
|
218
|
+
if (/^\{\{[^}]{0,300}\}\}$/.test(candidate)) return true; // {{ template }}
|
|
219
|
+
if (/^<[^>]{0,300}>$/.test(candidate)) return true; // <placeholder>
|
|
89
220
|
|
|
90
221
|
// Keyword / low-entropy values that are never real secrets
|
|
91
222
|
if (/^(null|undefined|true|false|none|changeme|example)$/i.test(candidate)) return true;
|
|
@@ -301,7 +432,159 @@ function formatScrubLine(counts) {
|
|
|
301
432
|
const entries = Object.entries(counts);
|
|
302
433
|
const total = entries.reduce((sum, [, n]) => sum + n, 0);
|
|
303
434
|
const parts = entries.map(([slug, n]) => slug + ':' + n);
|
|
304
|
-
return
|
|
435
|
+
return SCRUB_LINE_PREFIX + total + ' [' + parts.join(',') + ']';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// [DR-14] Three pure helpers, and main() is a dispatcher over them
|
|
440
|
+
//
|
|
441
|
+
// Without this split main() would parse arguments, run two scrub passes,
|
|
442
|
+
// generate randomness, hash, manage a temp-file lifecycle, select between two
|
|
443
|
+
// output framings and choose among four exit codes — nine responsibilities in
|
|
444
|
+
// the D11 sink for every provider, with the only structural mitigation being
|
|
445
|
+
// boundary-scoped. Each helper below is also the ONLY way to reach one arm:
|
|
446
|
+
// parseArgs is observable without a subprocess, and the nonce-failure arm is
|
|
447
|
+
// reachable through injection and through nothing else.
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* @typedef {{ kind: 'emit', inputPath: string }} EmitArgs
|
|
452
|
+
* @typedef {{ kind: 'file', inputPath: string, outputPath: string }} FileArgs
|
|
453
|
+
* @typedef {{ kind: 'usage', usage: string }} UsageError
|
|
454
|
+
*/
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Parse argv into a mode and its positionals.
|
|
458
|
+
*
|
|
459
|
+
* THE FLAG IS READ BEFORE THE POSITIONALS ARE BOUND, so `--emit` can never bind
|
|
460
|
+
* as a FILENAME and die at statSync with exit 2 — reporting "your input is
|
|
461
|
+
* missing" for what is actually an unsupported flag.
|
|
462
|
+
*
|
|
463
|
+
* `kind` is the DISCRIMINANT, and main() dispatches on it alone. The three
|
|
464
|
+
* shapes also differ in which fields they carry, but reading the mode off field
|
|
465
|
+
* presence makes a renamed field — or a fourth shape — resolve to an existing
|
|
466
|
+
* arm instead of failing, and the arms differ in whether the scrubbed body
|
|
467
|
+
* reaches stdout.
|
|
468
|
+
*
|
|
469
|
+
* Arity is exact in both modes. A third positional is a usage error rather than
|
|
470
|
+
* an ignored argument: `--emit in out` is a caller who believes they are writing
|
|
471
|
+
* a file, and silently printing the body to stdout instead would put a scrubbed
|
|
472
|
+
* comment body into a terminal log they never read.
|
|
473
|
+
*
|
|
474
|
+
* @param {string[]} argv process.argv
|
|
475
|
+
* @returns {EmitArgs | FileArgs | UsageError}
|
|
476
|
+
*/
|
|
477
|
+
function parseArgs(argv) {
|
|
478
|
+
const FILE_USAGE = 'Usage: node redact-secrets.cjs <input-file> <output-file>';
|
|
479
|
+
const EMIT_USAGE = 'Usage: node redact-secrets.cjs --emit <input-file>';
|
|
480
|
+
|
|
481
|
+
let emit = false;
|
|
482
|
+
/** @type {string[]} */
|
|
483
|
+
const positionals = [];
|
|
484
|
+
for (const arg of argv.slice(2)) {
|
|
485
|
+
if (arg === '--emit') {
|
|
486
|
+
emit = true;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (arg.startsWith('-')) {
|
|
490
|
+
return {
|
|
491
|
+
kind: 'usage',
|
|
492
|
+
usage: 'redact-secrets: unrecognised flag ' + arg + '\n' + FILE_USAGE + '\n' + EMIT_USAGE,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
positionals.push(arg);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (emit) {
|
|
499
|
+
if (positionals.length !== 1) return { kind: 'usage', usage: EMIT_USAGE };
|
|
500
|
+
return { kind: 'emit', inputPath: positionals[0] };
|
|
501
|
+
}
|
|
502
|
+
if (positionals.length !== 2) return { kind: 'usage', usage: FILE_USAGE };
|
|
503
|
+
return { kind: 'file', inputPath: positionals[0], outputPath: positionals[1] };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Scrub, then scrub the RESULT again.
|
|
508
|
+
*
|
|
509
|
+
* The second pass is the gate: it re-scrubs what the first pass produced, so a
|
|
510
|
+
* zero second count is evidence that the first pass left nothing behind. Passing
|
|
511
|
+
* the ORIGINAL content twice would find the same secrets again and the gate would
|
|
512
|
+
* never open — the one wiring mistake that turns the whole mode off, which is why
|
|
513
|
+
* a test pins which content the second call receives.
|
|
514
|
+
*
|
|
515
|
+
* Nearly free: idempotency is already pinned by shouldSkip's marker-stripped
|
|
516
|
+
* skip, which passes over a value that is this script's own output.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} content
|
|
519
|
+
* @param {(c: string) => ScrubResult} [scrubFn] Injectable so the refusal arm is
|
|
520
|
+
* provable without a pathological fixture — the real rules ARE idempotent, so
|
|
521
|
+
* no input reaches a non-zero second pass.
|
|
522
|
+
* @returns {{ text: string, first: Record<string, number>, second: Record<string, number> }}
|
|
523
|
+
*/
|
|
524
|
+
function scrubTwice(content, scrubFn) {
|
|
525
|
+
const doScrub = scrubFn || scrub;
|
|
526
|
+
const first = doScrub(content);
|
|
527
|
+
const second = doScrub(first.result);
|
|
528
|
+
return { text: first.result, first: first.counts, second: second.counts };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Default nonce source: 16 CSPRNG bytes as lowercase hex. */
|
|
532
|
+
function defaultNonceSource() {
|
|
533
|
+
return crypto.randomBytes(NONCE_HEX_CHARS / 2).toString('hex');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Build the `D11-OK` framing line for a scrubbed body.
|
|
538
|
+
*
|
|
539
|
+
* The line carries four facts, and each answers a specific way the channel can
|
|
540
|
+
* fail between this process's stdout and the tool call that posts the body:
|
|
541
|
+
* <nonce> per-invocation, so the line cannot be forged from inside the body;
|
|
542
|
+
* <sha256> identifies these exact bytes;
|
|
543
|
+
* <bytes> [DR-06] the UTF-8 byte length, so a consumer can detect a
|
|
544
|
+
* harness-TRUNCATED result. Truncation keeps line 1 intact, so a bare
|
|
545
|
+
* "no framing line ⇒ do not post" gate passes while the body is
|
|
546
|
+
* partial — a guard that appears to work while failing;
|
|
547
|
+
* <n> […] [DR-01] the FIRST pass's count and per-type payload. The second
|
|
548
|
+
* pass is always zero by construction, so without this the only
|
|
549
|
+
* signal that a real credential was present is computed and discarded,
|
|
550
|
+
* and the user is never told to rotate it.
|
|
551
|
+
*
|
|
552
|
+
* formatScrubLine stays the sole producer of the `N [type:count,…]` text; this
|
|
553
|
+
* embeds it by stripping the shared prefix rather than re-deriving the format.
|
|
554
|
+
*
|
|
555
|
+
* @param {string} scrubbed The scrubbed body.
|
|
556
|
+
* @param {string} scrubLine The FIRST pass's formatScrubLine output.
|
|
557
|
+
* @param {() => unknown} [nonceSource] `unknown` is the contract this function
|
|
558
|
+
* implements: it type-checks what the source returns and refuses anything that
|
|
559
|
+
* is not 32 hex characters, so declaring `() => string` would describe a
|
|
560
|
+
* narrower contract than the code and force every malformed-nonce fixture to
|
|
561
|
+
* cast past the check it exists to prove.
|
|
562
|
+
* @returns {{ emitLine: string, body: string } | { error: string }}
|
|
563
|
+
*/
|
|
564
|
+
function frameEmit(scrubbed, scrubLine, nonceSource) {
|
|
565
|
+
const source = nonceSource || defaultNonceSource;
|
|
566
|
+
let nonce;
|
|
567
|
+
try {
|
|
568
|
+
nonce = source();
|
|
569
|
+
} catch (/** @type {any} */ err) {
|
|
570
|
+
return { error: 'nonce generation failed: ' + (err.code || err.message) };
|
|
571
|
+
}
|
|
572
|
+
if (typeof nonce !== 'string' || !new RegExp('^[0-9a-f]{' + NONCE_HEX_CHARS + '}$').test(nonce)) {
|
|
573
|
+
// A short, empty or non-hex nonce is unforgeable-by-accident only; treating it
|
|
574
|
+
// as usable would ship a framing line whose one security property is absent.
|
|
575
|
+
return { error: 'nonce generation failed: malformed nonce' };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const sha256 = crypto.createHash('sha256').update(scrubbed, 'utf8').digest('hex');
|
|
579
|
+
const bytes = Buffer.byteLength(scrubbed, 'utf8');
|
|
580
|
+
const payload = scrubLine.startsWith(SCRUB_LINE_PREFIX)
|
|
581
|
+
? scrubLine.slice(SCRUB_LINE_PREFIX.length)
|
|
582
|
+
: scrubLine;
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
emitLine: 'D11-OK ' + nonce + ' ' + sha256 + ' ' + bytes + ' ' + payload,
|
|
586
|
+
body: scrubbed,
|
|
587
|
+
};
|
|
305
588
|
}
|
|
306
589
|
|
|
307
590
|
// ---------------------------------------------------------------------------
|
|
@@ -315,61 +598,74 @@ function formatScrubLine(counts) {
|
|
|
315
598
|
// ---------------------------------------------------------------------------
|
|
316
599
|
|
|
317
600
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
601
|
+
* Read the input, enforcing the size bound.
|
|
602
|
+
*
|
|
603
|
+
* Shared by both modes so the two cannot drift on what "unreadable" means. The
|
|
604
|
+
* `message` is exactly the stderr text the file mode has always written — the
|
|
605
|
+
* mode-specific part is only which stdout framing (if any) accompanies it.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} inputPath
|
|
608
|
+
* @returns {{ ok: true, content: string } | { ok: false, code: number, reason: string, message: string }}
|
|
320
609
|
*/
|
|
321
|
-
function
|
|
322
|
-
const inputPath = argv[2];
|
|
323
|
-
const outputPath = argv[3];
|
|
324
|
-
|
|
325
|
-
if (!inputPath || !outputPath) {
|
|
326
|
-
process.stderr.write('Usage: node redact-secrets.cjs <input-file> <output-file>\n');
|
|
327
|
-
return 1;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ---- stat input ----
|
|
610
|
+
function readInput(inputPath) {
|
|
331
611
|
let stat;
|
|
332
612
|
try {
|
|
333
613
|
stat = fs.statSync(inputPath);
|
|
334
614
|
} catch (/** @type {any} */ err) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
code: 2,
|
|
618
|
+
reason: D11_FAIL_REASONS.INPUT_UNREADABLE,
|
|
619
|
+
message: 'redact-secrets: cannot stat input: ' + inputPath + ': ' + (err.code || err.message) + '\n',
|
|
620
|
+
};
|
|
339
621
|
}
|
|
340
622
|
|
|
341
623
|
if (stat.size > MAX_INPUT_BYTES) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
624
|
+
return {
|
|
625
|
+
ok: false,
|
|
626
|
+
code: 2,
|
|
627
|
+
reason: D11_FAIL_REASONS.INPUT_TOO_LARGE,
|
|
628
|
+
message: 'redact-secrets: input exceeds 1 MiB: ' + inputPath + '\n',
|
|
629
|
+
};
|
|
346
630
|
}
|
|
347
631
|
|
|
348
|
-
// ---- read input ----
|
|
349
|
-
let rawBuffer;
|
|
350
632
|
try {
|
|
351
|
-
|
|
633
|
+
return { ok: true, content: fs.readFileSync(inputPath).toString('utf8') };
|
|
352
634
|
} catch (/** @type {any} */ err) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
code: 2,
|
|
638
|
+
reason: D11_FAIL_REASONS.INPUT_UNREADABLE,
|
|
639
|
+
message: 'redact-secrets: cannot read input: ' + inputPath + ': ' + (err.code || err.message) + '\n',
|
|
640
|
+
};
|
|
357
641
|
}
|
|
642
|
+
}
|
|
358
643
|
|
|
359
|
-
|
|
360
|
-
|
|
644
|
+
/**
|
|
645
|
+
* The FILE-sink mode — byte-for-byte the behaviour that shipped before `--emit`.
|
|
646
|
+
*
|
|
647
|
+
* Deliberately calls `scrub` ONCE, not scrubTwice: the recipes, the Tracker
|
|
648
|
+
* agent's write chain and every `gh` call depend on this path's exact stdout and
|
|
649
|
+
* exit codes, and a second pass here would add a failure mode to a contract
|
|
650
|
+
* nothing asked to change.
|
|
651
|
+
*
|
|
652
|
+
* @param {FileArgs} args
|
|
653
|
+
* @param {string} content
|
|
654
|
+
* @returns {number | { scrubLine: string }}
|
|
655
|
+
*/
|
|
656
|
+
function runFileMode(args, content) {
|
|
361
657
|
const { result, counts } = scrub(content);
|
|
362
658
|
|
|
363
659
|
// ---- atomic write (PF-011: temp-sibling + rename) ----
|
|
364
|
-
const tmpPath = outputPath + '.tmp';
|
|
660
|
+
const tmpPath = args.outputPath + '.tmp';
|
|
365
661
|
try {
|
|
366
662
|
fs.writeFileSync(tmpPath, result, 'utf8');
|
|
367
|
-
fs.renameSync(tmpPath, outputPath);
|
|
663
|
+
fs.renameSync(tmpPath, args.outputPath);
|
|
368
664
|
} catch (/** @type {any} */ err) {
|
|
369
665
|
// Best-effort cleanup of the temp file; ignore errors (the temp may not exist)
|
|
370
666
|
try { fs.unlinkSync(tmpPath); } catch (_) { /* intentionally ignored */ }
|
|
371
667
|
process.stderr.write(
|
|
372
|
-
'redact-secrets: cannot write output: ' + outputPath + ': ' + (err.code || err.message) + '\n',
|
|
668
|
+
'redact-secrets: cannot write output: ' + args.outputPath + ': ' + (err.code || err.message) + '\n',
|
|
373
669
|
);
|
|
374
670
|
return 3;
|
|
375
671
|
}
|
|
@@ -377,34 +673,166 @@ function main(argv) {
|
|
|
377
673
|
return { scrubLine: formatScrubLine(counts) };
|
|
378
674
|
}
|
|
379
675
|
|
|
676
|
+
/**
|
|
677
|
+
* The TOOL-CALL-sink mode.
|
|
678
|
+
*
|
|
679
|
+
* Always returns an `{ emitLine, body, code }` triple, and `body` is `''` on
|
|
680
|
+
* every non-zero code. That is what makes "no body on any non-zero exit" a
|
|
681
|
+
* property of the type rather than a rule each arm has to remember: the boundary
|
|
682
|
+
* writes `emitLine + '\n' + body` unconditionally, so a failing arm cannot emit a
|
|
683
|
+
* body even by forgetting to suppress one.
|
|
684
|
+
*
|
|
685
|
+
* THIS MODE TOUCHES NO FILE. It holds the bytes it returns and the boundary
|
|
686
|
+
* prints them, so there is nothing for a write discipline to protect: a scrubbed
|
|
687
|
+
* comment body put on disk is a second copy with the input directory's lifetime,
|
|
688
|
+
* and proving that directory writable would let a filesystem property refuse a
|
|
689
|
+
* clean, fully gated body. The file mode's temp-sibling + rename (PF-011) guards
|
|
690
|
+
* the one file this script does write.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} content
|
|
693
|
+
* @param {{ scrubFn?: (c: string) => ScrubResult, nonceSource?: () => unknown }} deps
|
|
694
|
+
* @returns {{ emitLine: string, body: string, code: number }}
|
|
695
|
+
*/
|
|
696
|
+
function runEmitMode(content, deps) {
|
|
697
|
+
const { text, first, second } = scrubTwice(content, deps.scrubFn);
|
|
698
|
+
|
|
699
|
+
// THE GATE. A non-zero second pass means the first pass did not hold, so the
|
|
700
|
+
// body is not publishable and no amount of re-running changes that.
|
|
701
|
+
const secondLine = formatScrubLine(second);
|
|
702
|
+
if (secondLine !== ZERO_SCRUB_LINE) {
|
|
703
|
+
process.stderr.write(
|
|
704
|
+
'redact-secrets: second scrub pass was non-zero (' + secondLine + ') — refusing to emit\n',
|
|
705
|
+
);
|
|
706
|
+
return { emitLine: 'D11-FAIL ' + D11_FAIL_REASONS.SECOND_PASS_NONZERO, body: '', code: 5 };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// THE FRAMING GATE. A body that carries a framing line of its own lets its
|
|
710
|
+
// author decide where a consumer thinks the body begins. Refusing is fail-closed
|
|
711
|
+
// in the direction the sink needs: the item degrades and nothing is posted.
|
|
712
|
+
if (FRAMING_IN_BODY_RE.test(text)) {
|
|
713
|
+
process.stderr.write(
|
|
714
|
+
'redact-secrets: the scrubbed body carries a D11 framing line — refusing to emit\n',
|
|
715
|
+
);
|
|
716
|
+
return { emitLine: 'D11-FAIL ' + D11_FAIL_REASONS.BODY_CONTAINS_FRAMING, body: '', code: 5 };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const framed = frameEmit(text, formatScrubLine(first), deps.nonceSource);
|
|
720
|
+
if (framed.error !== undefined) {
|
|
721
|
+
process.stderr.write('redact-secrets: ' + framed.error + ' — refusing to emit\n');
|
|
722
|
+
return { emitLine: 'D11-FAIL ' + D11_FAIL_REASONS.NONCE_UNAVAILABLE, body: '', code: 5 };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return { emitLine: framed.emitLine, body: framed.body, code: 0 };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* @param {string[]} argv process.argv
|
|
730
|
+
* @param {{ scrubFn?: (c: string) => ScrubResult, nonceSource?: () => unknown }} [deps]
|
|
731
|
+
* Injected only by tests, and only to reach the two arms no fixture can: a
|
|
732
|
+
* non-idempotent scrub and an unavailable nonce. Defaulted here rather than at
|
|
733
|
+
* each use site so production has exactly one set of dependencies.
|
|
734
|
+
* @returns {number | { scrubLine: string } | { emitLine: string, body: string, code: number }}
|
|
735
|
+
*/
|
|
736
|
+
function main(argv, deps) {
|
|
737
|
+
const args = parseArgs(argv);
|
|
738
|
+
if (args.kind === 'usage') {
|
|
739
|
+
// The one failure that precedes mode selection, so no framing line can
|
|
740
|
+
// describe it: stdout stays entirely empty and stderr carries the usage.
|
|
741
|
+
process.stderr.write(args.usage + '\n');
|
|
742
|
+
return 1;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const read = readInput(args.inputPath);
|
|
746
|
+
if (!read.ok) {
|
|
747
|
+
process.stderr.write(read.message);
|
|
748
|
+
// Each mode owns the SHAPE of its refusal: the tool-call sink frames every
|
|
749
|
+
// failure it can name, the file sink returns a bare code.
|
|
750
|
+
return args.kind === 'emit'
|
|
751
|
+
? { emitLine: 'D11-FAIL ' + read.reason, body: '', code: read.code }
|
|
752
|
+
: read.code;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return args.kind === 'emit'
|
|
756
|
+
? runEmitMode(read.content, deps || {})
|
|
757
|
+
: runFileMode(args, read.content);
|
|
758
|
+
}
|
|
759
|
+
|
|
380
760
|
// ---------------------------------------------------------------------------
|
|
381
761
|
// Top-level boundary
|
|
382
762
|
//
|
|
383
763
|
// This is the ONLY place that writes to stdout and sets process.exitCode.
|
|
384
764
|
// No other code path may call process.exit() or write to stdout.
|
|
385
765
|
// (PF-014: single synchronous write, no pending cleanup, no buffered output)
|
|
766
|
+
//
|
|
767
|
+
// AMENDED for --emit, not bypassed. main()'s return widened from
|
|
768
|
+
// `number | {scrubLine}` to also carry `{emitLine, body, code}`, and the write
|
|
769
|
+
// became a TWO-BRANCH synchronous write — one branch per output shape. There are
|
|
770
|
+
// still exactly two process.stdout.write sites in this file, and a guard asserts
|
|
771
|
+
// that count: a third site is precisely how a body would reach stdout without
|
|
772
|
+
// passing the gate.
|
|
773
|
+
//
|
|
774
|
+
// The emit branch writes `emitLine + '\n' + body` UNCONDITIONALLY, because a
|
|
775
|
+
// failing emit result carries `body: ''` by construction. Suppressing the body
|
|
776
|
+
// here instead would put the "no body on failure" property in this block, where
|
|
777
|
+
// a future arm could forget it; putting it in the result keeps it a property of
|
|
778
|
+
// every arm that can produce one.
|
|
779
|
+
//
|
|
780
|
+
// Guarded by `require.main === module` so the pure helpers above are importable
|
|
781
|
+
// by their unit tests. Nothing else changes: `node redact-secrets.cjs …` still
|
|
782
|
+
// enters here, and the block is still the only exit-code and stdout authority.
|
|
386
783
|
// ---------------------------------------------------------------------------
|
|
387
784
|
|
|
388
|
-
|
|
389
|
-
let
|
|
785
|
+
if (require.main === module) {
|
|
786
|
+
let exitCode = 0;
|
|
787
|
+
let scrubLine = /** @type {string | null} */ (null);
|
|
788
|
+
let emitted = /** @type {{ emitLine: string, body: string, code: number } | null} */ (null);
|
|
789
|
+
|
|
790
|
+
try {
|
|
791
|
+
const mainResult = main(process.argv);
|
|
792
|
+
if (typeof mainResult === 'number') {
|
|
793
|
+
exitCode = mainResult;
|
|
794
|
+
} else if (mainResult.emitLine !== undefined) {
|
|
795
|
+
emitted = /** @type {any} */ (mainResult);
|
|
796
|
+
exitCode = emitted.code;
|
|
797
|
+
} else {
|
|
798
|
+
scrubLine = /** @type {any} */ (mainResult).scrubLine;
|
|
799
|
+
exitCode = 0;
|
|
800
|
+
}
|
|
801
|
+
} catch (/** @type {any} */ err) {
|
|
802
|
+
process.stderr.write('redact-secrets: internal error: ' + err.message + '\n');
|
|
803
|
+
exitCode = 4;
|
|
804
|
+
}
|
|
390
805
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
} else {
|
|
396
|
-
scrubLine
|
|
397
|
-
exitCode = 0;
|
|
806
|
+
// Synchronous stdout writes (must complete before process exits) — one per
|
|
807
|
+
// output shape, and no third site anywhere in this file.
|
|
808
|
+
if (emitted !== null) {
|
|
809
|
+
process.stdout.write(emitted.emitLine + '\n' + emitted.body);
|
|
810
|
+
} else if (scrubLine !== null) {
|
|
811
|
+
process.stdout.write(scrubLine + '\n');
|
|
398
812
|
}
|
|
399
|
-
} catch (/** @type {any} */ err) {
|
|
400
|
-
process.stderr.write('redact-secrets: internal error: ' + err.message + '\n');
|
|
401
|
-
exitCode = 4;
|
|
402
|
-
}
|
|
403
813
|
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
process.stdout.write(scrubLine + '\n');
|
|
814
|
+
// Set exitCode (preferred over process.exit() — does not bypass event loop cleanup)
|
|
815
|
+
process.exitCode = exitCode;
|
|
407
816
|
}
|
|
408
817
|
|
|
409
|
-
//
|
|
410
|
-
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
// Exports — for the unit tests of the pure helpers only [DR-14]
|
|
820
|
+
//
|
|
821
|
+
// parseArgs' behaviour is otherwise observable only end-to-end, and the
|
|
822
|
+
// nonce-failure arm is not observable at all: no argv and no fixture can make
|
|
823
|
+
// crypto.randomBytes fail. Exporting the helpers is what makes those two arms
|
|
824
|
+
// assertable instead of argued-from-construction.
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
module.exports = {
|
|
828
|
+
NONCE_HEX_CHARS,
|
|
829
|
+
ZERO_SCRUB_LINE,
|
|
830
|
+
D11_FAIL_REASONS: Object.freeze(Object.values(D11_FAIL_REASONS)),
|
|
831
|
+
shouldSkip,
|
|
832
|
+
scrub,
|
|
833
|
+
formatScrubLine,
|
|
834
|
+
parseArgs,
|
|
835
|
+
scrubTwice,
|
|
836
|
+
frameEmit,
|
|
837
|
+
main,
|
|
838
|
+
};
|