claude-nomad 0.30.0 → 0.31.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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Push-time interactive recovery menu for gitleaks findings. When `nomad push`
3
+ * detects secrets in the staged tree and the process is running on a real TTY,
4
+ * `resolveLeakFindings` presents a per-finding Redact / Allow / Drop / Skip
5
+ * menu (default Skip), applies each resolved action, then re-stages and
6
+ * re-scans. The push proceeds only when zero findings remain unresolved.
7
+ *
8
+ * Non-TTY contexts (CI, piped input) keep the existing `buildSessionAwareFatal`
9
+ * abort unchanged: the function throws a `NomadFatal` carrying the existing
10
+ * recovery body verbatim (D-01: zero CI behavior change).
11
+ *
12
+ * `--redact-all` bypasses the prompt and redacts every real finding in batch
13
+ * without requiring a TTY.
14
+ *
15
+ * Action helpers and per-finding dispatch live in
16
+ * `commands.push.recovery.actions.ts` to keep both modules under the 220-line
17
+ * advisory cap.
18
+ */
19
+
20
+ import { createInterface } from 'node:readline/promises';
21
+
22
+ import type { PathMap } from './config.ts';
23
+ import { REPO_HOME } from './config.ts';
24
+ import {
25
+ type FindingAction,
26
+ type PromptFn,
27
+ collectActions,
28
+ dispatchActions,
29
+ findingKey,
30
+ redactAllFindings,
31
+ } from './commands.push.recovery.actions.ts';
32
+ import type { Finding } from './push-gitleaks.scan.ts';
33
+ import { scanFile } from './push-gitleaks.scan.ts';
34
+ import { buildSessionAwareFatal, partitionFindings } from './push-gitleaks.ts';
35
+ import type { LeakVerdict } from './push-leak-verdict.ts';
36
+ import { NomadFatal, gitOrFatal } from './utils.ts';
37
+
38
+ export type { FindingAction };
39
+
40
+ /**
41
+ * Dependency injection object for `resolveLeakFindings`. Provides seams for
42
+ * the prompt loop, the re-scan, the clock, and the TTY check so tests can
43
+ * drive the interactive flow without a real terminal.
44
+ */
45
+ export type RecoveryDeps = {
46
+ /** Override TTY detection (default: `isTTY()`). */
47
+ isTTYCheck?: () => boolean;
48
+ /** Override `scanPushVerdict` for the post-action re-scan. */
49
+ scanVerdict?: () => LeakVerdict;
50
+ /** Injectable clock for live-session detection (default: `Date.now`). */
51
+ nowMs?: () => number;
52
+ /** When true, redact all findings without prompting; no TTY required. */
53
+ redactAll?: boolean;
54
+ /** Injectable prompt factory for tests (default: real readline). */
55
+ makePrompt?: () => PromptFn;
56
+ /** Injectable single-file scan for redaction (default: `scanFile`). */
57
+ scan?: (p: string) => Finding[] | null;
58
+ /** Injectable legend printer for tests (default: `printRecoveryLegend`). */
59
+ printLegend?: () => void;
60
+ };
61
+
62
+ /**
63
+ * True when both stdin and stdout are interactive TTYs. Accepts injectable
64
+ * stream objects so tests can drive the branch without a real TTY.
65
+ *
66
+ * @param stdin Readable with optional `isTTY` flag (default: `process.stdin`).
67
+ * @param stdout Writable with optional `isTTY` flag (default: `process.stdout`).
68
+ * @returns True iff both streams report `isTTY === true`.
69
+ */
70
+ export function isTTY(
71
+ stdin: { isTTY?: boolean } = process.stdin,
72
+ stdout: { isTTY?: boolean } = process.stdout,
73
+ ): boolean {
74
+ return stdin.isTTY === true && stdout.isTTY === true;
75
+ }
76
+
77
+ /**
78
+ * True when any value in the actions map is `'skip'`, meaning at least one
79
+ * finding was left unresolved. Pure, no I/O.
80
+ *
81
+ * @param actions Per-finding action map keyed by `findingKey`.
82
+ * @returns True when at least one action is `'skip'`.
83
+ */
84
+ export function hasUnresolved(actions: Map<string, FindingAction>): boolean {
85
+ for (const action of actions.values()) {
86
+ if (action === 'skip') return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * Print a one-time action legend to stdout before the interactive menu loop.
93
+ * Called exactly once on the TTY path; never called on non-TTY or --redact-all.
94
+ *
95
+ * @param print Output sink (default: `console.log`). Injectable for tests.
96
+ */
97
+ export function printRecoveryLegend(print: (line: string) => void = console.log): void {
98
+ print('');
99
+ print('Recovery actions:');
100
+ print(' Redact - scrub the secret from the local transcript, push the cleaned copy');
101
+ print(' Allow - mark as false positive (adds a .gitleaksignore fingerprint), push as-is');
102
+ print(' Drop session - exclude this session from this push (local transcript kept, running');
103
+ print(' session is not stopped)');
104
+ print(' Skip - leave unresolved (the push aborts)');
105
+ print('');
106
+ }
107
+
108
+ /** Build the real-TTY readline-based prompt function (one interface per call). */
109
+ function makeRealPrompt(): PromptFn {
110
+ return async (prompt: string) => {
111
+ const rl = createInterface({
112
+ input: process.stdin,
113
+ output: process.stdout,
114
+ terminal: true,
115
+ });
116
+ try {
117
+ return await rl.question(prompt);
118
+ } finally {
119
+ rl.close();
120
+ }
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Resolve the gitleaks findings from `verdict` interactively (TTY path) or
126
+ * via `--redact-all` (non-interactive batch path). On a non-TTY context with
127
+ * no `--redact-all` flag, throws `NomadFatal` carrying the existing recovery
128
+ * body verbatim (D-01: zero CI behavior change).
129
+ *
130
+ * TTY flow (D-02, D-03): prompts once per finding with R/A/D/S (default Skip
131
+ * on empty input), collects all actions, dispatches them, then re-stages via
132
+ * `git add -A` and re-scans. If the re-scan still has findings the menu loops
133
+ * on the new set. If any finding remains Skipped after triage, throws the
134
+ * session-aware FATAL so the push aborts with the same non-zero exit.
135
+ *
136
+ * @param verdict The current leak verdict from `scanPushVerdict`.
137
+ * @param ts Backup timestamp created at the start of this push run.
138
+ * @param map Parsed `path-map.json` for session path resolution.
139
+ * @param deps Optional dependency overrides for testing.
140
+ * @returns The final clean `LeakVerdict` after all findings are resolved.
141
+ */
142
+ export async function resolveLeakFindings(
143
+ verdict: LeakVerdict,
144
+ ts: string,
145
+ map: PathMap,
146
+ deps: RecoveryDeps = {},
147
+ ): Promise<LeakVerdict> {
148
+ const {
149
+ isTTYCheck = isTTY,
150
+ nowMs = Date.now,
151
+ redactAll = false,
152
+ makePrompt: makePromptFn = makeRealPrompt,
153
+ scan = scanFile,
154
+ printLegend = printRecoveryLegend,
155
+ } = deps;
156
+
157
+ const scanVerdict = deps.scanVerdict ?? (await import('./push-leak-verdict.ts')).scanPushVerdict;
158
+
159
+ let current = verdict;
160
+
161
+ if (redactAll) {
162
+ redactAllFindings(current.findings, ts, map, nowMs, scan);
163
+ gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
164
+ const next = scanVerdict();
165
+ if (next.leak) {
166
+ const { bySession, other } = partitionFindings(next.findings);
167
+ throw new NomadFatal(buildSessionAwareFatal(bySession, other));
168
+ }
169
+ return next;
170
+ }
171
+
172
+ if (!isTTYCheck()) {
173
+ // Every leak:true verdict has a non-null recovery body. The fallback covers
174
+ // the defensive unreachable case (scan-crash with leak=true).
175
+ /* c8 ignore next */
176
+ throw new NomadFatal(current.recovery ?? 'gitleaks detected secrets');
177
+ }
178
+
179
+ const prompt = makePromptFn();
180
+ printLegend();
181
+
182
+ while (current.leak && current.findings.length > 0) {
183
+ const actions = await collectActions(current.findings, prompt);
184
+
185
+ if (hasUnresolved(actions)) {
186
+ // collectActions populates an entry for every finding, so `get` never
187
+ // returns undefined here; an explicit `=== 'skip'` needs no default.
188
+ const unresolved = current.findings.filter((f) => actions.get(findingKey(f)) === 'skip');
189
+ const { bySession, other } = partitionFindings(unresolved);
190
+ throw new NomadFatal(buildSessionAwareFatal(bySession, other));
191
+ }
192
+
193
+ dispatchActions(current.findings, actions, ts, map, nowMs, scan);
194
+ gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
195
+ current = scanVerdict();
196
+ }
197
+ return current;
198
+ }
@@ -3,6 +3,7 @@ import { join, relative } from 'node:path';
3
3
 
4
4
  import { HOME, HOST, type PathMap, REPO_HOME } from './config.ts';
5
5
  import { enforceAllowList } from './commands.push.allowlist.ts';
6
+ import { resolveLeakFindings } from './commands.push.recovery.ts';
6
7
  import { type PushState, renderNoScanTree, renderPushTree } from './commands.push.sections.ts';
7
8
  import { remapExtrasPush } from './extras-sync.ts';
8
9
  import { scanPushVerdict } from './push-leak-verdict.ts';
@@ -35,27 +36,27 @@ function guardGitlinks(): void {
35
36
  }
36
37
 
37
38
  /**
38
- * The staged-tree leak gate + commit/push for the REAL push path. Runs
39
- * `scanPushVerdict` AFTER `git add -A` (sees what would push) but BEFORE commit
40
- * (a detection unwinds cleanly with no commit to revert). On a leak it renders
41
- * the tree (with the ✗ Leak scan row + Summary) so the tree precedes the
42
- * recovery block, then throws the recovery body as a `NomadFatal` (the catch
43
- * prints it and sets a non-zero exit). On a clean scan it commits, pushes, and
44
- * renders the tree with the `✓ no leaks` row.
39
+ * Staged-tree leak gate + commit/push. Stages with `git add -A`, scans, and
40
+ * on a leak renders the tree row then delegates to `resolveLeakFindings`
41
+ * (TTY interactive menu or non-TTY FATAL throw, D-01 preserved). On a clean
42
+ * scan commits, pushes, and renders the `✓ no leaks` row.
45
43
  *
46
- * @param st - The collected push state for the final tree render.
44
+ * @param st - Push state for the tree render.
45
+ * @param ts - Backup timestamp passed to the recovery flow.
46
+ * @param map - Parsed path-map for session path resolution.
47
+ * @param redactAll - When true, redact all findings non-interactively.
47
48
  */
48
- function commitAndPush(st: PushState): void {
49
- // gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
49
+ async function commitAndPush(
50
+ st: PushState,
51
+ ts: string,
52
+ map: PathMap,
53
+ redactAll: boolean,
54
+ ): Promise<void> {
50
55
  gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
51
- const verdict = scanPushVerdict();
56
+ let verdict = scanPushVerdict();
52
57
  if (verdict.leak) {
53
58
  renderPushTree(st, verdict);
54
- // Every `leak: true` branch of scanPushVerdict sets a non-null recovery
55
- // body, so the `?? fallback` is defensively unreachable (excluded from
56
- // coverage rather than contorting a test to fake an impossible state).
57
- /* c8 ignore next */
58
- throw new NomadFatal(verdict.recovery ?? 'gitleaks detected secrets');
59
+ verdict = await resolveLeakFindings(verdict, ts, map, { redactAll });
59
60
  }
60
61
  gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
61
62
  gitOrFatal(['push'], 'git push', REPO_HOME);
@@ -144,8 +145,9 @@ function runDryRunPreview(st: PushState, map: PathMap | null): void {
144
145
  * pre-existing violation surfaces; an empty status has nothing to classify.
145
146
  * Mirrors `cmdPull`'s `dryRun` contract.
146
147
  */
147
- export function cmdPush(opts: { dryRun?: boolean } = {}): void {
148
+ export async function cmdPush(opts: { dryRun?: boolean; redactAll?: boolean } = {}): Promise<void> {
148
149
  const dryRun = opts.dryRun === true;
150
+ const redactAll = opts.redactAll === true;
149
151
  if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
150
152
  const handle = acquireLock('push');
151
153
  if (handle === null) process.exit(0);
@@ -202,7 +204,7 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
202
204
  // dryRun skips git add / commit / push: run the read-only leak preview,
203
205
  // which prints any recovery below the rendered tree.
204
206
  if (dryRun) return runDryRunPreview(st, map);
205
- commitAndPush(st);
207
+ await commitAndPush(st, ts, map, redactAll);
206
208
  } catch (err) {
207
209
  if (err instanceof NomadFatal) {
208
210
  fail(err.message);
@@ -0,0 +1,94 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { REPO_HOME } from './config.ts';
5
+
6
+ /**
7
+ * Replace every occurrence of a literal secret value in a raw line. Uses
8
+ * split/join to avoid regex escaping and to replace all occurrences. Pure,
9
+ * no I/O.
10
+ *
11
+ * The replacement token `[REDACTED:<ruleId>]` contains no JSON-special
12
+ * characters, so the result remains valid JSON when the value sits inside a
13
+ * JSON string token.
14
+ *
15
+ * @param line Raw JSONL line text.
16
+ * @param match Literal secret value to replace (empty string is a no-op).
17
+ * @param ruleId Gitleaks rule identifier included in the replacement token.
18
+ * @returns Line with all occurrences of `match` replaced by `[REDACTED:<ruleId>]`.
19
+ */
20
+ export function redactValue(line: string, match: string, ruleId: string): string {
21
+ if (match === '') return line;
22
+ return line.split(match).join(`[REDACTED:${ruleId}]`);
23
+ }
24
+
25
+ /** Minimal finding shape consumed by `applyRedactions`. */
26
+ export type RedactFinding = {
27
+ StartLine: number;
28
+ Match: string;
29
+ RuleID: string;
30
+ };
31
+
32
+ /**
33
+ * Apply all findings for one file in memory. Replaces each finding's `Match`
34
+ * value globally across the whole content string (split/join, no column
35
+ * arithmetic). To avoid a shorter secret being a substring of a longer one
36
+ * causing a partial match, findings are sorted by `Match.length` descending so
37
+ * the longer secret is replaced first. Findings with an empty `Match` are
38
+ * silently skipped (a defensive guard: an empty match would otherwise inject
39
+ * the token between every character). Pure, no I/O.
40
+ *
41
+ * @param content Full file content as a single string.
42
+ * @param findings Array of finding descriptors.
43
+ * @returns Redacted file content.
44
+ */
45
+ export function applyRedactions(content: string, findings: readonly RedactFinding[]): string {
46
+ const sorted = [...findings].sort((a, b) => b.Match.length - a.Match.length);
47
+ let result = content;
48
+ for (const f of sorted) {
49
+ if (f.Match === '') continue;
50
+ result = result.split(f.Match).join(`[REDACTED:${f.RuleID}]`);
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Format a gitleaks fingerprint for appending to `.gitleaksignore`. Strips any
57
+ * embedded `\r` or `\n` characters (a newline in a fingerprint must not inject
58
+ * extra ignore lines) and appends exactly one trailing newline. Pure.
59
+ *
60
+ * @param fingerprint Raw fingerprint string from `Finding.Fingerprint`.
61
+ * @returns Sanitized fingerprint with a single trailing newline.
62
+ */
63
+ export function formatFingerprint(fingerprint: string): string {
64
+ return fingerprint.replace(/[\r\n]/g, '') + '\n';
65
+ }
66
+
67
+ /**
68
+ * True if the file mtime is within `thresholdMs` of `nowMs` (heuristic for a
69
+ * live session that Claude Code may still be writing). Pure, injectable for
70
+ * tests.
71
+ *
72
+ * @param mtimeMs File modification time in milliseconds.
73
+ * @param nowMs Current epoch time in milliseconds.
74
+ * @param thresholdMs Threshold window in milliseconds (default 5 minutes).
75
+ * @returns True when the file was modified within the threshold.
76
+ */
77
+ export function isRecentlyModified(
78
+ mtimeMs: number,
79
+ nowMs: number,
80
+ thresholdMs = 5 * 60 * 1000,
81
+ ): boolean {
82
+ return nowMs - mtimeMs < thresholdMs;
83
+ }
84
+
85
+ /**
86
+ * Append one sanitized fingerprint line to `REPO_HOME/.gitleaksignore`. The
87
+ * fingerprint is passed through `formatFingerprint` to strip embedded newlines
88
+ * before the append.
89
+ *
90
+ * @param fingerprint Raw fingerprint from `Finding.Fingerprint`.
91
+ */
92
+ export function appendGitleaksIgnore(fingerprint: string): void {
93
+ appendFileSync(join(REPO_HOME, '.gitleaksignore'), formatFingerprint(fingerprint), 'utf8');
94
+ }
@@ -0,0 +1,187 @@
1
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
+ import { applyRedactions, isRecentlyModified } from './commands.redact.core.ts';
6
+ import { type Finding, scanFile } from './push-gitleaks.scan.ts';
7
+ import { backupBeforeWrite, freshBackupTs } from './utils.fs.ts';
8
+ import { encodePath, readJson } from './utils.json.ts';
9
+ import { die, fail, log, NomadFatal } from './utils.ts';
10
+ import { acquireLock, releaseLock } from './utils.lockfile.ts';
11
+
12
+ export type { RedactFinding } from './commands.redact.core.ts';
13
+ export {
14
+ applyRedactions,
15
+ appendGitleaksIgnore,
16
+ formatFingerprint,
17
+ isRecentlyModified,
18
+ redactValue,
19
+ } from './commands.redact.core.ts';
20
+
21
+ /**
22
+ * Resolve a session id to the live local transcript path on this host via
23
+ * `path-map.json`. Returns the absolute path when it exists on disk, or `null`
24
+ * when the path-map is absent, the session is unmapped on this host, or the
25
+ * local file is already gone. Mirrors `resolveLiveTranscript` from
26
+ * `commands.drop-session.scrub-hint.ts`.
27
+ *
28
+ * @param id Already-validated session id.
29
+ * @returns Absolute live transcript path, or null when unresolvable.
30
+ */
31
+ export function resolveLiveTranscript(id: string): string | null {
32
+ try {
33
+ const mapPath = join(REPO_HOME, 'path-map.json');
34
+ if (!existsSync(mapPath)) return null;
35
+ const projects = readJson<PathMap>(mapPath).projects;
36
+ for (const hostMap of Object.values(projects)) {
37
+ const abs = hostMap[HOST];
38
+ if (abs === undefined) continue;
39
+ const live = join(CLAUDE_HOME, 'projects', encodePath(abs), `${id}.jsonl`);
40
+ if (existsSync(live)) return live;
41
+ }
42
+ return null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** Options for the `nomad redact` subcommand. */
49
+ export type RedactOpts = {
50
+ /** Session id (validated against `[A-Za-z0-9_-]+`, length 1..128). */
51
+ id: string;
52
+ /** Limit redaction to findings of this gitleaks rule id only. */
53
+ rule?: string;
54
+ /** When true, print the plan and write nothing. */
55
+ dryRun?: boolean;
56
+ /**
57
+ * Findings to redact. When provided (push-time recovery flow), used directly
58
+ * after applying the optional `rule` filter. When omitted (standalone
59
+ * `nomad redact`), `cmdRedact` scans the local transcript with `gitleaks
60
+ * detect --no-git` and uses the resulting findings. A scan error (gitleaks
61
+ * absent or crashed) is reported as a distinct failure, not silently treated
62
+ * as "no findings".
63
+ */
64
+ findings?: readonly {
65
+ StartLine: number;
66
+ Match: string;
67
+ RuleID: string;
68
+ }[];
69
+ };
70
+
71
+ /**
72
+ * Resolve the findings list for a redact operation. When `rawFindings` is
73
+ * provided (push-time recovery), returns them filtered by `rule`. When
74
+ * `rawFindings` is undefined (standalone `nomad redact`), calls `scan` on the
75
+ * local transcript and returns the findings filtered by `rule`, or `null` when
76
+ * the scan itself fails (gitleaks absent or crashed).
77
+ *
78
+ * @param localPath Absolute path to the local transcript.
79
+ * @param rawFindings Pre-supplied findings or undefined to trigger a scan.
80
+ * @param rule Optional rule-id filter applied after findings are resolved.
81
+ * @param scan Injectable scan function (default: `scanFile`).
82
+ * @returns Filtered findings array, or `null` when scan fails.
83
+ */
84
+ function resolveRedactFindings(
85
+ localPath: string,
86
+ rawFindings: RedactOpts['findings'],
87
+ rule: string | undefined,
88
+ scan: (p: string) => Finding[] | null,
89
+ ): readonly { StartLine: number; Match: string; RuleID: string }[] | null {
90
+ const source = rawFindings ?? scan(localPath);
91
+ if (source === null) return null;
92
+ return source.filter((f) => rule === undefined || f.RuleID === rule);
93
+ }
94
+
95
+ /**
96
+ * Non-interactive redaction of a session transcript's secret spans. Rewrites
97
+ * the LOCAL source transcript at `~/.claude/projects/<encoded>/<id>.jsonl` in
98
+ * place (same inode) after backing it up via `backupBeforeWrite`. Refuses to
99
+ * touch a transcript whose mtime is within the live-session threshold (D-06).
100
+ *
101
+ * When `opts.findings` is provided, uses it directly (push-time recovery flow).
102
+ * When `opts.findings` is omitted, scans the local transcript with gitleaks
103
+ * detect via the injected `scan` function. A scan failure (null return) is
104
+ * reported as a distinct error rather than silently treated as "no findings".
105
+ *
106
+ * Validates `id` before any path resolution or lock acquisition. Uses
107
+ * `acquireLock('redact')` with the standard `try/catch(NomadFatal)/finally
108
+ * releaseLock` shape.
109
+ *
110
+ * @param opts Redact options: session id, optional rule filter, optional dry-run, optional findings.
111
+ * @param nowMs Injectable clock for live-session detection (tests inject a fixed value).
112
+ * @param scan Injectable single-file scan function (tests inject a fake; default: `scanFile`).
113
+ */
114
+ export function cmdRedact(
115
+ opts: RedactOpts,
116
+ nowMs: () => number = Date.now,
117
+ scan: (p: string) => Finding[] | null = scanFile,
118
+ ): void {
119
+ const { id, rule, dryRun = false, findings: rawFindings } = opts;
120
+
121
+ if (id.length === 0 || id.length > 128 || !/^[A-Za-z0-9_-]+$/.test(id)) {
122
+ fail(`invalid session id: ${id}`);
123
+ process.exit(1);
124
+ }
125
+ if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
126
+
127
+ const handle = acquireLock('redact');
128
+ if (handle === null) process.exit(0);
129
+ try {
130
+ const localPath = resolveLiveTranscript(id);
131
+ if (localPath === null || !existsSync(localPath)) {
132
+ fail(`could not resolve local transcript for session ${id} on this host`);
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ const mtimeMs = statSync(localPath).mtimeMs;
138
+ if (isRecentlyModified(mtimeMs, nowMs())) {
139
+ log(
140
+ `session ${id} was modified recently and may be active.\n` +
141
+ ' Refusing to rewrite a potentially live transcript.\n' +
142
+ ' To proceed: wait for the session to end, then re-run nomad redact.\n' +
143
+ ` Or drop from the staged tree: nomad drop-session ${id}\n` +
144
+ ' Or skip this finding during nomad push.',
145
+ );
146
+ return;
147
+ }
148
+
149
+ const findings = resolveRedactFindings(localPath, rawFindings, rule, scan);
150
+ if (findings === null) {
151
+ fail(`gitleaks scan failed for session ${id} (is gitleaks installed?)`);
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+
156
+ if (findings.length === 0) {
157
+ log(`no findings${rule !== undefined ? ` for rule ${rule}` : ''} in session ${id}`);
158
+ return;
159
+ }
160
+
161
+ if (dryRun) {
162
+ log(
163
+ `dry-run: would redact ${findings.length} finding(s) in ${localPath}\n` +
164
+ findings.map((f) => ` line ${f.StartLine} [${f.RuleID}]`).join('\n'),
165
+ );
166
+ return;
167
+ }
168
+
169
+ const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
170
+ const ts = freshBackupTs(backupBase);
171
+ backupBeforeWrite(localPath, ts);
172
+
173
+ const original = readFileSync(localPath, 'utf8');
174
+ const redacted = applyRedactions(original, findings);
175
+ writeFileSync(localPath, redacted, 'utf8');
176
+ log(`redacted ${findings.length} finding(s) in ${localPath} (backup: ${ts})`);
177
+ } catch (err) {
178
+ /* c8 ignore next 3 */
179
+ if (!(err instanceof NomadFatal)) {
180
+ throw err;
181
+ }
182
+ fail(err.message);
183
+ process.exitCode = 1;
184
+ } finally {
185
+ releaseLock(handle);
186
+ }
187
+ }
package/src/config.ts CHANGED
@@ -180,6 +180,7 @@ export const PUSH_ALLOWED_STATIC = [
180
180
  'shared/hooks/',
181
181
  'hosts/',
182
182
  'path-map.json',
183
+ '.gitleaksignore', // written by nomad push Allow action (D-04)
183
184
  ] as const;
184
185
 
185
186
  /**
@@ -23,3 +23,60 @@ export function parseFlags(argv: string[], known: Set<string>): Set<string> | nu
23
23
  }
24
24
  return seen;
25
25
  }
26
+
27
+ /** Parsed result from {@link parseRedactArgs}. */
28
+ export type RedactArgs = {
29
+ /** Validated session id. */
30
+ id: string;
31
+ /** Optional gitleaks rule id filter passed via `--rule <id>`. */
32
+ rule: string | undefined;
33
+ /** True when `--dry-run` was present. */
34
+ dryRun: boolean;
35
+ };
36
+
37
+ /**
38
+ * Argv parser for `nomad redact <session-id> [--rule <rule-id>] [--dry-run]`.
39
+ *
40
+ * Handles a required positional id at argv[3], an optional boolean
41
+ * `--dry-run`, and an optional `--rule <value>` that consumes the next token.
42
+ * Returns `null` on any parse error: missing id, id failing the validation
43
+ * regex, unknown flag, `--rule` with no value or a value that looks like
44
+ * another flag, or a repeated flag.
45
+ *
46
+ * The id regex (`/^\w[\w-]{0,127}$/`) mirrors the `drop-session` arm: the
47
+ * leading `\w` prevents leading-dash ids so `nomad redact --bogus` shows
48
+ * usage rather than passing an invalid id to `cmdRedact`.
49
+ *
50
+ * @param argv The full process argv array (parsing starts at index 3).
51
+ * @returns Parsed redact arguments, or `null` on any parse error.
52
+ */
53
+ export function parseRedactArgs(argv: string[]): RedactArgs | null {
54
+ const id = argv[3];
55
+ if (typeof id !== 'string' || !/^\w[\w-]{0,127}$/.test(id)) {
56
+ return null;
57
+ }
58
+ let rule: string | undefined;
59
+ let dryRun = false;
60
+ let sawRule = false;
61
+ let sawDryRun = false;
62
+ let i = 4;
63
+ while (i < argv.length) {
64
+ const token = argv[i];
65
+ if (token === '--dry-run') {
66
+ if (sawDryRun) return null;
67
+ sawDryRun = true;
68
+ dryRun = true;
69
+ i++;
70
+ } else if (token === '--rule') {
71
+ if (sawRule) return null;
72
+ sawRule = true;
73
+ const val = argv[i + 1];
74
+ if (val === undefined || val.startsWith('--')) return null;
75
+ rule = val;
76
+ i += 2;
77
+ } else {
78
+ return null;
79
+ }
80
+ }
81
+ return { id, rule, dryRun };
82
+ }
package/src/nomad.help.ts CHANGED
@@ -37,6 +37,8 @@ export const DEFAULT_HELP = [
37
37
  row(' push', 'Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.'),
38
38
  row(' --dry-run', 'Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview'),
39
39
  cont('remap, without staging or pushing.'),
40
+ row(' --redact-all', 'Redact all findings non-interactively (backup, no prompt); no TTY'),
41
+ cont('required. Does not auto-Allow.'),
40
42
  '',
41
43
  row(' diff', 'Offline preview of what `pull` would change against local repo state.'),
42
44
  cont('No git pull, no lock acquired.'),
@@ -59,6 +61,14 @@ export const DEFAULT_HELP = [
59
61
  'Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
60
62
  ),
61
63
  '',
64
+ row(
65
+ ' redact <session-id>',
66
+ 'Rewrite the secret span in the local source transcript for a session,',
67
+ ),
68
+ cont('backed up to ~/.cache/claude-nomad/backup/. Safe to re-run.'),
69
+ row(' --rule <id>', 'Limit redaction to one gitleaks rule id.'),
70
+ row(' --dry-run', 'Show what would change without writing.'),
71
+ '',
62
72
  row(' update', 'Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.'),
63
73
  row(' --dry-run', 'Detect topology + pre-flight, print would-be git commands only.'),
64
74
  row(' --force', 'Proceed even when the working tree is not clean.'),