claude-nomad 0.25.0 → 0.25.1
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 +2 -2
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +158 -0
- package/src/commands.doctor.check-shared.ts +58 -189
- package/src/commands.doctor.checks.pathmap.ts +101 -0
- package/src/commands.doctor.checks.repo.ts +101 -0
- package/src/commands.doctor.checks.repository.ts +105 -0
- package/src/commands.doctor.checks.settings.ts +88 -0
- package/src/commands.doctor.format.ts +18 -0
- package/src/commands.doctor.ts +10 -7
- package/src/commands.drop-session.git.ts +81 -0
- package/src/commands.drop-session.ts +79 -138
- package/src/commands.pull.ts +3 -2
- package/src/commands.push.allowlist.ts +119 -0
- package/src/commands.push.ts +6 -121
- package/src/commands.update.git.ts +90 -0
- package/src/commands.update.resolve.ts +138 -0
- package/src/commands.update.test-helpers.git.ts +107 -0
- package/src/commands.update.ts +4 -221
- package/src/diff.ts +2 -1
- package/src/extras-sync.diff.ts +40 -0
- package/src/extras-sync.guards.ts +52 -0
- package/src/extras-sync.ts +146 -236
- package/src/init.classify.ts +1 -1
- package/src/init.snapshot.ts +3 -1
- package/src/init.ts +2 -1
- package/src/links.ts +3 -10
- package/src/nomad.dispatch.ts +25 -0
- package/src/nomad.help.ts +43 -0
- package/src/nomad.ts +6 -68
- package/src/preview.ts +2 -1
- package/src/push-gitleaks.scan.ts +115 -0
- package/src/push-gitleaks.ts +50 -106
- package/src/remap.ts +3 -1
- package/src/resume.ts +2 -1
- package/src/update.fork-extras.ts +2 -1
- package/src/utils.fs.ts +152 -0
- package/src/utils.json.ts +55 -0
- package/src/utils.lockfile.ts +131 -0
- package/src/utils.ts +0 -327
- package/src/commands.doctor.checks.ts +0 -350
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.1](https://github.com/funkadelic/claude-nomad/compare/v0.25.0...v0.25.1) (2026-05-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
* **doctor:** scan --check-shared like push to fix false negatives ([#134](https://github.com/funkadelic/claude-nomad/issues/134)) ([5063028](https://github.com/funkadelic/claude-nomad/commit/5063028c6a695709f7f2d2dfe3cceabedecc17f9))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
* split over-cap source and test files under the ~200-line cap ([#136](https://github.com/funkadelic/claude-nomad/issues/136)) ([0cc7eed](https://github.com/funkadelic/claude-nomad/commit/0cc7eed13abbc85084b04cfc8b686e53b26133c6))
|
|
14
|
+
|
|
3
15
|
## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
|
|
4
16
|
|
|
5
17
|
|
package/README.md
CHANGED
|
@@ -409,7 +409,7 @@ Exit codes:
|
|
|
409
409
|
- `0` on any drop, including an idempotent re-run.
|
|
410
410
|
- `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
|
|
411
411
|
|
|
412
|
-
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real,
|
|
412
|
+
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrubbing or removing the local files is REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.ts`) re-mirrors the local content into the staged tree on the next push, so a drop without a local scrub re-stages the same secret.
|
|
413
413
|
|
|
414
414
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
415
415
|
|
|
@@ -427,7 +427,7 @@ After recovery, re-run nomad push.
|
|
|
427
427
|
|
|
428
428
|
Two branches from here:
|
|
429
429
|
|
|
430
|
-
1. **Real secret.** Rotate the credential at its provider (revoke in dashboard, issue replacement)
|
|
430
|
+
1. **Real secret.** Rotate the credential at its provider first (revoke in dashboard, issue replacement) before touching anything else. Running `nomad drop-session <sid-aaaa>` clears the contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in `src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/` on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and re-stages the same secret. The durable fix is to rotate AND scrub or remove the local transcript at `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent directory under that encoded dir, if present) so the next `remapPush` carries clean content forward. Do not leave the local file un-scrubbed and expect the staged-tree drop to hold.
|
|
431
431
|
|
|
432
432
|
2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist propagates to deploy hosts via `nomad update`.
|
|
433
433
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan-result classification and row emission for the `nomad doctor
|
|
3
|
+
* --check-shared` preflight. Split out of `commands.doctor.check-shared.ts` to
|
|
4
|
+
* keep both files under the line cap; `reportCheckShared` (the public reporter)
|
|
5
|
+
* stays in the sibling and calls `scanAndReport` after staging the temp tree.
|
|
6
|
+
*
|
|
7
|
+
* Owns the post-stage block: run the shared `scanStagedTree` (the same git
|
|
8
|
+
* init + add + `gitleaks protect --staged` mechanism push uses), classify the
|
|
9
|
+
* findings via `partitionFindings`, and emit the doctor glyph rows (clean,
|
|
10
|
+
* per-session leak with rotate-and-scrub guidance, and the nested "other"
|
|
11
|
+
* bucket). All external work flows through `scanStagedTree`; this module spawns
|
|
12
|
+
* nothing itself.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { green, red, okGlyph, failGlyph } from './color.ts';
|
|
18
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
19
|
+
import { CLAUDE_HOME } from './config.ts';
|
|
20
|
+
import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recover the absolute live transcript path
|
|
24
|
+
* `~/.claude/projects/<encoded>/<sid>.jsonl` by mapping the finding's
|
|
25
|
+
* `<logical>` through the staging association, falling back to the logical name
|
|
26
|
+
* when the association is missing (defensive; the temp-tree build guarantees a hit).
|
|
27
|
+
*/
|
|
28
|
+
function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
|
|
29
|
+
/* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
|
|
30
|
+
const encoded = logicalToEncoded.get(logical) ?? logical;
|
|
31
|
+
return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Emit one fail row per affected session plus rotate-and-scrub + allowlist
|
|
36
|
+
* guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
|
|
37
|
+
* `<logical>` captured from the same match that keyed `bySession`, so the
|
|
38
|
+
* scrub-path hint reuses the authoritative parse. The hint guard omits a row
|
|
39
|
+
* rather than print a wrong path if the invariant ever breaks; the leak row is
|
|
40
|
+
* always emitted.
|
|
41
|
+
*/
|
|
42
|
+
function reportSessionFindings(
|
|
43
|
+
section: DoctorSection,
|
|
44
|
+
bySession: Map<string, Map<string, number>>,
|
|
45
|
+
logicalBySession: Map<string, string>,
|
|
46
|
+
logicalToEncoded: Map<string, string>,
|
|
47
|
+
): void {
|
|
48
|
+
for (const [sid, counts] of bySession) {
|
|
49
|
+
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
50
|
+
addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
|
|
51
|
+
const logical = logicalBySession.get(sid);
|
|
52
|
+
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
53
|
+
if (logical !== undefined) {
|
|
54
|
+
addItem(
|
|
55
|
+
section,
|
|
56
|
+
` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
|
|
60
|
+
}
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Emit one fail row per non-session ("other"-bucket) finding and set
|
|
66
|
+
* `process.exitCode = 1`. These are findings whose `File` did not match the
|
|
67
|
+
* flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
|
|
68
|
+
* which `nomad push` would still stage). Names the repo-relative path and
|
|
69
|
+
* RuleID only, never the matched secret.
|
|
70
|
+
*/
|
|
71
|
+
function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
72
|
+
for (const f of other) {
|
|
73
|
+
addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
|
|
74
|
+
}
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Captures both the `<logical>` segment and the `<sid>` from a repo-relative
|
|
80
|
+
* `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
|
|
81
|
+
* the exported `SESSION_PATH` shape; the `<logical>` group lets the scrub-path
|
|
82
|
+
* hint reuse this single authoritative parse.
|
|
83
|
+
*/
|
|
84
|
+
const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Emit the single canonical clean row reporting the scanned-project count
|
|
88
|
+
* (`staged` is the number of mapped project dirs staged, not a transcript
|
|
89
|
+
* total). Centralizing the literal keeps every clean path (zero-staged,
|
|
90
|
+
* scanned-clean, findings-but-no-`other`) phrased consistently.
|
|
91
|
+
*/
|
|
92
|
+
export function emitClean(section: DoctorSection, staged: number): void {
|
|
93
|
+
addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the `sid -> <logical>` association from the findings, capturing both
|
|
98
|
+
* groups from the same `SESSION_PATH_LOGICAL` match so the scrub-path hint
|
|
99
|
+
* never re-derives the logical name. First match per sid wins (the scrub path
|
|
100
|
+
* is per session, not per finding).
|
|
101
|
+
*/
|
|
102
|
+
function buildLogicalBySession(findings: Finding[]): Map<string, string> {
|
|
103
|
+
const logicalBySession = new Map<string, string>();
|
|
104
|
+
for (const f of findings) {
|
|
105
|
+
const m = SESSION_PATH_LOGICAL.exec(f.File);
|
|
106
|
+
if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
|
|
107
|
+
/* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
|
|
108
|
+
logicalBySession.set(m[2], m[1] ?? '');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return logicalBySession;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scan the staged temp tree through the shared `scanStagedTree` and emit the
|
|
116
|
+
* result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
|
|
117
|
+
* try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
|
|
118
|
+
* stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
|
|
119
|
+
* and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
|
|
120
|
+
* finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
|
|
121
|
+
* nor any session) is still a stageable secret push would catch, so a
|
|
122
|
+
* `bySession`-only gate would make the preflight weaker than the push scan.
|
|
123
|
+
*/
|
|
124
|
+
export function scanAndReport(
|
|
125
|
+
section: DoctorSection,
|
|
126
|
+
tmpRoot: string,
|
|
127
|
+
staged: number,
|
|
128
|
+
logicalToEncoded: Map<string, string>,
|
|
129
|
+
): void {
|
|
130
|
+
let findings: Finding[] | null;
|
|
131
|
+
try {
|
|
132
|
+
findings = scanStagedTree(tmpRoot);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// ENOENT (binary vanished mid-flow) or a git failure. The top-of-flow probe
|
|
135
|
+
// WARN-skips a truly missing gitleaks; this catch reports a scan-failed FAIL
|
|
136
|
+
// row with err.message only (never stderr/stdout, which can echo
|
|
137
|
+
// redacted-but-sensitive scan output).
|
|
138
|
+
addItem(section, `${red(failGlyph)} scan failed: ${(err as Error).message}`);
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (findings === null) {
|
|
143
|
+
// Non-zero gitleaks exit with no parseable report. Carry no stream output,
|
|
144
|
+
// matching runGitleaksScan on the push side.
|
|
145
|
+
addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const { bySession, other } = partitionFindings(findings);
|
|
150
|
+
if (bySession.size === 0 && other.length === 0) {
|
|
151
|
+
emitClean(section, staged);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (other.length > 0) reportOtherFindings(section, other);
|
|
155
|
+
if (bySession.size > 0) {
|
|
156
|
+
reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Owns the `nomad doctor --check-shared` preflight reporter.
|
|
3
3
|
*
|
|
4
|
-
* Read-only diagnostic that runs gitleaks against the LOCAL session
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Read-only diagnostic that runs gitleaks against the LOCAL session transcripts
|
|
5
|
+
* `nomad push` would stage (each path-map entry mapped to this host), surfacing
|
|
6
|
+
* leaks BEFORE the push pipeline fires. Stages a temp COPY of the live
|
|
7
|
+
* transcripts into a throwaway git repo and delegates the scan + row emission
|
|
8
|
+
* to `scanAndReport` (`./commands.doctor.check-shared.scan.ts`), which runs the
|
|
9
|
+
* shared `scanStagedTree` (`gitleaks protect --staged`, the same mechanism push
|
|
10
|
+
* uses), so the preflight cannot miss a secret the push gate would catch. Emits
|
|
11
|
+
* doctor glyph rows + `process.exitCode` instead of throwing a FATAL.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
13
|
+
* This file owns probe-readiness, temp-tree staging, and orchestration; the
|
|
14
|
+
* findings classification + guidance composer live in the `.scan.ts` sibling.
|
|
15
|
+
* All external calls use `execFileSync` argv-array form (PUSH-04).
|
|
21
16
|
*/
|
|
22
17
|
|
|
23
18
|
import { randomBytes } from 'node:crypto';
|
|
@@ -26,19 +21,18 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
|
26
21
|
import { homedir } from 'node:os';
|
|
27
22
|
import { join } from 'node:path';
|
|
28
23
|
|
|
29
|
-
import {
|
|
24
|
+
import { red, yellow, failGlyph, warnGlyph } from './color.ts';
|
|
25
|
+
import { emitClean, scanAndReport } from './commands.doctor.check-shared.scan.ts';
|
|
30
26
|
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
31
27
|
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
32
|
-
import { type Finding, partitionFindings, readGitleaksReport } from './push-gitleaks.ts';
|
|
33
28
|
import { copyDirJsonlOnly } from './remap.ts';
|
|
34
|
-
import {
|
|
29
|
+
import { nowTimestamp } from './utils.fs.ts';
|
|
30
|
+
import { encodePath, readJson } from './utils.json.ts';
|
|
35
31
|
|
|
36
32
|
/**
|
|
37
33
|
* Result of staging the scan tree. `malformed` is true when `path-map.json`
|
|
38
34
|
* exists but does not parse as JSON; the caller emits a FAIL row and stops
|
|
39
|
-
*
|
|
40
|
-
* the `SyntaxError` propagate past `nomad.ts`'s `NomadFatal`-only handler and
|
|
41
|
-
* abort the whole doctor run with a stack trace.
|
|
35
|
+
* rather than letting the `SyntaxError` abort the whole doctor run.
|
|
42
36
|
*/
|
|
43
37
|
type ScanTree = {
|
|
44
38
|
logicalToEncoded: Map<string, string>;
|
|
@@ -48,12 +42,10 @@ type ScanTree = {
|
|
|
48
42
|
|
|
49
43
|
/**
|
|
50
44
|
* Build the temp staging tree under `tmpRoot/shared/projects/<logical>/` by
|
|
51
|
-
* copying each local encoded session dir that
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* (the D-03 scope: exactly what `remapPush` would stage). Uses the same
|
|
56
|
-
* depth-0 `*.jsonl` filter as push via `copyDirJsonlOnly`. A malformed
|
|
45
|
+
* copying each local encoded session dir that maps to a path-map logical for
|
|
46
|
+
* this host (exactly what `remapPush` would stage; same depth-0 `*.jsonl`
|
|
47
|
+
* filter via `copyDirJsonlOnly`). Returns the `logical -> encoded-dir`
|
|
48
|
+
* association (for the scrub-path hint) plus the count staged. A malformed
|
|
57
49
|
* `path-map.json` sets `malformed: true` rather than throwing.
|
|
58
50
|
*/
|
|
59
51
|
function buildScanTree(tmpRoot: string): ScanTree {
|
|
@@ -93,11 +85,9 @@ function buildScanTree(tmpRoot: string): ScanTree {
|
|
|
93
85
|
|
|
94
86
|
/**
|
|
95
87
|
* Probe for the gitleaks binary on PATH, distinguishing the not-installed case
|
|
96
|
-
* (ENOENT -> `'missing'`, a WARN skip
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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`.
|
|
88
|
+
* (ENOENT -> `'missing'`, a WARN skip) from a real probe failure (EACCES,
|
|
89
|
+
* corrupt binary -> `{ fail: message }`, a FAIL). Mirrors `reportGitleaksProbe`'s
|
|
90
|
+
* ENOENT-vs-other split; probes directly so the doctor flavor stays read-only.
|
|
101
91
|
*/
|
|
102
92
|
function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
|
|
103
93
|
try {
|
|
@@ -110,123 +100,46 @@ function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
|
|
|
110
100
|
}
|
|
111
101
|
|
|
112
102
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
103
|
+
* Probe-readiness guard ladder. Returns true to proceed to the scan, false to
|
|
104
|
+
* stop after emitting an early row. When the orchestrator already probed
|
|
105
|
+
* (`gitleaksReady === true`) the subcommand is not re-invoked; otherwise this
|
|
106
|
+
* probes for itself, mapping `missing` to a WARN skip (exit untouched) and a
|
|
107
|
+
* non-ENOENT failure to a FAIL row + `process.exitCode = 1`.
|
|
118
108
|
*/
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
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`);
|
|
109
|
+
function ensureGitleaksReady(section: DoctorSection, gitleaksReady?: boolean): boolean {
|
|
110
|
+
if (gitleaksReady === true) return true;
|
|
111
|
+
const probe = probeGitleaksForScan();
|
|
112
|
+
if (probe === 'missing') {
|
|
113
|
+
addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
|
|
114
|
+
return false;
|
|
154
115
|
}
|
|
155
|
-
|
|
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}`);
|
|
116
|
+
if (probe !== 'ok') {
|
|
117
|
+
addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return false;
|
|
170
120
|
}
|
|
171
|
-
|
|
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`);
|
|
121
|
+
return true;
|
|
193
122
|
}
|
|
194
123
|
|
|
195
124
|
/**
|
|
196
125
|
* Run the `--check-shared` preflight and append its rows to `section`.
|
|
197
126
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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).
|
|
127
|
+
* Thin orchestrator (D-01..D-10): `ensureGitleaksReady` gates entry (a missing
|
|
128
|
+
* binary WARN-skips, a probe failure FAILs); `buildScanTree` stages a temp copy
|
|
129
|
+
* of this-host mapped session dirs (a malformed `path-map.json` -> FAIL row,
|
|
130
|
+
* no crash); `scanAndReport` runs the shared `scanStagedTree` (the same
|
|
131
|
+
* mechanism push uses, so the preflight cannot miss what push catches) and
|
|
132
|
+
* emits the clean / leak / scan-failed rows, setting `process.exitCode = 1` on
|
|
133
|
+
* any failure. The temp report + tree (including the injected throwaway `.git`)
|
|
134
|
+
* are removed in `finally` on every path. Never writes to stderr (read-only
|
|
135
|
+
* doctor contract: `scanStagedTree` runs with `forwardStreams` left false).
|
|
211
136
|
*
|
|
212
|
-
* `gitleaksReady` lets the doctor orchestrator pass the
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* standalone contract) this reporter probes for itself.
|
|
137
|
+
* `gitleaksReady` lets the doctor orchestrator pass the Repository section's
|
|
138
|
+
* probe result so `version` is not invoked twice on a `--check-shared` run;
|
|
139
|
+
* when omitted (the standalone contract) this reporter probes for itself.
|
|
216
140
|
*/
|
|
217
141
|
export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolean): void {
|
|
218
|
-
if (gitleaksReady
|
|
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
|
-
}
|
|
142
|
+
if (!ensureGitleaksReady(section, gitleaksReady)) return;
|
|
230
143
|
|
|
231
144
|
const cacheDir = join(homedir(), '.cache', 'claude-nomad');
|
|
232
145
|
mkdirSync(cacheDir, { recursive: true });
|
|
@@ -252,56 +165,12 @@ export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolea
|
|
|
252
165
|
emitClean(section, 0);
|
|
253
166
|
return;
|
|
254
167
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
}
|
|
168
|
+
// Scan the temp tree through the SAME mechanism push uses (scanStagedTree:
|
|
169
|
+
// git init + add + gitleaks protect --staged), so the preflight cannot miss
|
|
170
|
+
// a secret the push gate would catch. forwardStreams stays false so the
|
|
171
|
+
// read-only doctor never writes gitleaks output to stderr; the injected
|
|
172
|
+
// throwaway .git under tmpRoot is removed by the finally below.
|
|
173
|
+
scanAndReport(section, tmpRoot, staged, logicalToEncoded);
|
|
305
174
|
} finally {
|
|
306
175
|
rmSync(reportPath, { force: true });
|
|
307
176
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { blue, cyan, dim, failGlyph, green, infoGlyph, okGlyph, red } from './color.ts';
|
|
5
|
+
import { HOST, NEVER_SYNC, REPO_HOME, type PathMap } from './config.ts';
|
|
6
|
+
import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
|
|
7
|
+
import { encodePath } from './utils.json.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Path-map reporters for `cmdDoctor`: the mapped-projects listing, the
|
|
11
|
+
* path-encoding collision scan, and the never-sync visibility line. Each helper
|
|
12
|
+
* appends items to its target `DoctorSection` and signals failure by setting
|
|
13
|
+
* `process.exitCode = 1`. Read-only: FAIL lines stay on stdout.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Emits the mapped-projects header for the current host and one line per mapped project. */
|
|
17
|
+
function reportMappedProjects(section: DoctorSection, map: PathMap): void {
|
|
18
|
+
const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
|
|
19
|
+
addItem(
|
|
20
|
+
section,
|
|
21
|
+
`${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
|
|
22
|
+
);
|
|
23
|
+
for (const [name, hosts] of mapped) {
|
|
24
|
+
addItem(section, `${dim(infoGlyph)} ${name} -> ${blue(hosts[HOST])}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Scans every host of every project for encodePath collisions; emits failGlyph per collision (sets exitCode=1), okGlyph when clean. */
|
|
29
|
+
function reportPathCollisions(section: DoctorSection, map: PathMap): void {
|
|
30
|
+
const seen = new Map<string, string>();
|
|
31
|
+
let collisionCount = 0;
|
|
32
|
+
for (const hosts of Object.values(map.projects)) {
|
|
33
|
+
for (const abspath of Object.values(hosts)) {
|
|
34
|
+
if (!abspath || abspath === 'TBD') continue;
|
|
35
|
+
const encoded = encodePath(abspath);
|
|
36
|
+
const prior = seen.get(encoded);
|
|
37
|
+
if (prior !== undefined && prior !== abspath) {
|
|
38
|
+
addItem(
|
|
39
|
+
section,
|
|
40
|
+
`${red(failGlyph)} path-encoding collision: ${prior} and ${abspath} both encode to ${encoded}`,
|
|
41
|
+
);
|
|
42
|
+
collisionCount++;
|
|
43
|
+
} else {
|
|
44
|
+
seen.set(encoded, abspath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (collisionCount > 0) process.exitCode = 1;
|
|
49
|
+
else addItem(section, `${green(okGlyph)} path-encoding: no collisions`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pushes mapped projects for the current host and FAILs on path-encoding collisions across hosts; FAILs when path-map.json is missing. */
|
|
53
|
+
export function reportPathMap(section: DoctorSection): void {
|
|
54
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
55
|
+
if (!existsSync(mapPath)) {
|
|
56
|
+
addItem(section, `${red(failGlyph)} path-map.json missing at ${blue(mapPath)}`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const map = readJsonSafe<PathMap>(mapPath, mapPath, section);
|
|
61
|
+
if (map === null) return;
|
|
62
|
+
// Guard non-object `projects` and per-project non-object `hosts` so the
|
|
63
|
+
// helpers' `hosts[HOST]` / `Object.values(hosts)` cannot throw mid-output
|
|
64
|
+
// and break the tolerant-doctor contract.
|
|
65
|
+
const projects: unknown = (map as { projects?: unknown }).projects;
|
|
66
|
+
if (projects === null || typeof projects !== 'object' || Array.isArray(projects)) {
|
|
67
|
+
addItem(
|
|
68
|
+
section,
|
|
69
|
+
`${red(failGlyph)} path-map.json invalid schema: "projects" must be an object`,
|
|
70
|
+
);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const [name, hosts] of Object.entries(projects as Record<string, unknown>)) {
|
|
75
|
+
if (hosts === null || typeof hosts !== 'object' || Array.isArray(hosts)) {
|
|
76
|
+
addItem(
|
|
77
|
+
section,
|
|
78
|
+
`${red(failGlyph)} path-map.json invalid schema: project "${name}" hosts must be an object`,
|
|
79
|
+
);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const [hostName, mappedPath] of Object.entries(hosts as Record<string, unknown>)) {
|
|
84
|
+
if (typeof mappedPath !== 'string') {
|
|
85
|
+
addItem(
|
|
86
|
+
section,
|
|
87
|
+
`${red(failGlyph)} path-map.json invalid schema: project "${name}" host "${hostName}" path must be a string`,
|
|
88
|
+
);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
reportMappedProjects(section, map);
|
|
95
|
+
reportPathCollisions(section, map);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Pushes the comma-joined NEVER_SYNC set for informational visibility. */
|
|
99
|
+
export function reportNeverSync(section: DoctorSection): void {
|
|
100
|
+
addItem(section, `${dim(infoGlyph)} never-sync items: ${[...NEVER_SYNC].join(', ')}`);
|
|
101
|
+
}
|