claude-nomad 0.23.0 → 0.24.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 +12 -0
- package/README.md +22 -20
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.ts +309 -0
- package/src/commands.doctor.checks.ts +9 -2
- package/src/commands.doctor.ts +15 -3
- package/src/nomad.ts +16 -5
- package/src/push-gitleaks.ts +2 -2
- package/src/remap.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **doctor:** add --check-shared preflight gitleaks scan ([#117](https://github.com/funkadelic/claude-nomad/issues/117)) ([0089d09](https://github.com/funkadelic/claude-nomad/commit/0089d09ef91ff7b6778b065bcfe8be97f4c54d1b))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **readme:** document nomad doctor --check-shared preflight ([#119](https://github.com/funkadelic/claude-nomad/issues/119)) ([d08ed91](https://github.com/funkadelic/claude-nomad/commit/d08ed91bbfd4c976f56c510b086e375c4595e682))
|
|
14
|
+
|
|
3
15
|
## [0.23.0](https://github.com/funkadelic/claude-nomad/compare/v0.22.3...v0.23.0) (2026-05-23)
|
|
4
16
|
|
|
5
17
|
|
package/README.md
CHANGED
|
@@ -287,10 +287,11 @@ nomad init --keep-actions
|
|
|
287
287
|
Edit `path-map.json` to add your logical projects (see [Path remapping](#path-remapping)), then:
|
|
288
288
|
|
|
289
289
|
```bash
|
|
290
|
-
nomad doctor
|
|
291
|
-
nomad
|
|
292
|
-
nomad
|
|
293
|
-
nomad
|
|
290
|
+
nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
|
|
291
|
+
nomad doctor --check-shared # read-only gitleaks preflight over the session transcripts a push would stage
|
|
292
|
+
nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
|
|
293
|
+
nomad push # send current state to the private remote
|
|
294
|
+
nomad pull # apply on another host (or this one after a remote update)
|
|
294
295
|
```
|
|
295
296
|
|
|
296
297
|
`nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs `git pull` so you see what the next real pull would do given the latest remote, then exits without mutating.
|
|
@@ -358,21 +359,22 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
|
|
|
358
359
|
|
|
359
360
|
## Commands
|
|
360
361
|
|
|
361
|
-
| Command | Description
|
|
362
|
-
| -------------------------------- |
|
|
363
|
-
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)).
|
|
364
|
-
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`.
|
|
365
|
-
| `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror.
|
|
366
|
-
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing.
|
|
367
|
-
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing.
|
|
368
|
-
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state.
|
|
369
|
-
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push.
|
|
370
|
-
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push.
|
|
371
|
-
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows).
|
|
372
|
-
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool).
|
|
373
|
-
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check.
|
|
374
|
-
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)).
|
|
375
|
-
| `nomad --
|
|
362
|
+
| Command | Description |
|
|
363
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
364
|
+
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
|
|
365
|
+
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
|
|
366
|
+
| `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
|
|
367
|
+
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
|
|
368
|
+
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
|
|
369
|
+
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
370
|
+
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
371
|
+
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
|
|
372
|
+
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
|
|
373
|
+
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
374
|
+
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check. |
|
|
375
|
+
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
376
|
+
| `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
|
|
377
|
+
| `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
|
|
376
378
|
|
|
377
379
|
The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
|
|
378
380
|
|
|
@@ -409,7 +411,7 @@ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` f
|
|
|
409
411
|
|
|
410
412
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
411
413
|
|
|
412
|
-
`nomad push` runs `gitleaks protect --staged` before commit. When findings live in a session transcript, the FATAL names every affected session id and the recovery command:
|
|
414
|
+
`nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`, which stages and scans the exact transcripts a push would publish. When findings live in a session transcript, the push FATAL names every affected session id and the recovery command:
|
|
413
415
|
|
|
414
416
|
```text
|
|
415
417
|
✗ gitleaks detected secrets in 1 session transcript(s).
|
package/package.json
CHANGED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the `nomad doctor --check-shared` preflight reporter.
|
|
3
|
+
*
|
|
4
|
+
* Read-only diagnostic that runs gitleaks against the LOCAL session
|
|
5
|
+
* transcripts `nomad push` would stage (each path-map entry mapped to this
|
|
6
|
+
* host), surfacing secret leaks BEFORE the push pipeline fires. Mirrors the
|
|
7
|
+
* push-time scan (`runGitleaksScan` in `./push-gitleaks.ts`) but: scans a
|
|
8
|
+
* temp COPY of the live transcripts (never the live dir), uses the
|
|
9
|
+
* purpose-built `gitleaks dir` subcommand, and emits doctor-flavored glyph
|
|
10
|
+
* rows + `process.exitCode` instead of throwing a push-flavored FATAL.
|
|
11
|
+
*
|
|
12
|
+
* Composition only: reuses `partitionFindings` / `readGitleaksReport` /
|
|
13
|
+
* `SESSION_PATH` (the gitleaks JSON parser) and `copyDirJsonlOnly` (the
|
|
14
|
+
* push-fidelity source filter) verbatim. The doctor-flavored guidance
|
|
15
|
+
* composer is new (push's `buildSessionAwareFatal` is wrong at doctor time:
|
|
16
|
+
* `nomad drop-session` operates on the staged tree, and nothing is staged
|
|
17
|
+
* during a preflight).
|
|
18
|
+
*
|
|
19
|
+
* All external calls use `execFileSync` argv-array form (no shell), the
|
|
20
|
+
* codebase PUSH-04 invariant.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { green, red, yellow, okGlyph, failGlyph, warnGlyph } from './color.ts';
|
|
30
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
31
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
32
|
+
import { type Finding, partitionFindings, readGitleaksReport } from './push-gitleaks.ts';
|
|
33
|
+
import { copyDirJsonlOnly } from './remap.ts';
|
|
34
|
+
import { encodePath, nowTimestamp, readJson } from './utils.ts';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Result of staging the scan tree. `malformed` is true when `path-map.json`
|
|
38
|
+
* exists but does not parse as JSON; the caller emits a FAIL row and stops
|
|
39
|
+
* (mirroring `reportPathMap`'s `readJsonSafe` degradation) rather than letting
|
|
40
|
+
* the `SyntaxError` propagate past `nomad.ts`'s `NomadFatal`-only handler and
|
|
41
|
+
* abort the whole doctor run with a stack trace.
|
|
42
|
+
*/
|
|
43
|
+
type ScanTree = {
|
|
44
|
+
logicalToEncoded: Map<string, string>;
|
|
45
|
+
staged: number;
|
|
46
|
+
malformed: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the temp staging tree under `tmpRoot/shared/projects/<logical>/` by
|
|
51
|
+
* copying each local encoded session dir that resolves to a path-map logical
|
|
52
|
+
* for this host. Returns the `logical -> encoded-dir` association so the
|
|
53
|
+
* scrub-path hint can name the live `~/.claude/projects/<encoded>/<sid>.jsonl`
|
|
54
|
+
* file, plus the count of session dirs staged. Skips `TBD`/unmapped entries
|
|
55
|
+
* (the D-03 scope: exactly what `remapPush` would stage). Uses the same
|
|
56
|
+
* depth-0 `*.jsonl` filter as push via `copyDirJsonlOnly`. A malformed
|
|
57
|
+
* `path-map.json` sets `malformed: true` rather than throwing.
|
|
58
|
+
*/
|
|
59
|
+
function buildScanTree(tmpRoot: string): ScanTree {
|
|
60
|
+
const logicalToEncoded = new Map<string, string>();
|
|
61
|
+
let staged = 0;
|
|
62
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
63
|
+
if (!existsSync(mapPath)) return { logicalToEncoded, staged, malformed: false };
|
|
64
|
+
let map: PathMap;
|
|
65
|
+
try {
|
|
66
|
+
map = readJson<PathMap>(mapPath);
|
|
67
|
+
} catch {
|
|
68
|
+
return { logicalToEncoded, staged, malformed: true };
|
|
69
|
+
}
|
|
70
|
+
if (typeof map.projects !== 'object' || map.projects === null) {
|
|
71
|
+
return { logicalToEncoded, staged, malformed: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const reverse = new Map<string, string>();
|
|
75
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
76
|
+
if (typeof hosts !== 'object' || hosts === null) continue;
|
|
77
|
+
const p = hosts[HOST];
|
|
78
|
+
if (!p || p === 'TBD') continue;
|
|
79
|
+
reverse.set(encodePath(p), logical);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const localProjects = join(CLAUDE_HOME, 'projects');
|
|
83
|
+
if (!existsSync(localProjects)) return { logicalToEncoded, staged, malformed: false };
|
|
84
|
+
for (const dir of readdirSync(localProjects)) {
|
|
85
|
+
const logical = reverse.get(dir);
|
|
86
|
+
if (!logical) continue;
|
|
87
|
+
copyDirJsonlOnly(join(localProjects, dir), join(tmpRoot, 'shared', 'projects', logical));
|
|
88
|
+
logicalToEncoded.set(logical, dir);
|
|
89
|
+
staged++;
|
|
90
|
+
}
|
|
91
|
+
return { logicalToEncoded, staged, malformed: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Probe for the gitleaks binary on PATH, distinguishing the not-installed case
|
|
96
|
+
* (ENOENT -> `'missing'`, a WARN skip per the read-only doctor contract) from a
|
|
97
|
+
* real probe failure (EACCES, corrupt binary -> `{ fail: message }`, a FAIL).
|
|
98
|
+
* Mirrors `reportGitleaksProbe`'s ENOENT-vs-other split rather than collapsing
|
|
99
|
+
* every failure into "not on PATH". Probes directly (not via `probeGitleaks`)
|
|
100
|
+
* so the doctor flavor stays read-only and need not unwrap a `NomadFatal`.
|
|
101
|
+
*/
|
|
102
|
+
function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
|
|
103
|
+
try {
|
|
104
|
+
execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
105
|
+
return 'ok';
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';
|
|
108
|
+
return { fail: (err as Error).message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recover the live encoded-dir for a finding by mapping its `<logical>`
|
|
114
|
+
* segment through the staging association. Returns the absolute live
|
|
115
|
+
* transcript path `~/.claude/projects/<encoded>/<sid>.jsonl`, falling back to
|
|
116
|
+
* the logical name when the association is missing (defensive; the temp-tree
|
|
117
|
+
* model guarantees a hit).
|
|
118
|
+
*/
|
|
119
|
+
function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
|
|
120
|
+
/* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
|
|
121
|
+
const encoded = logicalToEncoded.get(logical) ?? logical;
|
|
122
|
+
return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Emit one fail row per affected session plus rotate-and-scrub + allowlist
|
|
127
|
+
* guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
|
|
128
|
+
* `<logical>` segment captured from the same `SESSION_PATH` match that keyed
|
|
129
|
+
* `bySession`, so the scrub-path hint reuses the authoritative parse rather
|
|
130
|
+
* than re-deriving the logical name from the finding `File`. Every `bySession`
|
|
131
|
+
* sid is keyed in `logicalBySession` (both come from the identical sid capture),
|
|
132
|
+
* so the scrub hint always renders; the guard omits the hint rather than
|
|
133
|
+
* printing a wrong path if that invariant ever breaks, and the leak row itself
|
|
134
|
+
* is always emitted.
|
|
135
|
+
*/
|
|
136
|
+
function reportSessionFindings(
|
|
137
|
+
section: DoctorSection,
|
|
138
|
+
bySession: Map<string, Map<string, number>>,
|
|
139
|
+
logicalBySession: Map<string, string>,
|
|
140
|
+
logicalToEncoded: Map<string, string>,
|
|
141
|
+
): void {
|
|
142
|
+
for (const [sid, counts] of bySession) {
|
|
143
|
+
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
144
|
+
addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
|
|
145
|
+
const logical = logicalBySession.get(sid);
|
|
146
|
+
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
147
|
+
if (logical !== undefined) {
|
|
148
|
+
addItem(
|
|
149
|
+
section,
|
|
150
|
+
` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
|
|
154
|
+
}
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Emit one fail row per non-session ("other"-bucket) finding and set
|
|
160
|
+
* `process.exitCode = 1`. These are findings whose `File` did not match the
|
|
161
|
+
* flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
|
|
162
|
+
* etc., which `copyDirJsonlOnly` copies recursively and `nomad push` would
|
|
163
|
+
* stage). Names the repo-relative path and RuleID only, never the matched
|
|
164
|
+
* secret. Mirrors the push-side guarantee that any finding outside `bySession`
|
|
165
|
+
* still fails the scan (`buildSessionAwareFatal`'s `LEGACY_FATAL` fallback).
|
|
166
|
+
*/
|
|
167
|
+
function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
168
|
+
for (const f of other) {
|
|
169
|
+
addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
|
|
170
|
+
}
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Captures both the `<logical>` segment and the `<sid>` from a repo-relative
|
|
176
|
+
* `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
|
|
177
|
+
* the exported `SESSION_PATH` shape; the extra `<logical>` group lets the
|
|
178
|
+
* scrub-path hint reuse this single authoritative parse.
|
|
179
|
+
*/
|
|
180
|
+
const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Emit the single canonical clean row reporting the scanned-project count
|
|
184
|
+
* (`staged` is the number of mapped project directories whose transcripts were
|
|
185
|
+
* staged, not a transcript total). Centralizing the literal (zero-staged,
|
|
186
|
+
* scanned-clean, and the findings-but-no-`other` paths all route through here)
|
|
187
|
+
* keeps the phrasing consistent and prevents one copy drifting from another,
|
|
188
|
+
* which is what let a "no session findings == clean" path slip past the
|
|
189
|
+
* `other`-bucket gate.
|
|
190
|
+
*/
|
|
191
|
+
function emitClean(section: DoctorSection, staged: number): void {
|
|
192
|
+
addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run the `--check-shared` preflight and append its rows to `section`.
|
|
197
|
+
*
|
|
198
|
+
* Flow (D-01..D-10): probe gitleaks (missing -> one WARN row, exit untouched;
|
|
199
|
+
* a non-ENOENT probe failure -> FAIL row + exit 1, mirroring
|
|
200
|
+
* `reportGitleaksProbe`); stage a temp copy of this-host mapped session dirs
|
|
201
|
+
* (a malformed `path-map.json` -> FAIL row + exit 1, no crash); scan with the
|
|
202
|
+
* positional `gitleaks dir shared/projects` invocation (NOT `--source`, which
|
|
203
|
+
* `gitleaks dir` rejects with exit 126); on a clean scan emit one ok row
|
|
204
|
+
* reporting the scanned-project count; on findings emit per-session fail rows
|
|
205
|
+
* with rotate-and-scrub guidance and set `process.exitCode = 1`; on a non-zero
|
|
206
|
+
* exit with no parseable report emit a scan-failed fail row carrying the
|
|
207
|
+
* gitleaks error message (never its stderr/stdout, which may hold secrets) +
|
|
208
|
+
* exit 1 (do not chase phantom sessions). Removes both the temp report and the
|
|
209
|
+
* temp tree in `finally` on success and failure. Never writes to stderr
|
|
210
|
+
* (read-only doctor contract).
|
|
211
|
+
*
|
|
212
|
+
* `gitleaksReady` lets the doctor orchestrator pass the result of the
|
|
213
|
+
* Repository section's gitleaks probe so the `version` subcommand is not
|
|
214
|
+
* invoked a second time on a `--check-shared` run. When omitted (the module's
|
|
215
|
+
* standalone contract) this reporter probes for itself.
|
|
216
|
+
*/
|
|
217
|
+
export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolean): void {
|
|
218
|
+
if (gitleaksReady !== true) {
|
|
219
|
+
const probe = probeGitleaksForScan();
|
|
220
|
+
if (probe === 'missing') {
|
|
221
|
+
addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (probe !== 'ok') {
|
|
225
|
+
addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const cacheDir = join(homedir(), '.cache', 'claude-nomad');
|
|
232
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
233
|
+
// nowTimestamp() is second-resolution and --check-shared takes no lock
|
|
234
|
+
// (read-only), so two same-second, same-pid invocations would otherwise
|
|
235
|
+
// share a stamp and clobber each other's temp tree / report. The random
|
|
236
|
+
// suffix makes the stamp collision-resistant, matching the push report path.
|
|
237
|
+
const stamp = `${nowTimestamp()}-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
238
|
+
const reportPath = join(cacheDir, `check-shared-${stamp}.json`);
|
|
239
|
+
const tmpRoot = join(cacheDir, `check-shared-tree-${stamp}`);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const { logicalToEncoded, staged, malformed } = buildScanTree(tmpRoot);
|
|
243
|
+
if (malformed) {
|
|
244
|
+
addItem(section, `${red(failGlyph)} path-map.json malformed JSON; shared scan skipped`);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (staged === 0) {
|
|
249
|
+
// No path-map entry maps to this host (or all are TBD). Nothing would be
|
|
250
|
+
// staged by push either, so report clean without invoking gitleaks (a
|
|
251
|
+
// scan of a non-existent dir would exit non-zero and misfire).
|
|
252
|
+
emitClean(section, 0);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const tomlPath = join(REPO_HOME, '.gitleaks.toml');
|
|
256
|
+
const args: string[] = [
|
|
257
|
+
'dir',
|
|
258
|
+
'shared/projects',
|
|
259
|
+
'--redact',
|
|
260
|
+
'-v',
|
|
261
|
+
'--report-format=json',
|
|
262
|
+
`--report-path=${reportPath}`,
|
|
263
|
+
];
|
|
264
|
+
if (existsSync(tomlPath)) args.push('--config', tomlPath);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
execFileSync('gitleaks', args, { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
268
|
+
emitClean(section, staged);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const findings = readGitleaksReport(reportPath);
|
|
271
|
+
if (findings === null) {
|
|
272
|
+
// Carry the gitleaks error message only (never e.stderr/e.stdout, which
|
|
273
|
+
// can echo the redacted-but-still-sensitive scan output), matching
|
|
274
|
+
// runGitleaksScan on the push side.
|
|
275
|
+
const msg = (err as Error).message;
|
|
276
|
+
addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report (${msg})`);
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const { bySession, other } = partitionFindings(findings);
|
|
281
|
+
// Both buckets must gate the clean row. A finding routed to `other`
|
|
282
|
+
// (nested transcripts that match neither the flat SESSION_PATH nor any
|
|
283
|
+
// session) is still a stageable secret push would catch, so reporting
|
|
284
|
+
// clean on `bySession.size === 0` alone would make the preflight weaker
|
|
285
|
+
// than the push scan it stands in for.
|
|
286
|
+
if (bySession.size === 0 && other.length === 0) {
|
|
287
|
+
emitClean(section, staged);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (other.length > 0) reportOtherFindings(section, other);
|
|
291
|
+
if (bySession.size > 0) {
|
|
292
|
+
// Capture <logical> alongside <sid> from the same authoritative match
|
|
293
|
+
// so the scrub hint never re-derives the logical name independently.
|
|
294
|
+
const logicalBySession = new Map<string, string>();
|
|
295
|
+
for (const f of findings) {
|
|
296
|
+
const m = SESSION_PATH_LOGICAL.exec(f.File);
|
|
297
|
+
if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
|
|
298
|
+
/* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
|
|
299
|
+
logicalBySession.set(m[2], m[1] ?? '');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
reportSessionFindings(section, bySession, logicalBySession, logicalToEncoded);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(reportPath, { force: true });
|
|
307
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -272,13 +272,19 @@ export function reportNeverSync(section: DoctorSection): void {
|
|
|
272
272
|
addItem(section, `${dim(infoGlyph)} never-sync items: ${[...NEVER_SYNC].join(', ')}`);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
/**
|
|
276
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with
|
|
277
|
+
* ENOENT vs other-error distinction (sets exitCode=1). Returns `true` when a
|
|
278
|
+
* usable binary was found so the caller can skip a redundant second `version`
|
|
279
|
+
* probe (e.g. the `--check-shared` Shared scan section).
|
|
280
|
+
*/
|
|
281
|
+
export function reportGitleaksProbe(section: DoctorSection): boolean {
|
|
277
282
|
try {
|
|
278
283
|
const v = execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
279
284
|
.toString()
|
|
280
285
|
.trim();
|
|
281
286
|
addItem(section, `${green(okGlyph)} gitleaks: ${dim(v)}`);
|
|
287
|
+
return true;
|
|
282
288
|
} catch (err) {
|
|
283
289
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
284
290
|
addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
|
|
@@ -286,6 +292,7 @@ export function reportGitleaksProbe(section: DoctorSection): void {
|
|
|
286
292
|
addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
|
|
287
293
|
}
|
|
288
294
|
process.exitCode = 1;
|
|
295
|
+
return false;
|
|
289
296
|
}
|
|
290
297
|
}
|
|
291
298
|
|
package/src/commands.doctor.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
reportRepoState,
|
|
13
13
|
reportSharedLinks,
|
|
14
14
|
} from './commands.doctor.checks.ts';
|
|
15
|
+
import { reportCheckShared } from './commands.doctor.check-shared.ts';
|
|
15
16
|
import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
|
|
16
17
|
import { renderDoctor, section } from './commands.doctor.format.ts';
|
|
17
18
|
import { reportVersionCheck } from './commands.doctor.version.ts';
|
|
@@ -24,8 +25,13 @@ import { reportVersionCheck } from './commands.doctor.version.ts';
|
|
|
24
25
|
* inside the individual reporters, so a piped
|
|
25
26
|
* `nomad doctor 2>/dev/null` still exposes failures to scripts. Differs from
|
|
26
27
|
* `cmdPull` / `cmdPush` / `resumeCmd`, where FATAL is on stderr.
|
|
28
|
+
*
|
|
29
|
+
* `opts.checkShared` (the `--check-shared` sub-flag) appends a "Shared scan"
|
|
30
|
+
* section that runs the gitleaks preflight over the session transcripts a
|
|
31
|
+
* `nomad push` would stage. It is OFF by default so plain `nomad doctor`
|
|
32
|
+
* stays the fast read-only smoke test (no scan, no temp tree).
|
|
27
33
|
*/
|
|
28
|
-
export function cmdDoctor(): void {
|
|
34
|
+
export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
|
|
29
35
|
const host = section('Host');
|
|
30
36
|
reportHostAndPaths(host);
|
|
31
37
|
reportRepoState(host);
|
|
@@ -45,7 +51,7 @@ export function cmdDoctor(): void {
|
|
|
45
51
|
reportNeverSync(neverSync);
|
|
46
52
|
|
|
47
53
|
const repository = section('Repository');
|
|
48
|
-
reportGitleaksProbe(repository);
|
|
54
|
+
const gitleaksReady = reportGitleaksProbe(repository);
|
|
49
55
|
reportGitlinks(repository);
|
|
50
56
|
reportRemote(repository);
|
|
51
57
|
reportRebaseClean(repository);
|
|
@@ -54,5 +60,11 @@ export function cmdDoctor(): void {
|
|
|
54
60
|
reportVersionCheck(version);
|
|
55
61
|
reportNodeEngineCheck(version);
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
const sharedScan = section('Shared scan');
|
|
64
|
+
// Pass the Repository-section probe result so gitleaks `version` is not
|
|
65
|
+
// invoked a second time on a --check-shared run; reportCheckShared still
|
|
66
|
+
// probes for itself when called standalone.
|
|
67
|
+
if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
|
|
68
|
+
|
|
69
|
+
renderDoctor([version, host, links, settings, pathMap, neverSync, repository, sharedScan]);
|
|
58
70
|
}
|
package/src/nomad.ts
CHANGED
|
@@ -62,6 +62,8 @@ const DEFAULT_HELP = [
|
|
|
62
62
|
'',
|
|
63
63
|
' doctor Read-only health check (symlinks, host file, path-map,',
|
|
64
64
|
' gitleaks, gitlinks).',
|
|
65
|
+
' --check-shared Preflight gitleaks scan of the session transcripts a',
|
|
66
|
+
' `nomad push` would stage (a temp copy, never the live dir).',
|
|
65
67
|
' --resume-cmd <id> Print `cd <abspath> && claude --resume <id>` for a session id',
|
|
66
68
|
' from ~/.claude/projects/.',
|
|
67
69
|
'',
|
|
@@ -188,17 +190,26 @@ try {
|
|
|
188
190
|
break;
|
|
189
191
|
}
|
|
190
192
|
case 'doctor':
|
|
191
|
-
// Sub-
|
|
192
|
-
// read-only sidecar that prints `cd <abspath> && claude --resume <id
|
|
193
|
-
|
|
193
|
+
// Sub-flags: `doctor --resume-cmd <session-id>` dispatches to the
|
|
194
|
+
// read-only sidecar that prints `cd <abspath> && claude --resume <id>`;
|
|
195
|
+
// `doctor --check-shared` (no positional) appends the gitleaks preflight
|
|
196
|
+
// scan of the transcripts a push would stage. Bare `doctor` runs the
|
|
197
|
+
// plain read-only health check. Any other shape (unknown flag, extra
|
|
198
|
+
// positional, `--check-shared` with trailing args) is a usage error.
|
|
199
|
+
if (process.argv[3] === undefined) {
|
|
200
|
+
cmdDoctor();
|
|
201
|
+
} else if (process.argv[3] === '--check-shared' && process.argv.length === 4) {
|
|
202
|
+
cmdDoctor({ checkShared: true });
|
|
203
|
+
} else if (process.argv[3] === '--resume-cmd') {
|
|
194
204
|
const id = process.argv[4];
|
|
195
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
205
|
+
if (process.argv.length !== 5 || typeof id !== 'string' || id.length === 0) {
|
|
196
206
|
console.error('usage: nomad doctor --resume-cmd <session-id>');
|
|
197
207
|
process.exit(1);
|
|
198
208
|
}
|
|
199
209
|
resumeCmd(id);
|
|
200
210
|
} else {
|
|
201
|
-
|
|
211
|
+
console.error('usage: nomad doctor [--check-shared | --resume-cmd <session-id>]');
|
|
212
|
+
process.exit(1);
|
|
202
213
|
}
|
|
203
214
|
break;
|
|
204
215
|
case 'drop-session': {
|
package/src/push-gitleaks.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type Finding = {
|
|
|
43
43
|
* paths (e.g., `shared/projects/<logical>/subagents/<id>.jsonl`) fall
|
|
44
44
|
* through to the non-session `other` bucket.
|
|
45
45
|
*/
|
|
46
|
-
const SESSION_PATH = /^shared\/projects\/[^/]+\/([^/]+)\.jsonl$/;
|
|
46
|
+
export const SESSION_PATH = /^shared\/projects\/[^/]+\/([^/]+)\.jsonl$/;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Legacy fallback FATAL emitted when no finding's File matches the session
|
|
@@ -129,7 +129,7 @@ export function buildSessionAwareFatal(
|
|
|
129
129
|
* the failure path must NOT cascade into a parse-error stack trace; the
|
|
130
130
|
* caller falls back to the legacy FATAL string in that case.
|
|
131
131
|
*/
|
|
132
|
-
function readGitleaksReport(reportPath: string): Finding[] | null {
|
|
132
|
+
export function readGitleaksReport(reportPath: string): Finding[] | null {
|
|
133
133
|
try {
|
|
134
134
|
const raw = readFileSync(reportPath, 'utf8');
|
|
135
135
|
const parsed: unknown = JSON.parse(raw);
|
package/src/remap.ts
CHANGED
|
@@ -27,7 +27,7 @@ function copyDir(src: string, dst: string): void {
|
|
|
27
27
|
* keeps the unfiltered copyDir because the repo side is already curated
|
|
28
28
|
* by the push gate.
|
|
29
29
|
*/
|
|
30
|
-
function copyDirJsonlOnly(src: string, dst: string): void {
|
|
30
|
+
export function copyDirJsonlOnly(src: string, dst: string): void {
|
|
31
31
|
rmSync(dst, { recursive: true, force: true });
|
|
32
32
|
cpSync(src, dst, {
|
|
33
33
|
recursive: true,
|