@optimuslabs/harness-map-staging 1.5.18 → 1.5.19

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.
@@ -89,6 +89,12 @@ function targetsCurrentAgent(entry, agent) {
89
89
  complianceRunnerDiag(`Ignoring remediation with unknown target_agent: ${t}`);
90
90
  return false;
91
91
  }
92
+ // Claude Desktop cannot run hooks, so nothing ever executes with --agent=claude_desktop and
93
+ // these rows would otherwise never be applied by anyone. The Claude Code hook owns them: it
94
+ // writes Desktop's own config file and the spec's restart_command relaunches the app
95
+ // (trusted_restarts already maps claude_desktop -> claude for exactly this reason).
96
+ if (normalized === 'claude_desktop' && agent === 'claude')
97
+ return true;
92
98
  return normalized === agent;
93
99
  }
94
100
  // ---------------------------------------------------------------------------
@@ -361,6 +367,28 @@ function violationFromCheck(entry, compliance, check, expected) {
361
367
  message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Apply remediation ops for ${check.setting_path} in ${entry.config_file_path}`,
362
368
  };
363
369
  }
370
+ /**
371
+ * True when a secret-scan hit sits at (or under) a path this remediation has ops for.
372
+ *
373
+ * The scan reads the whole file, but the entry only carries ops for its own checks. Blocking on
374
+ * any other hit pins the row non-compliant forever -- the gate re-prompts on every turn with a fix
375
+ * that cannot resolve it (e.g. Claude Code's own 64-hex `machineID` matching a provider pattern).
376
+ */
377
+ function secretFindingIsOwnedByEntry(findingPath, entry, checks) {
378
+ return checks.some((check) => {
379
+ const target = canonicalComplianceSettingPath(entry.config_file_path, check);
380
+ if (!target)
381
+ return false;
382
+ if (target.includes('*')) {
383
+ const pattern = target
384
+ .split('.')
385
+ .map((seg) => (seg === '*' ? '[^.]+' : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
386
+ .join('\\.');
387
+ return new RegExp(`^${pattern}(\\.|$)`).test(findingPath);
388
+ }
389
+ return findingPath === target || findingPath.startsWith(`${target}.`);
390
+ });
391
+ }
364
392
  /** Evaluate one manifest row against on-disk config (used by gate + post-restart verify). */
365
393
  export function evaluateManifestEntryCompliance(entry) {
366
394
  const compliance = entry.fix ?? entry.compliance;
@@ -370,11 +398,15 @@ export function evaluateManifestEntryCompliance(entry) {
370
398
  if (checks.length === 0)
371
399
  return { violations: [] };
372
400
  const loaded = loadRemediationConfigJson(entry.config_file_path, checks.map((c) => c.setting_path));
373
- if (!loaded.ok)
374
- return { violations: [] };
401
+ if (!loaded.ok) {
402
+ // A config we cannot read is not a config we can call compliant. file_not_found is genuinely
403
+ // compliant (the config is gone); any other reason means the file is there and we simply could
404
+ // not parse it, so callers must not treat the empty violation list as "verified".
405
+ return { violations: [], unevaluable: loaded.reason !== 'file_not_found' };
406
+ }
375
407
  const configJson = loaded.json;
376
408
  if (compliance.requires_secret_scan === true) {
377
- const secretFindings = scanJsonForHardcodedSecrets(configJson);
409
+ const secretFindings = scanJsonForHardcodedSecrets(configJson).filter((f) => secretFindingIsOwnedByEntry(f.path, entry, checks));
378
410
  return secretFindings.length === 0
379
411
  ? { violations: [] }
380
412
  : {
@@ -565,8 +597,12 @@ export function reportPostRestartVerificationOutcomes(violations) {
565
597
  const entriesByUuid = new Map(remediations.map((entry) => [entry.uuid, entry]));
566
598
  const outcomes = processPendingPostRestartVerifications((uuid) => {
567
599
  const entry = entriesByUuid.get(uuid);
568
- if (entry && collectManifestEntryViolations(entry).length > 0)
569
- return true;
600
+ if (entry) {
601
+ // Unreadable config counts as still-violating: never verify what could not be checked.
602
+ const { violations: entryViolations, unevaluable } = evaluateManifestEntryCompliance(entry);
603
+ if (unevaluable || entryViolations.length > 0)
604
+ return true;
605
+ }
570
606
  return violations.some((v) => v.uuid === uuid);
571
607
  });
572
608
  const reportPromises = outcomes.map((o) => {
@@ -597,6 +633,35 @@ export async function runPostApplyVerification(agent = 'cursor') {
597
633
  }
598
634
  return outcomes;
599
635
  }
636
+ /**
637
+ * Re-read the remediated config so the server can verify the apply instead of trusting the report.
638
+ *
639
+ * Without a snapshot the server's post-apply re-check has nothing to evaluate and clears the
640
+ * linked findings on the endpoint's word alone -- the same blind close that let a fix that never
641
+ * happened be recorded as remediated. vscdb-backed paths are not JSON files, so they stay absent.
642
+ */
643
+ function configSnapshotForReport(configFilePath) {
644
+ const diskPath = resolveRemediationConfigPath(configFilePath);
645
+ if (diskPath.includes('#'))
646
+ return undefined;
647
+ try {
648
+ const parsed = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
649
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
650
+ return parsed;
651
+ }
652
+ }
653
+ catch {
654
+ /* unreadable: report without a snapshot rather than failing the report */
655
+ }
656
+ return undefined;
657
+ }
658
+ /** Report an apply outcome, attaching the on-disk config only when it can actually be read. */
659
+ function reportAutofixWithSnapshot(uuid, result, configFilePath) {
660
+ const snapshot = configSnapshotForReport(configFilePath);
661
+ return snapshot
662
+ ? reportAutofixApplied(uuid, result, { config_snapshot_after: snapshot })
663
+ : reportAutofixApplied(uuid, result);
664
+ }
600
665
  /**
601
666
  * Immediate autofix succeeded (inline recheck OK or Claude stale-recheck tolerance).
602
667
  * Clear pending verification locally and report verified so the next prompt does not POST
@@ -605,7 +670,7 @@ export async function runPostApplyVerification(agent = 'cursor') {
605
670
  export function confirmAppliedAutofixVerified(appliedViolations, reportPromises) {
606
671
  for (const v of appliedViolations) {
607
672
  markRemediationApplyVerified(v.uuid);
608
- reportPromises.push(reportAutofixApplied(v.uuid, 'verified'));
673
+ reportPromises.push(reportAutofixWithSnapshot(v.uuid, 'verified', v.config_file_path));
609
674
  }
610
675
  }
611
676
  export function applyAutofixViolations(violations, agent = 'cursor') {
@@ -682,7 +747,7 @@ export function applyAutofixViolations(violations, agent = 'cursor') {
682
747
  fixed++;
683
748
  appliedViolations.push(violation);
684
749
  hookRunLog(`autofix: applied uuid=${inst.uuid} path=${configPathForDisk}`);
685
- reportPromises.push(reportAutofixApplied(inst.uuid, 'success'));
750
+ reportPromises.push(reportAutofixWithSnapshot(inst.uuid, 'success', inst.config_file_path));
686
751
  // Every successful autofix (Cursor + Claude, restart or immediate JSON) awaits verification on
687
752
  // the next compliance check so we can quarantine stuck applies and stop restart/retry loops.
688
753
  markRemediationApplyPendingVerification(inst.uuid);
@@ -904,8 +969,8 @@ export function uploadSatisfiedManifestConfigs(agent = 'cursor') {
904
969
  const entries = remediations.filter((e) => targetsCurrentAgent(e, agent));
905
970
  const promises = [];
906
971
  for (const entry of entries) {
907
- const { violations } = evaluateManifestEntryCompliance(entry);
908
- if (violations.length > 0)
972
+ const { violations, unevaluable } = evaluateManifestEntryCompliance(entry);
973
+ if (violations.length > 0 || unevaluable)
909
974
  continue;
910
975
  const inst = entry;
911
976
  const uploadFileType = resolveRemediationUploadFileType(entry.config_file_path, inst.file_type ?? undefined);
@@ -969,8 +1034,8 @@ export function reportCompliantRemediationVerifiedStatus(agent = 'cursor') {
969
1034
  const entries = remediations.filter((e) => targetsCurrentAgent(e, agent));
970
1035
  const promises = [];
971
1036
  for (const entry of entries) {
972
- const { violations } = evaluateManifestEntryCompliance(entry);
973
- if (violations.length > 0)
1037
+ const { violations, unevaluable } = evaluateManifestEntryCompliance(entry);
1038
+ if (violations.length > 0 || unevaluable)
974
1039
  continue;
975
1040
  const tracking = readRemediationApplyTrackingFile();
976
1041
  const prev = tracking.entries[entry.uuid];
@@ -982,7 +1047,13 @@ export function reportCompliantRemediationVerifiedStatus(agent = 'cursor') {
982
1047
  const rawContent = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
983
1048
  if (rawContent === null)
984
1049
  continue;
985
- promises.push(reportAutofixApplied(entry.uuid, 'verified', { config_snapshot_after: rawContent }).then(() => {
1050
+ promises.push(
1051
+ // Nothing was applied on this path, so before === after. Sending both makes a no-op
1052
+ // "verified" distinguishable from a real apply in EnforcementLog.
1053
+ reportAutofixApplied(entry.uuid, 'verified', {
1054
+ config_snapshot_before: rawContent,
1055
+ config_snapshot_after: rawContent,
1056
+ }).then(() => {
986
1057
  markRemediationApplyVerified(entry.uuid);
987
1058
  hookRunLog(`compliance_check: reported verified (already compliant) uuid=${entry.uuid}`);
988
1059
  }));
@@ -557,9 +557,20 @@ const TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND_LEGACY = 'CURSOR_PROJECT=$(gi
557
557
  const TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND = 'REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT && ' +
558
558
  'CURSOR_PROJECT="${CURSOR_PROJECT_DIR:-$REPO_ROOT}" && export CURSOR_PROJECT && ' +
559
559
  "nohup bash -c 'sleep 2 && open -a Cursor \"$CURSOR_PROJECT\"' >/dev/null 2>&1 & killall -9 Cursor";
560
- const TRUSTED_CLAUDE_RESTART_COMMAND = "nohup bash -c 'sleep 2 && open -a Claude' >/dev/null 2>&1 & pkill -x 'Claude'";
560
+ /** Pre-wait-loop command; still accepted so an older manifest keeps restarting on a new client. */
561
+ const TRUSTED_CLAUDE_RESTART_COMMAND_LEGACY = "nohup bash -c 'sleep 2 && open -a Claude' >/dev/null 2>&1 & pkill -x 'Claude'";
562
+ /**
563
+ * Relaunch Claude Desktop once it has actually exited.
564
+ *
565
+ * The legacy command killed the app and relaunched after a flat 2s. A quit that takes longer (state
566
+ * save) meant `open -a Claude` landed while the app was still terminating, the launch was coalesced
567
+ * into the dying instance, and Desktop never came back. Poll for exit instead, bounded at ~20s.
568
+ * executeTrustedRestartCommands already spawns this detached, so it runs inline (no nohup/&).
569
+ */
570
+ const TRUSTED_CLAUDE_RESTART_COMMAND = "pkill -x 'Claude'; for i in $(seq 1 80); do pgrep -x 'Claude' >/dev/null || break; sleep 0.25; done; open -a Claude";
561
571
  export function isClaudeRestartCommand(cmd) {
562
- return cmd.trim() === TRUSTED_CLAUDE_RESTART_COMMAND;
572
+ const t = cmd.trim();
573
+ return t === TRUSTED_CLAUDE_RESTART_COMMAND || t === TRUSTED_CLAUDE_RESTART_COMMAND_LEGACY;
563
574
  }
564
575
  export function isCursorRestartCommand(cmd) {
565
576
  const t = cmd.trim();
@@ -579,7 +590,8 @@ export function isTrustedRestartCommandForAutofix(cmd) {
579
590
  return (t === TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND ||
580
591
  t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND ||
581
592
  t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND_LEGACY ||
582
- t === TRUSTED_CLAUDE_RESTART_COMMAND);
593
+ t === TRUSTED_CLAUDE_RESTART_COMMAND ||
594
+ t === TRUSTED_CLAUDE_RESTART_COMMAND_LEGACY);
583
595
  }
584
596
  /** Legacy Cursor: dedicated ItemTable row `composerState`. Current Cursor: nested under reactive `applicationUser` blob. */
585
597
  function cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp) {
@@ -1537,6 +1549,9 @@ export function reportAutofixApplied(remediationUuid, result, details) {
1537
1549
  if (details?.config_snapshot_after && typeof details.config_snapshot_after === 'object') {
1538
1550
  bodyPayload.config_snapshot_after = details.config_snapshot_after;
1539
1551
  }
1552
+ if (details?.config_snapshot_before && typeof details.config_snapshot_before === 'object') {
1553
+ bodyPayload.config_snapshot_before = details.config_snapshot_before;
1554
+ }
1540
1555
  const signature = createSignature(payload, authKey.key);
1541
1556
  const body = JSON.stringify({ ...bodyPayload, signature });
1542
1557
  return executeBody(url, 'POST', body, 8000)
@@ -38,10 +38,12 @@ const KNOWN_SECRET_PATTERNS = [
38
38
  { re: /ASIA[0-9A-Z]{16}/i, label: 'AWS temporary access key ID' },
39
39
  { re: /AIza[0-9A-Za-z_-]{35}/i, label: 'Google API key' },
40
40
  { re: /ya29\.[0-9A-Za-z_-]+/i, label: 'Google OAuth access token' },
41
- { re: /AC[a-z0-9]{32}/i, label: 'Twilio account SID' },
42
- { re: /SK[a-z0-9]{32}/i, label: 'Twilio API key' },
41
+ // Anchored + case-sensitive: Twilio SIDs are uppercase AC/SK + exactly 32 hex. Unanchored
42
+ // /AC[a-z0-9]{32}/i matched any long hex string containing "ac" (e.g. a 64-hex machine id).
43
+ { re: /\bAC[a-f0-9]{32}\b/, label: 'Twilio account SID' },
44
+ { re: /\bSK[a-f0-9]{32}\b/, label: 'Twilio API key' },
43
45
  { re: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/i, label: 'SendGrid API key' },
44
- { re: /key-[a-f0-9]{32}/i, label: 'Mailgun API key' },
46
+ { re: /\bkey-[a-f0-9]{32}\b/i, label: 'Mailgun API key' },
45
47
  { re: /npm_[a-zA-Z0-9]{36}/i, label: 'npm access token' },
46
48
  { re: /pypi-[a-zA-Z0-9_-]{50,}/i, label: 'PyPI API token' },
47
49
  { re: /discord(?:app)?\.com\/api\/webhooks\/\d+\/[a-zA-Z0-9_-]+/i, label: 'Discord webhook URL' },
@@ -57,7 +59,9 @@ const KNOWN_SECRET_PATTERNS = [
57
59
  { re: /Bearer\s+[a-zA-Z0-9\-_.]{20,}/i, label: 'Bearer token' },
58
60
  ];
59
61
  const BASIC_AUTH_IN_URL = /[a-zA-Z0-9._%+-]+:[a-zA-Z0-9._%+-]+@/gi;
60
- const GENERIC_TOKEN = /\b[a-zA-Z0-9]{32,}\b/g;
62
+ // Hyphens/underscores count as part of one token so hyphenated keys (ctx7sk-..., sk-ant-...)
63
+ // are measured as a single candidate instead of segment-by-segment (matches HardcodedSecretRule).
64
+ const GENERIC_TOKEN = /\b[a-zA-Z0-9][a-zA-Z0-9_-]{31,}\b/g;
61
65
  const GENERIC_KEY_HINT = /(?:api[_-]?key|app[_-]?key|secret|token|password|passwd|pass\b|(?<!o)auth|credential|private[_-]?key|access[_-]?key|master[_-]?key)/i;
62
66
  function basicAuthMatchIsCredential(value, matchIndex) {
63
67
  const before = value.slice(0, matchIndex);
@@ -70,6 +74,20 @@ function basicAuthMatchIsCredential(value, matchIndex) {
70
74
  function looksLikeUrlContext(value) {
71
75
  return value.includes(':') && (value.toLowerCase().includes('http') || value.includes('://'));
72
76
  }
77
+ /**
78
+ * Values to run GENERIC_TOKEN against. A URL's host/path is skipped (high false-positive rate),
79
+ * but query-string values are scanned — MCP tokens commonly live there. Mirrors
80
+ * HardcodedSecretRule._generic_token_scan_targets.
81
+ */
82
+ function genericTokenScanTargets(value) {
83
+ if (!looksLikeUrlContext(value))
84
+ return [value];
85
+ const queryStart = value.indexOf('?');
86
+ if (queryStart === -1)
87
+ return [];
88
+ const query = value.slice(queryStart + 1).split('#')[0] ?? '';
89
+ return [...new URLSearchParams(query).values()];
90
+ }
73
91
  /**
74
92
  * Scan one scalar config value. Returns secret type label or null if clean / env-backed.
75
93
  */
@@ -89,12 +107,14 @@ export function scanScalarForHardcodedSecret(value, keyName) {
89
107
  return 'Basic authentication credentials';
90
108
  }
91
109
  }
92
- if (!GENERIC_KEY_HINT.test(keyName) || looksLikeUrlContext(valueStr)) {
110
+ if (!GENERIC_KEY_HINT.test(keyName)) {
93
111
  return null;
94
112
  }
95
- const genericMatches = valueStr.match(GENERIC_TOKEN) ?? [];
96
- if (genericMatches.some((token) => token.length >= 40)) {
97
- return 'Potential token/secret';
113
+ for (const target of genericTokenScanTargets(valueStr)) {
114
+ const genericMatches = target.match(GENERIC_TOKEN) ?? [];
115
+ if (genericMatches.some((token) => token.length >= 40)) {
116
+ return 'Potential token/secret';
117
+ }
98
118
  }
99
119
  return null;
100
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimuslabs/harness-map-staging",
3
- "version": "1.5.18",
3
+ "version": "1.5.19",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {