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 CHANGED
@@ -38,3 +38,23 @@ paths = [
38
38
  '''^shared/projects/[^/]+/.*\.jsonl$''',
39
39
  ]
40
40
  condition = "AND"
41
+
42
+ # Path-scoped: SonarCloud issue-listing tool output (`gh`/sonar API dumps of
43
+ # the form `key: <20-char id>` immediately followed by `rule: <lang>:S<n>`)
44
+ # lands in session transcripts during PR reviews. The issue key is an opaque
45
+ # 20-char base64url identifier, not a credential, but it is shaped like a
46
+ # generic API key and does not carry the `AY` prefix the noise allowlist
47
+ # above keys on. Anchoring on the surrounding `key:`/`rule:` structure (via
48
+ # regexTarget = "line") keeps this from whitelisting a bare token: a real API
49
+ # key is never followed by `\n rule: <lang>:S####`. `condition = "AND"` plus
50
+ # the session-jsonl path scope double-locks it to synced transcripts.
51
+ [[allowlists]]
52
+ description = "claude-nomad: SonarCloud issue-listing output (key: <id> / rule: <lang>:S<n>) in synced session transcripts"
53
+ regexTarget = "line"
54
+ regexes = [
55
+ '''key:\s*[A-Za-z0-9_-]{19,24}\\n\s*rule:\s*\w+:S\d+''',
56
+ ]
57
+ paths = [
58
+ '''^shared/projects/[^/]+/.*\.jsonl$''',
59
+ ]
60
+ condition = "AND"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.31.0](https://github.com/funkadelic/claude-nomad/compare/v0.30.0...v0.31.0) (2026-05-29)
4
+
5
+
6
+ ### Added
7
+
8
+ * **push:** interactive secret recovery on push and nomad redact ([#181](https://github.com/funkadelic/claude-nomad/issues/181)) ([4931e27](https://github.com/funkadelic/claude-nomad/commit/4931e27ba30998a02b123c71edc1315069c9181a))
9
+
10
+
11
+ ### Changed
12
+
13
+ * **gitleaks:** allowlist SonarCloud issue-key tool-output noise in synced transcripts ([#179](https://github.com/funkadelic/claude-nomad/issues/179)) ([0f7d816](https://github.com/funkadelic/claude-nomad/commit/0f7d8161d4de3379cd6a4db00482f560c8b3f280))
14
+ * **settings-drift:** author PRs via app token and make regen idempotent ([#182](https://github.com/funkadelic/claude-nomad/issues/182)) ([062397c](https://github.com/funkadelic/claude-nomad/commit/062397c565926471eccc8e75f72d1ccf2e5cc8c0))
15
+
3
16
  ## [0.30.0](https://github.com/funkadelic/claude-nomad/compare/v0.29.1...v0.30.0) (2026-05-29)
4
17
 
5
18
 
package/README.md CHANGED
@@ -60,7 +60,9 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
60
60
  - [Commands](#commands)
61
61
  - [Recovery flows](#recovery-flows)
62
62
  - [`nomad drop-session <id>`](#nomad-drop-session-id)
63
+ - [`nomad redact <session-id>`](#nomad-redact-session-id)
63
64
  - [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)
65
+ - [Recovery flow: push-time interactive menu](#recovery-flow-push-time-interactive-menu)
64
66
  - [`.gitleaks.toml` allowlist policy](#gitleakstoml-allowlist-policy)
65
67
  - [Cross-OS resume](#cross-os-resume)
66
68
  - [Run tests](#run-tests)
@@ -610,7 +612,11 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
610
612
  | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
611
613
  | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
612
614
  | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
615
+ | `nomad push --redact-all` | Redact all findings non-interactively (backup written first) without a TTY. Does not auto-Allow findings. After redaction re-stages and re-scans; aborts with the session-aware FATAL if any finding survives. Use this in scripts or when you are confident every finding is a real secret that should be scrubbed. See [Recovery flow: push-time interactive menu](#recovery-flow-push-time-interactive-menu). |
613
616
  | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
617
+ | `nomad redact <session-id>` | Rewrite the secret span in the local source transcript for a session, backed up to `~/.cache/claude-nomad/backup/`. Refuses to touch a session that was modified recently (potential active session). Safe to re-run. See [`nomad redact <session-id>`](#nomad-redact-session-id). |
618
+ | `nomad redact --rule <id>` | Limit redaction to findings of one gitleaks rule id only. |
619
+ | `nomad redact --dry-run` | Show what `nomad redact` would change without writing anything. |
614
620
  | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
615
621
  | `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, a Hook targets check that fails (`✗`, exit 1) when `settings.json` references a hook command whose script under `~/.claude/` is missing on this host, plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
616
622
  | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
@@ -762,6 +768,38 @@ scrubbing (the exact path when `path-map.json` maps the project to the current h
762
768
  `~/.claude/projects/` source, not the staged tree, so it keeps flagging the secret until the local
763
769
  transcript is scrubbed.
764
770
 
771
+ ### `nomad redact <session-id>`
772
+
773
+ Rewrites the secret span in the local source transcript at
774
+ `~/.claude/projects/<encoded>/<session-id>.jsonl` in place, replacing each flagged span with
775
+ `[REDACTED:<rule>]`. Before rewriting, the original transcript is backed up to
776
+ `~/.cache/claude-nomad/backup/<timestamp>/`.
777
+
778
+ ```bash
779
+ $ nomad redact <session-id>
780
+ $ nomad redact <session-id> --rule github-pat # one rule only
781
+ $ nomad redact <session-id> --dry-run # preview without writing
782
+ ```
783
+
784
+ What it does: rewrites the LOCAL source transcript (not just the staged copy). This is the durable
785
+ fix for a gitleaks finding: `nomad drop-session` only removes the staged copy, but `remapPush`
786
+ re-copies from local on the next push, so the secret resurfaces. Redacting the local source means
787
+ future pushes carry clean content.
788
+
789
+ What it does NOT do: rotate credentials. Always rotate the secret at its provider first.
790
+
791
+ Safety checks:
792
+
793
+ - A session whose transcript was modified within the last 5 minutes is treated as potentially active
794
+ (Claude Code may still be writing to it). `nomad redact` refuses to touch it and suggests
795
+ `nomad drop-session` or waiting for the session to end.
796
+ - Before every rewrite, a backup is written to `~/.cache/claude-nomad/backup/<timestamp>/`, so the
797
+ original content is recoverable.
798
+ - `--dry-run` prints the planned redactions and writes nothing.
799
+
800
+ This command is safe to re-run: if the span was already redacted (the replacement token is already
801
+ present), the content is unchanged.
802
+
765
803
  ### Recovery flow: gitleaks FATAL on a session JSONL
766
804
 
767
805
  `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
@@ -792,10 +830,12 @@ Two branches from here:
792
830
  contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in
793
831
  `src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/`
794
832
  on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and
795
- re-stages the same secret. The durable fix is to rotate AND scrub or remove the local transcript
796
- at `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent
797
- directory under that encoded dir, if present) so the next `remapPush` carries clean content
798
- forward. Do not leave the local file un-scrubbed and expect the staged-tree drop to hold.
833
+ re-stages the same secret. The durable fix is to rotate AND scrub the local transcript. The
834
+ easiest way: `nomad redact <sid-aaaa>` (see [`nomad redact`](#nomad-redact-session-id)), which
835
+ rewrites the secret span in place with a backup. Alternatively, remove the local transcript at
836
+ `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent
837
+ directory, if present). Do not leave the local file un-scrubbed and expect the staged-tree drop
838
+ to hold.
799
839
 
800
840
  2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the
801
841
  noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist
@@ -804,6 +844,49 @@ Two branches from here:
804
844
  `nomad drop-session` only acts on the staged tree of `~/claude-nomad/`. Active Claude Code sessions
805
845
  writing to the local file are not disturbed.
806
846
 
847
+ ### Recovery flow: push-time interactive menu
848
+
849
+ When `nomad push` detects a secret and the process is running on an interactive TTY, it presents a
850
+ per-finding menu instead of aborting immediately. Each finding is shown with its rule id, file, and
851
+ line number (the secret value is never printed: the scan uses `--redact`).
852
+
853
+ ```text
854
+ Finding: github-pat in shared/projects/my-proj/abc123.jsonl line 42 (session: abc123)
855
+ [R]edact [A]llow [D]rop session [S]kip (default)
856
+ >
857
+ ```
858
+
859
+ What the actions do:
860
+
861
+ - **Redact** rewrites the secret span in the LOCAL source transcript in place (same flow as
862
+ `nomad redact`), backs up first, then re-copies the file to the staged tree. Refuses if the
863
+ session was modified in the last 5 minutes (potential active session): choose Drop or Skip instead
864
+ and wait for the session to end.
865
+ - **Allow** appends the finding's fingerprint to `.gitleaksignore` at the repo root. Use this for
866
+ confirmed false positives. The fingerprint format (`file:rule:line`) is tied to the current line,
867
+ so if the content moves gitleaks re-prompts rather than silently suppressing a new hit.
868
+ - **Drop session** excludes this session from the current push by unstaging it from the repo's git
869
+ index (same as `nomad drop-session <id>`). The local `~/.claude/projects/.../` transcript is kept
870
+ intact and any running Claude session is not stopped. Not durable: the next push re-copies from
871
+ local unless you also redact or remove the local transcript.
872
+ - **Skip** (default on bare Enter) leaves the finding unresolved for now.
873
+
874
+ After you respond to every finding, the menu applies your choices. If any finding was Skipped, the
875
+ push aborts with the session-aware FATAL (same exit as a non-interactive push with findings). If all
876
+ findings were resolved, the staged tree is updated and re-scanned. A clean re-scan proceeds to
877
+ commit and push. If new findings appear after the first round of actions, the menu loops on the new
878
+ set.
879
+
880
+ On a non-TTY (CI, piped input, or scripted `nomad push`), the menu never appears and the push aborts
881
+ with the existing session-aware FATAL unchanged.
882
+
883
+ **Batch redact without a TTY:** `nomad push --redact-all` redacts every finding non-interactively
884
+ (backup written first) without prompting and without requiring a TTY. It does not auto-Allow. After
885
+ redaction the staged tree is re-scanned; any surviving finding aborts with the FATAL. Use this in
886
+ scripts or when every finding is a real secret that should be scrubbed. For a single session,
887
+ `nomad redact <session-id>` (see [`nomad redact`](#nomad-redact-session-id)) gives you per-session
888
+ control with `--rule` and `--dry-run` options.
889
+
807
890
  ### `.gitleaks.toml` allowlist policy
808
891
 
809
892
  `gitleaks protect` runs against the staged tree on every `nomad push` and can flag
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -13,12 +13,12 @@ const SHARED_PROJECT_LOGICAL = /^shared\/projects\/([^/]+)\//;
13
13
 
14
14
  /**
15
15
  * After a successful drop, remind the operator that the unstage is per-push
16
- * only: the leaked secret still lives in the local transcript, so the next
17
- * `nomad push` re-copies it (via `remapPush`) and `nomad doctor --check-shared`
18
- * keeps reporting it (it scans the live `~/.claude/projects/` source, not the
19
- * repo index). Points at the exact live transcript when it resolves for this
20
- * host, or a generic `~/.claude/projects/<encoded>/<id>.jsonl` template
21
- * otherwise. Advisory output only; never mutates state.
16
+ * only: the local source still contains the secret, so the next `nomad push`
17
+ * re-copies it (via `remapPush`) and `nomad doctor --check-shared` keeps
18
+ * reporting it (it scans the live `~/.claude/projects/` source, not the repo
19
+ * index). Full remediation requires rotating the credential, then running
20
+ * `nomad redact <id>` (or scrubbing the local transcript manually). Advisory
21
+ * output only; never mutates state.
22
22
  *
23
23
  * @param id Already-validated session id.
24
24
  * @param matches Repo-relative paths collected by `collectMatches`.
@@ -27,10 +27,12 @@ export function reportScrubHint(id: string, matches: string[]): void {
27
27
  const live = resolveLiveTranscript(id, matches);
28
28
  const target = live ?? `~/.claude/projects/<encoded>/${id}.jsonl`;
29
29
  log(
30
- 'note: this only un-stages the session from the next push. The leaked secret\n' +
31
- ' is still in your local transcript, so nomad push re-stages it and nomad\n' +
32
- ' doctor --check-shared keeps reporting it. To remediate, rotate the\n' +
33
- ` credential, then scrub ${target}`,
30
+ 'note: this only un-stages the session from the next push.\n' +
31
+ ' The local source still contains the secret, so nomad push re-stages it\n' +
32
+ ' on the next run and nomad doctor --check-shared keeps reporting it.\n' +
33
+ ' To fully remediate: rotate the credential, then run:\n' +
34
+ ` nomad redact ${id}\n` +
35
+ ` (or scrub ${target} manually)`,
34
36
  );
35
37
  }
36
38
 
@@ -0,0 +1,165 @@
1
+ /**
2
+ * I/O action dispatchers for the push-time recovery menu: `applyAllow`,
3
+ * `applyRedact`, `collectActions`, `dispatchActions`, `redactAllFindings`.
4
+ * Pure seams live in `commands.push.recovery.seams.ts`; lock-free drop
5
+ * helper in `commands.push.recovery.drop.ts`.
6
+ */
7
+
8
+ import type { PathMap } from './config.ts';
9
+ import { appendGitleaksIgnore } from './commands.redact.ts';
10
+ import { applyRedact } from './commands.push.recovery.redact.ts';
11
+ import { dropSessionFromStaged } from './commands.push.recovery.drop.ts';
12
+ import type { Finding } from './push-gitleaks.scan.ts';
13
+ import { scanFile } from './push-gitleaks.scan.ts';
14
+ import { log } from './utils.ts';
15
+ import {
16
+ type FindingAction,
17
+ type PromptFn,
18
+ findingKey,
19
+ parseAction,
20
+ sessionIdFromFinding,
21
+ } from './commands.push.recovery.seams.ts';
22
+
23
+ export type { FindingAction, PromptFn };
24
+ export { dropSessionFromStaged, findingKey, parseAction, sessionIdFromFinding };
25
+
26
+ /** Apply the Allow action: append the finding's fingerprint to .gitleaksignore. */
27
+ export function applyAllow(f: Finding): void {
28
+ appendGitleaksIgnore(f.Fingerprint);
29
+ }
30
+
31
+ /**
32
+ * Walk all findings and prompt the user for one action each. Returns a map
33
+ * from `findingKey` to the chosen action, defaulting to `'skip'` on empty
34
+ * input.
35
+ *
36
+ * @param findings The findings to present.
37
+ * @param prompt An injectable prompt function (one question per call).
38
+ * @returns Populated actions map.
39
+ */
40
+ export async function collectActions(
41
+ findings: Finding[],
42
+ prompt: PromptFn,
43
+ ): Promise<Map<string, FindingAction>> {
44
+ const actions = new Map<string, FindingAction>();
45
+ for (const f of findings) {
46
+ const sid = sessionIdFromFinding(f);
47
+ const header =
48
+ `\nFinding: ${f.RuleID} in ${f.File} line ${f.StartLine}` +
49
+ (sid !== null ? ` (session: ${sid})` : '') +
50
+ '\n [R]edact [A]llow [D]rop session [S]kip (default)\n';
51
+ actions.set(findingKey(f), parseAction(await prompt(header + '> ')));
52
+ }
53
+ return actions;
54
+ }
55
+
56
+ /**
57
+ * Apply one finding's triaged action against local state. Extracted from
58
+ * `dispatchActions` so each function stays under the cognitive-complexity gate.
59
+ * `redactedSids` and `droppedSids` are mutated in place so per-session
60
+ * de-duplication is maintained across the caller's loop. Drop wins: once a
61
+ * session id appears in `droppedSids`, subsequent redact or allow actions for
62
+ * findings in that session are skipped.
63
+ *
64
+ * @param f The finding to act on.
65
+ * @param findings Full findings list (passed to `applyRedact` for per-session redaction).
66
+ * @param actions The action map returned by `collectActions`.
67
+ * @param ts Backup timestamp.
68
+ * @param map Parsed path-map.
69
+ * @param nowMs Injectable clock.
70
+ * @param scan Injectable scan function for `applyRedact`.
71
+ * @param drop Injectable staged-copy remover for the Drop action.
72
+ * @param redactedSids Set of already-redacted session ids (mutated in place).
73
+ * @param droppedSids Set of already-dropped session ids (mutated in place).
74
+ */
75
+ function dispatchOne(
76
+ f: Finding,
77
+ findings: Finding[],
78
+ actions: Map<string, FindingAction>,
79
+ ts: string,
80
+ map: PathMap,
81
+ nowMs: () => number,
82
+ scan: (p: string) => Finding[] | null,
83
+ drop: (sid: string, map: PathMap) => boolean,
84
+ redactedSids: Set<string>,
85
+ droppedSids: Set<string>,
86
+ ): void {
87
+ const action = actions.get(findingKey(f)) ?? 'skip';
88
+ if (action === 'skip') return;
89
+ if (action === 'allow') {
90
+ applyAllow(f);
91
+ return;
92
+ }
93
+ const sid = sessionIdFromFinding(f);
94
+ if (sid === null) return;
95
+ if (droppedSids.has(sid)) return;
96
+ if (action === 'drop') {
97
+ droppedSids.add(sid);
98
+ if (drop(sid, map)) {
99
+ log(
100
+ `dropped session ${sid} from this push (local transcript kept; the secret remains in your local copy)`,
101
+ );
102
+ }
103
+ return;
104
+ }
105
+ if (action === 'redact' && !redactedSids.has(sid)) {
106
+ if (applyRedact(f, findings, ts, map, nowMs, scan)) redactedSids.add(sid);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Dispatch all non-skip actions from the triage map against local state.
112
+ * Redacted sessions are de-duplicated: the first finding for a given session
113
+ * triggers the in-place rewrite; subsequent findings for the same session are
114
+ * skipped (the rewrite already covered all findings in one pass).
115
+ *
116
+ * @param findings Full findings list from the current verdict.
117
+ * @param actions The action map returned by `collectActions`.
118
+ * @param ts Backup timestamp.
119
+ * @param map Parsed path-map.
120
+ * @param nowMs Injectable clock.
121
+ * @param scan Injectable scan function for `applyRedact` (default: `scanFile`).
122
+ * @param drop Injectable staged-copy remover for the Drop action (default: `dropSessionFromStaged`).
123
+ */
124
+ export function dispatchActions(
125
+ findings: Finding[],
126
+ actions: Map<string, FindingAction>,
127
+ ts: string,
128
+ map: PathMap,
129
+ nowMs: () => number,
130
+ scan: (p: string) => Finding[] | null = scanFile,
131
+ drop: (sid: string, map: PathMap) => boolean = dropSessionFromStaged,
132
+ ): void {
133
+ const redactedSids = new Set<string>();
134
+ const droppedSids = new Set<string>();
135
+ for (const f of findings) {
136
+ dispatchOne(f, findings, actions, ts, map, nowMs, scan, drop, redactedSids, droppedSids);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Batch-redact all findings non-interactively (the `--redact-all` path).
142
+ * Does not require a TTY. Findings with no resolvable session id are skipped.
143
+ * Sessions are de-duplicated: the first finding per session triggers the
144
+ * rewrite.
145
+ *
146
+ * @param findings All findings from the current verdict.
147
+ * @param ts Backup timestamp.
148
+ * @param map Parsed path-map.
149
+ * @param nowMs Injectable clock.
150
+ * @param scan Injectable scan function for `applyRedact` (default: `scanFile`).
151
+ */
152
+ export function redactAllFindings(
153
+ findings: Finding[],
154
+ ts: string,
155
+ map: PathMap,
156
+ nowMs: () => number,
157
+ scan: (p: string) => Finding[] | null = scanFile,
158
+ ): void {
159
+ const redactedSids = new Set<string>();
160
+ for (const f of findings) {
161
+ const sid = sessionIdFromFinding(f);
162
+ if (sid === null || redactedSids.has(sid)) continue;
163
+ if (applyRedact(f, findings, ts, map, nowMs, scan)) redactedSids.add(sid);
164
+ }
165
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lock-free session drop helper for the push-time recovery menu.
3
+ * `dropSessionFromStaged` removes a session's generated copies from the
4
+ * `REPO_HOME/shared/projects/` tree so the recovery loop's subsequent
5
+ * `git add -A` stages the deletion rather than re-staging the file.
6
+ *
7
+ * Kept separate from `commands.push.recovery.actions.ts` to respect the
8
+ * ~220-line module cap.
9
+ */
10
+
11
+ import { rmSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import type { PathMap } from './config.ts';
15
+ import { REPO_HOME } from './config.ts';
16
+
17
+ /**
18
+ * Remove the session's generated copies from the staged tree under
19
+ * `REPO_HOME/shared/projects/<logical>/` so the subsequent `git add -A` in
20
+ * the recovery loop stages the deletion rather than re-staging the file.
21
+ *
22
+ * Removes both the flat `<sid>.jsonl` transcript and the sibling subagent
23
+ * directory `<sid>/` (if present) for every logical project in `map`. These
24
+ * are generated copies produced by `remapPush`; the originals under
25
+ * `~/.claude/projects/` are never touched.
26
+ *
27
+ * Lock-free by design: the caller (`dispatchActions`) runs inside a `push`
28
+ * that already holds the global nomad lock. Calling `cmdDropSession` here
29
+ * would deadlock on the lock it already owns.
30
+ *
31
+ * @param sid Session id to drop from the staged tree.
32
+ * @param map Parsed path-map; provides the logical project names.
33
+ * @returns True when `map.projects` has at least one logical entry (the
34
+ * session copies were targeted for removal), false when the map is empty
35
+ * and no paths were evaluated.
36
+ */
37
+ export function dropSessionFromStaged(sid: string, map: PathMap): boolean {
38
+ const logicals = Object.keys(map.projects);
39
+ if (logicals.length === 0) return false;
40
+ for (const logical of logicals) {
41
+ const jsonl = join(REPO_HOME, 'shared', 'projects', logical, `${sid}.jsonl`);
42
+ const dir = join(REPO_HOME, 'shared', 'projects', logical, sid);
43
+ rmSync(jsonl, { force: true });
44
+ rmSync(dir, { recursive: true, force: true });
45
+ }
46
+ return true;
47
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The Redact action for the push-time recovery menu. `applyRedact` resolves a
3
+ * finding's local transcript, refuses live sessions, re-scans the local file
4
+ * WITHOUT `--redact` to recover real secret values, rewrites it in place, and
5
+ * copies the cleaned file back to the staged tree.
6
+ *
7
+ * Split from `commands.push.recovery.actions.ts` to keep both modules under the
8
+ * ~220-line cap. Depends only on lower-level helpers (no import of the actions
9
+ * module), so the dependency direction stays acyclic: actions -> redact.
10
+ */
11
+
12
+ import { cpSync, readFileSync, statSync, writeFileSync } from 'node:fs';
13
+ import { join, sep } from 'node:path';
14
+
15
+ import type { PathMap } from './config.ts';
16
+ import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
17
+ import { applyRedactions, isRecentlyModified, resolveLiveTranscript } from './commands.redact.ts';
18
+ import type { Finding } from './push-gitleaks.scan.ts';
19
+ import { scanFile } from './push-gitleaks.scan.ts';
20
+ import { backupBeforeWrite } from './utils.fs.ts';
21
+ import { encodePath } from './utils.json.ts';
22
+ import { log } from './utils.ts';
23
+ import { sessionIdFromFinding } from './commands.push.recovery.seams.ts';
24
+
25
+ /**
26
+ * Apply the Redact action for one finding. Resolves the local transcript,
27
+ * checks the live-session guard, re-scans the local file (without `--redact`)
28
+ * to obtain real secret values, backs up, rewrites in place (same inode), and
29
+ * surgically copies the file back to the staged tree. Returns true on success,
30
+ * false when the session is active, unresolvable, or the local re-scan fails.
31
+ *
32
+ * The push-verdict findings (`f`, `allFindings`) drive which sessions to act on
33
+ * and provide session-id extraction, but their `Match` fields come from a
34
+ * `--redact` scan and are masked. The local re-scan (via `scan`) runs WITHOUT
35
+ * `--redact` so `applyRedactions` receives the real secret values.
36
+ *
37
+ * @param f Trigger finding (used for session-id extraction).
38
+ * @param allFindings Full finding set for this run (used for session-id
39
+ * matching; values are masked and not used for redaction).
40
+ * @param ts Backup timestamp for `backupBeforeWrite`.
41
+ * @param map Parsed path-map for staged-tree path resolution.
42
+ * @param nowMs Injectable clock for the live-session mtime check.
43
+ * @param scan Injectable scan function for local re-scan (default: `scanFile`).
44
+ * @returns True when the redaction was applied; false when refused or failed.
45
+ */
46
+ export function applyRedact(
47
+ f: Finding,
48
+ allFindings: Finding[],
49
+ ts: string,
50
+ map: PathMap,
51
+ nowMs: () => number,
52
+ scan: (p: string) => Finding[] | null = scanFile,
53
+ ): boolean {
54
+ /** Emit a refusal message and return false. */
55
+ const refuse = (msg: string): false => {
56
+ log(msg);
57
+ return false;
58
+ };
59
+
60
+ const sid = sessionIdFromFinding(f);
61
+ if (sid === null) {
62
+ return refuse(
63
+ `could not locate the local transcript for this finding; choose Skip or Drop session.`,
64
+ );
65
+ }
66
+ const localPath = resolveLiveTranscript(sid);
67
+ if (localPath === null) {
68
+ return refuse(
69
+ `could not locate the local transcript for session ${sid}; choose Skip or Drop session.`,
70
+ );
71
+ }
72
+ if (isRecentlyModified(statSync(localPath).mtimeMs, nowMs())) {
73
+ return refuse(
74
+ `session ${sid} looks active (modified within the last 5 minutes); refusing to redact, no changes made.\n` +
75
+ ` End the session and choose Redact again, or choose Drop session (holds this session back` +
76
+ ` from the push, local copy kept) or Skip.`,
77
+ );
78
+ }
79
+
80
+ // Re-scan without --redact to get real secret values for value-based redaction.
81
+ // Push-verdict findings have masked Match fields and cannot be used directly.
82
+ const realFindings = scan(localPath);
83
+ if (realFindings === null) {
84
+ return refuse(`re-scan of the transcript failed; choose Skip or Drop session.`);
85
+ }
86
+ if (realFindings.length === 0) {
87
+ return refuse(
88
+ `nothing to redact in the local transcript for session ${sid}; choose Skip or Drop session.`,
89
+ );
90
+ }
91
+
92
+ backupBeforeWrite(localPath, ts);
93
+ writeFileSync(localPath, applyRedactions(readFileSync(localPath, 'utf8'), realFindings), 'utf8');
94
+
95
+ let copied = false;
96
+ for (const [logical, hostMap] of Object.entries(map.projects)) {
97
+ const abs = hostMap[HOST];
98
+ if (abs === undefined) continue;
99
+ if (localPath.startsWith(join(CLAUDE_HOME, 'projects', encodePath(abs)) + sep)) {
100
+ cpSync(localPath, join(REPO_HOME, 'shared', 'projects', logical, `${sid}.jsonl`), {
101
+ force: true,
102
+ });
103
+ copied = true;
104
+ break;
105
+ }
106
+ }
107
+ if (!copied) {
108
+ return refuse(
109
+ `could not map the local transcript for session ${sid} to a staged copy; choose Drop session or Skip.`,
110
+ );
111
+ }
112
+ return true;
113
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pure, side-effect-free seams for the push-time recovery menu: key
3
+ * derivation, session-id extraction, and prompt-answer parsing. Extracted from
4
+ * `commands.push.recovery.actions.ts` so both modules stay under the 220-line
5
+ * advisory cap.
6
+ */
7
+
8
+ import type { Finding } from './push-gitleaks.scan.ts';
9
+ import { SESSION_PATH } from './push-gitleaks.ts';
10
+
11
+ /** Action a user can assign to one finding in the recovery menu. */
12
+ export type FindingAction = 'redact' | 'allow' | 'drop' | 'skip';
13
+
14
+ /** Prompt function: asks one question and returns the answer. */
15
+ export type PromptFn = (prompt: string) => Promise<string>;
16
+
17
+ /**
18
+ * Build a stable key for a finding used as the actions-map key. Includes the
19
+ * rule id so two findings at the same file/line/column but different rules
20
+ * produce distinct keys and do not collide in the actions map.
21
+ *
22
+ * @param f The gitleaks finding.
23
+ * @returns A colon-delimited key combining file, start line, start column, and rule id.
24
+ */
25
+ export function findingKey(f: Finding): string {
26
+ return `${f.File}:${f.StartLine}:${f.StartColumn}:${f.RuleID}`;
27
+ }
28
+
29
+ /** Valid session id charset: alphanumeric, hyphen, underscore (same as cmdDropSession/cmdRedact). */
30
+ const VALID_SID = /^[A-Za-z0-9_-]+$/;
31
+
32
+ /**
33
+ * Extract the session id from a finding's File path. Handles both the flat
34
+ * `shared/projects/<logical>/<sid>.jsonl` form (SESSION_PATH) and the deeper
35
+ * subagent form `shared/projects/<logical>/<sid>/...`. The extracted id is
36
+ * validated against `/^[A-Za-z0-9_-]+$/` before being returned; path-traversal
37
+ * segments (e.g. `..`) are rejected and cause a null return.
38
+ *
39
+ * @param f The gitleaks finding.
40
+ * @returns The session id, or null when the path matches neither pattern or the
41
+ * extracted id contains characters outside `[A-Za-z0-9_-]`.
42
+ */
43
+ export function sessionIdFromFinding(f: Finding): string | null {
44
+ // Try the flat `<sid>.jsonl` form first, then the deeper subagent form. Both
45
+ // patterns capture the session id at group 1; a matched capture group is
46
+ // always a string, so no nullish guard on `m[1]` is needed.
47
+ const m = SESSION_PATH.exec(f.File) ?? /^shared\/projects\/[^/]+\/([^/]+)\//.exec(f.File);
48
+ if (m === null) return null;
49
+ const sid = m[1];
50
+ return VALID_SID.test(sid) ? sid : null;
51
+ }
52
+
53
+ /**
54
+ * Parse a raw prompt answer into a `FindingAction`. Returns `'skip'` for
55
+ * empty, blank, or unrecognized input (D-02 default).
56
+ *
57
+ * @param raw The untrimmed string returned by the prompt.
58
+ * @returns The corresponding action, defaulting to `'skip'`.
59
+ */
60
+ export function parseAction(raw: string): FindingAction {
61
+ const t = raw.trim().toLowerCase();
62
+ if (t === 'r' || t === 'redact') return 'redact';
63
+ if (t === 'a' || t === 'allow') return 'allow';
64
+ if (t === 'd' || t === 'drop') return 'drop';
65
+ return 'skip';
66
+ }