claude-nomad 0.30.0 → 0.32.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,113 @@
1
+ /**
2
+ * The Redact action for the push-time recovery menu. `applyRedact` resolves a
3
+ * finding's local transcript, refuses live sessions, re-scans the local file
4
+ * WITHOUT `--redact` to recover real secret values, rewrites it in place, and
5
+ * copies the cleaned file back to the staged tree.
6
+ *
7
+ * Split from `commands.push.recovery.actions.ts` to keep both modules under the
8
+ * ~220-line cap. Depends only on lower-level helpers (no import of the actions
9
+ * module), so the dependency direction stays acyclic: actions -> redact.
10
+ */
11
+
12
+ import { cpSync, readFileSync, statSync, writeFileSync } from 'node:fs';
13
+ import { join, sep } from 'node:path';
14
+
15
+ import type { PathMap } from './config.ts';
16
+ import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
17
+ import { applyRedactions, isRecentlyModified, resolveLiveTranscript } from './commands.redact.ts';
18
+ import type { Finding } from './push-gitleaks.scan.ts';
19
+ import { scanFile } from './push-gitleaks.scan.ts';
20
+ import { backupBeforeWrite } from './utils.fs.ts';
21
+ import { encodePath } from './utils.json.ts';
22
+ import { log } from './utils.ts';
23
+ import { sessionIdFromFinding } from './commands.push.recovery.seams.ts';
24
+
25
+ /**
26
+ * Apply the Redact action for one finding. Resolves the local transcript,
27
+ * checks the live-session guard, re-scans the local file (without `--redact`)
28
+ * to obtain real secret values, backs up, rewrites in place (same inode), and
29
+ * surgically copies the file back to the staged tree. Returns true on success,
30
+ * false when the session is active, unresolvable, or the local re-scan fails.
31
+ *
32
+ * The push-verdict findings (`f`, `allFindings`) drive which sessions to act on
33
+ * and provide session-id extraction, but their `Match` fields come from a
34
+ * `--redact` scan and are masked. The local re-scan (via `scan`) runs WITHOUT
35
+ * `--redact` so `applyRedactions` receives the real secret values.
36
+ *
37
+ * @param f Trigger finding (used for session-id extraction).
38
+ * @param allFindings Full finding set for this run (used for session-id
39
+ * matching; values are masked and not used for redaction).
40
+ * @param ts Backup timestamp for `backupBeforeWrite`.
41
+ * @param map Parsed path-map for staged-tree path resolution.
42
+ * @param nowMs Injectable clock for the live-session mtime check.
43
+ * @param scan Injectable scan function for local re-scan (default: `scanFile`).
44
+ * @returns True when the redaction was applied; false when refused or failed.
45
+ */
46
+ export function applyRedact(
47
+ f: Finding,
48
+ allFindings: Finding[],
49
+ ts: string,
50
+ map: PathMap,
51
+ nowMs: () => number,
52
+ scan: (p: string) => Finding[] | null = scanFile,
53
+ ): boolean {
54
+ /** Emit a refusal message and return false. */
55
+ const refuse = (msg: string): false => {
56
+ log(msg);
57
+ return false;
58
+ };
59
+
60
+ const sid = sessionIdFromFinding(f);
61
+ if (sid === null) {
62
+ return refuse(
63
+ `could not locate the local transcript for this finding; choose Skip or Drop session.`,
64
+ );
65
+ }
66
+ const localPath = resolveLiveTranscript(sid);
67
+ if (localPath === null) {
68
+ return refuse(
69
+ `could not locate the local transcript for session ${sid}; choose Skip or Drop session.`,
70
+ );
71
+ }
72
+ if (isRecentlyModified(statSync(localPath).mtimeMs, nowMs())) {
73
+ return refuse(
74
+ `session ${sid} looks active (modified within the last 5 minutes); refusing to redact, no changes made.\n` +
75
+ ` End the session and choose Redact again, or choose Drop session (holds this session back` +
76
+ ` from the push, local copy kept) or Skip.`,
77
+ );
78
+ }
79
+
80
+ // Re-scan without --redact to get real secret values for value-based redaction.
81
+ // Push-verdict findings have masked Match fields and cannot be used directly.
82
+ const realFindings = scan(localPath);
83
+ if (realFindings === null) {
84
+ return refuse(`re-scan of the transcript failed; choose Skip or Drop session.`);
85
+ }
86
+ if (realFindings.length === 0) {
87
+ return refuse(
88
+ `nothing to redact in the local transcript for session ${sid}; choose Skip or Drop session.`,
89
+ );
90
+ }
91
+
92
+ backupBeforeWrite(localPath, ts);
93
+ writeFileSync(localPath, applyRedactions(readFileSync(localPath, 'utf8'), realFindings), 'utf8');
94
+
95
+ let copied = false;
96
+ for (const [logical, hostMap] of Object.entries(map.projects)) {
97
+ const abs = hostMap[HOST];
98
+ if (abs === undefined) continue;
99
+ if (localPath.startsWith(join(CLAUDE_HOME, 'projects', encodePath(abs)) + sep)) {
100
+ cpSync(localPath, join(REPO_HOME, 'shared', 'projects', logical, `${sid}.jsonl`), {
101
+ force: true,
102
+ });
103
+ copied = true;
104
+ break;
105
+ }
106
+ }
107
+ if (!copied) {
108
+ return refuse(
109
+ `could not map the local transcript for session ${sid} to a staged copy; choose Drop session or Skip.`,
110
+ );
111
+ }
112
+ return true;
113
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pure, side-effect-free seams for the push-time recovery menu: key
3
+ * derivation, session-id extraction, and prompt-answer parsing. Extracted from
4
+ * `commands.push.recovery.actions.ts` so both modules stay under the 220-line
5
+ * advisory cap.
6
+ */
7
+
8
+ import type { Finding } from './push-gitleaks.scan.ts';
9
+ import { SESSION_PATH } from './push-gitleaks.ts';
10
+
11
+ /** Action a user can assign to one finding in the recovery menu. */
12
+ export type FindingAction = 'redact' | 'allow' | 'drop' | 'skip';
13
+
14
+ /** Prompt function: asks one question and returns the answer. */
15
+ export type PromptFn = (prompt: string) => Promise<string>;
16
+
17
+ /**
18
+ * Build a stable key for a finding used as the actions-map key. Includes the
19
+ * rule id so two findings at the same file/line/column but different rules
20
+ * produce distinct keys and do not collide in the actions map.
21
+ *
22
+ * @param f The gitleaks finding.
23
+ * @returns A colon-delimited key combining file, start line, start column, and rule id.
24
+ */
25
+ export function findingKey(f: Finding): string {
26
+ return `${f.File}:${f.StartLine}:${f.StartColumn}:${f.RuleID}`;
27
+ }
28
+
29
+ /** Valid session id charset: alphanumeric, hyphen, underscore (same as cmdDropSession/cmdRedact). */
30
+ const VALID_SID = /^[A-Za-z0-9_-]+$/;
31
+
32
+ /**
33
+ * Extract the session id from a finding's File path. Handles both the flat
34
+ * `shared/projects/<logical>/<sid>.jsonl` form (SESSION_PATH) and the deeper
35
+ * subagent form `shared/projects/<logical>/<sid>/...`. The extracted id is
36
+ * validated against `/^[A-Za-z0-9_-]+$/` before being returned; path-traversal
37
+ * segments (e.g. `..`) are rejected and cause a null return.
38
+ *
39
+ * @param f The gitleaks finding.
40
+ * @returns The session id, or null when the path matches neither pattern or the
41
+ * extracted id contains characters outside `[A-Za-z0-9_-]`.
42
+ */
43
+ export function sessionIdFromFinding(f: Finding): string | null {
44
+ // Try the flat `<sid>.jsonl` form first, then the deeper subagent form. Both
45
+ // patterns capture the session id at group 1; a matched capture group is
46
+ // always a string, so no nullish guard on `m[1]` is needed.
47
+ const m = SESSION_PATH.exec(f.File) ?? /^shared\/projects\/[^/]+\/([^/]+)\//.exec(f.File);
48
+ if (m === null) return null;
49
+ const sid = m[1];
50
+ return VALID_SID.test(sid) ? sid : null;
51
+ }
52
+
53
+ /**
54
+ * Parse a raw prompt answer into a `FindingAction`. Returns `'skip'` for
55
+ * empty, blank, or unrecognized input (D-02 default).
56
+ *
57
+ * @param raw The untrimmed string returned by the prompt.
58
+ * @returns The corresponding action, defaulting to `'skip'`.
59
+ */
60
+ export function parseAction(raw: string): FindingAction {
61
+ const t = raw.trim().toLowerCase();
62
+ if (t === 'r' || t === 'redact') return 'redact';
63
+ if (t === 'a' || t === 'allow') return 'allow';
64
+ if (t === 'd' || t === 'drop') return 'drop';
65
+ return 'skip';
66
+ }
@@ -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
+ }