sneakoscope 3.1.9 → 3.1.11

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.
@@ -17,8 +17,16 @@ export async function dedupeProjectSkills(input) {
17
17
  const userEntries = group.filter((entry) => !entry.managed_by_sks);
18
18
  const managedEntries = group.filter((entry) => entry.managed_by_sks);
19
19
  if (userEntries.length > 0 && managedEntries.length > 0) {
20
- for (const user of userEntries)
21
- actions.push(actionRow(canonical, 'kept', user, null, 'user-authored skill preserved'));
20
+ const keepUser = userEntries[0];
21
+ if (keepUser)
22
+ actions.push(actionRow(canonical, 'kept', keepUser, null, 'user-authored skill preserved'));
23
+ const shouldMoveUserDuplicates = fix && yes && input.quarantineUserDuplicates === true;
24
+ for (const duplicateUser of userEntries.slice(1)) {
25
+ const quarantine = shouldMoveUserDuplicates ? await quarantineSkill(root, canonical, duplicateUser, 'user-authored duplicate skill') : null;
26
+ actions.push(actionRow(canonical, quarantine ? 'quarantined' : 'reported', duplicateUser, quarantine, 'user-authored duplicate skill requires --quarantine-user-duplicates --yes'));
27
+ }
28
+ if (userEntries.length > 1 && !shouldMoveUserDuplicates)
29
+ unresolvedUserDuplicates.push(canonical);
22
30
  for (const managed of managedEntries) {
23
31
  const quarantine = await maybeQuarantine(root, canonical, managed, fix, 'managed collision with user-authored skill');
24
32
  actions.push(actionRow(canonical, quarantine ? 'quarantined' : 'reported', managed, quarantine, 'managed collision with user-authored skill'));
@@ -49,7 +57,11 @@ export async function dedupeProjectSkills(input) {
49
57
  }
50
58
  }
51
59
  const duplicateNames = [...new Set(actions.filter((action) => action.action !== 'kept').map((action) => action.canonical_name))].sort();
52
- const blockers = unresolvedUserDuplicates.map((name) => `user_duplicate_requires_confirmation:${name}`);
60
+ const afterLedger = await buildSkillRegistryLedger({ root, reportPath: null });
61
+ const blockers = [
62
+ ...unresolvedUserDuplicates.map((name) => `user_duplicate_requires_confirmation:${name}`),
63
+ ...afterLedger.duplicate_active_canonical_names.map((name) => `duplicate_active_skill_name:${name}`)
64
+ ];
53
65
  const report = {
54
66
  schema: 'sks.project-skill-dedupe.v1',
55
67
  generated_at: nowIso(),
@@ -58,6 +70,9 @@ export async function dedupeProjectSkills(input) {
58
70
  fix,
59
71
  yes,
60
72
  actions,
73
+ active_unique_by_canonical_name: afterLedger.active_unique_by_canonical_name,
74
+ active_entries: afterLedger.active_entries,
75
+ duplicate_active_canonical_names: afterLedger.duplicate_active_canonical_names,
61
76
  duplicate_canonical_names: duplicateNames,
62
77
  unresolved_user_duplicates: unresolvedUserDuplicates,
63
78
  blockers
@@ -56,13 +56,20 @@ export async function buildSkillRegistryLedger(input) {
56
56
  entry.status = entry.status === 'user-owned' ? 'duplicate' : 'duplicate';
57
57
  });
58
58
  }
59
- const blockers = duplicates.map((name) => `duplicate_skill_name:${name}`);
59
+ const activeEntries = entries.filter((entry) => entry.status !== 'quarantined');
60
+ const activeGrouped = groupByCanonical(activeEntries);
61
+ const duplicateActiveNames = [...activeGrouped.entries()].filter(([, group]) => group.length > 1).map(([name]) => name).sort();
62
+ const activeUnique = duplicateActiveNames.length === 0;
63
+ const blockers = duplicateActiveNames.map((name) => `duplicate_active_skill_name:${name}`);
60
64
  const ledger = {
61
65
  schema: 'sks.skill-registry-ledger.v1',
62
66
  generated_at: nowIso(),
63
- ok: blockers.length === 0,
67
+ ok: activeUnique,
64
68
  root,
65
69
  entries,
70
+ active_unique_by_canonical_name: activeUnique,
71
+ active_entries: activeEntries,
72
+ duplicate_active_canonical_names: duplicateActiveNames,
66
73
  duplicate_canonical_names: duplicates,
67
74
  blockers
68
75
  };
@@ -185,7 +185,9 @@ export async function initCommand(args = []) {
185
185
  export async function fixPathCommand(args = []) {
186
186
  const root = await projectRoot();
187
187
  const installScope = installScopeFromArgs(args);
188
- await initProject(root, { installScope, localOnly: flag(args, '--local-only'), globalCommand: 'sks', force: true });
188
+ await withSecretPreservationGuard(root, 'fix-path-command', async () => {
189
+ await initProject(root, { installScope, localOnly: flag(args, '--local-only'), globalCommand: 'sks', force: true });
190
+ });
189
191
  const result = {
190
192
  schema: 'sks.fix-path.v1',
191
193
  ok: true,
@@ -180,7 +180,7 @@ async function researchRun(args) {
180
180
  await runResearchCycle(dir, researchWorkGraph, { cycle: 0, status: mock ? 'mock_native_orchestrator_planned' : 'native_orchestrator_planned' });
181
181
  await setCurrent(root, { mission_id: id, mode: 'RESEARCH', phase: 'RESEARCH_RUNNING_NO_QUESTIONS', questions_allowed: false, implementation_allowed: false, research_real_run_required: !mock, research_cycle_timeout_minutes: cycleTimeoutMinutes });
182
182
  await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'research.run.started', maxCycles, mock, cycleTimeoutMinutes, real_run_required: !mock });
183
- const nativeAgentRun = await runNativeAgentOrchestrator({ root, missionId: id, route: flag(args, '--autoresearch') ? '$AutoResearch' : '$Research', prompt: mission.prompt || plan.prompt || 'Research run', backend: mock ? 'fake' : 'codex-sdk', mock, agents: requestedAgents, targetActiveSlots, desiredWorkItemCount: effectiveDesiredWorkItemCount, minimumWorkItems: effectiveMinimumWorkItems, maxQueueExpansion, concurrency: Math.min(requestedAgents, 5), readonly: true, profile, writeMode: writeMode, applyPatches: false, dryRunPatches, maxWriteAgents, roster: plan.native_agent_plan, routeCommand: 'sks research run', routeBlackboxKind: 'actual_research_command', narutoWorkGraph: nativeResearchWorkGraph });
183
+ const nativeAgentRun = await runNativeAgentOrchestrator({ root, missionId: id, route: flag(args, '--autoresearch') ? '$AutoResearch' : '$Research', prompt: mission.prompt || plan.prompt || 'Research run', backend: mock ? 'fake' : 'codex-sdk', mock, agents: requestedAgents, targetActiveSlots, desiredWorkItemCount: effectiveDesiredWorkItemCount, minimumWorkItems: effectiveMinimumWorkItems, maxQueueExpansion, concurrency: Math.min(requestedAgents, 5), readonly: true, profile, writeMode: writeMode, applyPatches: false, dryRunPatches, maxWriteAgents, roster: plan.native_agent_plan, routeCommand: 'sks research run', routeBlackboxKind: 'actual_research_command', narutoWorkGraph: researchWorkGraph });
184
184
  await writeJsonAtomic(path.join(dir, 'research-native-agent-run.json'), nativeAgentRun);
185
185
  await appendJsonlBounded(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'research.native_agents.completed', backend: nativeAgentRun.backend, ok: nativeAgentRun.ok, proof: nativeAgentRun.proof?.status });
186
186
  if (!nativeAgentRun.ok) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { ensureDir, nowIso, writeJsonAtomic, writeTextAtomic } from '../fsx.js';
3
+ import { ensureDir, nowIso, sha256, writeJsonAtomic, writeTextAtomic } from '../fsx.js';
4
4
  import { isProtectedSecretKey, PROTECTED_SECRET_KEYS } from './supabase-secret-preservation.js';
5
5
  export async function writeManagedJsonConfig(file, current, managed) {
6
6
  const next = safeMergeObject(current, managed);
@@ -23,13 +23,14 @@ export async function writeManagedEnvConfig(file, currentText, managedLines) {
23
23
  const next = additions.length ? `${String(currentText || '').replace(/\s*$/, '\n')}${additions.join('\n')}\n` : String(currentText || '');
24
24
  return writeMergedText(file, currentText, next, 'env', protectedKeysInText(currentText));
25
25
  }
26
- export function safeMergeObject(current, managed) {
26
+ export function safeMergeObject(current, managed, prefix = '') {
27
27
  const out = { ...current };
28
28
  for (const [key, value] of Object.entries(managed)) {
29
- if (isProtectedSecretKey(key) && current[key] != null)
29
+ const dotted = prefix ? `${prefix}.${key}` : key;
30
+ if (isProtectedSecretKey(dotted) && current[key] != null)
30
31
  continue;
31
32
  if (isPlainObject(value) && isPlainObject(current[key]))
32
- out[key] = safeMergeObject(current[key], value);
33
+ out[key] = safeMergeObject(current[key], value, dotted);
33
34
  else
34
35
  out[key] = value;
35
36
  }
@@ -51,11 +52,23 @@ function upsertTomlBlockPreservingSecrets(text, block) {
51
52
  break;
52
53
  }
53
54
  }
54
- const existingSecretLines = lines.slice(start + 1, end).filter((line) => {
55
- const key = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/)?.[1] || '';
56
- return isProtectedSecretKey(`${header}.${key}`) || isProtectedSecretKey(key);
57
- });
58
- lines.splice(start, end - start, ...blockLines, ...existingSecretLines.filter((line) => !blockLines.includes(line)));
55
+ const existingBody = lines.slice(start + 1, end);
56
+ const nextBody = [...existingBody];
57
+ for (const managedLine of blockLines.slice(1)) {
58
+ const managedKey = managedLine.match(/^\s*([A-Za-z0-9_.-]+)\s*=/)?.[1] || '';
59
+ if (!managedKey) {
60
+ if (!nextBody.includes(managedLine))
61
+ nextBody.push(managedLine);
62
+ continue;
63
+ }
64
+ const existingIndex = nextBody.findIndex((line) => (line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/)?.[1] || '') === managedKey);
65
+ const protectedLine = isProtectedSecretKey(`${header}.${managedKey}`) || isProtectedSecretKey(managedKey);
66
+ if (existingIndex === -1)
67
+ nextBody.push(managedLine);
68
+ else if (!protectedLine)
69
+ nextBody[existingIndex] = managedLine;
70
+ }
71
+ lines.splice(start, end - start, blockLines[0] || `[${header}]`, ...nextBody);
59
72
  return lines.join('\n').replace(/\n{3,}/g, '\n\n');
60
73
  }
61
74
  async function writeMergedText(file, before, after, format, preserved) {
@@ -68,6 +81,7 @@ async function writeMergedText(file, before, after, format, preserved) {
68
81
  }
69
82
  await writeTextAtomic(file, after);
70
83
  }
84
+ const preservedSecretLineHashes = protectedSecretLineHashes(before);
71
85
  return {
72
86
  schema: 'sks.managed-config-merge.v1',
73
87
  generated_at: nowIso(),
@@ -77,6 +91,8 @@ async function writeMergedText(file, before, after, format, preserved) {
77
91
  changed: before !== after,
78
92
  backup_path: backupPath,
79
93
  protected_keys_preserved: preserved,
94
+ preserved_secret_lines_sha256: preservedSecretLineHashes,
95
+ idempotent: before === after || protectedSecretLineHashes(after).every((hash) => preservedSecretLineHashes.includes(hash)),
80
96
  blockers: []
81
97
  };
82
98
  }
@@ -88,7 +104,40 @@ function protectedKeysPresent(value) {
88
104
  return found;
89
105
  }
90
106
  function protectedKeysInText(text) {
91
- return PROTECTED_SECRET_KEYS.filter((key) => new RegExp(`(^|\\n)\\s*(?:export\\s+)?${String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`).test(text)).map(String);
107
+ const found = new Set();
108
+ for (const key of PROTECTED_SECRET_KEYS) {
109
+ if (new RegExp(`(^|\\n)\\s*(?:export\\s+)?${String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`).test(text))
110
+ found.add(String(key));
111
+ }
112
+ let section = '';
113
+ for (const line of String(text || '').split(/\r?\n/)) {
114
+ const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
115
+ if (sectionMatch) {
116
+ section = String(sectionMatch[1] || '');
117
+ continue;
118
+ }
119
+ const key = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/)?.[1] || '';
120
+ if (key && section && isProtectedSecretKey(`${section}.${key}`))
121
+ found.add(`${section}.${key}`);
122
+ }
123
+ return [...found].sort();
124
+ }
125
+ function protectedSecretLineHashes(text) {
126
+ const hashes = [];
127
+ let section = '';
128
+ for (const line of String(text || '').split(/\r?\n/)) {
129
+ const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
130
+ if (sectionMatch) {
131
+ section = String(sectionMatch[1] || '');
132
+ continue;
133
+ }
134
+ const key = line.match(/^\s*(?:export\s+)?([A-Za-z0-9_.-]+)\s*=/)?.[1] || '';
135
+ if (!key)
136
+ continue;
137
+ if (isProtectedSecretKey(key) || (section && isProtectedSecretKey(`${section}.${key}`)))
138
+ hashes.push(sha256(line));
139
+ }
140
+ return hashes.sort();
92
141
  }
93
142
  function lookupPath(value, dotted) {
94
143
  let current = value;
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { ensureDir, nowIso, readJson, readText, sha256, writeJsonAtomic } from '../fsx.js';
5
5
  import { PROTECTED_SECRET_KEYS, PROTECTED_SUPABASE_ENV_KEYS } from './supabase-secret-preservation.js';
6
+ const activeGuardRoots = new Set();
6
7
  export async function captureSecretPreservationSnapshot(input) {
7
8
  const root = path.resolve(input.root);
8
9
  const sources = secretSources(root);
@@ -31,56 +32,83 @@ export async function captureSecretPreservationSnapshot(input) {
31
32
  }
32
33
  export async function withSecretPreservationGuard(root, operationName, fn) {
33
34
  const resolvedRoot = path.resolve(root);
35
+ if (activeGuardRoots.has(resolvedRoot))
36
+ return fn();
37
+ activeGuardRoots.add(resolvedRoot);
34
38
  const reportDir = path.join(resolvedRoot, '.sneakoscope', 'reports');
35
39
  await ensureDir(reportDir);
36
40
  const beforePath = path.join(reportDir, 'secret-preservation-before.json');
37
41
  const afterPath = path.join(reportDir, 'secret-preservation-after.json');
38
42
  const guardPath = path.join(reportDir, 'secret-preservation-guard.json');
39
43
  const before = await captureSecretPreservationSnapshot({ root: resolvedRoot, artifactPath: beforePath });
44
+ const backup = await backupSecretBearingSources(resolvedRoot, operationName, before);
40
45
  let result;
46
+ let operationError = null;
41
47
  try {
42
48
  result = await fn();
43
49
  }
44
50
  catch (err) {
45
- await writeJsonAtomic(guardPath, {
46
- schema: 'sks.secret-preservation-guard.v1',
47
- generated_at: nowIso(),
48
- ok: false,
49
- operation: operationName,
50
- before_path: beforePath,
51
- after_path: null,
52
- restored_keys_count: 0,
53
- missing_after: [],
54
- raw_values_recorded: false,
55
- operation_error: err instanceof Error ? err.message : String(err)
56
- }).catch(() => undefined);
57
- throw err;
51
+ operationError = err;
58
52
  }
59
- const after = await captureSecretPreservationSnapshot({ root: resolvedRoot, artifactPath: afterPath });
60
- const missing = missingProtectedSecrets(before, after);
61
- const report = {
62
- schema: 'sks.secret-preservation-guard.v1',
63
- generated_at: nowIso(),
64
- ok: missing.length === 0,
65
- operation: operationName,
66
- before_path: beforePath,
67
- after_path: afterPath,
68
- restored_keys_count: 0,
69
- missing_after: missing,
70
- raw_values_recorded: false
71
- };
72
- await writeJsonAtomic(guardPath, report).catch(() => undefined);
73
- if (missing.length) {
74
- throw new Error(`secret_preservation_failed:${missing.map((item) => `${item.source}:${item.key}`).join(',')}`);
53
+ try {
54
+ const after = await captureSecretPreservationSnapshot({ root: resolvedRoot, artifactPath: afterPath });
55
+ const changedOrMissing = changedOrMissingProtectedSecrets(before, after);
56
+ let rollbackAttempted = false;
57
+ let rollbackOk = false;
58
+ let restoredKeysCount = 0;
59
+ if (changedOrMissing.length) {
60
+ rollbackAttempted = true;
61
+ await restoreChangedSecretSources(changedOrMissing, backup.bySource);
62
+ const restored = await captureSecretPreservationSnapshot({
63
+ root: resolvedRoot,
64
+ artifactPath: path.join(reportDir, 'secret-preservation-after-restore.json')
65
+ });
66
+ const remaining = changedOrMissingProtectedSecrets(before, restored);
67
+ rollbackOk = remaining.length === 0;
68
+ restoredKeysCount = rollbackOk ? changedOrMissing.length : 0;
69
+ if (!rollbackOk) {
70
+ const failedReport = guardReport(operationName, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, false, backup.paths);
71
+ await writeJsonAtomic(guardPath, operationError ? { ...failedReport, ok: false, operation_error: sanitizeErrorMessage(operationError) } : failedReport).catch(() => undefined);
72
+ throw new Error(`secret_preservation_rollback_failed:${changedOrMissing.map((item) => `${safeSourceForError(resolvedRoot, item.source)}:${item.key}:${item.reason}`).join(',')}`);
73
+ }
74
+ }
75
+ const report = guardReport(operationName, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, rollbackAttempted ? rollbackOk : true, backup.paths);
76
+ if (operationError) {
77
+ report.ok = false;
78
+ report.operation_error = sanitizeErrorMessage(operationError);
79
+ }
80
+ await writeJsonAtomic(guardPath, report).catch(() => undefined);
81
+ if (operationError)
82
+ throw operationError;
83
+ if (operationName === 'doctor-fix' && rollbackAttempted) {
84
+ throw new Error(`secret_preservation_restored:${changedOrMissing.map((item) => `${safeSourceForError(resolvedRoot, item.source)}:${item.key}:${item.reason}`).join(',')}`);
85
+ }
86
+ return result;
87
+ }
88
+ finally {
89
+ activeGuardRoots.delete(resolvedRoot);
75
90
  }
76
- return result;
77
91
  }
78
92
  export function missingProtectedSecrets(before, after) {
93
+ return changedOrMissingProtectedSecrets(before, after)
94
+ .filter((item) => item.reason === 'missing')
95
+ .map((item) => ({ key: item.key, source: item.source }));
96
+ }
97
+ export function changedOrMissingProtectedSecrets(before, after) {
79
98
  const afterMap = new Map(after.fingerprints.filter((fp) => fp.present).map((fp) => [`${fp.source}\0${fp.key}`, fp]));
80
99
  return before.fingerprints
81
100
  .filter((fp) => fp.present && fp.value_sha256)
82
- .filter((fp) => !afterMap.has(`${fp.source}\0${fp.key}`))
83
- .map((fp) => ({ key: fp.key, source: fp.source }));
101
+ .map((fp) => {
102
+ const afterFp = afterMap.get(`${fp.source}\0${fp.key}`);
103
+ if (!afterFp) {
104
+ return { key: fp.key, source: fp.source, before_sha256: fp.value_sha256, after_sha256: null, reason: 'missing' };
105
+ }
106
+ if (afterFp.value_sha256 !== fp.value_sha256) {
107
+ return { key: fp.key, source: fp.source, before_sha256: fp.value_sha256, after_sha256: afterFp.value_sha256, reason: 'changed' };
108
+ }
109
+ return null;
110
+ })
111
+ .filter((item) => Boolean(item));
84
112
  }
85
113
  function secretSources(root) {
86
114
  const home = process.env.HOME || os.homedir();
@@ -90,8 +118,10 @@ function secretSources(root) {
90
118
  '.env.development',
91
119
  '.env.production',
92
120
  '.sneakoscope/config.json',
93
- '.codex/config.toml'
94
- ].map((rel) => path.join(root, rel)).concat(path.join(home, '.codex', 'config.toml'));
121
+ '.codex/config.toml',
122
+ '.cursor/mcp.json',
123
+ 'mcp.json'
124
+ ].map((rel) => path.join(root, rel)).concat(path.join(home, '.codex', 'config.toml'), path.join(home, '.config', 'sks', 'config.json'));
95
125
  }
96
126
  function fingerprintsFromText(text, source) {
97
127
  const rows = [];
@@ -101,6 +131,7 @@ function fingerprintsFromText(text, source) {
101
131
  continue;
102
132
  rows.push(fingerprint(String(key), source, value));
103
133
  }
134
+ rows.push(...fingerprintsFromTomlSections(text, source));
104
135
  for (const envKey of PROTECTED_SUPABASE_ENV_KEYS) {
105
136
  const value = readAssignment(text, envKey);
106
137
  if (value)
@@ -118,6 +149,25 @@ function fingerprintsFromObject(value, source) {
118
149
  }
119
150
  return rows;
120
151
  }
152
+ function fingerprintsFromTomlSections(text, source) {
153
+ const rows = [];
154
+ let section = '';
155
+ for (const line of String(text || '').split(/\r?\n/)) {
156
+ const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
157
+ if (sectionMatch) {
158
+ section = String(sectionMatch[1] || '').trim();
159
+ continue;
160
+ }
161
+ const kv = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*(.+?)\s*$/);
162
+ if (!kv || !section)
163
+ continue;
164
+ const key = `${section}.${kv[1]}`;
165
+ if (!PROTECTED_SECRET_KEYS.includes(key))
166
+ continue;
167
+ rows.push(fingerprint(key, source, unquote(String(kv[2] || ''))));
168
+ }
169
+ return rows;
170
+ }
121
171
  function fingerprint(key, source, value) {
122
172
  return {
123
173
  key,
@@ -143,9 +193,7 @@ function redactPreview(value) {
143
193
  const text = String(value || '');
144
194
  if (!text)
145
195
  return '';
146
- const head = text.slice(0, Math.min(3, text.length));
147
- const tail = text.length > 6 ? text.slice(-3) : '';
148
- return `${head}...${tail || 'redacted'}(${text.length})`;
196
+ return `sha256:${sha256(text).slice(0, 12)}(${text.length})`;
149
197
  }
150
198
  function flattenObject(value, prefix = '') {
151
199
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -166,4 +214,64 @@ function dedupeFingerprints(fingerprints) {
166
214
  byKey.set(`${fp.source}\0${fp.key}`, fp);
167
215
  return [...byKey.values()].sort((a, b) => a.source.localeCompare(b.source) || a.key.localeCompare(b.key));
168
216
  }
217
+ function guardReport(operation, beforePath, afterPath, changedOrMissing, restoredKeysCount, rollbackAttempted, rollbackOk, backupPaths) {
218
+ return {
219
+ schema: 'sks.secret-preservation-guard.v1',
220
+ generated_at: nowIso(),
221
+ ok: changedOrMissing.length === 0 || rollbackOk,
222
+ operation,
223
+ before_path: beforePath,
224
+ after_path: afterPath,
225
+ restored_keys_count: restoredKeysCount,
226
+ changed_or_missing: changedOrMissing,
227
+ missing_after: changedOrMissing.filter((item) => item.reason === 'missing').map((item) => ({ key: item.key, source: item.source })),
228
+ rollback_attempted: rollbackAttempted,
229
+ rollback_ok: rollbackOk,
230
+ backup_paths: backupPaths,
231
+ raw_values_recorded: false
232
+ };
233
+ }
234
+ async function backupSecretBearingSources(root, operationName, snapshot) {
235
+ const bySource = new Map();
236
+ const sources = [...new Set(snapshot.fingerprints.filter((fp) => fp.present).map((fp) => fp.source))];
237
+ if (!sources.length)
238
+ return { bySource, paths: [] };
239
+ const backupRoot = path.join(root, '.sneakoscope', 'backups', 'secrets', sanitizeSegment(operationName), new Date().toISOString().replace(/[:.]/g, '-'));
240
+ for (const source of sources) {
241
+ const backupPath = path.join(backupRoot, sanitizeSourcePath(root, source));
242
+ await ensureDir(path.dirname(backupPath));
243
+ await fs.copyFile(source, backupPath);
244
+ bySource.set(source, backupPath);
245
+ }
246
+ return { bySource, paths: [...bySource.values()] };
247
+ }
248
+ async function restoreChangedSecretSources(changedOrMissing, backups) {
249
+ for (const source of [...new Set(changedOrMissing.map((item) => item.source))]) {
250
+ const backup = backups.get(source);
251
+ if (!backup)
252
+ continue;
253
+ await ensureDir(path.dirname(source));
254
+ await fs.copyFile(backup, source);
255
+ }
256
+ }
257
+ function sanitizeSegment(value) {
258
+ return String(value || 'operation').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'operation';
259
+ }
260
+ function sanitizeSourcePath(root, source) {
261
+ return safeSourceForError(root, source).replace(/[^A-Za-z0-9._/-]+/g, '_').replace(/^\/+/, '');
262
+ }
263
+ function safeSourceForError(root, source) {
264
+ const rel = path.relative(root, source);
265
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel))
266
+ return rel;
267
+ const home = process.env.HOME || os.homedir();
268
+ const homeRel = path.relative(home, source);
269
+ if (homeRel && !homeRel.startsWith('..') && !path.isAbsolute(homeRel))
270
+ return `~/${homeRel}`;
271
+ return path.basename(source);
272
+ }
273
+ function sanitizeErrorMessage(err) {
274
+ const message = err instanceof Error ? err.message : String(err);
275
+ return message.replace(/([A-Za-z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD)[A-Za-z0-9_]*=)[^\s,;]+/gi, '$1<redacted>');
276
+ }
169
277
  //# sourceMappingURL=secret-preservation.js.map