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.
- package/.gitleaks.toml +20 -0
- package/CHANGELOG.md +13 -0
- package/README.md +87 -4
- package/package.json +1 -1
- package/src/commands.drop-session.scrub-hint.ts +12 -10
- package/src/commands.push.recovery.actions.ts +165 -0
- package/src/commands.push.recovery.drop.ts +47 -0
- package/src/commands.push.recovery.redact.ts +113 -0
- package/src/commands.push.recovery.seams.ts +66 -0
- package/src/commands.push.recovery.ts +198 -0
- package/src/commands.push.ts +20 -18
- package/src/commands.redact.core.ts +94 -0
- package/src/commands.redact.ts +187 -0
- package/src/config.ts +1 -0
- package/src/nomad.dispatch.ts +57 -0
- package/src/nomad.help.ts +10 -0
- package/src/nomad.ts +21 -11
- package/src/push-gitleaks.scan.ts +72 -1
- package/src/push-gitleaks.ts +35 -6
- package/src/push-leak-verdict.ts +15 -3
- package/src/push-preview.ts +1 -1
- package/src/settings-keys.ts +1 -1
|
@@ -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
|
+
}
|
package/src/commands.push.ts
CHANGED
|
@@ -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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* (
|
|
41
|
-
*
|
|
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 -
|
|
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(
|
|
49
|
-
|
|
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
|
-
|
|
56
|
+
let verdict = scanPushVerdict();
|
|
52
57
|
if (verdict.leak) {
|
|
53
58
|
renderPushTree(st, verdict);
|
|
54
|
-
|
|
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
package/src/nomad.dispatch.ts
CHANGED
|
@@ -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.'),
|