@synkro-sh/cli 1.6.27 → 1.6.29

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/bootstrap.js CHANGED
@@ -336,6 +336,12 @@ function installCursorHooks(hooksJsonPath, config) {
336
336
  failClosed: false,
337
337
  [SYNKRO_MARKER2]: true
338
338
  });
339
+ h.beforeShellExecution.push({
340
+ command: cursorCcCmd(config.cwePrecheckScriptPath),
341
+ timeout: 60,
342
+ failClosed: false,
343
+ [SYNKRO_MARKER2]: true
344
+ });
339
345
  h.beforeShellExecution.push({
340
346
  command: bunRunCmd(config.bashJudgeScriptPath),
341
347
  timeout: 15,
@@ -351,6 +357,13 @@ function installCursorHooks(hooksJsonPath, config) {
351
357
  matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command",
352
358
  [SYNKRO_MARKER2]: true
353
359
  });
360
+ h.preToolUse.push({
361
+ command: cursorCcCmd(config.cwePrecheckScriptPath),
362
+ timeout: 60,
363
+ failClosed: false,
364
+ matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command",
365
+ [SYNKRO_MARKER2]: true
366
+ });
354
367
  h.preToolUse.push({
355
368
  command: bunRunCmd(config.bashJudgeScriptPath),
356
369
  timeout: 15,
@@ -773,7 +786,8 @@ var init_hookScriptsTs = __esm({
773
786
  "use strict";
774
787
  SYNKRO_COMMON_TS = `
775
788
  // Shared Synkro hook utilities \u2014 imported by all hook scripts.
776
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync, readdirSync, statSync } from 'node:fs';
789
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync, readdirSync, statSync, createReadStream } from 'node:fs';
790
+ import { createInterface } from 'node:readline';
777
791
  import { join, dirname, basename, extname, resolve as resolvePath } from 'node:path';
778
792
  import { homedir } from 'node:os';
779
793
  import { execSync } from 'node:child_process';
@@ -827,6 +841,51 @@ export function isPathUnder(filePath: string, cwd: string): boolean {
827
841
  return resolved.startsWith(base + '/') || resolved === base;
828
842
  }
829
843
 
844
+ const SHELL_CODE_FILE_EXT = /.(ts|tsx|js|jsx|mjs|cjs|py|go|java|rb|php|rs|vue|svelte)$/i;
845
+
846
+ export interface ShellCodeWrite {
847
+ filePath: string;
848
+ content: string;
849
+ }
850
+
851
+ /** Detect shell commands that write/rewrite source files (closes Edit-tool CWE bypass). */
852
+ export function extractShellCodeWrites(command: string, cwd: string): ShellCodeWrite[] {
853
+ if (!command.trim()) return [];
854
+ const writes: ShellCodeWrite[] = [];
855
+ const seen = new Set<string>();
856
+
857
+ function add(rawPath: string, content: string) {
858
+ const trimmed = rawPath.trim().replace(/^['"]|['"]$/g, '');
859
+ if (!trimmed) return;
860
+ const resolved = trimmed.startsWith('/') ? resolvePath(trimmed) : resolvePath(cwd || '.', trimmed);
861
+ if (!SHELL_CODE_FILE_EXT.test(resolved)) return;
862
+ const key = resolved;
863
+ if (seen.has(key)) return;
864
+ seen.add(key);
865
+ writes.push({ filePath: resolved, content: content.slice(0, 6000) });
866
+ }
867
+
868
+ const heredocBodies: string[] = [];
869
+ const heredocRe = /<<-?\\s*['"]?(\\w+)['"]?\\s*\\n([\\s\\S]*?)\\n\\1\\b/g;
870
+ let hm: RegExpExecArray | null;
871
+ while ((hm = heredocRe.exec(command)) !== null) {
872
+ heredocBodies.push(hm[2]);
873
+ }
874
+ const body = heredocBodies.length > 0 ? heredocBodies.join('\\n\\n') : command;
875
+
876
+ for (const m of command.matchAll(/Path\\s*\\(\\s*['"]([^'"]+)['"]\\s*\\)/g)) add(m[1], body);
877
+ for (const m of command.matchAll(/writeFileSync\\s*\\(\\s*['"]([^'"]+)['"]/g)) add(m[1], body);
878
+ for (const m of command.matchAll(/writeFile\\s*\\(\\s*['"]([^'"]+)['"]/g)) add(m[1], body);
879
+ for (const m of command.matchAll(/(?:^|[\\s;|])(?:>>?)\\s*([^\\s;&|]+\\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|java|rb|php|rs|vue|svelte))\\b/gim)) {
880
+ add(m[1], body);
881
+ }
882
+ for (const m of command.matchAll(/\\btee(?:\\s+-a)?\\s+([^\\s;&|]+\\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|java|rb|php|rs|vue|svelte))\\b/gi)) {
883
+ add(m[1], body);
884
+ }
885
+
886
+ return writes;
887
+ }
888
+
830
889
  // \u2500\u2500\u2500 Logging \u2500\u2500\u2500
831
890
 
832
891
  // Hooks must keep stderr quiet for non-error paths. Claude Code's PreToolUse
@@ -1220,10 +1279,11 @@ export async function route(config: HookConfig): Promise<'local' | 'cloud'> {
1220
1279
  return 'cloud';
1221
1280
  }
1222
1281
 
1223
- export async function cweRoute(config: HookConfig): Promise<'local' | 'cloud'> {
1224
- if (config.captureDepth === 'local_only') return 'local';
1282
+ export async function cweRoute(config: HookConfig): Promise<'local' | 'byok' | 'skip'> {
1283
+ const gradingMode = process.env.SYNKRO_GRADING_MODE || config.gradingMode || 'local';
1284
+ if (gradingMode === 'byok') return 'byok';
1225
1285
  if (await cweChannelUp()) return 'local';
1226
- return 'cloud';
1286
+ return 'skip';
1227
1287
  }
1228
1288
 
1229
1289
  // \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
@@ -1299,10 +1359,10 @@ export async function localGrade(surface: string, prompt: string, timeoutMs = 30
1299
1359
  return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 18929, timeoutMs, agentKind);
1300
1360
  }
1301
1361
 
1302
- export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code'): Promise<string> {
1362
+ export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code', timeoutMs = 45000): Promise<string> {
1303
1363
  const jwt = loadJwt();
1304
1364
  if (!jwt) throw new Error('NO_JWT');
1305
- return channelGrade('grade-cwe', prompt, jwt, 18930, 45000, agentKind);
1365
+ return channelGrade('grade-cwe', prompt, jwt, 18930, timeoutMs, agentKind);
1306
1366
  }
1307
1367
 
1308
1368
  // \u2500\u2500\u2500 Rule Pre-Filter (embedding-based) \u2500\u2500\u2500
@@ -1768,7 +1828,7 @@ export function dispatchCapture(
1768
1828
  },
1769
1829
  ): void {
1770
1830
  // Fire-and-forget
1771
- const eventId = 'evt_' + Date.now() + '_' + process.pid;
1831
+ const eventId = mintEventId('evt');
1772
1832
  const model = normalizeCaptureModel(opts?.ccModel || 'unknown');
1773
1833
 
1774
1834
  const body: Record<string, any> = {
@@ -1817,105 +1877,194 @@ export function dispatchCapture(
1817
1877
 
1818
1878
  // \u2500\u2500\u2500 Durable Telemetry Spool \u2500\u2500\u2500
1819
1879
  // Telemetry must survive process death, container restarts, and ingest-server
1820
- // backpressure. Instead of a fire-and-forget POST (which silently dropped
1821
- // captures under parallel load), every event is appended synchronously to a
1822
- // local JSONL spool \u2014 a write that completes before the function returns and
1823
- // outlives the hook process. A background drainer (kicked from loadConfig, so
1824
- // it overlaps the multi-second grade) batch-ships the spool to the ingest
1825
- // server and only deletes events after a confirmed write. Ingest is idempotent
1826
- // (ON CONFLICT DO NOTHING on event_id), so a retried or double-drained event
1827
- // is harmless.
1880
+ // backpressure. Every event is appended synchronously to a local JSONL spool.
1881
+ // drainSpool streams claim files line-by-line (never loads multi-GB into RAM),
1882
+ // ships in batches, and on partial failure only re-spools the batches that
1883
+ // did not land \u2014 never the whole claim file (that caused 2GB amplification).
1828
1884
 
1829
1885
  const TELEMETRY_SPOOL = join(HOME, '.synkro', 'telemetry-spool.jsonl');
1830
1886
  const SPOOL_DRAIN_PREFIX = 'telemetry-spool.jsonl.draining.';
1887
+ const SPOOL_DRAIN_LOCK = join(HOME, '.synkro', 'telemetry-spool.drain.lock');
1888
+ const SPOOL_MAX_CLAIM_BYTES = 50 * 1024 * 1024;
1889
+ const SPOOL_BATCH_SIZE = 200;
1890
+ const SPOOL_DRAIN_LOCK_STALE_MS = 120_000;
1891
+
1892
+ /** Stable id for spool rows \u2014 required for idempotent ingest (ON CONFLICT). */
1893
+ export function mintEventId(prefix = 'evt'): string {
1894
+ return prefix + '_' + Date.now() + '_' + process.pid;
1895
+ }
1831
1896
 
1832
- // appendLocalTelemetry \u2014 durably records one telemetry event. The synchronous
1833
- // append IS the durability guarantee; nothing here can drop the event.
1834
1897
  export function appendLocalTelemetry(body: Record<string, any>): void {
1835
- // Cloud storage mode: the dashboard reads Timescale and a cloud-only setup
1836
- // has no container to drain a local spool \u2014 so skip it. Cloud telemetry goes
1837
- // via shipCloud; the local spool is only meaningful in local storage mode.
1838
1898
  if ((process.env.SYNKRO_STORAGE_MODE || 'local') !== 'local') return;
1839
- const event = { ...body, _ts: new Date().toISOString() };
1899
+ const event = { ...body };
1900
+ if (!event.event_id) {
1901
+ const ct = String(event.capture_type || '');
1902
+ const prefix = ct === 'usage_tick' ? 'usage' : ct === 'edit_scan' ? 'edit' : 'evt';
1903
+ event.event_id = mintEventId(prefix);
1904
+ }
1905
+ if (!event._ts) event._ts = new Date().toISOString();
1840
1906
  try {
1841
1907
  appendFileSync(TELEMETRY_SPOOL, JSON.stringify(event) + '\\n');
1842
1908
  } catch {}
1843
1909
  }
1844
1910
 
1845
- // drainSpool \u2014 claims the spool via atomic rename, batch-ships it to the
1846
- // ingest server, deletes on success, re-spools on failure. Fire-and-forget:
1847
- // callers kick it and let it run concurrently with the grade.
1848
- export async function drainSpool(): Promise<void> {
1849
- const dir = join(HOME, '.synkro');
1850
- const claimed: string[] = [];
1851
-
1852
- // 1. Claim the live spool by atomic rename \u2014 a fresh spool takes new writes.
1911
+ function tryAcquireDrainLock(): boolean {
1853
1912
  try {
1854
- if (existsSync(TELEMETRY_SPOOL) && statSync(TELEMETRY_SPOOL).size > 0) {
1855
- const claim = join(dir, SPOOL_DRAIN_PREFIX + process.pid + '.' + Date.now());
1856
- renameSync(TELEMETRY_SPOOL, claim);
1857
- claimed.push(claim);
1913
+ if (existsSync(SPOOL_DRAIN_LOCK)) {
1914
+ if (Date.now() - statSync(SPOOL_DRAIN_LOCK).mtimeMs < SPOOL_DRAIN_LOCK_STALE_MS) return false;
1915
+ unlinkSync(SPOOL_DRAIN_LOCK);
1858
1916
  }
1859
- } catch {}
1917
+ mkdirSync(dirname(SPOOL_DRAIN_LOCK), { recursive: true });
1918
+ writeFileSync(SPOOL_DRAIN_LOCK, String(process.pid) + '\\n');
1919
+ return true;
1920
+ } catch {
1921
+ return false;
1922
+ }
1923
+ }
1860
1924
 
1861
- // 2. Recover orphaned claim files \u2014 a previous hook died mid-drain. Only
1862
- // adopt claims older than 30s so we never steal another hook's in-flight drain.
1925
+ function releaseDrainLock(): void {
1926
+ try { unlinkSync(SPOOL_DRAIN_LOCK); } catch {}
1927
+ }
1928
+
1929
+ async function postSpoolBatch(mcpPort: string, mcpToken: string, events: any[]): Promise<boolean> {
1930
+ if (!events.length) return true;
1863
1931
  try {
1864
- for (const f of readdirSync(dir)) {
1865
- if (!f.startsWith(SPOOL_DRAIN_PREFIX)) continue;
1866
- const full = join(dir, f);
1867
- if (claimed.indexOf(full) !== -1) continue;
1868
- try {
1869
- if (Date.now() - statSync(full).mtimeMs > 30000) claimed.push(full);
1870
- } catch {}
1932
+ const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/ingest/batch', {
1933
+ method: 'POST',
1934
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1935
+ body: JSON.stringify({ events }),
1936
+ signal: AbortSignal.timeout(30000),
1937
+ });
1938
+ if (!resp.ok) {
1939
+ const errBody = await resp.text().catch(() => '');
1940
+ log('drainSpool batch HTTP ' + resp.status + ': ' + errBody.slice(0, 120));
1871
1941
  }
1872
- } catch {}
1873
- if (claimed.length === 0) return;
1942
+ return resp.ok;
1943
+ } catch (e) {
1944
+ log('drainSpool batch error: ' + ((e as Error).message || String(e)).slice(0, 120));
1945
+ return false;
1946
+ }
1947
+ }
1874
1948
 
1875
- // 3. Read every event out of the claimed files.
1876
- const events: any[] = [];
1877
- for (const f of claimed) {
1878
- try {
1879
- for (const line of readFileSync(f, 'utf-8').split('\\n')) {
1880
- const t = line.trim();
1881
- if (!t) continue;
1882
- try { events.push(JSON.parse(t)); } catch {}
1883
- }
1884
- } catch {}
1949
+ function quarantineOversizedClaim(claimPath: string): void {
1950
+ const stuck = claimPath + '.STUCK-OVERSIZED.bak';
1951
+ try {
1952
+ renameSync(claimPath, stuck);
1953
+ log('drainSpool quarantined oversized claim \u2192 ' + basename(stuck));
1954
+ } catch (e) {
1955
+ log('drainSpool quarantine failed: ' + String(e));
1885
1956
  }
1886
- if (events.length === 0) {
1887
- for (const f of claimed) { try { unlinkSync(f); } catch {} }
1957
+ }
1958
+
1959
+ async function drainClaimFile(claimPath: string, mcpPort: string, mcpToken: string): Promise<void> {
1960
+ let sz = 0;
1961
+ try { sz = statSync(claimPath).size; } catch { return; }
1962
+ if (sz === 0) {
1963
+ try { unlinkSync(claimPath); } catch {}
1964
+ return;
1965
+ }
1966
+ if (sz > SPOOL_MAX_CLAIM_BYTES) {
1967
+ quarantineOversizedClaim(claimPath);
1888
1968
  return;
1889
1969
  }
1890
1970
 
1891
- // 4. Ship to /api/ingest/batch in chunks. A token is required by the server.
1892
- const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
1893
- let mcpToken = '';
1894
- try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
1895
- if (!mcpToken) return; // leave claim files; a later drain retries them
1971
+ const pending: any[] = [];
1972
+ let batch: any[] = [];
1973
+ let failed = false;
1896
1974
 
1897
- let allOk = true;
1898
- for (let i = 0; i < events.length; i += 200) {
1975
+ const rl = createInterface({
1976
+ input: createReadStream(claimPath, { encoding: 'utf8' }),
1977
+ crlfDelay: Infinity,
1978
+ });
1979
+
1980
+ for await (const line of rl) {
1981
+ if (failed) {
1982
+ const t = line.trim();
1983
+ if (!t) continue;
1984
+ try { pending.push(JSON.parse(t)); } catch {}
1985
+ continue;
1986
+ }
1987
+ const t = line.trim();
1988
+ if (!t) continue;
1899
1989
  try {
1900
- const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/ingest/batch', {
1901
- method: 'POST',
1902
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1903
- body: JSON.stringify({ events: events.slice(i, i + 200) }),
1904
- signal: AbortSignal.timeout(8000),
1905
- });
1906
- if (!resp.ok) { allOk = false; break; }
1907
- } catch { allOk = false; break; }
1990
+ batch.push(JSON.parse(t));
1991
+ } catch {
1992
+ continue;
1993
+ }
1994
+ if (batch.length < SPOOL_BATCH_SIZE) continue;
1995
+ const chunk = batch.splice(0, SPOOL_BATCH_SIZE);
1996
+ if (!(await postSpoolBatch(mcpPort, mcpToken, chunk))) {
1997
+ pending.push(...chunk);
1998
+ failed = true;
1999
+ }
1908
2000
  }
1909
2001
 
1910
- // 5. Success \u2192 drop the claim files. Failure \u2192 re-spool for the next drain.
1911
- if (allOk) {
1912
- for (const f of claimed) { try { unlinkSync(f); } catch {} }
1913
- } else {
2002
+ if (!failed && batch.length > 0) {
2003
+ if (!(await postSpoolBatch(mcpPort, mcpToken, batch))) {
2004
+ pending.push(...batch);
2005
+ failed = true;
2006
+ } else {
2007
+ batch = [];
2008
+ }
2009
+ } else if (failed && batch.length > 0) {
2010
+ pending.push(...batch);
2011
+ }
2012
+
2013
+ if (pending.length === 0) {
2014
+ try { unlinkSync(claimPath); } catch {}
2015
+ return;
2016
+ }
2017
+
2018
+ try {
2019
+ for (const evt of pending) {
2020
+ appendFileSync(TELEMETRY_SPOOL, JSON.stringify(evt) + '\\n');
2021
+ }
2022
+ try { unlinkSync(claimPath); } catch {}
2023
+ log('drainSpool re-spooled ' + pending.length + ' events from ' + basename(claimPath));
2024
+ } catch (e) {
2025
+ log('drainSpool re-spool failed: ' + String(e));
2026
+ }
2027
+ }
2028
+
2029
+ export async function drainSpool(): Promise<void> {
2030
+ if (!tryAcquireDrainLock()) return;
2031
+
2032
+ const dir = join(HOME, '.synkro');
2033
+ const claimed: string[] = [];
2034
+
2035
+ try {
1914
2036
  try {
1915
- appendFileSync(TELEMETRY_SPOOL, events.map(e => JSON.stringify(e)).join('\\n') + '\\n');
1916
- for (const f of claimed) { try { unlinkSync(f); } catch {} }
2037
+ if (existsSync(TELEMETRY_SPOOL) && statSync(TELEMETRY_SPOOL).size > 0) {
2038
+ const claim = join(dir, SPOOL_DRAIN_PREFIX + process.pid + '.' + Date.now());
2039
+ renameSync(TELEMETRY_SPOOL, claim);
2040
+ claimed.push(claim);
2041
+ }
1917
2042
  } catch {}
1918
- // if re-spool failed, claim files remain and are recovered as orphans later
2043
+
2044
+ try {
2045
+ for (const f of readdirSync(dir)) {
2046
+ if (!f.startsWith(SPOOL_DRAIN_PREFIX)) continue;
2047
+ if (f.includes('.STUCK-') || f.endsWith('.bak')) continue;
2048
+ const full = join(dir, f);
2049
+ if (claimed.indexOf(full) !== -1) continue;
2050
+ try {
2051
+ if (Date.now() - statSync(full).mtimeMs > 30000) claimed.push(full);
2052
+ } catch {}
2053
+ }
2054
+ } catch {}
2055
+
2056
+ if (claimed.length === 0) return;
2057
+
2058
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
2059
+ let mcpToken = '';
2060
+ try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
2061
+ if (!mcpToken) return;
2062
+
2063
+ for (const f of claimed) {
2064
+ await drainClaimFile(f, mcpPort, mcpToken);
2065
+ }
2066
+ } finally {
2067
+ releaseDrainLock();
1919
2068
  }
1920
2069
  }
1921
2070
 
@@ -2058,7 +2207,7 @@ export async function readStdin(): Promise<string> {
2058
2207
 
2059
2208
  /** Cursor stores transcripts under ~/.cursor/projects/{slug}/agent-transcripts/ */
2060
2209
  export function cursorProjectSlug(workspaceRoot: string): string {
2061
- return workspaceRoot.replace(/^/+/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2210
+ return workspaceRoot.replace(new RegExp('^[/]+'), '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2062
2211
  }
2063
2212
 
2064
2213
  /** Resolve transcript JSONL \u2014 payload field, CURSOR_TRANSCRIPT_PATH, or Cursor agent-transcripts dir. */
@@ -2708,7 +2857,7 @@ export function emitUsageTick(params: {
2708
2857
  }
2709
2858
  appendLocalTelemetry({
2710
2859
  capture_type: 'usage_tick',
2711
- event_id: 'usage_' + Date.now() + '_' + process.pid,
2860
+ event_id: mintEventId('usage'),
2712
2861
  hook_type: hookType,
2713
2862
  verdict: 'allow',
2714
2863
  severity: 'none',
@@ -2816,6 +2965,38 @@ export function dispatchFinding(
2816
2965
  shipCloud(jwt, '/api/v1/hook/finding', cloudBody);
2817
2966
  }
2818
2967
 
2968
+ export function dispatchScanResult(
2969
+ jwt: string,
2970
+ scan: {
2971
+ session_id: string;
2972
+ file_path: string;
2973
+ scan_type: 'cve' | 'cwe' | 'pkg';
2974
+ result: 'pass' | 'block' | 'error';
2975
+ finding_count: number;
2976
+ finding_ids?: string[];
2977
+ severity?: string;
2978
+ repo?: string;
2979
+ },
2980
+ ): void {
2981
+ const localEntry: Record<string, any> = {
2982
+ capture_type: 'scan_result',
2983
+ event_id: 'scan_' + Date.now() + '_' + process.pid,
2984
+ _ts: new Date().toISOString(),
2985
+ ...scan,
2986
+ };
2987
+ appendLocalTelemetry(localEntry);
2988
+ shipCloud(jwt, '/api/v1/hook/scan-result', {
2989
+ scan_type: scan.scan_type,
2990
+ result: scan.result,
2991
+ finding_count: scan.finding_count,
2992
+ finding_ids: scan.finding_ids,
2993
+ severity: scan.severity,
2994
+ session_id: scan.session_id,
2995
+ file_path: scan.file_path,
2996
+ repo: scan.repo,
2997
+ });
2998
+ }
2999
+
2819
3000
  // \u2500\u2500\u2500 Hook tool-name sets (CC + Cursor) \u2500\u2500\u2500
2820
3001
 
2821
3002
  export const EDIT_TOOL_NAMES = new Set([
@@ -3194,7 +3375,8 @@ main();
3194
3375
  import {
3195
3376
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
3196
3377
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3197
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3378
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, isShellTool, isCursorHookFormat,
3379
+ extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3198
3380
  logGraderUnavailable, resolveTranscriptPath,
3199
3381
  } from './_synkro-common.ts';
3200
3382
  import { basename, extname, resolve, join, dirname } from 'node:path';
@@ -3215,6 +3397,22 @@ interface PackageCapability {
3215
3397
  sourceExcerpt: string;
3216
3398
  }
3217
3399
 
3400
+ const NON_CODE_EXTS = new Set([
3401
+ '.md', '.mdx', '.txt', '.rst', '.adoc', '.org',
3402
+ '.log', '.csv', '.tsv', '.html', '.htm',
3403
+ '.lock', '.gitignore', '.dockerignore', '.npmignore',
3404
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf',
3405
+ '.woff', '.woff2', '.ttf', '.otf',
3406
+ ]);
3407
+
3408
+ interface CweScanTarget {
3409
+ filePath: string;
3410
+ cweContent: string;
3411
+ cweDiffSection: string;
3412
+ toolName: string;
3413
+ toolInput: any;
3414
+ }
3415
+
3218
3416
  const JS_DANGEROUS_MODULES = new Set([
3219
3417
  'child_process', 'net', 'dgram', 'http', 'https', 'fs', 'vm',
3220
3418
  'worker_threads', 'cluster', 'dns', 'tls', 'crypto',
@@ -3322,69 +3520,90 @@ async function main() {
3322
3520
 
3323
3521
  const payload = JSON.parse(input);
3324
3522
  const toolName = payload.tool_name || '';
3325
- if (!isEditTool(toolName)) {
3326
- outputEmpty();
3327
- return;
3328
- }
3329
-
3330
3523
  const toolInput = payload.tool_input || {};
3331
3524
  const sessionId = hookSessionId(payload);
3332
3525
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3333
3526
  const cwd = payload.cwd || workspaceRoots[0] || '';
3334
3527
  const transcriptPath = resolveTranscriptPath(payload);
3335
-
3336
- const filePath = filePathFromToolInput(toolInput);
3337
- if (!filePath) { outputEmpty(); return; }
3338
- const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3528
+ const shellCommand = typeof payload.command === 'string' ? payload.command.trim() : '';
3339
3529
  const ccModel = detectModel(payload);
3340
3530
 
3341
- if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
3531
+ const targets: CweScanTarget[] = [];
3532
+
3533
+ if (isCursorHookFormat() && (shellCommand || isShellTool(toolName))) {
3534
+ const cmd = shellCommand || String(toolInput.command || '');
3535
+ if (!cmd) { outputEmpty(); return; }
3536
+ for (const w of extractShellCodeWrites(cmd, cwd)) {
3537
+ if (w.filePath.includes('/.synkro/hooks/')) continue;
3538
+ const ext = extname(w.filePath).toLowerCase();
3539
+ if (NON_CODE_EXTS.has(ext)) continue;
3540
+ targets.push({
3541
+ filePath: w.filePath,
3542
+ cweContent: w.content,
3543
+ cweDiffSection: '',
3544
+ toolName: toolName || 'Shell',
3545
+ toolInput: {},
3546
+ });
3547
+ }
3548
+ if (targets.length === 0) { outputEmpty(); return; }
3549
+ } else if (isEditTool(toolName)) {
3550
+ const filePath = filePathFromToolInput(toolInput);
3551
+ if (!filePath) { outputEmpty(); return; }
3552
+ if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
3553
+ const fileExt = extname(filePath).toLowerCase();
3554
+ if (NON_CODE_EXTS.has(fileExt)) { outputEmpty(); return; }
3555
+
3556
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
3557
+ if (!proposed) { outputEmpty(); return; }
3558
+
3559
+ let cweContent: string;
3560
+ let cweDiffSection = '';
3561
+ if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'edit_file' || toolName === 'reapply' || toolName === 'ApplyPatch' || toolName === 'apply_patch') {
3562
+ const newStr = toolName === 'Edit' || toolName === 'edit_file' || toolName === 'reapply'
3563
+ ? (toolInput.new_string || '')
3564
+ : toolName === 'ApplyPatch' || toolName === 'apply_patch'
3565
+ ? (toolInput.patch || toolInput.content || toolInput.code_edit || '')
3566
+ : (Array.isArray(toolInput.edits) ? toolInput.edits.map((e: any) => e?.new_string || '').join('\n') : '');
3567
+ cweDiffSection = newStr.slice(0, 4000);
3568
+ const changeIdx = proposed.indexOf(newStr);
3569
+ if (changeIdx >= 0 && proposed.length > 6000) {
3570
+ const start = Math.max(0, changeIdx - 2000);
3571
+ const end = Math.min(proposed.length, changeIdx + newStr.length + 2000);
3572
+ cweContent = proposed.slice(start, end);
3573
+ } else {
3574
+ cweContent = proposed.slice(0, 6000);
3575
+ }
3576
+ } else {
3577
+ cweContent = proposed.slice(0, 4000);
3578
+ }
3342
3579
 
3343
- const fileShort = basename(filePath);
3344
- const fileExt = extname(filePath).toLowerCase();
3345
-
3346
- // Skip prose / non-executable files — CWE scanning is for source code only.
3347
- // Without this guard, prose that *mentions* a CWE (plans, notes, READMEs)
3348
- // can trigger a false positive on the literal string.
3349
- const NON_CODE_EXTS = new Set([
3350
- '.md', '.mdx', '.txt', '.rst', '.adoc', '.org',
3351
- '.log', '.csv', '.tsv', '.html', '.htm',
3352
- '.lock', '.gitignore', '.dockerignore', '.npmignore',
3353
- '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf',
3354
- '.woff', '.woff2', '.ttf', '.otf',
3355
- ]);
3356
- if (NON_CODE_EXTS.has(fileExt)) { outputEmpty(); return; }
3580
+ targets.push({ filePath, cweContent, cweDiffSection, toolName, toolInput });
3581
+ } else {
3582
+ outputEmpty();
3583
+ return;
3584
+ }
3357
3585
 
3358
3586
  let jwt = loadJwt();
3359
3587
  if (!jwt) { outputEmpty(); return; }
3360
3588
  jwt = await ensureFreshJwt(jwt);
3361
3589
 
3362
- const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
3363
- if (!proposed) { outputEmpty(); return; }
3590
+ const config = await loadConfig(jwt);
3591
+ const rt = await cweRoute(config);
3364
3592
 
3365
- let cweContent: string;
3366
- let cweDiffSection = '';
3367
- if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'edit_file' || toolName === 'reapply' || toolName === 'ApplyPatch' || toolName === 'apply_patch') {
3368
- const newStr = toolName === 'Edit' || toolName === 'edit_file' || toolName === 'reapply'
3369
- ? (toolInput.new_string || '')
3370
- : toolName === 'ApplyPatch' || toolName === 'apply_patch'
3371
- ? (toolInput.patch || toolInput.content || toolInput.code_edit || '')
3372
- : (Array.isArray(toolInput.edits) ? toolInput.edits.map((e: any) => e?.new_string || '').join('\n') : '');
3373
- cweDiffSection = newStr.slice(0, 4000);
3374
- const changeIdx = proposed.indexOf(newStr);
3375
- if (changeIdx >= 0 && proposed.length > 6000) {
3376
- const start = Math.max(0, changeIdx - 2000);
3377
- const end = Math.min(proposed.length, changeIdx + newStr.length + 2000);
3378
- cweContent = proposed.slice(start, end);
3379
- } else {
3380
- cweContent = proposed.slice(0, 6000);
3381
- }
3382
- } else {
3383
- cweContent = proposed.slice(0, 4000);
3593
+ if (config.silent) {
3594
+ outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] skipped (silent mode)' });
3595
+ return;
3384
3596
  }
3385
3597
 
3386
- const config = await loadConfig(jwt);
3387
- const rt = await cweRoute(config);
3598
+ for (const scan of targets) {
3599
+ const filePath = scan.filePath;
3600
+ const cweContent = scan.cweContent;
3601
+ const cweDiffSection = scan.cweDiffSection;
3602
+ const scanToolName = scan.toolName;
3603
+ const scanToolInput = scan.toolInput;
3604
+ const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3605
+ const fileShort = basename(filePath);
3606
+ const fileExt = extname(filePath).toLowerCase();
3388
3607
 
3389
3608
  const exemptedCwes = new Set<string>();
3390
3609
  for (const ex of config.scanExemptions) {
@@ -3392,12 +3611,120 @@ async function main() {
3392
3611
  exemptedCwes.add(ex.cwe_id.toUpperCase());
3393
3612
  }
3394
3613
  }
3395
- if (config.silent) {
3396
- outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
3614
+
3615
+ const cweTag = '[synkro:' + rt + ':cweScan]';
3616
+
3617
+ if (rt === 'skip') {
3618
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 local CWE grader unavailable, skipped' });
3397
3619
  return;
3398
3620
  }
3399
3621
 
3400
- const cweTag = '[synkro:' + rt + ':cweScan]';
3622
+ if (rt === 'byok') {
3623
+ let packageContext: PackageCapability[] | undefined;
3624
+ if (cwd) {
3625
+ const newImports = detectNewImports(scanToolName, scanToolInput, fileExt);
3626
+ if (newImports.length > 0) {
3627
+ const caps = newImports
3628
+ .slice(0, 5)
3629
+ .map(pkg => scanPackageCapabilities(pkg, cwd))
3630
+ .filter((c): c is PackageCapability => c !== null);
3631
+ if (caps.length > 0) packageContext = caps;
3632
+ }
3633
+ }
3634
+ const scanBody: any = { file_path: filePath, content: cweContent };
3635
+ if (packageContext) {
3636
+ scanBody.package_context = packageContext.map(c => ({
3637
+ name: c.name, description: c.description, capabilities: c.capabilities, source_excerpt: c.sourceExcerpt,
3638
+ }));
3639
+ }
3640
+
3641
+ let cweResp: any;
3642
+ try {
3643
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-scan', {
3644
+ method: 'POST',
3645
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3646
+ body: JSON.stringify(scanBody),
3647
+ signal: AbortSignal.timeout(12000),
3648
+ });
3649
+ if (!resp.ok) {
3650
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 cloud CWE scan failed (HTTP ' + resp.status + '), skipped' });
3651
+ return;
3652
+ }
3653
+ cweResp = await resp.json();
3654
+ } catch {
3655
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 cloud CWE scan timeout, skipped' });
3656
+ return;
3657
+ }
3658
+
3659
+ const findings = Array.isArray(cweResp?.findings) ? cweResp.findings : [];
3660
+ if (cweResp?.action === 'deny' && findings.length > 0) {
3661
+ const activeCweIds = findings
3662
+ .filter((f: any) => f.mode === 'blocking' || f.mode === 'ask')
3663
+ .map((f: any) => f.cwe)
3664
+ .filter((id: string) => !exemptedCwes.has(id.toUpperCase()));
3665
+
3666
+ if (activeCweIds.length === 0) {
3667
+ continue;
3668
+ }
3669
+
3670
+ const displayIds = activeCweIds.slice(0, 3).join(', ');
3671
+ const count = activeCweIds.length;
3672
+ const label = count === 1 ? 'match' : 'matches';
3673
+ const cweMsg = cweTag + ' ' + fileShort + ' \u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
3674
+
3675
+ const fixLines = findings
3676
+ .filter((f: any) => activeCweIds.includes(f.cwe) && f.suggested_fix)
3677
+ .map((f: any) => '[' + f.cwe + '] Fix: ' + f.suggested_fix);
3678
+ const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
3679
+ const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
3680
+ const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
3681
+
3682
+ emitBlockScanFindings(
3683
+ jwt,
3684
+ config.captureDepth,
3685
+ { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3686
+ activeCweIds.map((cweId) => {
3687
+ const f = findings.find((x: any) => x.cwe === cweId);
3688
+ return {
3689
+ finding_type: 'cwe' as const,
3690
+ finding_id: cweId,
3691
+ severity: f?.severity || 'high',
3692
+ detail: f?.reason || 'code weakness detected',
3693
+ cwe_name: f?.name || undefined,
3694
+ };
3695
+ }),
3696
+ {
3697
+ finding_type: 'cwe',
3698
+ finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
3699
+ severity: findings[0]?.severity || 'high',
3700
+ detail: denyDetail,
3701
+ },
3702
+ );
3703
+
3704
+ dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
3705
+ scanToolName, gitRepo, sessionId, config.captureDepth, {
3706
+ command: (isShellTool(scanToolName) ? 'shell write ' : 'edit ') + filePath,
3707
+ reasoning: denyDetail,
3708
+ violatedRules: activeCweIds,
3709
+ ccModel,
3710
+ });
3711
+
3712
+ outputJson({
3713
+ systemMessage: cweMsg,
3714
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3715
+ });
3716
+ return;
3717
+ }
3718
+
3719
+ dispatchFinding(jwt, {
3720
+ session_id: sessionId,
3721
+ file_path: filePath,
3722
+ finding_type: 'cwe',
3723
+ finding_id: 'pass',
3724
+ status: 'resolved',
3725
+ }, config.captureDepth);
3726
+ continue;
3727
+ }
3401
3728
 
3402
3729
  if (rt === 'local') {
3403
3730
  let cweRules: any[] = [];
@@ -3431,7 +3758,7 @@ async function main() {
3431
3758
 
3432
3759
  let localPkgContext = '';
3433
3760
  if (cwd) {
3434
- const newImports = detectNewImports(toolName, toolInput, fileExt);
3761
+ const newImports = detectNewImports(scanToolName, scanToolInput, fileExt);
3435
3762
  const caps = newImports.slice(0, 5)
3436
3763
  .map(pkg => scanPackageCapabilities(pkg, cwd))
3437
3764
  .filter((c): c is PackageCapability => c !== null);
@@ -3526,8 +3853,7 @@ async function main() {
3526
3853
  const activeCweIds = cweIds.filter(id => !exemptedCwes.has(id.toUpperCase()));
3527
3854
 
3528
3855
  if (activeCweIds.length === 0) {
3529
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 clean (exempted: ' + cweIds.join(', ') + ')' });
3530
- return;
3856
+ continue;
3531
3857
  }
3532
3858
 
3533
3859
  const cweNameMap = new Map<string, string>();
@@ -3566,8 +3892,8 @@ async function main() {
3566
3892
  );
3567
3893
 
3568
3894
  dispatchCapture(jwt, 'cwe', 'block', verdict.severity || 'high', verdict.category || 'security',
3569
- toolName, gitRepo, sessionId, config.captureDepth, {
3570
- command: 'edit ' + filePath,
3895
+ scanToolName, gitRepo, sessionId, config.captureDepth, {
3896
+ command: (isShellTool(scanToolName) ? 'shell write ' : 'edit ') + filePath,
3571
3897
  reasoning: denyDetail,
3572
3898
  violatedRules: activeCweIds,
3573
3899
  ccModel,
@@ -3587,114 +3913,11 @@ async function main() {
3587
3913
  finding_id: 'pass',
3588
3914
  status: 'resolved',
3589
3915
  }, config.captureDepth);
3590
-
3591
- const cleanMsg = cweTag + ' ' + fileShort + ' \u2192 clean' + (verdict.reason ? ' (' + verdict.reason + ')' : '');
3592
- outputJson({ systemMessage: cleanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cleanMsg } });
3593
- return;
3916
+ continue;
3594
3917
  }
3595
-
3596
- // Cloud path \u2014 thin client, all grading logic server-side
3597
- // Detect imports and scan capabilities for cloud grading
3598
- let packageContext: PackageCapability[] | undefined;
3599
- if (cwd) {
3600
- const newImports = detectNewImports(toolName, toolInput, fileExt);
3601
- if (newImports.length > 0) {
3602
- const caps = newImports
3603
- .slice(0, 5)
3604
- .map(pkg => scanPackageCapabilities(pkg, cwd))
3605
- .filter((c): c is PackageCapability => c !== null);
3606
- if (caps.length > 0) packageContext = caps;
3607
- }
3608
- }
3609
- const scanBody: any = { file_path: filePath, content: cweContent };
3610
- if (packageContext) scanBody.package_context = packageContext.map(c => ({
3611
- name: c.name, description: c.description, capabilities: c.capabilities, source_excerpt: c.sourceExcerpt,
3612
- }));
3613
- let cweResp: any;
3614
- try {
3615
- const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-scan', {
3616
- method: 'POST',
3617
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3618
- body: JSON.stringify(scanBody),
3619
- signal: AbortSignal.timeout(12000),
3620
- });
3621
- cweResp = await resp.json();
3622
- } catch {
3623
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 cloud grader timeout, skipped' });
3624
- return;
3625
- }
3626
-
3627
- const findings = Array.isArray(cweResp?.findings) ? cweResp.findings : [];
3628
- if (cweResp?.action === 'deny' && findings.length > 0) {
3629
- const activeCweIds = findings
3630
- .filter((f: any) => f.mode === 'blocking' || f.mode === 'ask')
3631
- .map((f: any) => f.cwe)
3632
- .filter((id: string) => !exemptedCwes.has(id.toUpperCase()));
3633
-
3634
- if (activeCweIds.length === 0) {
3635
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 clean (exempted)' });
3636
- return;
3637
- }
3638
-
3639
- const displayIds = activeCweIds.slice(0, 3).join(', ');
3640
- const count = activeCweIds.length;
3641
- const label = count === 1 ? 'match' : 'matches';
3642
- const cweMsg = cweTag + ' ' + fileShort + ' \u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
3643
-
3644
- const fixLines = findings
3645
- .filter((f: any) => activeCweIds.includes(f.cwe) && f.suggested_fix)
3646
- .map((f: any) => '[' + f.cwe + '] Fix: ' + f.suggested_fix);
3647
- const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
3648
- const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
3649
- const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
3650
-
3651
- emitBlockScanFindings(
3652
- jwt,
3653
- config.captureDepth,
3654
- { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3655
- activeCweIds.map((cweId) => {
3656
- const f = findings.find((x: any) => x.cwe === cweId);
3657
- return {
3658
- finding_type: 'cwe' as const,
3659
- finding_id: cweId,
3660
- severity: f?.severity || 'high',
3661
- detail: f?.reason || 'code weakness detected',
3662
- cwe_name: f?.name || undefined,
3663
- };
3664
- }),
3665
- {
3666
- finding_type: 'cwe',
3667
- finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
3668
- severity: findings[0]?.severity || 'high',
3669
- detail: denyDetail,
3670
- },
3671
- );
3672
-
3673
- dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
3674
- toolName, gitRepo, sessionId, config.captureDepth, {
3675
- command: 'edit ' + filePath,
3676
- reasoning: denyDetail,
3677
- violatedRules: activeCweIds,
3678
- ccModel,
3679
- });
3680
-
3681
- outputJson({
3682
- systemMessage: cweMsg,
3683
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3684
- });
3685
- return;
3686
3918
  }
3687
3919
 
3688
- dispatchFinding(jwt, {
3689
- session_id: sessionId,
3690
- file_path: filePath,
3691
- finding_type: 'cwe',
3692
- finding_id: 'pass',
3693
- status: 'resolved',
3694
- }, config.captureDepth);
3695
-
3696
- const cleanMsg = cweTag + ' ' + fileShort + ' \u2192 clean' + (cweResp?.summary ? ' (cloud)' : '');
3697
- outputJson({ systemMessage: cleanMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: cleanMsg } });
3920
+ outputEmpty();
3698
3921
  } catch (err) {
3699
3922
  log('cweGuard error: ' + String(err));
3700
3923
  outputEmpty();
@@ -3707,7 +3930,7 @@ main();
3707
3930
  import {
3708
3931
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3709
3932
  reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
3710
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, extractTranscript, emitBlockScanFindings, resolveTranscriptPath, GATEWAY_URL,
3933
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, dispatchScanResult, extractTranscript, emitBlockScanFindings, resolveTranscriptPath, GATEWAY_URL,
3711
3934
  } from './_synkro-common.ts';
3712
3935
  import { basename } from 'node:path';
3713
3936
  import { readFileSync } from 'node:fs';
@@ -3858,6 +4081,12 @@ async function main() {
3858
4081
  violatedRules: violatedIds,
3859
4082
  ccModel,
3860
4083
  });
4084
+ dispatchScanResult(jwt, {
4085
+ session_id: sessionId, file_path: filePath, scan_type: 'pkg',
4086
+ result: 'block', finding_count: violatedIds.length,
4087
+ finding_ids: violatedIds, severity: 'critical',
4088
+ repo: gitRepo || undefined,
4089
+ });
3861
4090
  const tagStr = '[synkro:' + rt + ':pkgScan]';
3862
4091
  const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
3863
4092
  outputJson({
@@ -3942,6 +4171,12 @@ async function main() {
3942
4171
  reasoning: top3,
3943
4172
  violatedRules: cveIds,
3944
4173
  });
4174
+ dispatchScanResult(jwt, {
4175
+ session_id: sessionId, file_path: filePath, scan_type: 'cve',
4176
+ result: 'block', finding_count: findings.length,
4177
+ finding_ids: cveIds, severity: 'critical',
4178
+ repo: gitRepo || undefined,
4179
+ });
3945
4180
 
3946
4181
  outputJson({
3947
4182
  systemMessage: cveMsg,
@@ -3950,6 +4185,11 @@ async function main() {
3950
4185
  return;
3951
4186
  }
3952
4187
 
4188
+ dispatchScanResult(jwt, {
4189
+ session_id: sessionId, file_path: filePath, scan_type: 'cve',
4190
+ result: 'pass', finding_count: 0,
4191
+ repo: gitRepo || undefined,
4192
+ });
3953
4193
  outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \u2192 clean' });
3954
4194
  } catch (err) {
3955
4195
  log('cveGuard error: ' + String(err));
@@ -3962,7 +4202,7 @@ main();
3962
4202
  INSTALL_SCAN_TS = `#!/usr/bin/env bun
3963
4203
  import {
3964
4204
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3965
- readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, hashCommand,
4205
+ readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, dispatchScanResult, hashCommand,
3966
4206
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
3967
4207
  resolveTranscriptPath, isCursorHookFormat,
3968
4208
  } from './_synkro-common.ts';
@@ -4045,6 +4285,12 @@ async function main() {
4045
4285
  violatedRules: scan.violatedIds,
4046
4286
  ccModel: model || undefined,
4047
4287
  });
4288
+ dispatchScanResult(jwt, {
4289
+ session_id: sessionId, file_path: command, scan_type: 'pkg',
4290
+ result: 'block', finding_count: scan.violatedIds?.length || 1,
4291
+ finding_ids: scan.violatedIds, severity: 'critical',
4292
+ repo: repo || undefined,
4293
+ });
4048
4294
  const denyReason = '[synkro:installScan] BLOCKED: ' + scan.summary + '\\nDo not retry this install. Suggest a safe version to the user instead.';
4049
4295
  outputJson({
4050
4296
  systemMessage: denyReason,
@@ -5339,10 +5585,10 @@ import {
5339
5585
  loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
5340
5586
  appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
5341
5587
  countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
5588
+ isLocalStorageMode,
5342
5589
  } from './_synkro-common.ts';
5343
5590
  import { existsSync, readFileSync } from 'node:fs';
5344
5591
  import { basename, dirname, join } from 'node:path';
5345
- import { homedir } from 'node:os';
5346
5592
 
5347
5593
  let hookDone = false;
5348
5594
 
@@ -5391,16 +5637,8 @@ async function main() {
5391
5637
  if (!jwt) finish();
5392
5638
  jwt = await ensureFreshJwt(jwt);
5393
5639
 
5394
- let fileContent = '';
5395
- const fullPath = filePath.startsWith('/') ? filePath : (cwd ? join(cwd, filePath) : filePath);
5396
- try {
5397
- if (existsSync(fullPath)) {
5398
- const buf = readFileSync(fullPath);
5399
- fileContent = buf.slice(0, 50000).toString('utf-8');
5400
- }
5401
- } catch {}
5402
-
5403
5640
  let dependencies: Record<string, string> = {};
5641
+ const fullPath = filePath.startsWith('/') ? filePath : (cwd ? join(cwd, filePath) : filePath);
5404
5642
  let pkgDir = cwd || dirname(fullPath);
5405
5643
  while (pkgDir !== '/' && pkgDir !== '.') {
5406
5644
  const pkgPath = join(pkgDir, 'package.json');
@@ -5416,20 +5654,39 @@ async function main() {
5416
5654
  pkgDir = parent;
5417
5655
  }
5418
5656
 
5419
- const captureBody: Record<string, any> = {
5657
+ const localSpoolBody: Record<string, any> = {
5420
5658
  capture_type: 'edit_scan',
5421
- tool_input: { file_path: filePath, content: fileContent },
5659
+ tool_input: { file_path: filePath, lines_added: linesAdded, lines_removed: linesRemoved },
5422
5660
  edit_verdict: { ok: true },
5423
5661
  dependencies,
5424
5662
  cc_model: model,
5425
5663
  model,
5426
5664
  };
5427
- if (sessionId) captureBody.session_id = sessionId;
5428
- if (cwd) captureBody.cwd = cwd;
5429
- if (repo) captureBody.repo = repo;
5665
+ if (sessionId) localSpoolBody.session_id = sessionId;
5666
+ if (cwd) localSpoolBody.cwd = cwd;
5667
+ if (repo) localSpoolBody.repo = repo;
5668
+ appendLocalTelemetry(localSpoolBody);
5430
5669
 
5431
- appendLocalTelemetry(captureBody);
5432
- shipCloud(jwt, '/api/v1/hook/capture', captureBody, 10000);
5670
+ if (!isLocalStorageMode()) {
5671
+ let fileContent = '';
5672
+ try {
5673
+ if (existsSync(fullPath)) {
5674
+ fileContent = readFileSync(fullPath).slice(0, 50000).toString('utf-8');
5675
+ }
5676
+ } catch {}
5677
+ const cloudBody: Record<string, any> = {
5678
+ capture_type: 'edit_scan',
5679
+ tool_input: { file_path: filePath, content: fileContent },
5680
+ edit_verdict: { ok: true },
5681
+ dependencies,
5682
+ cc_model: model,
5683
+ model,
5684
+ };
5685
+ if (sessionId) cloudBody.session_id = sessionId;
5686
+ if (cwd) cloudBody.cwd = cwd;
5687
+ if (repo) cloudBody.repo = repo;
5688
+ shipCloud(jwt, '/api/v1/hook/capture', cloudBody, 10000);
5689
+ }
5433
5690
 
5434
5691
  if (sessionId) {
5435
5692
  dispatchCapture(jwt, 'edit', 'pass', 'clean', 'trivial_edit', 'Edit', repo, sessionId, 'full', {
@@ -6032,12 +6289,13 @@ var init_githubSetup = __esm({
6032
6289
  });
6033
6290
 
6034
6291
  // cli/commands/repoConnect.ts
6035
- import { execSync as execSync3 } from "child_process";
6292
+ import { execSync as execSync3, execFileSync } from "child_process";
6293
+ import { readdirSync } from "fs";
6036
6294
  import { createServer as createServer2 } from "http";
6037
6295
  import { createInterface } from "readline";
6038
6296
  function detectGitRepo() {
6039
6297
  try {
6040
- const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
6298
+ const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
6041
6299
  const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
6042
6300
  const httpMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
6043
6301
  const match = sshMatch || httpMatch;
@@ -6048,6 +6306,33 @@ function detectGitRepo() {
6048
6306
  return null;
6049
6307
  }
6050
6308
  }
6309
+ function detectSubdirRepos() {
6310
+ try {
6311
+ const entries = readdirSync(".", { withFileTypes: true });
6312
+ const repos = [];
6313
+ for (const entry of entries) {
6314
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
6315
+ try {
6316
+ const remoteUrl = execFileSync("git", ["-C", entry.name, "remote", "get-url", "origin"], {
6317
+ encoding: "utf-8",
6318
+ timeout: 5e3,
6319
+ stdio: ["pipe", "pipe", "pipe"]
6320
+ }).trim();
6321
+ const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
6322
+ const httpMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
6323
+ const match = sshMatch || httpMatch;
6324
+ if (match) {
6325
+ const fullName = match[1];
6326
+ repos.push({ fullName, shortName: fullName.split("/").pop() || fullName });
6327
+ }
6328
+ } catch {
6329
+ }
6330
+ }
6331
+ return repos;
6332
+ } catch {
6333
+ return [];
6334
+ }
6335
+ }
6051
6336
  function ask(rl, question) {
6052
6337
  return new Promise((resolve3) => rl.question(question, resolve3));
6053
6338
  }
@@ -6149,6 +6434,7 @@ async function connectGithubAndSelectRepos() {
6149
6434
  }
6150
6435
  async function promptRepoConnection(opts) {
6151
6436
  const localRepo = detectGitRepo();
6437
+ const subdirRepos = !localRepo ? detectSubdirRepos() : [];
6152
6438
  if (opts?.linkRepo && localRepo) {
6153
6439
  console.log("Connect repos to Synkro:\n");
6154
6440
  try {
@@ -6171,46 +6457,35 @@ async function promptRepoConnection(opts) {
6171
6457
  const rl = createInterface({ input: process.stdin, output: process.stdout });
6172
6458
  try {
6173
6459
  console.log("Connect repos to Synkro:\n");
6174
- const options = [];
6175
- if (localRepo) {
6176
- options.push(`Link this repo (${localRepo.fullName})`);
6177
- }
6178
- options.push("Connect GitHub to select repos");
6179
- options.push("Skip for now");
6180
- options.forEach((opt, i) => {
6181
- console.log(` ${i + 1}. ${opt}`);
6182
- });
6183
- console.log();
6184
- const choice = await ask(rl, " Choose (number): ");
6185
- const choiceNum = parseInt(choice.trim(), 10);
6186
- console.log();
6187
- rl.close();
6188
- const localIdx = localRepo ? 1 : -1;
6189
- const githubIdx = localRepo ? 2 : 1;
6190
- const skipIdx = localRepo ? 3 : 2;
6191
- if (choiceNum === localIdx && localRepo) {
6460
+ let existingFullNames = /* @__PURE__ */ new Set();
6461
+ if (subdirRepos.length > 0) {
6192
6462
  try {
6193
6463
  const existing = await listProjects();
6194
- const alreadyLinked = existing.some(
6195
- (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
6464
+ existingFullNames = new Set(
6465
+ existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
6196
6466
  );
6197
- if (!alreadyLinked) {
6198
- await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
6199
- console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
6200
- } else {
6201
- console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
6202
- }
6203
- } catch (err) {
6204
- console.warn(` \u26A0 Could not link repo: ${err.message}`);
6467
+ } catch {
6205
6468
  }
6206
- } else if (choiceNum === githubIdx) {
6207
- const selectedRepos = await connectGithubAndSelectRepos();
6208
- if (selectedRepos.length > 0) {
6209
- try {
6210
- const existing = await listProjects();
6211
- const existingFullNames = new Set(
6212
- existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
6213
- );
6469
+ }
6470
+ if (subdirRepos.length > 0) {
6471
+ console.log(` Found ${subdirRepos.length} repo(s) in this directory:
6472
+ `);
6473
+ subdirRepos.forEach((r, i) => {
6474
+ const linked = existingFullNames.has(r.fullName);
6475
+ console.log(` ${String(i + 1).padStart(3)}. ${r.fullName}${linked ? " \u2713 linked" : ""}`);
6476
+ });
6477
+ const ghIdx = subdirRepos.length + 1;
6478
+ const skipIdx = subdirRepos.length + 2;
6479
+ console.log(` ${String(ghIdx).padStart(3)}. Connect GitHub to select repos`);
6480
+ console.log(` ${String(skipIdx).padStart(3)}. Skip for now`);
6481
+ console.log();
6482
+ const selection = await ask(rl, " Select repos to link (comma-separated numbers, e.g. 1,3): ");
6483
+ const nums = selection.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
6484
+ console.log();
6485
+ rl.close();
6486
+ if (nums.includes(ghIdx)) {
6487
+ const selectedRepos = await connectGithubAndSelectRepos();
6488
+ if (selectedRepos.length > 0) {
6214
6489
  const newRepos = selectedRepos.filter((r) => !existingFullNames.has(r.full_name));
6215
6490
  if (newRepos.length === 0) {
6216
6491
  console.log(" \u2713 All selected repos are already linked.");
@@ -6219,14 +6494,83 @@ async function promptRepoConnection(opts) {
6219
6494
  await createProject(projectName, newRepos);
6220
6495
  console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
6221
6496
  }
6222
- } catch (err) {
6223
- console.warn(` \u26A0 Could not link repos: ${err.message}`);
6497
+ }
6498
+ } else if (nums.includes(skipIdx) || nums.length === 0) {
6499
+ console.log(" Skipped. Run `synkro link` later to connect repos.");
6500
+ } else {
6501
+ const repoIndices = nums.map((n) => n - 1).filter((n) => n >= 0 && n < subdirRepos.length);
6502
+ const toLink = repoIndices.map((i) => subdirRepos[i]).filter((r) => !existingFullNames.has(r.fullName));
6503
+ if (toLink.length === 0) {
6504
+ console.log(" \u2713 All selected repos are already linked.");
6505
+ } else {
6506
+ for (const repo of toLink) {
6507
+ try {
6508
+ await createProject(repo.shortName, [{ full_name: repo.fullName }]);
6509
+ console.log(` \u2713 Created project "${repo.shortName}" linked to ${repo.fullName}`);
6510
+ } catch (err) {
6511
+ console.warn(` \u26A0 Could not link ${repo.fullName}: ${err.message}`);
6512
+ }
6513
+ }
6224
6514
  }
6225
6515
  }
6226
- } else if (choiceNum === skipIdx) {
6227
- console.log(" Skipped. Run `synkro link` later to connect repos.");
6228
6516
  } else {
6229
- console.log(" Invalid choice. Skipping repo connection.");
6517
+ const options = [];
6518
+ if (localRepo) {
6519
+ options.push(`Link this repo (${localRepo.fullName})`);
6520
+ }
6521
+ options.push("Connect GitHub to select repos");
6522
+ options.push("Skip for now");
6523
+ options.forEach((opt, i) => {
6524
+ console.log(` ${i + 1}. ${opt}`);
6525
+ });
6526
+ console.log();
6527
+ const choice = await ask(rl, " Choose (number): ");
6528
+ const choiceNum = parseInt(choice.trim(), 10);
6529
+ console.log();
6530
+ rl.close();
6531
+ const localIdx = localRepo ? 1 : -1;
6532
+ const githubIdx = localRepo ? 2 : 1;
6533
+ const skipIdx = localRepo ? 3 : 2;
6534
+ if (choiceNum === localIdx && localRepo) {
6535
+ try {
6536
+ const existing = await listProjects();
6537
+ const alreadyLinked = existing.some(
6538
+ (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
6539
+ );
6540
+ if (!alreadyLinked) {
6541
+ await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
6542
+ console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
6543
+ } else {
6544
+ console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
6545
+ }
6546
+ } catch (err) {
6547
+ console.warn(` \u26A0 Could not link repo: ${err.message}`);
6548
+ }
6549
+ } else if (choiceNum === githubIdx) {
6550
+ const selectedRepos = await connectGithubAndSelectRepos();
6551
+ if (selectedRepos.length > 0) {
6552
+ try {
6553
+ const existing = await listProjects();
6554
+ const linkedNames = new Set(
6555
+ existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
6556
+ );
6557
+ const newRepos = selectedRepos.filter((r) => !linkedNames.has(r.full_name));
6558
+ if (newRepos.length === 0) {
6559
+ console.log(" \u2713 All selected repos are already linked.");
6560
+ } else {
6561
+ const projectName = newRepos.length === 1 ? newRepos[0].full_name.split("/").pop() || "Project" : "Multi-Repo Project";
6562
+ await createProject(projectName, newRepos);
6563
+ console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
6564
+ }
6565
+ } catch (err) {
6566
+ console.warn(` \u26A0 Could not link repos: ${err.message}`);
6567
+ }
6568
+ }
6569
+ } else if (choiceNum === skipIdx) {
6570
+ console.log(" Skipped. Run `synkro link` later to connect repos.");
6571
+ } else {
6572
+ console.log(" Invalid choice. Skipping repo connection.");
6573
+ }
6230
6574
  }
6231
6575
  } catch {
6232
6576
  rl.close();
@@ -6477,7 +6821,7 @@ __export(dockerInstall_exports, {
6477
6821
  splitWorkers: () => splitWorkers,
6478
6822
  waitForContainerReady: () => waitForContainerReady
6479
6823
  });
6480
- import { copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync } from "fs";
6824
+ import { copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync2 } from "fs";
6481
6825
  import { homedir as homedir6 } from "os";
6482
6826
  import { join as join6 } from "path";
6483
6827
  import { spawnSync as spawnSync2 } from "child_process";
@@ -6849,7 +7193,7 @@ async function dockerSafeStart() {
6849
7193
  return { ok: false, pgdataState: "no_container", error: "No synkro-server container found. Run `synkro install` first." };
6850
7194
  }
6851
7195
  const pgCheck = checkPgdata();
6852
- if (existsSync7(PGDATA_PATH) && readdirSync(PGDATA_PATH).length > 0) {
7196
+ if (existsSync7(PGDATA_PATH) && readdirSync2(PGDATA_PATH).length > 0) {
6853
7197
  if (pgCheck.healthy) {
6854
7198
  console.log(` pgdata: existing data found \u2014 ${pgCheck.details}`);
6855
7199
  } else {
@@ -6890,7 +7234,7 @@ async function dockerSafeRestart() {
6890
7234
  }
6891
7235
  function checkPgdata() {
6892
7236
  if (!existsSync7(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
6893
- const entries = readdirSync(PGDATA_PATH);
7237
+ const entries = readdirSync2(PGDATA_PATH);
6894
7238
  if (entries.length === 0) return { healthy: true, details: "empty (fresh start)" };
6895
7239
  const hasPidFile = entries.includes("postmaster.pid");
6896
7240
  const hasWalDir = entries.includes("pg_wal");
@@ -7299,7 +7643,7 @@ __export(install_exports, {
7299
7643
  installCommand: () => installCommand,
7300
7644
  parseArgs: () => parseArgs
7301
7645
  });
7302
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
7646
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
7303
7647
  import { homedir as homedir8 } from "os";
7304
7648
  import { join as join8 } from "path";
7305
7649
  import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
@@ -7409,6 +7753,19 @@ async function promptStorageMode() {
7409
7753
  );
7410
7754
  });
7411
7755
  }
7756
+ async function promptTranscriptConsent() {
7757
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
7758
+ return new Promise((resolve3) => {
7759
+ rl.question(
7760
+ "Import and embed your Claude Code session history?\nThis indexes past sessions so Ask Synkro can answer questions\nabout your coding patterns and the dashboard shows full history. (Y/n) ",
7761
+ (answer) => {
7762
+ rl.close();
7763
+ const trimmed = answer.trim().toLowerCase();
7764
+ resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
7765
+ }
7766
+ );
7767
+ });
7768
+ }
7412
7769
  function ensureSynkroDir() {
7413
7770
  mkdirSync8(SYNKRO_DIR4, { recursive: true });
7414
7771
  mkdirSync8(HOOKS_DIR, { recursive: true });
@@ -7532,7 +7889,7 @@ function writeConfigEnv(opts) {
7532
7889
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
7533
7890
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
7534
7891
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
7535
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.27")}`
7892
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.29")}`
7536
7893
  ];
7537
7894
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
7538
7895
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7603,7 +7960,7 @@ function collectLocalMetadata() {
7603
7960
  }
7604
7961
  try {
7605
7962
  const sessionsDir = join8(claudeDir, "sessions");
7606
- const files = readdirSync2(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
7963
+ const files = readdirSync3(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
7607
7964
  for (const f of files) {
7608
7965
  const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
7609
7966
  if (s.version) {
@@ -7756,7 +8113,15 @@ async function installCommand(opts = {}) {
7756
8113
  } catch {
7757
8114
  }
7758
8115
  }
7759
- const transcriptConsent = false;
8116
+ let transcriptConsent = true;
8117
+ if (process.stdin.isTTY) {
8118
+ transcriptConsent = await promptTranscriptConsent();
8119
+ if (transcriptConsent) {
8120
+ console.log(" \u2713 Session import enabled\n");
8121
+ } else {
8122
+ console.log(" \u2717 Session import skipped\n");
8123
+ }
8124
+ }
7760
8125
  let hasClaudeCode = false;
7761
8126
  let hasCursor = false;
7762
8127
  for (const agent of agents) {
@@ -7955,6 +8320,23 @@ async function installCommand(opts = {}) {
7955
8320
  const ready = await waitForContainerReady(6e4);
7956
8321
  if (ready) {
7957
8322
  console.log(" \u2713 container ready");
8323
+ const mcpJwt = readFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8324
+ try {
8325
+ const ingestResp = await fetch(`http://127.0.0.1:${hostMcpPort}/api/ingest`, {
8326
+ method: "POST",
8327
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${mcpJwt}` },
8328
+ body: JSON.stringify({ capture_type: "local_verdict", event_id: `healthcheck_${Date.now()}`, verdict: "pass", severity: "clean", hook_type: "install", tool_name: "healthcheck" }),
8329
+ signal: AbortSignal.timeout(5e3)
8330
+ });
8331
+ if (ingestResp.ok) {
8332
+ console.log(" \u2713 ingest endpoint verified");
8333
+ } else {
8334
+ console.warn(` \u26A0 ingest endpoint returned ${ingestResp.status} \u2014 telemetry spool may not drain.`);
8335
+ console.warn(" The .mcp-jwt token may not match the container. Try: synkro uninstall && synkro install");
8336
+ }
8337
+ } catch {
8338
+ console.warn(" \u26A0 ingest endpoint unreachable \u2014 telemetry spool may not drain.");
8339
+ }
7958
8340
  } else {
7959
8341
  console.error(" \u2717 container did not become healthy within 60s");
7960
8342
  console.error(" Run `docker logs synkro-server` to debug.");
@@ -7963,31 +8345,68 @@ async function installCommand(opts = {}) {
7963
8345
  console.log();
7964
8346
  }
7965
8347
  if (transcriptConsent) {
7966
- try {
7967
- const repo = detectGitRepo2();
7968
- if (repo) {
7969
- const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
7970
- if (ingested > 0) {
7971
- console.log(`Indexed ${ingested} session insights from Claude Code history for ${repo}.`);
7972
- console.log(" This helps the safety judge understand your workflow.\n");
8348
+ const repo = detectGitRepo2();
8349
+ if (repo) {
8350
+ if (storageMode === "local") {
8351
+ try {
8352
+ let mcpToken = "";
8353
+ try {
8354
+ mcpToken = readFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8355
+ } catch {
8356
+ }
8357
+ if (mcpToken) {
8358
+ const mcpPort = parseInt(process.env.SYNKRO_MCP_PORT || "18931", 10);
8359
+ const result = await syncTranscriptsLocal(mcpPort, mcpToken, repo);
8360
+ if (result.messages > 0) {
8361
+ console.log(` \u2713 Imported ${result.sessions} sessions (${result.messages} messages) into local store.`);
8362
+ console.log(" Embeddings generated. Analyzing for rule suggestions...");
8363
+ try {
8364
+ const suggestResp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/suggest-rules`, {
8365
+ method: "POST",
8366
+ headers: { "Content-Type": "application/json" },
8367
+ signal: AbortSignal.timeout(9e4)
8368
+ });
8369
+ if (suggestResp.ok) {
8370
+ const suggestResult = await suggestResp.json();
8371
+ if (suggestResult.suggested && suggestResult.suggested > 0) {
8372
+ console.log(` \u2713 Generated ${suggestResult.suggested} rule suggestions from your session history.`);
8373
+ console.log(' Ask Claude: "show me suggested rules" to review them.\n');
8374
+ } else {
8375
+ console.log(" No rule suggestions generated yet \u2014 more session data needed.\n");
8376
+ }
8377
+ }
8378
+ } catch {
8379
+ console.log(" Rule analysis will run automatically as more sessions are recorded.\n");
8380
+ }
8381
+ }
8382
+ } else {
8383
+ console.warn(" \u26A0 Session import skipped \u2014 container auth token not found.\n");
8384
+ }
8385
+ } catch (err) {
8386
+ console.warn(` \u26A0 Local session import failed: ${err.message}
8387
+ `);
7973
8388
  }
7974
- }
7975
- } catch (err) {
7976
- console.warn(` \u26A0 Session indexing skipped: ${err.message}
8389
+ } else {
8390
+ try {
8391
+ const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
8392
+ if (ingested > 0) {
8393
+ console.log(` \u2713 Indexed ${ingested} session insights from Claude Code history.`);
8394
+ }
8395
+ } catch (err) {
8396
+ console.warn(` \u26A0 Session indexing skipped: ${err.message}
7977
8397
  `);
7978
- }
7979
- try {
7980
- const repo = detectGitRepo2();
7981
- if (repo) {
7982
- const result = await syncTranscriptsBulk(gatewayUrl, token, repo);
7983
- if (result.messages > 0) {
7984
- console.log(`Synced ${result.sessions} sessions (${result.messages} messages) from Claude Code history.`);
7985
- console.log(" This data will be used to suggest guardrail rules.\n");
7986
8398
  }
7987
- }
7988
- } catch (err) {
7989
- console.warn(` \u26A0 Transcript sync skipped: ${err.message}
8399
+ try {
8400
+ const result = await syncTranscriptsBulk(gatewayUrl, token, repo);
8401
+ if (result.messages > 0) {
8402
+ console.log(` \u2713 Synced ${result.sessions} sessions (${result.messages} messages) to cloud.`);
8403
+ console.log(" Embeddings use your configured inference provider.\n");
8404
+ }
8405
+ } catch (err) {
8406
+ console.warn(` \u26A0 Transcript sync skipped: ${err.message}
7990
8407
  `);
8408
+ }
8409
+ }
7991
8410
  }
7992
8411
  }
7993
8412
  if (ghToken) {
@@ -8019,7 +8438,7 @@ function getClaudeProjectsFolder() {
8019
8438
  }
8020
8439
  function extractSessionInsights(projectsDir) {
8021
8440
  const insights = [];
8022
- const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
8441
+ const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
8023
8442
  for (const file of files) {
8024
8443
  const sessionId = file.replace(".jsonl", "");
8025
8444
  const filePath = join8(projectsDir, file);
@@ -8137,10 +8556,52 @@ function parseTranscriptFile(filePath) {
8137
8556
  }
8138
8557
  return messages;
8139
8558
  }
8559
+ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
8560
+ const projectsDir = getClaudeProjectsFolder();
8561
+ if (!projectsDir) return { sessions: 0, messages: 0 };
8562
+ const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
8563
+ if (files.length === 0) return { sessions: 0, messages: 0 };
8564
+ console.log(` Found ${files.length} CC session transcripts, importing + embedding...`);
8565
+ let totalSessions = 0;
8566
+ let totalMessages = 0;
8567
+ for (let i = 0; i < files.length; i++) {
8568
+ const file = files[i];
8569
+ const sessionId = file.replace(".jsonl", "");
8570
+ const filePath = join8(projectsDir, file);
8571
+ try {
8572
+ const allMessages = parseTranscriptFile(filePath);
8573
+ const messages = allMessages.length > 500 ? allMessages.slice(-500) : allMessages;
8574
+ if (messages.length === 0) continue;
8575
+ const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/conversation-sync`, {
8576
+ method: "POST",
8577
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${mcpToken}` },
8578
+ body: JSON.stringify({ session_id: sessionId, repo, messages }),
8579
+ signal: AbortSignal.timeout(15e3)
8580
+ });
8581
+ if (resp.ok) {
8582
+ const result = await resp.json();
8583
+ totalSessions++;
8584
+ totalMessages += result.ingested ?? messages.length;
8585
+ }
8586
+ } catch {
8587
+ }
8588
+ if ((i + 1) % 10 === 0 || i === files.length - 1) {
8589
+ process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
8590
+ }
8591
+ try {
8592
+ const content = readFileSync7(join8(projectsDir, file), "utf-8");
8593
+ const lineCount = content.split("\n").filter(Boolean).length;
8594
+ writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
8595
+ } catch {
8596
+ }
8597
+ }
8598
+ if (totalSessions > 0) process.stdout.write("\n");
8599
+ return { sessions: totalSessions, messages: totalMessages };
8600
+ }
8140
8601
  async function syncTranscriptsBulk(gatewayUrl, token, repo) {
8141
8602
  const projectsDir = getClaudeProjectsFolder();
8142
8603
  if (!projectsDir) return { sessions: 0, messages: 0 };
8143
- const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
8604
+ const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
8144
8605
  if (files.length === 0) return { sessions: 0, messages: 0 };
8145
8606
  console.log(`Found ${files.length} CC session transcripts, syncing...`);
8146
8607
  const maxMessagesPerSession = 500;
@@ -8751,7 +9212,7 @@ var disconnect_exports = {};
8751
9212
  __export(disconnect_exports, {
8752
9213
  disconnectCommand: () => disconnectCommand
8753
9214
  });
8754
- import { existsSync as existsSync11, rmSync, readdirSync as readdirSync3 } from "fs";
9215
+ import { existsSync as existsSync11, rmSync, readdirSync as readdirSync4 } from "fs";
8755
9216
  import { homedir as homedir10 } from "os";
8756
9217
  import { join as join10 } from "path";
8757
9218
  import { spawnSync as spawnSync5 } from "child_process";
@@ -8834,7 +9295,7 @@ async function disconnectCommand(args2 = []) {
8834
9295
  } else {
8835
9296
  const keep = /* @__PURE__ */ new Set([join10(SYNKRO_DIR5, "pgdata"), join10(SYNKRO_DIR5, "pgdata-backups")]);
8836
9297
  const preserved = [];
8837
- for (const entry of readdirSync3(SYNKRO_DIR5)) {
9298
+ for (const entry of readdirSync4(SYNKRO_DIR5)) {
8838
9299
  const full = join10(SYNKRO_DIR5, entry);
8839
9300
  if (keep.has(full)) {
8840
9301
  preserved.push(entry);
@@ -9140,7 +9601,7 @@ var init_grade = __esm({
9140
9601
  });
9141
9602
 
9142
9603
  // cli/local-cc/pueue.ts
9143
- import { execFileSync, spawnSync as spawnSync6, spawn } from "child_process";
9604
+ import { execFileSync as execFileSync2, spawnSync as spawnSync6, spawn } from "child_process";
9144
9605
  import { homedir as homedir12 } from "os";
9145
9606
  import { join as join12 } from "path";
9146
9607
  import { connect as connect2 } from "net";
@@ -10200,7 +10661,7 @@ var args = process.argv.slice(2);
10200
10661
  var cmd = args[0] || "";
10201
10662
  var subArgs = args.slice(1);
10202
10663
  function printVersion() {
10203
- console.log("1.6.27");
10664
+ console.log("1.6.29");
10204
10665
  }
10205
10666
  function printHelp2() {
10206
10667
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents