claude-nomad 0.62.6 → 0.63.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/dist/nomad.mjs CHANGED
@@ -1082,12 +1082,40 @@ import { execFileSync as execFileSync8 } from "node:child_process";
1082
1082
  import { mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync as readFileSync6, rmSync as rmSync8 } from "node:fs";
1083
1083
  import { homedir as homedir3 } from "node:os";
1084
1084
  import { join as join23 } from "node:path";
1085
+ function toFinding(entry) {
1086
+ if (typeof entry !== "object" || entry === null) return null;
1087
+ const e = entry;
1088
+ const str = (v) => typeof v === "string" ? v : "";
1089
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
1090
+ const file = str(e.File);
1091
+ const rule = str(e.RuleID);
1092
+ if (file === "" || rule === "") return null;
1093
+ return {
1094
+ RuleID: rule,
1095
+ File: file,
1096
+ StartLine: num(e.StartLine),
1097
+ EndLine: Number.isFinite(e.EndLine) ? e.EndLine : void 0,
1098
+ StartColumn: num(e.StartColumn),
1099
+ EndColumn: num(e.EndColumn),
1100
+ Match: str(e.Match),
1101
+ Fingerprint: str(e.Fingerprint),
1102
+ Description: typeof e.Description === "string" ? e.Description : void 0,
1103
+ Secret: typeof e.Secret === "string" ? e.Secret : void 0,
1104
+ Entropy: Number.isFinite(e.Entropy) ? e.Entropy : void 0
1105
+ };
1106
+ }
1085
1107
  function readGitleaksReport(reportPath) {
1086
1108
  try {
1087
1109
  const raw = readFileSync6(reportPath, "utf8");
1088
1110
  const parsed = JSON.parse(raw);
1089
1111
  if (!Array.isArray(parsed)) return null;
1090
- return parsed;
1112
+ const findings = [];
1113
+ for (const entry of parsed) {
1114
+ const finding = toFinding(entry);
1115
+ if (finding === null) return null;
1116
+ findings.push(finding);
1117
+ }
1118
+ return findings;
1091
1119
  } catch {
1092
1120
  return null;
1093
1121
  }
@@ -1796,6 +1824,10 @@ function* eachExtrasTarget(v, counts) {
1796
1824
  }
1797
1825
  }
1798
1826
  function copyExtrasOverlayFiltered(src, dst, blockSet) {
1827
+ const dstStat = lstatSync2(dst, { throwIfNoEntry: false });
1828
+ if (dstStat !== void 0 && !dstStat.isDirectory()) {
1829
+ rmSync2(dst, { recursive: true, force: true });
1830
+ }
1799
1831
  stripCollidingDstSymlinks(src, dst, (name) => isDeniedName(blockSet, name));
1800
1832
  cpSyncGuarded(
1801
1833
  src,
@@ -1961,16 +1993,32 @@ function applySharedLinks(ts, map, opts = {}) {
1961
1993
  }
1962
1994
  }
1963
1995
  function syncSharedLinksPush(map) {
1996
+ mirrorSharedNames(map, { adoptNew: true, overlay: false });
1997
+ }
1998
+ function stageLocalSharedEdits(map, ts) {
1999
+ mirrorSharedNames(map, { adoptNew: false, overlay: true, backupTs: ts });
2000
+ }
2001
+ function mirrorOneSharedName(name, claude, repo, policy) {
2002
+ const localPath = join5(claude, name);
2003
+ const stat = lstatSync4(localPath, { throwIfNoEntry: false });
2004
+ if (stat === void 0) return;
2005
+ if (stat.isSymbolicLink()) return;
2006
+ const target = join5(repo, "shared", name);
2007
+ if (!policy.adoptNew && !existsSync4(target)) return;
2008
+ if (policy.backupTs !== void 0) backupRepoWrite(target, policy.backupTs, repo);
2009
+ if (policy.overlay && stat.isDirectory()) {
2010
+ copyExtrasOverlayFiltered(localPath, target, ALWAYS_NEVER_SYNC);
2011
+ } else {
2012
+ copyExtrasFiltered(localPath, target, ALWAYS_NEVER_SYNC);
2013
+ }
2014
+ }
2015
+ function mirrorSharedNames(map, policy) {
1964
2016
  if (process.platform !== "win32") return;
1965
2017
  if (map === null) return;
1966
2018
  const claude = claudeHome();
1967
2019
  const repo = repoHome();
1968
2020
  for (const name of allSharedLinks(map)) {
1969
- const localPath = join5(claude, name);
1970
- const stat = lstatSync4(localPath, { throwIfNoEntry: false });
1971
- if (stat === void 0) continue;
1972
- if (stat.isSymbolicLink()) continue;
1973
- copyExtrasFiltered(localPath, join5(repo, "shared", name), ALWAYS_NEVER_SYNC);
2021
+ mirrorOneSharedName(name, claude, repo, policy);
1974
2022
  }
1975
2023
  }
1976
2024
  function readExistingSettings(settingsPath) {
@@ -4883,8 +4931,10 @@ function reportLongPathsCheck(section2, run = execFileSync12) {
4883
4931
  "enable LongPathsEnabled in Local Group Policy or the registry (admin required)"
4884
4932
  );
4885
4933
  }
4934
+ var MODALITY_COPY_SYNC = "copy-sync (native Windows; local edits reach the repo on the next pull or push)";
4935
+ var MODALITY_SYMLINK = "symlink (posix)";
4886
4936
  function reportSyncModality(section2) {
4887
- const modality = process.platform === "win32" ? "copy-sync (win32)" : "symlink (posix)";
4937
+ const modality = process.platform === "win32" ? MODALITY_COPY_SYNC : MODALITY_SYMLINK;
4888
4938
  addItem(section2, `${dim(infoGlyph)} sync modality: ${modality}`);
4889
4939
  }
4890
4940
 
@@ -4955,248 +5005,19 @@ init_config();
4955
5005
  import { readFileSync as readFileSync17 } from "node:fs";
4956
5006
  import { isAbsolute as isAbsolute2, resolve as resolve3, sep as sep8 } from "node:path";
4957
5007
 
4958
- // src/commands.push.recovery.redact.ts
4959
- init_config();
4960
- init_config_sharedDirs_guard();
4961
- import { cpSync as cpSync8, existsSync as existsSync31, mkdirSync as mkdirSync10 } from "node:fs";
4962
- import { dirname as dirname11, join as join39, sep as sep6 } from "node:path";
4963
-
4964
- // src/commands.redact.ts
4965
- init_config();
4966
- import { existsSync as existsSync30 } from "node:fs";
4967
- import { dirname as dirname9, join as join37 } from "node:path";
4968
-
4969
- // src/commands.redact.subtree.ts
4970
- import { existsSync as existsSync29, lstatSync as lstatSync11, readFileSync as readFileSync14, readdirSync as readdirSync13, statSync as statSync6, writeFileSync as writeFileSync6 } from "node:fs";
4971
- import { join as join36 } from "node:path";
4972
- init_utils_fs();
4973
- init_utils();
4974
- function collectFiles(dir, out) {
4975
- if (!existsSync29(dir)) return;
4976
- const st = lstatSync11(dir);
4977
- if (!st.isDirectory()) return;
4978
- for (const entry of readdirSync13(dir)) {
4979
- const abs = join36(dir, entry);
4980
- const lst = lstatSync11(abs);
4981
- if (lst.isSymbolicLink()) continue;
4982
- if (lst.isDirectory()) {
4983
- collectFiles(abs, out);
4984
- continue;
4985
- }
4986
- if (lst.isFile()) out.push(abs);
4987
- }
4988
- }
4989
- function listSubtreeFiles(sessionDir) {
4990
- const out = [];
4991
- collectFiles(sessionDir, out);
4992
- return out.sort((a, b) => a.localeCompare(b));
4993
- }
4994
- function newestSubtreeMtimeMs(mainPath, subtreeFiles, statMtime = (p) => statSync6(p).mtimeMs) {
4995
- let newest = statMtime(mainPath);
4996
- for (const filePath of subtreeFiles) {
4997
- const t = statMtime(filePath);
4998
- if (t > newest) newest = t;
4999
- }
5000
- return newest;
5001
- }
5002
- function isSubtreeActive(mainPath, subtreeFiles, now) {
5003
- const subtreeMtime = newestSubtreeMtimeMs(mainPath, subtreeFiles, (p) => statSync6(p).mtimeMs);
5004
- return isRecentlyModified(subtreeMtime, now);
5005
- }
5006
- function applySubtreeRedactions(mainPath, mainFindings, subtreeFiles, rule, ts, scan, dryRun) {
5007
- const dirty = [];
5008
- if (mainFindings.length > 0) dirty.push({ path: mainPath, findings: mainFindings });
5009
- for (const filePath of subtreeFiles) {
5010
- const raw = scan(filePath);
5011
- if (raw === null || raw.length === 0) continue;
5012
- const filtered = rule === void 0 ? raw : raw.filter((f) => f.RuleID === rule);
5013
- if (filtered.length === 0) continue;
5014
- dirty.push({ path: filePath, findings: filtered });
5015
- }
5016
- const total = dirty.reduce((n, e) => n + e.findings.length, 0);
5017
- if (!dryRun && total > 0) {
5018
- for (const { path: filePath, findings } of dirty) {
5019
- backupBeforeWrite(filePath, ts);
5020
- const before = readFileSync14(filePath, "utf8");
5021
- const after = applyRedactions(before, findings);
5022
- if (after === before) {
5023
- log(
5024
- `warning: no redaction applied to ${filePath}: finding match values were not located in the file. Inspect it manually; the push re-scan still blocks a real leak.`
5025
- );
5026
- }
5027
- writeFileSync6(filePath, after, "utf8");
5028
- }
5029
- }
5030
- return { total, dirty };
5031
- }
5032
-
5033
- // src/commands.pushed-history.ts
5034
- init_utils();
5035
- import { execFileSync as execFileSync14 } from "node:child_process";
5036
- function pushedRef(repo) {
5037
- try {
5038
- const ref = execFileSync14("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
5039
- cwd: repo,
5040
- stdio: ["ignore", "pipe", "ignore"]
5041
- }).toString().trim();
5042
- return ref.length > 0 ? ref : null;
5043
- } catch {
5044
- return null;
5045
- }
5046
- }
5047
- function sessionInPushedHistory(id, repo) {
5048
- const ref = pushedRef(repo);
5049
- if (ref === null) return false;
5050
- try {
5051
- const out = execFileSync14(
5052
- "git",
5053
- [
5054
- "log",
5055
- ref,
5056
- "--oneline",
5057
- "-1",
5058
- "--",
5059
- `shared/projects/*/${id}.jsonl`,
5060
- `shared/projects/*/${id}/*`
5061
- ],
5062
- { cwd: repo, stdio: ["ignore", "pipe", "ignore"] }
5063
- ).toString().trim();
5064
- return out.length > 0;
5065
- } catch {
5066
- return false;
5067
- }
5068
- }
5069
- function warnIfSessionPushed(id, repo) {
5070
- if (!sessionInPushedHistory(id, repo)) return;
5071
- log(
5072
- `warning: session ${id} is already in pushed history (origin).
5073
- This command only changes your local copy and the next push; it does NOT
5074
- remove the secret from commits already on the remote.
5075
- To fully remediate a real secret: rotate the credential, then rewrite
5076
- history (e.g. with git filter-repo) and force-push, coordinating with
5077
- anyone else who has cloned the repo.`
5078
- );
5079
- }
5080
-
5081
- // src/commands.redact.ts
5082
- init_push_gitleaks_scan();
5083
- init_exit_codes();
5084
- init_utils_fs();
5085
- init_utils_json();
5086
- init_utils();
5087
- function resolveLiveTranscript(id) {
5088
- try {
5089
- const mapPath = join37(repoHome(), "path-map.json");
5090
- if (!existsSync30(mapPath)) return null;
5091
- const projects = readJson(mapPath).projects;
5092
- const claude = claudeHome();
5093
- for (const hostMap of Object.values(projects)) {
5094
- const abs = hostMap[HOST];
5095
- if (abs === void 0) continue;
5096
- const live = join37(claude, "projects", encodePath(abs), `${id}.jsonl`);
5097
- if (existsSync30(live)) return live;
5098
- }
5099
- return null;
5100
- } catch {
5101
- return null;
5102
- }
5103
- }
5104
- function resolveRedactFindings(localPath, rawFindings, rule, scan) {
5105
- const source = rawFindings ?? scan(localPath);
5106
- if (source === null) return null;
5107
- return source.filter((f) => rule === void 0 || f.RuleID === rule);
5108
- }
5109
- function cmdRedact(opts, nowMs = Date.now, scan = scanFile) {
5110
- const { id, rule, dryRun = false, findings: rawFindings } = opts;
5111
- if (id.length === 0 || id.length > 128 || !/^[A-Za-z0-9_-]+$/.test(id)) {
5112
- fail(`invalid session id: ${id}`);
5113
- process.exit(EXIT.USAGE);
5114
- }
5115
- const repo = repoHome();
5116
- const backup = backupBase();
5117
- if (!existsSync30(repo)) die(`repo not cloned at ${repo}`);
5118
- const handle = acquireLock("redact");
5119
- if (handle === null) process.exit(0);
5120
- try {
5121
- const localPath = resolveLiveTranscript(id);
5122
- if (localPath === null || !existsSync30(localPath)) {
5123
- fail(`could not resolve local transcript for session ${id} on this host`);
5124
- process.exitCode = 1;
5125
- return;
5126
- }
5127
- const sessionDir = join37(dirname9(localPath), id);
5128
- const subtreeFiles = listSubtreeFiles(sessionDir);
5129
- if (isSubtreeActive(localPath, subtreeFiles, nowMs())) {
5130
- log(
5131
- `session ${id} was modified recently and may be active.
5132
- Refusing to rewrite a potentially live transcript.
5133
- To proceed: wait for the session to end, then re-run nomad redact.
5134
- Or drop from the staged tree: nomad drop-session ${id}
5135
- Or skip this finding during nomad push.`
5136
- );
5137
- return;
5138
- }
5139
- const mainFindings = resolveRedactFindings(localPath, rawFindings, rule, scan);
5140
- if (mainFindings === null) {
5141
- fail(`gitleaks scan failed for session ${id} (is gitleaks installed?)`);
5142
- process.exitCode = 1;
5143
- return;
5144
- }
5145
- const ts = freshBackupTs(backup);
5146
- const { total: totalCount, dirty } = applySubtreeRedactions(
5147
- localPath,
5148
- mainFindings,
5149
- subtreeFiles,
5150
- rule,
5151
- ts,
5152
- scan,
5153
- dryRun
5154
- );
5155
- if (totalCount === 0) {
5156
- const ruleClause = rule === void 0 ? "" : ` for rule ${rule}`;
5157
- log(`no findings${ruleClause} in session ${id}`);
5158
- return;
5159
- }
5160
- if (dryRun) {
5161
- const lines = dirty.flatMap((e) => e.findings.map((f) => ` ${e.path} line ${f.StartLine} [${f.RuleID}]`)).join("\n");
5162
- log(`dry-run: would redact ${totalCount} finding(s) in session ${id}
5163
- ${lines}`);
5164
- return;
5165
- }
5166
- log(`redacted ${totalCount} finding(s) in ${localPath} (backup: ${ts})`);
5167
- warnIfSessionPushed(id, repo);
5168
- } catch (err) {
5169
- if (!(err instanceof NomadFatal)) {
5170
- throw err;
5171
- }
5172
- fail(err.message);
5173
- process.exitCode = 1;
5174
- } finally {
5175
- releaseLock(handle);
5176
- }
5177
- }
5178
-
5179
- // src/commands.push.recovery.redact.ts
5180
- init_push_gitleaks_scan();
5181
- init_utils_json();
5182
- init_utils();
5183
-
5184
- // src/commands.push.recovery.seams.ts
5185
- init_push_gitleaks();
5186
-
5187
5008
  // src/commands.push.recovery.memory.ts
5188
5009
  init_config();
5189
5010
  init_config_sharedDirs_guard();
5190
5011
  import {
5191
5012
  cpSync as cpSync7,
5192
- lstatSync as lstatSync12,
5013
+ lstatSync as lstatSync11,
5193
5014
  mkdirSync as mkdirSync9,
5194
- readFileSync as readFileSync15,
5015
+ readFileSync as readFileSync14,
5195
5016
  realpathSync as realpathSync4,
5196
- statSync as statSync7,
5197
- writeFileSync as writeFileSync7
5017
+ statSync as statSync6,
5018
+ writeFileSync as writeFileSync6
5198
5019
  } from "node:fs";
5199
- import { dirname as dirname10, join as join38, sep as sep5 } from "node:path";
5020
+ import { dirname as dirname9, join as join36, sep as sep5 } from "node:path";
5200
5021
  init_push_gitleaks_scan();
5201
5022
 
5202
5023
  // src/rel-path-guard.ts
@@ -5225,15 +5046,15 @@ function resolveMemoryLocalPath(logical, relPath, map) {
5225
5046
  if (!isSafeRelPath(relPath)) return null;
5226
5047
  const abs = map.projects[logical]?.[HOST];
5227
5048
  if (abs === void 0) return null;
5228
- const memoryRoot = join38(claudeHome(), "projects", encodePath(abs), "memory");
5229
- const localPath = join38(memoryRoot, ...relPath.split("/"));
5049
+ const memoryRoot = join36(claudeHome(), "projects", encodePath(abs), "memory");
5050
+ const localPath = join36(memoryRoot, ...relPath.split("/"));
5230
5051
  if (localPath !== memoryRoot && !localPath.startsWith(memoryRoot + sep5)) return null;
5231
5052
  try {
5232
- if (lstatSync12(localPath).isSymbolicLink()) return null;
5053
+ if (lstatSync11(localPath).isSymbolicLink()) return null;
5233
5054
  const realLocal = realpathSync4(localPath);
5234
5055
  const realRoot = realpathSync4(memoryRoot);
5235
5056
  if (!realLocal.startsWith(realRoot + sep5)) return null;
5236
- if (!statSync7(realLocal).isFile()) return null;
5057
+ if (!statSync6(realLocal).isFile()) return null;
5237
5058
  } catch {
5238
5059
  return null;
5239
5060
  }
@@ -5272,30 +5093,181 @@ function applyMemoryRedact(f, ts, map, scan = scanFile) {
5272
5093
  return refuse(`nothing to redact in ${logical}/memory/${relPath}; choose Skip.`);
5273
5094
  }
5274
5095
  backupBeforeWrite(localPath, ts);
5275
- const before = readFileSync15(localPath, "utf8");
5096
+ const before = readFileSync14(localPath, "utf8");
5276
5097
  const after = applyRedactions(before, findings);
5277
5098
  if (after === before) {
5278
5099
  warn(
5279
5100
  `no redaction applied to ${logical}/memory/${relPath}: finding match values were not located in the file. Inspect it manually; the push re-scan still blocks a real leak.`
5280
5101
  );
5281
5102
  }
5282
- writeFileSync7(localPath, after, "utf8");
5283
- const dest = join38(repoHome(), "shared", "projects", logical, "memory", ...relPath.split("/"));
5284
- mkdirSync9(dirname10(dest), { recursive: true });
5103
+ writeFileSync6(localPath, after, "utf8");
5104
+ const dest = join36(repoHome(), "shared", "projects", logical, "memory", ...relPath.split("/"));
5105
+ mkdirSync9(dirname9(dest), { recursive: true });
5285
5106
  cpSync7(localPath, dest, { force: true });
5286
5107
  return true;
5287
5108
  }
5288
5109
 
5289
- // src/commands.push.recovery.seams.ts
5290
- var MASK_LEAD = 4;
5291
- var MASK_BODY = "************";
5292
- var CONTEXT_WINDOW = 40;
5293
- var CONTROL_CHARS = /[\x00-\x1f\x7f]/g;
5294
- function findingKey(f) {
5295
- return `${f.File}:${f.StartLine}:${f.StartColumn}:${f.RuleID}`;
5110
+ // src/commands.push.recovery.skills.ts
5111
+ init_config();
5112
+ import {
5113
+ cpSync as cpSync8,
5114
+ lstatSync as lstatSync13,
5115
+ mkdirSync as mkdirSync11,
5116
+ readFileSync as readFileSync15,
5117
+ realpathSync as realpathSync5,
5118
+ statSync as statSync7,
5119
+ writeFileSync as writeFileSync7
5120
+ } from "node:fs";
5121
+ import { dirname as dirname10, join as join38, sep as sep6 } from "node:path";
5122
+
5123
+ // src/skills-sync.ts
5124
+ init_config();
5125
+ import { existsSync as existsSync29, lstatSync as lstatSync12, mkdirSync as mkdirSync10, readdirSync as readdirSync13, rmSync as rmSync13 } from "node:fs";
5126
+ import { join as join37 } from "node:path";
5127
+
5128
+ // src/skills-sync.tracked.ts
5129
+ init_utils();
5130
+ import { basename as basename3 } from "node:path";
5131
+ function trackedRootSkillsAt(ref, repo) {
5132
+ try {
5133
+ const raw = gitCaptureRaw(["ls-tree", "--name-only", "-z", ref, "--", "shared/skills/"], repo);
5134
+ const entries = raw.split("\0").filter((entry) => entry !== "");
5135
+ return new Set(entries.map((entry) => basename3(entry)));
5136
+ } catch {
5137
+ return /* @__PURE__ */ new Set();
5138
+ }
5296
5139
  }
5297
- var VALID_SID = /^[A-Za-z0-9_-]+$/;
5298
- var SUBTREE_PATH = /^shared\/projects\/[^/]+\/([^/]+)\/.+$/;
5140
+
5141
+ // src/skills-sync.ts
5142
+ init_utils_fs();
5143
+ function isGsdOwned(name) {
5144
+ return name.startsWith(GSD_PREFIX);
5145
+ }
5146
+ function isSkillExcluded(name) {
5147
+ return isGsdOwned(name) || isDeniedName(ALWAYS_NEVER_SYNC, name);
5148
+ }
5149
+ function copySkillsPush(src, dst) {
5150
+ const srcNames = readdirSync13(src, { encoding: "utf8" });
5151
+ const blockSet = /* @__PURE__ */ new Set([
5152
+ ...srcNames.filter((n) => isGsdOwned(n)),
5153
+ ...ALWAYS_NEVER_SYNC
5154
+ ]);
5155
+ copyExtrasFiltered(src, dst, blockSet);
5156
+ }
5157
+ function copySkillsPull(src, dst, isRootPreserved) {
5158
+ copyExtrasFilteredPreservingBy(src, dst, isSkillExcluded, isRootPreserved);
5159
+ }
5160
+ function syncSkillsPull(ts, prePostHeads) {
5161
+ const sharedSkills = join37(repoHome(), "shared", "skills");
5162
+ if (!existsSync29(sharedSkills)) return;
5163
+ const localSkills = join37(claudeHome(), "skills");
5164
+ backupBeforeWrite(localSkills, ts);
5165
+ const dstStat = lstatSync12(localSkills, { throwIfNoEntry: false });
5166
+ if (dstStat?.isSymbolicLink() === true) {
5167
+ rmSync13(localSkills, { recursive: true, force: true });
5168
+ mkdirSync10(localSkills, { recursive: true });
5169
+ }
5170
+ const tracked = prePostHeads === void 0 ? /* @__PURE__ */ new Set() : trackedRootSkillsAt(prePostHeads.pre, repoHome());
5171
+ copySkillsPull(sharedSkills, localSkills, (name) => !tracked.has(name));
5172
+ }
5173
+ function syncSkillsPush() {
5174
+ const localSkills = join37(claudeHome(), "skills");
5175
+ const stat = lstatSync12(localSkills, { throwIfNoEntry: false });
5176
+ if (stat === void 0) return;
5177
+ if (stat.isSymbolicLink()) return;
5178
+ const sharedSkills = join37(repoHome(), "shared", "skills");
5179
+ copySkillsPush(localSkills, sharedSkills);
5180
+ }
5181
+
5182
+ // src/commands.push.recovery.skills.ts
5183
+ init_push_gitleaks_scan();
5184
+ init_utils_fs();
5185
+ init_utils();
5186
+ var SKILL_FINDING_PATH = /^shared\/skills\/([^/]+)\/(.+)$/;
5187
+ var SAFE_SKILL_NAME = /^[A-Za-z0-9._-]+$/;
5188
+ var SKILL_DIR_PATH = /^shared\/skills\/[^/]+\//;
5189
+ function skillFileFromFinding(f) {
5190
+ const m = SKILL_FINDING_PATH.exec(f.File);
5191
+ if (m?.[1] === void 0 || m[2] === void 0) return null;
5192
+ return { name: m[1], relPath: m[2] };
5193
+ }
5194
+ function isSkillFindingPath(f) {
5195
+ return SKILL_DIR_PATH.test(f.File);
5196
+ }
5197
+ function resolveSkillLocalPath(name, relPath) {
5198
+ if (!SAFE_SKILL_NAME.test(name) || name === "." || name === "..") return null;
5199
+ if (isGsdOwned(name)) return null;
5200
+ if (!isSafeRelPath(relPath)) return null;
5201
+ const skillsRoot = join38(claudeHome(), "skills");
5202
+ const skillRoot = join38(skillsRoot, name);
5203
+ const localPath = join38(skillRoot, ...relPath.split("/"));
5204
+ if (localPath !== skillRoot && !localPath.startsWith(skillRoot + sep6)) return null;
5205
+ try {
5206
+ if (lstatSync13(localPath).isSymbolicLink()) return null;
5207
+ const realLocal = realpathSync5(localPath);
5208
+ const realRoot = realpathSync5(skillsRoot);
5209
+ if (!realLocal.startsWith(realRoot + sep6)) return null;
5210
+ if (!statSync7(realLocal).isFile()) return null;
5211
+ } catch {
5212
+ return null;
5213
+ }
5214
+ return localPath;
5215
+ }
5216
+ function preflightSkillRedactable(f) {
5217
+ const parsed = skillFileFromFinding(f);
5218
+ if (parsed === null) return "a finding is not a skill file";
5219
+ const localPath = resolveSkillLocalPath(parsed.name, parsed.relPath);
5220
+ if (localPath === null) {
5221
+ return `skill file ${parsed.name}/${parsed.relPath}: local file not found or unresolvable`;
5222
+ }
5223
+ return null;
5224
+ }
5225
+ function applySkillRedact(f, ts, scan = scanFile) {
5226
+ const refuse = (msg) => {
5227
+ log(msg);
5228
+ return false;
5229
+ };
5230
+ const parsed = skillFileFromFinding(f);
5231
+ if (parsed === null) {
5232
+ return refuse("could not parse this finding as a skill file; choose Skip.");
5233
+ }
5234
+ const { name, relPath } = parsed;
5235
+ const localPath = resolveSkillLocalPath(name, relPath);
5236
+ if (localPath === null) {
5237
+ return refuse(`could not locate the local skill file for ${name}/${relPath}; choose Skip.`);
5238
+ }
5239
+ const findings = scan(localPath);
5240
+ if (findings === null) {
5241
+ return refuse(`re-scan of ${name}/${relPath} failed; choose Skip.`);
5242
+ }
5243
+ if (findings.length === 0) {
5244
+ return refuse(`nothing to redact in ${name}/${relPath}; choose Skip.`);
5245
+ }
5246
+ backupBeforeWrite(localPath, ts);
5247
+ const before = readFileSync15(localPath, "utf8");
5248
+ const after = applyRedactions(before, findings);
5249
+ if (after === before) {
5250
+ warn(
5251
+ `no redaction applied to ${name}/${relPath}: finding match values were not located in the file. Inspect it manually; the push re-scan still blocks a real leak.`
5252
+ );
5253
+ }
5254
+ writeFileSync7(localPath, after, "utf8");
5255
+ const dest = join38(repoHome(), "shared", "skills", name, ...relPath.split("/"));
5256
+ mkdirSync11(dirname10(dest), { recursive: true });
5257
+ cpSync8(localPath, dest, { force: true });
5258
+ return true;
5259
+ }
5260
+
5261
+ // src/commands.push.recovery.allow-gate.ts
5262
+ init_utils();
5263
+
5264
+ // src/commands.push.recovery.seams.ts
5265
+ init_push_gitleaks();
5266
+ function findingKey(f) {
5267
+ return `${f.File}:${f.StartLine}:${f.StartColumn}:${f.RuleID}`;
5268
+ }
5269
+ var VALID_SID = /^[A-Za-z0-9_-]+$/;
5270
+ var SUBTREE_PATH = /^shared\/projects\/[^/]+\/([^/]+)\/.+$/;
5299
5271
  function sessionIdFromFinding(f) {
5300
5272
  if (isMemoryFindingPath(f)) return null;
5301
5273
  const m = SESSION_PATH.exec(f.File) ?? SUBTREE_PATH.exec(f.File);
@@ -5310,43 +5282,283 @@ function parseAction(raw) {
5310
5282
  if (t === "d" || t === "drop") return "drop";
5311
5283
  return "skip";
5312
5284
  }
5313
- function maskSecret(secret) {
5314
- return secret.slice(0, MASK_LEAD) + MASK_BODY;
5285
+
5286
+ // src/commands.push.recovery.allow-gate.ts
5287
+ function isCleared(f, state) {
5288
+ const action = state.actions.get(findingKey(f)) ?? "skip";
5289
+ if (action === "allow") return true;
5290
+ const sid = sessionIdFromFinding(f);
5291
+ if (sid !== null) return state.droppedSids.has(sid) || state.redactedSids.has(sid);
5292
+ if (isMemoryFindingPath(f)) {
5293
+ const parsed = memoryFileFromFinding(f);
5294
+ return parsed !== null && state.redactedMemory.has(`${parsed.logical}/${parsed.relPath}`);
5295
+ }
5296
+ if (isSkillFindingPath(f)) {
5297
+ const parsed = skillFileFromFinding(f);
5298
+ return parsed !== null && state.redactedSkills.has(`${parsed.name}/${parsed.relPath}`);
5299
+ }
5300
+ return false;
5315
5301
  }
5316
- function buildFindingContext(finding, readLine) {
5317
- const raw = readLine(finding.File, finding.StartLine);
5318
- if (raw !== null) {
5319
- const len = raw.length;
5320
- const startCol = Math.max(1, Math.min(finding.StartColumn, len + 1));
5321
- const endCol = Math.max(startCol, Math.min(finding.EndColumn, len));
5322
- const spanStart = startCol - 1;
5323
- const spanEnd = endCol;
5324
- const secret = raw.slice(spanStart, spanEnd);
5325
- const masked = maskSecret(secret);
5326
- const fullPrefix = raw.slice(0, spanStart);
5327
- const fullSuffix = raw.slice(spanEnd);
5328
- const prefixTruncated = fullPrefix.length > CONTEXT_WINDOW;
5329
- const suffixTruncated = fullSuffix.length > CONTEXT_WINDOW;
5330
- const prefix = prefixTruncated ? fullPrefix.slice(fullPrefix.length - CONTEXT_WINDOW) : fullPrefix;
5331
- const suffix = suffixTruncated ? fullSuffix.slice(0, CONTEXT_WINDOW) : fullSuffix;
5332
- const excerpt = (prefixTruncated ? "..." : "") + prefix + masked + suffix + (suffixTruncated ? "..." : "");
5333
- const stripped = excerpt.replace(CONTROL_CHARS, "");
5334
- if (stripped.trim().length > 0) return stripped;
5335
- }
5336
- if (finding.Match.length > 0) {
5337
- return maskSecret(finding.Match).replace(CONTROL_CHARS, "");
5302
+ function byFingerprint(findings) {
5303
+ const groups = /* @__PURE__ */ new Map();
5304
+ for (const f of findings) {
5305
+ if (f.Fingerprint.length === 0) continue;
5306
+ const existing = groups.get(f.Fingerprint);
5307
+ if (existing === void 0) groups.set(f.Fingerprint, [f]);
5308
+ else existing.push(f);
5309
+ }
5310
+ return groups;
5311
+ }
5312
+ function applyDeferredAllows(findings, state, repo) {
5313
+ for (const [fingerprint, peers] of byFingerprint(findings)) {
5314
+ const allows = peers.filter((p) => {
5315
+ if ((state.actions.get(findingKey(p)) ?? "skip") !== "allow") return false;
5316
+ const sid = sessionIdFromFinding(p);
5317
+ return sid === null || !state.droppedSids.has(sid);
5318
+ });
5319
+ if (allows.length === 0) continue;
5320
+ if (peers.every((p) => isCleared(p, state))) {
5321
+ appendGitleaksIgnore(fingerprint, repo);
5322
+ continue;
5323
+ }
5324
+ warn(
5325
+ `not allowing ${fingerprint}: another secret sharing that line was not cleaned, and the entry would suppress it too`
5326
+ );
5338
5327
  }
5339
- return null;
5340
5328
  }
5341
5329
 
5342
5330
  // src/commands.push.recovery.redact.ts
5331
+ init_config();
5332
+ init_config_sharedDirs_guard();
5333
+ import { cpSync as cpSync9, existsSync as existsSync32, mkdirSync as mkdirSync12 } from "node:fs";
5334
+ import { dirname as dirname12, join as join41, sep as sep7 } from "node:path";
5335
+
5336
+ // src/commands.redact.ts
5337
+ init_config();
5338
+ import { existsSync as existsSync31 } from "node:fs";
5339
+ import { dirname as dirname11, join as join40 } from "node:path";
5340
+
5341
+ // src/commands.redact.subtree.ts
5342
+ import { existsSync as existsSync30, lstatSync as lstatSync14, readFileSync as readFileSync16, readdirSync as readdirSync14, statSync as statSync8, writeFileSync as writeFileSync8 } from "node:fs";
5343
+ import { join as join39 } from "node:path";
5344
+ init_utils_fs();
5345
+ init_utils();
5346
+ function collectFiles(dir, out) {
5347
+ if (!existsSync30(dir)) return;
5348
+ const st = lstatSync14(dir);
5349
+ if (!st.isDirectory()) return;
5350
+ for (const entry of readdirSync14(dir)) {
5351
+ const abs = join39(dir, entry);
5352
+ const lst = lstatSync14(abs);
5353
+ if (lst.isSymbolicLink()) continue;
5354
+ if (lst.isDirectory()) {
5355
+ collectFiles(abs, out);
5356
+ continue;
5357
+ }
5358
+ if (lst.isFile()) out.push(abs);
5359
+ }
5360
+ }
5361
+ function listSubtreeFiles(sessionDir) {
5362
+ const out = [];
5363
+ collectFiles(sessionDir, out);
5364
+ return out.sort((a, b) => a.localeCompare(b));
5365
+ }
5366
+ function newestSubtreeMtimeMs(mainPath, subtreeFiles, statMtime = (p) => statSync8(p).mtimeMs) {
5367
+ let newest = statMtime(mainPath);
5368
+ for (const filePath of subtreeFiles) {
5369
+ const t = statMtime(filePath);
5370
+ if (t > newest) newest = t;
5371
+ }
5372
+ return newest;
5373
+ }
5374
+ function isSubtreeActive(mainPath, subtreeFiles, now) {
5375
+ const subtreeMtime = newestSubtreeMtimeMs(mainPath, subtreeFiles, (p) => statSync8(p).mtimeMs);
5376
+ return isRecentlyModified(subtreeMtime, now);
5377
+ }
5378
+ function applySubtreeRedactions(mainPath, mainFindings, subtreeFiles, rule, ts, scan, dryRun) {
5379
+ const dirty = [];
5380
+ if (mainFindings.length > 0) dirty.push({ path: mainPath, findings: mainFindings });
5381
+ for (const filePath of subtreeFiles) {
5382
+ const raw = scan(filePath);
5383
+ if (raw === null || raw.length === 0) continue;
5384
+ const filtered = rule === void 0 ? raw : raw.filter((f) => f.RuleID === rule);
5385
+ if (filtered.length === 0) continue;
5386
+ dirty.push({ path: filePath, findings: filtered });
5387
+ }
5388
+ const total = dirty.reduce((n, e) => n + e.findings.length, 0);
5389
+ if (!dryRun && total > 0) {
5390
+ for (const { path: filePath, findings } of dirty) {
5391
+ backupBeforeWrite(filePath, ts);
5392
+ const before = readFileSync16(filePath, "utf8");
5393
+ const after = applyRedactions(before, findings);
5394
+ if (after === before) {
5395
+ log(
5396
+ `warning: no redaction applied to ${filePath}: finding match values were not located in the file. Inspect it manually; the push re-scan still blocks a real leak.`
5397
+ );
5398
+ }
5399
+ writeFileSync8(filePath, after, "utf8");
5400
+ }
5401
+ }
5402
+ return { total, dirty };
5403
+ }
5404
+
5405
+ // src/commands.pushed-history.ts
5406
+ init_utils();
5407
+ import { execFileSync as execFileSync14 } from "node:child_process";
5408
+ function pushedRef(repo) {
5409
+ try {
5410
+ const ref = execFileSync14("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
5411
+ cwd: repo,
5412
+ stdio: ["ignore", "pipe", "ignore"]
5413
+ }).toString().trim();
5414
+ return ref.length > 0 ? ref : null;
5415
+ } catch {
5416
+ return null;
5417
+ }
5418
+ }
5419
+ function sessionInPushedHistory(id, repo) {
5420
+ const ref = pushedRef(repo);
5421
+ if (ref === null) return false;
5422
+ try {
5423
+ const out = execFileSync14(
5424
+ "git",
5425
+ [
5426
+ "log",
5427
+ ref,
5428
+ "--oneline",
5429
+ "-1",
5430
+ "--",
5431
+ `shared/projects/*/${id}.jsonl`,
5432
+ `shared/projects/*/${id}/*`
5433
+ ],
5434
+ { cwd: repo, stdio: ["ignore", "pipe", "ignore"] }
5435
+ ).toString().trim();
5436
+ return out.length > 0;
5437
+ } catch {
5438
+ return false;
5439
+ }
5440
+ }
5441
+ function warnIfSessionPushed(id, repo) {
5442
+ if (!sessionInPushedHistory(id, repo)) return;
5443
+ log(
5444
+ `warning: session ${id} is already in pushed history (origin).
5445
+ This command only changes your local copy and the next push; it does NOT
5446
+ remove the secret from commits already on the remote.
5447
+ To fully remediate a real secret: rotate the credential, then rewrite
5448
+ history (e.g. with git filter-repo) and force-push, coordinating with
5449
+ anyone else who has cloned the repo.`
5450
+ );
5451
+ }
5452
+
5453
+ // src/commands.redact.ts
5454
+ init_push_gitleaks_scan();
5455
+ init_exit_codes();
5456
+ init_utils_fs();
5457
+ init_utils_json();
5458
+ init_utils();
5459
+ function resolveLiveTranscript(id) {
5460
+ try {
5461
+ const mapPath = join40(repoHome(), "path-map.json");
5462
+ if (!existsSync31(mapPath)) return null;
5463
+ const projects = readJson(mapPath).projects;
5464
+ const claude = claudeHome();
5465
+ for (const hostMap of Object.values(projects)) {
5466
+ const abs = hostMap[HOST];
5467
+ if (abs === void 0) continue;
5468
+ const live = join40(claude, "projects", encodePath(abs), `${id}.jsonl`);
5469
+ if (existsSync31(live)) return live;
5470
+ }
5471
+ return null;
5472
+ } catch {
5473
+ return null;
5474
+ }
5475
+ }
5476
+ function resolveRedactFindings(localPath, rawFindings, rule, scan) {
5477
+ const source = rawFindings ?? scan(localPath);
5478
+ if (source === null) return null;
5479
+ return source.filter((f) => rule === void 0 || f.RuleID === rule);
5480
+ }
5481
+ function cmdRedact(opts, nowMs = Date.now, scan = scanFile) {
5482
+ const { id, rule, dryRun = false, findings: rawFindings } = opts;
5483
+ if (id.length === 0 || id.length > 128 || !/^[A-Za-z0-9_-]+$/.test(id)) {
5484
+ fail(`invalid session id: ${id}`);
5485
+ process.exit(EXIT.USAGE);
5486
+ }
5487
+ const repo = repoHome();
5488
+ const backup = backupBase();
5489
+ if (!existsSync31(repo)) die(`repo not cloned at ${repo}`);
5490
+ const handle = acquireLock("redact");
5491
+ if (handle === null) process.exit(0);
5492
+ try {
5493
+ const localPath = resolveLiveTranscript(id);
5494
+ if (localPath === null || !existsSync31(localPath)) {
5495
+ fail(`could not resolve local transcript for session ${id} on this host`);
5496
+ process.exitCode = 1;
5497
+ return;
5498
+ }
5499
+ const sessionDir = join40(dirname11(localPath), id);
5500
+ const subtreeFiles = listSubtreeFiles(sessionDir);
5501
+ if (isSubtreeActive(localPath, subtreeFiles, nowMs())) {
5502
+ log(
5503
+ `session ${id} was modified recently and may be active.
5504
+ Refusing to rewrite a potentially live transcript.
5505
+ To proceed: wait for the session to end, then re-run nomad redact.
5506
+ Or drop from the staged tree: nomad drop-session ${id}
5507
+ Or skip this finding during nomad push.`
5508
+ );
5509
+ return;
5510
+ }
5511
+ const mainFindings = resolveRedactFindings(localPath, rawFindings, rule, scan);
5512
+ if (mainFindings === null) {
5513
+ fail(`gitleaks scan failed for session ${id} (is gitleaks installed?)`);
5514
+ process.exitCode = 1;
5515
+ return;
5516
+ }
5517
+ const ts = freshBackupTs(backup);
5518
+ const { total: totalCount, dirty } = applySubtreeRedactions(
5519
+ localPath,
5520
+ mainFindings,
5521
+ subtreeFiles,
5522
+ rule,
5523
+ ts,
5524
+ scan,
5525
+ dryRun
5526
+ );
5527
+ if (totalCount === 0) {
5528
+ const ruleClause = rule === void 0 ? "" : ` for rule ${rule}`;
5529
+ log(`no findings${ruleClause} in session ${id}`);
5530
+ return;
5531
+ }
5532
+ if (dryRun) {
5533
+ const lines = dirty.flatMap((e) => e.findings.map((f) => ` ${e.path} line ${f.StartLine} [${f.RuleID}]`)).join("\n");
5534
+ log(`dry-run: would redact ${totalCount} finding(s) in session ${id}
5535
+ ${lines}`);
5536
+ return;
5537
+ }
5538
+ log(`redacted ${totalCount} finding(s) in ${localPath} (backup: ${ts})`);
5539
+ warnIfSessionPushed(id, repo);
5540
+ } catch (err) {
5541
+ if (!(err instanceof NomadFatal)) {
5542
+ throw err;
5543
+ }
5544
+ fail(err.message);
5545
+ process.exitCode = 1;
5546
+ } finally {
5547
+ releaseLock(handle);
5548
+ }
5549
+ }
5550
+
5551
+ // src/commands.push.recovery.redact.ts
5552
+ init_push_gitleaks_scan();
5553
+ init_utils_json();
5554
+ init_utils();
5343
5555
  function resolveStagedDir(localPath, map, claude, repo) {
5344
5556
  for (const [logical, hostMap] of Object.entries(map.projects)) {
5345
5557
  assertSafeLogical(logical);
5346
5558
  const abs = hostMap[HOST];
5347
5559
  if (abs === void 0) continue;
5348
- if (localPath.startsWith(join39(claude, "projects", encodePath(abs)) + sep6)) {
5349
- return join39(repo, "shared", "projects", logical);
5560
+ if (localPath.startsWith(join41(claude, "projects", encodePath(abs)) + sep7)) {
5561
+ return join41(repo, "shared", "projects", logical);
5350
5562
  }
5351
5563
  }
5352
5564
  return null;
@@ -5356,7 +5568,7 @@ function preflightRedactable(f, map, nowMs) {
5356
5568
  if (sid === null) return "a finding has no resolvable session id (not a session transcript)";
5357
5569
  const localPath = resolveLiveTranscript(sid);
5358
5570
  if (localPath === null) return `session ${sid}: local transcript not found`;
5359
- const sessionDir = join39(dirname11(localPath), sid);
5571
+ const sessionDir = join41(dirname12(localPath), sid);
5360
5572
  const subtreeFiles = listSubtreeFiles(sessionDir);
5361
5573
  if (isSubtreeActive(localPath, subtreeFiles, nowMs())) {
5362
5574
  return `session ${sid}: looks active (modified within the last 5 minutes)`;
@@ -5385,7 +5597,7 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
5385
5597
  `could not locate the local transcript for session ${sid}; choose Skip or Drop session.`
5386
5598
  );
5387
5599
  }
5388
- const sessionDir = join39(dirname11(localPath), sid);
5600
+ const sessionDir = join41(dirname12(localPath), sid);
5389
5601
  const subtreeFiles = listSubtreeFiles(sessionDir);
5390
5602
  if (isSubtreeActive(localPath, subtreeFiles, nowMs())) {
5391
5603
  return refuse(
@@ -5417,188 +5629,209 @@ function applyRedact(f, ts, map, nowMs, scan = scanFile) {
5417
5629
  `nothing to redact in the local transcript for session ${sid}; choose Skip or Drop session.`
5418
5630
  );
5419
5631
  }
5420
- mkdirSync10(stagedProjectDir, { recursive: true });
5421
- cpSync8(localPath, join39(stagedProjectDir, `${sid}.jsonl`), { force: true });
5422
- if (existsSync31(sessionDir)) {
5423
- cpSync8(sessionDir, join39(stagedProjectDir, sid), { force: true, recursive: true });
5632
+ mkdirSync12(stagedProjectDir, { recursive: true });
5633
+ cpSync9(localPath, join41(stagedProjectDir, `${sid}.jsonl`), { force: true });
5634
+ if (existsSync32(sessionDir)) {
5635
+ cpSync9(sessionDir, join41(stagedProjectDir, sid), { force: true, recursive: true });
5424
5636
  }
5425
5637
  return true;
5426
5638
  }
5427
5639
 
5428
5640
  // src/commands.push.recovery.drop.ts
5429
5641
  init_config();
5430
- import { rmSync as rmSync13 } from "node:fs";
5431
- import { join as join40 } from "node:path";
5642
+ import { rmSync as rmSync14 } from "node:fs";
5643
+ import { join as join42 } from "node:path";
5432
5644
  function dropSessionFromStaged(sid, map) {
5433
5645
  const logicals = Object.keys(map.projects);
5434
5646
  if (logicals.length === 0) return false;
5435
5647
  const repo = repoHome();
5436
5648
  for (const logical of logicals) {
5437
- const jsonl = join40(repo, "shared", "projects", logical, `${sid}.jsonl`);
5438
- const dir = join40(repo, "shared", "projects", logical, sid);
5439
- rmSync13(jsonl, { force: true });
5440
- rmSync13(dir, { recursive: true, force: true });
5649
+ const jsonl = join42(repo, "shared", "projects", logical, `${sid}.jsonl`);
5650
+ const dir = join42(repo, "shared", "projects", logical, sid);
5651
+ rmSync14(jsonl, { force: true });
5652
+ rmSync14(dir, { recursive: true, force: true });
5441
5653
  }
5442
5654
  return true;
5443
5655
  }
5444
5656
 
5445
- // src/commands.push.recovery.skills.ts
5446
- init_config();
5447
- import {
5448
- cpSync as cpSync9,
5449
- lstatSync as lstatSync14,
5450
- mkdirSync as mkdirSync12,
5451
- readFileSync as readFileSync16,
5452
- realpathSync as realpathSync5,
5453
- statSync as statSync8,
5454
- writeFileSync as writeFileSync8
5455
- } from "node:fs";
5456
- import { dirname as dirname12, join as join42, sep as sep7 } from "node:path";
5457
-
5458
- // src/skills-sync.ts
5459
- init_config();
5460
- import { existsSync as existsSync32, lstatSync as lstatSync13, mkdirSync as mkdirSync11, readdirSync as readdirSync14, rmSync as rmSync14 } from "node:fs";
5461
- import { join as join41 } from "node:path";
5462
-
5463
- // src/skills-sync.tracked.ts
5657
+ // src/commands.push.recovery.actions.ts
5658
+ init_push_gitleaks_scan();
5464
5659
  init_utils();
5465
- import { basename as basename3 } from "node:path";
5466
- function trackedRootSkillsAt(ref, repo) {
5467
- try {
5468
- const raw = gitCaptureRaw(["ls-tree", "--name-only", "-z", ref, "--", "shared/skills/"], repo);
5469
- const entries = raw.split("\0").filter((entry) => entry !== "");
5470
- return new Set(entries.map((entry) => basename3(entry)));
5471
- } catch {
5472
- return /* @__PURE__ */ new Set();
5473
- }
5474
- }
5475
5660
 
5476
- // src/skills-sync.ts
5477
- init_utils_fs();
5478
- function isGsdOwned(name) {
5479
- return name.startsWith(GSD_PREFIX);
5480
- }
5481
- function isSkillExcluded(name) {
5482
- return isGsdOwned(name) || isDeniedName(ALWAYS_NEVER_SYNC, name);
5483
- }
5484
- function copySkillsPush(src, dst) {
5485
- const srcNames = readdirSync14(src, { encoding: "utf8" });
5486
- const blockSet = /* @__PURE__ */ new Set([
5487
- ...srcNames.filter((n) => isGsdOwned(n)),
5488
- ...ALWAYS_NEVER_SYNC
5489
- ]);
5490
- copyExtrasFiltered(src, dst, blockSet);
5491
- }
5492
- function copySkillsPull(src, dst, isRootPreserved) {
5493
- copyExtrasFilteredPreservingBy(src, dst, isSkillExcluded, isRootPreserved);
5661
+ // src/commands.push.recovery.secret-shape.ts
5662
+ var VALUE_HEAD = 4;
5663
+ var VALUE_TAIL = 4;
5664
+ var MIN_ELIDE_LEN = 16;
5665
+ var FRAGMENT_SEP = "...";
5666
+ var NEAR_WINDOW = 40;
5667
+ var VALUE_PAD = 16;
5668
+ var REDACTED_TOKEN = "REDACTED";
5669
+ var CONTROL_CHARS = /[\x00-\x1f\x7f]/g;
5670
+ function classifyCharset(value) {
5671
+ if (/^[0-9a-fA-F]+$/.test(value)) return "hex";
5672
+ if (/^[0-9a-zA-Z]+$/.test(value)) return "alphanumeric";
5673
+ if (/^[0-9a-zA-Z_-]+$/.test(value)) return "base64url";
5674
+ return "mixed";
5675
+ }
5676
+ function describeSecret(secret, entropy) {
5677
+ const parts = [`${secret.length} chars`, classifyCharset(secret)];
5678
+ if (typeof entropy === "number" && Number.isFinite(entropy) && entropy > 0) {
5679
+ parts.push(`entropy ${entropy.toFixed(2)}`);
5680
+ }
5681
+ const summary = parts.join(", ");
5682
+ if (secret.length < MIN_ELIDE_LEN) return summary;
5683
+ const fragment = secret.slice(0, VALUE_HEAD) + FRAGMENT_SEP + secret.slice(-VALUE_TAIL);
5684
+ return `${fragment.padEnd(VALUE_PAD)} ${summary}`;
5685
+ }
5686
+ function emptyToNull(text) {
5687
+ return text.length > 0 ? text : null;
5688
+ }
5689
+ function withoutValue(near, value) {
5690
+ if (near === null || value === null || value.length === 0) return near;
5691
+ const idx = near.indexOf(value);
5692
+ return idx < 0 ? near : emptyToNull(near.slice(0, idx));
5693
+ }
5694
+ function alignRedactedMatch(finding, span, lead) {
5695
+ const idx = finding.Match.indexOf(REDACTED_TOKEN);
5696
+ if (idx < 0) return null;
5697
+ const pre = finding.Match.slice(0, idx);
5698
+ const post = finding.Match.slice(idx + REDACTED_TOKEN.length);
5699
+ if (span !== finding.Match && span.startsWith(pre) && span.endsWith(post) && span.length > pre.length + post.length) {
5700
+ return {
5701
+ value: span.slice(pre.length, span.length - post.length),
5702
+ near: emptyToNull(lead + pre),
5703
+ exact: true
5704
+ };
5705
+ }
5706
+ return { value: null, near: emptyToNull(lead + pre), exact: false };
5707
+ }
5708
+ function maskSiblingSpans(buf, siblings, ownStart, ownEnd) {
5709
+ if (siblings.length === 0) return buf;
5710
+ const copy = Buffer.from(buf);
5711
+ for (const sibling of siblings) {
5712
+ const start = Math.max(1, Math.min(sibling.StartColumn, copy.length + 1)) - 1;
5713
+ const end = Math.max(start, Math.min(sibling.EndColumn, copy.length));
5714
+ const headEnd = Math.min(end, ownStart);
5715
+ if (start < headEnd) copy.fill(0, start, headEnd);
5716
+ const tailStart = Math.max(start, ownEnd);
5717
+ if (tailStart < end) copy.fill(0, tailStart, end);
5718
+ }
5719
+ return copy;
5720
+ }
5721
+ function resolveSecretContext(finding, readLine, siblings = []) {
5722
+ const resolved = resolveSpanContext(finding, readLine, siblings);
5723
+ const ownStripped = withoutValue(resolved.near, resolved.value);
5724
+ const multiLine = typeof finding.EndLine === "number" && finding.EndLine > finding.StartLine;
5725
+ return {
5726
+ value: resolved.value,
5727
+ near: ownStripped,
5728
+ exact: resolved.exact && !multiLine
5729
+ };
5494
5730
  }
5495
- function syncSkillsPull(ts, prePostHeads) {
5496
- const sharedSkills = join41(repoHome(), "shared", "skills");
5497
- if (!existsSync32(sharedSkills)) return;
5498
- const localSkills = join41(claudeHome(), "skills");
5499
- backupBeforeWrite(localSkills, ts);
5500
- const dstStat = lstatSync13(localSkills, { throwIfNoEntry: false });
5501
- if (dstStat?.isSymbolicLink() === true) {
5502
- rmSync14(localSkills, { recursive: true, force: true });
5503
- mkdirSync11(localSkills, { recursive: true });
5731
+ function resolveSpanContext(finding, readLine, siblings = []) {
5732
+ const raw = readLine(finding.File, finding.StartLine);
5733
+ let span;
5734
+ let lead;
5735
+ if (raw !== null) {
5736
+ const buf = Buffer.from(raw, "utf8");
5737
+ const len = buf.length;
5738
+ const startCol = Math.max(1, Math.min(finding.StartColumn, len + 1));
5739
+ const endCol = Math.max(startCol, Math.min(finding.EndColumn, len));
5740
+ span = buf.subarray(startCol - 1, endCol).toString("utf8");
5741
+ lead = maskSiblingSpans(buf, siblings, startCol - 1, endCol).subarray(0, startCol - 1).toString("utf8");
5742
+ } else {
5743
+ span = finding.Match;
5744
+ lead = "";
5504
5745
  }
5505
- const tracked = prePostHeads === void 0 ? /* @__PURE__ */ new Set() : trackedRootSkillsAt(prePostHeads.pre, repoHome());
5506
- copySkillsPull(sharedSkills, localSkills, (name) => !tracked.has(name));
5746
+ const aligned = alignRedactedMatch(finding, span, lead);
5747
+ if (aligned !== null) return aligned;
5748
+ if (typeof finding.Secret === "string" && finding.Secret.length > 0 && span.includes(finding.Secret)) {
5749
+ return {
5750
+ value: finding.Secret,
5751
+ near: emptyToNull(lead + span.slice(0, span.indexOf(finding.Secret))),
5752
+ exact: true
5753
+ };
5754
+ }
5755
+ return {
5756
+ value: span.length > 0 ? span : null,
5757
+ near: emptyToNull(lead),
5758
+ exact: false
5759
+ };
5507
5760
  }
5508
- function syncSkillsPush() {
5509
- const localSkills = join41(claudeHome(), "skills");
5510
- const stat = lstatSync13(localSkills, { throwIfNoEntry: false });
5511
- if (stat === void 0) return;
5512
- if (stat.isSymbolicLink()) return;
5513
- const sharedSkills = join41(repoHome(), "shared", "skills");
5514
- copySkillsPush(localSkills, sharedSkills);
5761
+ function formatNear(text) {
5762
+ const stripped = elideTokenRuns(text.replace(CONTROL_CHARS, ""));
5763
+ const truncated = stripped.length > NEAR_WINDOW;
5764
+ const window = stripped.slice(-NEAR_WINDOW).trim();
5765
+ if (window.length === 0) return null;
5766
+ return (truncated ? FRAGMENT_SEP : "") + window;
5767
+ }
5768
+ function elideTokenRuns(text) {
5769
+ return text.replace(/[A-Za-z0-9_+=-]+/g, (atom) => {
5770
+ if (atom.length < MIN_ELIDE_LEN) return atom;
5771
+ if (/^[A-Za-z_-]+$/.test(atom)) return atom;
5772
+ return FRAGMENT_SEP;
5773
+ });
5515
5774
  }
5516
5775
 
5517
- // src/commands.push.recovery.skills.ts
5518
- init_push_gitleaks_scan();
5519
- init_utils_fs();
5520
- init_utils();
5521
- var SKILL_FINDING_PATH = /^shared\/skills\/([^/]+)\/(.+)$/;
5522
- var SAFE_SKILL_NAME = /^[A-Za-z0-9._-]+$/;
5523
- var SKILL_DIR_PATH = /^shared\/skills\/[^/]+\//;
5524
- function skillFileFromFinding(f) {
5525
- const m = SKILL_FINDING_PATH.exec(f.File);
5526
- if (m?.[1] === void 0 || m[2] === void 0) return null;
5527
- return { name: m[1], relPath: m[2] };
5528
- }
5529
- function isSkillFindingPath(f) {
5530
- return SKILL_DIR_PATH.test(f.File);
5776
+ // src/commands.push.recovery.display.ts
5777
+ var RULE_PAD = 24;
5778
+ var GROUP_SEP = "\0";
5779
+ function sessionSuffix(finding) {
5780
+ const sid = sessionIdFromFinding(finding);
5781
+ if (sid === null) return "";
5782
+ const rawBasename = finding.File.replace(/^.*\//, "");
5783
+ const basename4 = rawBasename.endsWith(".jsonl") ? rawBasename.slice(0, -".jsonl".length) : rawBasename;
5784
+ return basename4 === sid ? "" : ` (session: ${sid})`;
5785
+ }
5786
+ function renderFindingBlock(finding, readLine, siblings = []) {
5787
+ const lines = [` file: ${finding.File}:${finding.StartLine}${sessionSuffix(finding)}`];
5788
+ const { value, near, exact } = resolveSecretContext(finding, readLine, siblings);
5789
+ if (value !== null) {
5790
+ lines.push(` value: ${describeSecret(value, exact ? finding.Entropy : void 0)}`);
5791
+ }
5792
+ const formattedNear = near !== null ? formatNear(near) : null;
5793
+ if (formattedNear !== null) lines.push(` near: ${formattedNear}`);
5794
+ return lines;
5531
5795
  }
5532
- function resolveSkillLocalPath(name, relPath) {
5533
- if (!SAFE_SKILL_NAME.test(name) || name === "." || name === "..") return null;
5534
- if (isGsdOwned(name)) return null;
5535
- if (!isSafeRelPath(relPath)) return null;
5536
- const skillsRoot = join42(claudeHome(), "skills");
5537
- const skillRoot = join42(skillsRoot, name);
5538
- const localPath = join42(skillRoot, ...relPath.split("/"));
5539
- if (localPath !== skillRoot && !localPath.startsWith(skillRoot + sep7)) return null;
5540
- try {
5541
- if (lstatSync14(localPath).isSymbolicLink()) return null;
5542
- const realLocal = realpathSync5(localPath);
5543
- const realRoot = realpathSync5(skillsRoot);
5544
- if (!realLocal.startsWith(realRoot + sep7)) return null;
5545
- if (!statSync8(realLocal).isFile()) return null;
5546
- } catch {
5547
- return null;
5796
+ function groupFindingsForPrompt(findings, readLine) {
5797
+ const groups = [];
5798
+ const indexByKey = /* @__PURE__ */ new Map();
5799
+ for (const f of findings) {
5800
+ const { value } = resolveSecretContext(f, readLine);
5801
+ const key = f.Fingerprint.length > 0 && value !== null ? `${f.Fingerprint}${GROUP_SEP}${value}` : (
5802
+ // findingKey omits the fingerprint, so carry it here too, or two
5803
+ // findings differing ONLY by fingerprint would merge.
5804
+ `${f.Fingerprint}${GROUP_SEP}${findingKey(f)}`
5805
+ );
5806
+ const idx = indexByKey.get(key);
5807
+ if (idx === void 0) {
5808
+ indexByKey.set(key, groups.length);
5809
+ groups.push([f]);
5810
+ } else {
5811
+ groups[idx].push(f);
5812
+ }
5548
5813
  }
5549
- return localPath;
5814
+ return groups;
5550
5815
  }
5551
- function preflightSkillRedactable(f) {
5552
- const parsed = skillFileFromFinding(f);
5553
- if (parsed === null) return "a finding is not a skill file";
5554
- const localPath = resolveSkillLocalPath(parsed.name, parsed.relPath);
5555
- if (localPath === null) {
5556
- return `skill file ${parsed.name}/${parsed.relPath}: local file not found or unresolvable`;
5557
- }
5558
- return null;
5816
+ function sharedFingerprintPeers(groups, index) {
5817
+ const target = groups[index][0].Fingerprint;
5818
+ if (target.length === 0) return 0;
5819
+ return groups.filter((g, i) => i !== index && g[0].Fingerprint === target).length;
5559
5820
  }
5560
- function applySkillRedact(f, ts, scan = scanFile) {
5561
- const refuse = (msg) => {
5562
- log(msg);
5563
- return false;
5564
- };
5565
- const parsed = skillFileFromFinding(f);
5566
- if (parsed === null) {
5567
- return refuse("could not parse this finding as a skill file; choose Skip.");
5568
- }
5569
- const { name, relPath } = parsed;
5570
- const localPath = resolveSkillLocalPath(name, relPath);
5571
- if (localPath === null) {
5572
- return refuse(`could not locate the local skill file for ${name}/${relPath}; choose Skip.`);
5573
- }
5574
- const findings = scan(localPath);
5575
- if (findings === null) {
5576
- return refuse(`re-scan of ${name}/${relPath} failed; choose Skip.`);
5577
- }
5578
- if (findings.length === 0) {
5579
- return refuse(`nothing to redact in ${name}/${relPath}; choose Skip.`);
5821
+ function buildPromptHeader(group, index, total, readLine, peers = 0, siblings = []) {
5822
+ const first = group[0];
5823
+ const headerLine = group.length === 1 ? `Finding ${index}/${total}: ${first.RuleID}` : `Finding ${index}/${total}: ${first.RuleID.padEnd(RULE_PAD)} ${group.length} occurrences, 1 ignore entry`;
5824
+ const body = renderFindingBlock(first, readLine, siblings);
5825
+ if (peers > 0) {
5826
+ const other = peers === 1 ? "secret" : "secrets";
5827
+ body.push(` warn: Allow also suppresses ${peers} other distinct ${other} on this line`);
5580
5828
  }
5581
- backupBeforeWrite(localPath, ts);
5582
- const before = readFileSync16(localPath, "utf8");
5583
- const after = applyRedactions(before, findings);
5584
- if (after === before) {
5585
- warn(
5586
- `no redaction applied to ${name}/${relPath}: finding match values were not located in the file. Inspect it manually; the push re-scan still blocks a real leak.`
5587
- );
5588
- }
5589
- writeFileSync8(localPath, after, "utf8");
5590
- const dest = join42(repoHome(), "shared", "skills", name, ...relPath.split("/"));
5591
- mkdirSync12(dirname12(dest), { recursive: true });
5592
- cpSync9(localPath, dest, { force: true });
5593
- return true;
5829
+ const sid = sessionIdFromFinding(first);
5830
+ const menu = sid === null ? " [R]edact [A]llow [S]kip (default)" : " [R]edact [A]llow [D]rop session [S]kip (default)";
5831
+ return ["", headerLine, ...body, menu, ""].join("\n");
5594
5832
  }
5595
5833
 
5596
5834
  // src/commands.push.recovery.actions.ts
5597
- init_push_gitleaks_scan();
5598
- init_utils();
5599
- function applyAllow(f, repo) {
5600
- appendGitleaksIgnore(f.Fingerprint, repo);
5601
- }
5602
5835
  function allowAllFindings(findings, repo) {
5603
5836
  for (const f of findings) {
5604
5837
  appendGitleaksIgnore(f.Fingerprint, repo);
@@ -5615,6 +5848,7 @@ function allowFindingsByRule(findings, ruleId, repo) {
5615
5848
  return count;
5616
5849
  }
5617
5850
  function makeDefaultReadLine(repo) {
5851
+ const cache = /* @__PURE__ */ new Map();
5618
5852
  return (file, line) => {
5619
5853
  try {
5620
5854
  const repoRoot = resolve3(repo);
@@ -5622,8 +5856,11 @@ function makeDefaultReadLine(repo) {
5622
5856
  if (isAbsolute2(file) || target !== repoRoot && !target.startsWith(repoRoot + sep8)) {
5623
5857
  return null;
5624
5858
  }
5625
- const content = readFileSync17(target, "utf8");
5626
- const lines = content.split(/\r?\n/);
5859
+ let lines = cache.get(target);
5860
+ if (lines === void 0) {
5861
+ lines = readFileSync17(target, "utf8").split(/\r?\n/);
5862
+ cache.set(target, lines);
5863
+ }
5627
5864
  const idx = line - 1;
5628
5865
  if (idx < 0 || idx >= lines.length) return null;
5629
5866
  return lines[idx] ?? null;
@@ -5634,16 +5871,19 @@ function makeDefaultReadLine(repo) {
5634
5871
  }
5635
5872
  async function collectActions(findings, prompt, readLine) {
5636
5873
  const reader = readLine ?? makeDefaultReadLine(repoHome());
5874
+ const groups = groupFindingsForPrompt(findings, reader);
5637
5875
  const actions = /* @__PURE__ */ new Map();
5638
- for (const f of findings) {
5639
- const sid = sessionIdFromFinding(f);
5640
- const ctx = buildFindingContext(f, reader);
5641
- const header = `
5642
- Finding: ${f.RuleID} in ${f.File} line ${f.StartLine}` + (sid === null ? "" : ` (session: ${sid})`) + (ctx === null ? "" : `
5643
- context: ${ctx}`) + // [D]rop applies only to session findings; a null sid (memory/skill
5644
- // finding) has no session to drop, so omit the dead affordance.
5645
- (sid === null ? "\n [R]edact [A]llow [S]kip (default)\n" : "\n [R]edact [A]llow [D]rop session [S]kip (default)\n");
5646
- actions.set(findingKey(f), parseAction(await prompt(header + "> ")));
5876
+ for (let i = 0; i < groups.length; i++) {
5877
+ const group = groups[i];
5878
+ const peers = sharedFingerprintPeers(groups, i);
5879
+ const siblings = findings.filter(
5880
+ (o) => o.File === group[0].File && o.StartLine === group[0].StartLine && !group.includes(o)
5881
+ );
5882
+ const header = buildPromptHeader(group, i + 1, groups.length, reader, peers, siblings);
5883
+ const action = parseAction(await prompt(header + "> "));
5884
+ for (const f of group) {
5885
+ actions.set(findingKey(f), action);
5886
+ }
5647
5887
  }
5648
5888
  return actions;
5649
5889
  }
@@ -5683,13 +5923,9 @@ function dispatchNonSession(f, action, ctx) {
5683
5923
  }
5684
5924
  function dispatchOne(f, ctx) {
5685
5925
  const action = ctx.actions.get(findingKey(f)) ?? "skip";
5686
- if (action === "skip") return;
5926
+ if (action === "skip" || action === "allow") return;
5687
5927
  const sid = sessionIdFromFinding(f);
5688
5928
  if (sid !== null && ctx.droppedSids.has(sid)) return;
5689
- if (action === "allow") {
5690
- applyAllow(f, ctx.repo);
5691
- return;
5692
- }
5693
5929
  if (sid === null) {
5694
5930
  dispatchNonSession(f, action, ctx);
5695
5931
  return;
@@ -5725,6 +5961,7 @@ function dispatchActions(findings, actions, opts) {
5725
5961
  for (const f of findings) {
5726
5962
  dispatchOne(f, ctx);
5727
5963
  }
5964
+ applyDeferredAllows(findings, ctx, repo);
5728
5965
  }
5729
5966
 
5730
5967
  // src/commands.push.recovery.redact-all.ts
@@ -6228,11 +6465,19 @@ function isProblem(item2) {
6228
6465
  function isRepoStateLine(item2) {
6229
6466
  return item2.includes("repo state:");
6230
6467
  }
6468
+ function isCopySyncModalityLine(item2) {
6469
+ return item2.includes("sync modality: copy-sync");
6470
+ }
6231
6471
  function compactSections(sections) {
6232
6472
  return sections.map((s) => {
6233
6473
  if (ALWAYS_FULL.has(s.header)) return s;
6234
6474
  if (s.header === "Environment") {
6235
- return { ...s, items: s.items.filter((it) => isRepoStateLine(it) || isProblem(it)) };
6475
+ return {
6476
+ ...s,
6477
+ items: s.items.filter(
6478
+ (it) => isRepoStateLine(it) || isCopySyncModalityLine(it) || isProblem(it)
6479
+ )
6480
+ };
6236
6481
  }
6237
6482
  return { ...s, items: s.items.filter(isProblem) };
6238
6483
  });
@@ -7547,6 +7792,22 @@ function handleWedge(repo, forceRemote) {
7547
7792
  const state = wedge === "rebase" ? "mid-rebase" : "mid-merge";
7548
7793
  die(wedgeMarkerRunbookText(state), { code: EXIT.CONFLICT });
7549
7794
  }
7795
+ function readMapForMirror(mapPath) {
7796
+ if (!existsSync42(mapPath)) return { projects: {} };
7797
+ try {
7798
+ return readPathMap(mapPath);
7799
+ } catch {
7800
+ return null;
7801
+ }
7802
+ }
7803
+ function mirrorSharedLinksBeforePull(repo, ts) {
7804
+ if (process.platform !== "win32") return;
7805
+ try {
7806
+ stageLocalSharedEdits(readMapForMirror(join53(repo, "path-map.json")), ts);
7807
+ } catch (err) {
7808
+ warn(`could not stage local shared edits before the pull: ${err.message}`);
7809
+ }
7810
+ }
7550
7811
  function runPullCore(opts = {}) {
7551
7812
  const dryRun = opts.dryRun === true;
7552
7813
  const forceRemote = opts.forceRemote === true;
@@ -7568,6 +7829,7 @@ function runPullCore(opts = {}) {
7568
7829
  dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
7569
7830
  );
7570
7831
  }
7832
+ if (!dryRun && !forceRemote) mirrorSharedLinksBeforePull(repo, ts);
7571
7833
  const prePostHeads = capturePrePostHeads(repo, () => {
7572
7834
  gitOrFatal(["pull", "--rebase", "--autostash"], "git pull --rebase", repo);
7573
7835
  });
@@ -9103,7 +9365,7 @@ function parsePullArgs(argv) {
9103
9365
 
9104
9366
  // src/nomad.dispatch.push.ts
9105
9367
  var RULE_ID_RE = /^\w[\w-]*$/;
9106
- function applyAllow2(argv, i, st) {
9368
+ function applyAllow(argv, i, st) {
9107
9369
  if (st.allowRule !== void 0) return REJECT;
9108
9370
  const val = extractFlagValue(argv, i);
9109
9371
  if (val === null || !RULE_ID_RE.test(val)) return REJECT;
@@ -9119,7 +9381,7 @@ function applyPushToken(argv, i, st) {
9119
9381
  case "--allow-all":
9120
9382
  return applyBool(st.allowAll, () => st.allowAll = true);
9121
9383
  case "--allow":
9122
- return applyAllow2(argv, i, st);
9384
+ return applyAllow(argv, i, st);
9123
9385
  case "--full-scan":
9124
9386
  return applyBool(st.fullScan, () => st.fullScan = true);
9125
9387
  default:
@@ -9177,7 +9439,7 @@ function parseSyncArgs(argv) {
9177
9439
  // package.json
9178
9440
  var package_default = {
9179
9441
  name: "claude-nomad",
9180
- version: "0.62.6",
9442
+ version: "0.63.0",
9181
9443
  type: "module",
9182
9444
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
9183
9445
  keywords: [