@synkro-sh/cli 1.6.28 → 1.6.30

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,209 @@ 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 {}
1909
+ // Realtime: fire-and-forget POST to the local server so events appear
1910
+ // in the dashboard immediately. Spool file remains the durable fallback.
1911
+ try {
1912
+ const port = process.env.SYNKRO_MCP_PORT || '18931';
1913
+ let token = '';
1914
+ try { token = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
1915
+ if (token) {
1916
+ fetch('http://127.0.0.1:' + port + '/api/ingest', {
1917
+ method: 'POST',
1918
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
1919
+ body: JSON.stringify(event),
1920
+ signal: AbortSignal.timeout(3000),
1921
+ }).catch(() => {});
1922
+ }
1923
+ } catch {}
1843
1924
  }
1844
1925
 
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.
1926
+ function tryAcquireDrainLock(): boolean {
1853
1927
  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);
1928
+ if (existsSync(SPOOL_DRAIN_LOCK)) {
1929
+ if (Date.now() - statSync(SPOOL_DRAIN_LOCK).mtimeMs < SPOOL_DRAIN_LOCK_STALE_MS) return false;
1930
+ unlinkSync(SPOOL_DRAIN_LOCK);
1858
1931
  }
1859
- } catch {}
1932
+ mkdirSync(dirname(SPOOL_DRAIN_LOCK), { recursive: true });
1933
+ writeFileSync(SPOOL_DRAIN_LOCK, String(process.pid) + '\\n');
1934
+ return true;
1935
+ } catch {
1936
+ return false;
1937
+ }
1938
+ }
1860
1939
 
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.
1940
+ function releaseDrainLock(): void {
1941
+ try { unlinkSync(SPOOL_DRAIN_LOCK); } catch {}
1942
+ }
1943
+
1944
+ async function postSpoolBatch(mcpPort: string, mcpToken: string, events: any[]): Promise<boolean> {
1945
+ if (!events.length) return true;
1863
1946
  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 {}
1947
+ const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/ingest/batch', {
1948
+ method: 'POST',
1949
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1950
+ body: JSON.stringify({ events }),
1951
+ signal: AbortSignal.timeout(30000),
1952
+ });
1953
+ if (!resp.ok) {
1954
+ const errBody = await resp.text().catch(() => '');
1955
+ log('drainSpool batch HTTP ' + resp.status + ': ' + errBody.slice(0, 120));
1871
1956
  }
1872
- } catch {}
1873
- if (claimed.length === 0) return;
1957
+ return resp.ok;
1958
+ } catch (e) {
1959
+ log('drainSpool batch error: ' + ((e as Error).message || String(e)).slice(0, 120));
1960
+ return false;
1961
+ }
1962
+ }
1874
1963
 
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 {}
1964
+ function quarantineOversizedClaim(claimPath: string): void {
1965
+ const stuck = claimPath + '.STUCK-OVERSIZED.bak';
1966
+ try {
1967
+ renameSync(claimPath, stuck);
1968
+ log('drainSpool quarantined oversized claim \u2192 ' + basename(stuck));
1969
+ } catch (e) {
1970
+ log('drainSpool quarantine failed: ' + String(e));
1885
1971
  }
1886
- if (events.length === 0) {
1887
- for (const f of claimed) { try { unlinkSync(f); } catch {} }
1972
+ }
1973
+
1974
+ async function drainClaimFile(claimPath: string, mcpPort: string, mcpToken: string): Promise<void> {
1975
+ let sz = 0;
1976
+ try { sz = statSync(claimPath).size; } catch { return; }
1977
+ if (sz === 0) {
1978
+ try { unlinkSync(claimPath); } catch {}
1979
+ return;
1980
+ }
1981
+ if (sz > SPOOL_MAX_CLAIM_BYTES) {
1982
+ quarantineOversizedClaim(claimPath);
1888
1983
  return;
1889
1984
  }
1890
1985
 
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
1986
+ const pending: any[] = [];
1987
+ let batch: any[] = [];
1988
+ let failed = false;
1989
+
1990
+ const rl = createInterface({
1991
+ input: createReadStream(claimPath, { encoding: 'utf8' }),
1992
+ crlfDelay: Infinity,
1993
+ });
1896
1994
 
1897
- let allOk = true;
1898
- for (let i = 0; i < events.length; i += 200) {
1995
+ for await (const line of rl) {
1996
+ if (failed) {
1997
+ const t = line.trim();
1998
+ if (!t) continue;
1999
+ try { pending.push(JSON.parse(t)); } catch {}
2000
+ continue;
2001
+ }
2002
+ const t = line.trim();
2003
+ if (!t) continue;
1899
2004
  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; }
2005
+ batch.push(JSON.parse(t));
2006
+ } catch {
2007
+ continue;
2008
+ }
2009
+ if (batch.length < SPOOL_BATCH_SIZE) continue;
2010
+ const chunk = batch.splice(0, SPOOL_BATCH_SIZE);
2011
+ if (!(await postSpoolBatch(mcpPort, mcpToken, chunk))) {
2012
+ pending.push(...chunk);
2013
+ failed = true;
2014
+ }
1908
2015
  }
1909
2016
 
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 {
2017
+ if (!failed && batch.length > 0) {
2018
+ if (!(await postSpoolBatch(mcpPort, mcpToken, batch))) {
2019
+ pending.push(...batch);
2020
+ failed = true;
2021
+ } else {
2022
+ batch = [];
2023
+ }
2024
+ } else if (failed && batch.length > 0) {
2025
+ pending.push(...batch);
2026
+ }
2027
+
2028
+ if (pending.length === 0) {
2029
+ try { unlinkSync(claimPath); } catch {}
2030
+ return;
2031
+ }
2032
+
2033
+ try {
2034
+ for (const evt of pending) {
2035
+ appendFileSync(TELEMETRY_SPOOL, JSON.stringify(evt) + '\\n');
2036
+ }
2037
+ try { unlinkSync(claimPath); } catch {}
2038
+ log('drainSpool re-spooled ' + pending.length + ' events from ' + basename(claimPath));
2039
+ } catch (e) {
2040
+ log('drainSpool re-spool failed: ' + String(e));
2041
+ }
2042
+ }
2043
+
2044
+ export async function drainSpool(): Promise<void> {
2045
+ if (!tryAcquireDrainLock()) return;
2046
+
2047
+ const dir = join(HOME, '.synkro');
2048
+ const claimed: string[] = [];
2049
+
2050
+ try {
2051
+ try {
2052
+ if (existsSync(TELEMETRY_SPOOL) && statSync(TELEMETRY_SPOOL).size > 0) {
2053
+ const claim = join(dir, SPOOL_DRAIN_PREFIX + process.pid + '.' + Date.now());
2054
+ renameSync(TELEMETRY_SPOOL, claim);
2055
+ claimed.push(claim);
2056
+ }
2057
+ } catch {}
2058
+
1914
2059
  try {
1915
- appendFileSync(TELEMETRY_SPOOL, events.map(e => JSON.stringify(e)).join('\\n') + '\\n');
1916
- for (const f of claimed) { try { unlinkSync(f); } catch {} }
2060
+ for (const f of readdirSync(dir)) {
2061
+ if (!f.startsWith(SPOOL_DRAIN_PREFIX)) continue;
2062
+ if (f.includes('.STUCK-') || f.endsWith('.bak')) continue;
2063
+ const full = join(dir, f);
2064
+ if (claimed.indexOf(full) !== -1) continue;
2065
+ try {
2066
+ if (Date.now() - statSync(full).mtimeMs > 30000) claimed.push(full);
2067
+ } catch {}
2068
+ }
1917
2069
  } catch {}
1918
- // if re-spool failed, claim files remain and are recovered as orphans later
2070
+
2071
+ if (claimed.length === 0) return;
2072
+
2073
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
2074
+ let mcpToken = '';
2075
+ try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
2076
+ if (!mcpToken) return;
2077
+
2078
+ for (const f of claimed) {
2079
+ await drainClaimFile(f, mcpPort, mcpToken);
2080
+ }
2081
+ } finally {
2082
+ releaseDrainLock();
1919
2083
  }
1920
2084
  }
1921
2085
 
@@ -2708,7 +2872,7 @@ export function emitUsageTick(params: {
2708
2872
  }
2709
2873
  appendLocalTelemetry({
2710
2874
  capture_type: 'usage_tick',
2711
- event_id: 'usage_' + Date.now() + '_' + process.pid,
2875
+ event_id: mintEventId('usage'),
2712
2876
  hook_type: hookType,
2713
2877
  verdict: 'allow',
2714
2878
  severity: 'none',
@@ -2816,6 +2980,38 @@ export function dispatchFinding(
2816
2980
  shipCloud(jwt, '/api/v1/hook/finding', cloudBody);
2817
2981
  }
2818
2982
 
2983
+ export function dispatchScanResult(
2984
+ jwt: string,
2985
+ scan: {
2986
+ session_id: string;
2987
+ file_path: string;
2988
+ scan_type: 'cve' | 'cwe' | 'pkg';
2989
+ result: 'pass' | 'block' | 'error';
2990
+ finding_count: number;
2991
+ finding_ids?: string[];
2992
+ severity?: string;
2993
+ repo?: string;
2994
+ },
2995
+ ): void {
2996
+ const localEntry: Record<string, any> = {
2997
+ capture_type: 'scan_result',
2998
+ event_id: 'scan_' + Date.now() + '_' + process.pid,
2999
+ _ts: new Date().toISOString(),
3000
+ ...scan,
3001
+ };
3002
+ appendLocalTelemetry(localEntry);
3003
+ shipCloud(jwt, '/api/v1/hook/scan-result', {
3004
+ scan_type: scan.scan_type,
3005
+ result: scan.result,
3006
+ finding_count: scan.finding_count,
3007
+ finding_ids: scan.finding_ids,
3008
+ severity: scan.severity,
3009
+ session_id: scan.session_id,
3010
+ file_path: scan.file_path,
3011
+ repo: scan.repo,
3012
+ });
3013
+ }
3014
+
2819
3015
  // \u2500\u2500\u2500 Hook tool-name sets (CC + Cursor) \u2500\u2500\u2500
2820
3016
 
2821
3017
  export const EDIT_TOOL_NAMES = new Set([
@@ -3194,7 +3390,8 @@ main();
3194
3390
  import {
3195
3391
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
3196
3392
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3197
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3393
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, isShellTool, isCursorHookFormat,
3394
+ extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3198
3395
  logGraderUnavailable, resolveTranscriptPath,
3199
3396
  } from './_synkro-common.ts';
3200
3397
  import { basename, extname, resolve, join, dirname } from 'node:path';
@@ -3215,6 +3412,22 @@ interface PackageCapability {
3215
3412
  sourceExcerpt: string;
3216
3413
  }
3217
3414
 
3415
+ const NON_CODE_EXTS = new Set([
3416
+ '.md', '.mdx', '.txt', '.rst', '.adoc', '.org',
3417
+ '.log', '.csv', '.tsv', '.html', '.htm',
3418
+ '.lock', '.gitignore', '.dockerignore', '.npmignore',
3419
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf',
3420
+ '.woff', '.woff2', '.ttf', '.otf',
3421
+ ]);
3422
+
3423
+ interface CweScanTarget {
3424
+ filePath: string;
3425
+ cweContent: string;
3426
+ cweDiffSection: string;
3427
+ toolName: string;
3428
+ toolInput: any;
3429
+ }
3430
+
3218
3431
  const JS_DANGEROUS_MODULES = new Set([
3219
3432
  'child_process', 'net', 'dgram', 'http', 'https', 'fs', 'vm',
3220
3433
  'worker_threads', 'cluster', 'dns', 'tls', 'crypto',
@@ -3322,69 +3535,90 @@ async function main() {
3322
3535
 
3323
3536
  const payload = JSON.parse(input);
3324
3537
  const toolName = payload.tool_name || '';
3325
- if (!isEditTool(toolName)) {
3326
- outputEmpty();
3327
- return;
3328
- }
3329
-
3330
3538
  const toolInput = payload.tool_input || {};
3331
3539
  const sessionId = hookSessionId(payload);
3332
3540
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3333
3541
  const cwd = payload.cwd || workspaceRoots[0] || '';
3334
3542
  const transcriptPath = resolveTranscriptPath(payload);
3335
-
3336
- const filePath = filePathFromToolInput(toolInput);
3337
- if (!filePath) { outputEmpty(); return; }
3338
- const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3543
+ const shellCommand = typeof payload.command === 'string' ? payload.command.trim() : '';
3339
3544
  const ccModel = detectModel(payload);
3340
3545
 
3341
- if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
3546
+ const targets: CweScanTarget[] = [];
3547
+
3548
+ if (isCursorHookFormat() && (shellCommand || isShellTool(toolName))) {
3549
+ const cmd = shellCommand || String(toolInput.command || '');
3550
+ if (!cmd) { outputEmpty(); return; }
3551
+ for (const w of extractShellCodeWrites(cmd, cwd)) {
3552
+ if (w.filePath.includes('/.synkro/hooks/')) continue;
3553
+ const ext = extname(w.filePath).toLowerCase();
3554
+ if (NON_CODE_EXTS.has(ext)) continue;
3555
+ targets.push({
3556
+ filePath: w.filePath,
3557
+ cweContent: w.content,
3558
+ cweDiffSection: '',
3559
+ toolName: toolName || 'Shell',
3560
+ toolInput: {},
3561
+ });
3562
+ }
3563
+ if (targets.length === 0) { outputEmpty(); return; }
3564
+ } else if (isEditTool(toolName)) {
3565
+ const filePath = filePathFromToolInput(toolInput);
3566
+ if (!filePath) { outputEmpty(); return; }
3567
+ if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
3568
+ const fileExt = extname(filePath).toLowerCase();
3569
+ if (NON_CODE_EXTS.has(fileExt)) { outputEmpty(); return; }
3570
+
3571
+ const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
3572
+ if (!proposed) { outputEmpty(); return; }
3573
+
3574
+ let cweContent: string;
3575
+ let cweDiffSection = '';
3576
+ if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'edit_file' || toolName === 'reapply' || toolName === 'ApplyPatch' || toolName === 'apply_patch') {
3577
+ const newStr = toolName === 'Edit' || toolName === 'edit_file' || toolName === 'reapply'
3578
+ ? (toolInput.new_string || '')
3579
+ : toolName === 'ApplyPatch' || toolName === 'apply_patch'
3580
+ ? (toolInput.patch || toolInput.content || toolInput.code_edit || '')
3581
+ : (Array.isArray(toolInput.edits) ? toolInput.edits.map((e: any) => e?.new_string || '').join('\n') : '');
3582
+ cweDiffSection = newStr.slice(0, 4000);
3583
+ const changeIdx = proposed.indexOf(newStr);
3584
+ if (changeIdx >= 0 && proposed.length > 6000) {
3585
+ const start = Math.max(0, changeIdx - 2000);
3586
+ const end = Math.min(proposed.length, changeIdx + newStr.length + 2000);
3587
+ cweContent = proposed.slice(start, end);
3588
+ } else {
3589
+ cweContent = proposed.slice(0, 6000);
3590
+ }
3591
+ } else {
3592
+ cweContent = proposed.slice(0, 4000);
3593
+ }
3342
3594
 
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; }
3595
+ targets.push({ filePath, cweContent, cweDiffSection, toolName, toolInput });
3596
+ } else {
3597
+ outputEmpty();
3598
+ return;
3599
+ }
3357
3600
 
3358
3601
  let jwt = loadJwt();
3359
3602
  if (!jwt) { outputEmpty(); return; }
3360
3603
  jwt = await ensureFreshJwt(jwt);
3361
3604
 
3362
- const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
3363
- if (!proposed) { outputEmpty(); return; }
3605
+ const config = await loadConfig(jwt);
3606
+ const rt = await cweRoute(config);
3364
3607
 
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);
3608
+ if (config.silent) {
3609
+ outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] skipped (silent mode)' });
3610
+ return;
3384
3611
  }
3385
3612
 
3386
- const config = await loadConfig(jwt);
3387
- const rt = await cweRoute(config);
3613
+ for (const scan of targets) {
3614
+ const filePath = scan.filePath;
3615
+ const cweContent = scan.cweContent;
3616
+ const cweDiffSection = scan.cweDiffSection;
3617
+ const scanToolName = scan.toolName;
3618
+ const scanToolInput = scan.toolInput;
3619
+ const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3620
+ const fileShort = basename(filePath);
3621
+ const fileExt = extname(filePath).toLowerCase();
3388
3622
 
3389
3623
  const exemptedCwes = new Set<string>();
3390
3624
  for (const ex of config.scanExemptions) {
@@ -3392,12 +3626,120 @@ async function main() {
3392
3626
  exemptedCwes.add(ex.cwe_id.toUpperCase());
3393
3627
  }
3394
3628
  }
3395
- if (config.silent) {
3396
- outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
3629
+
3630
+ const cweTag = '[synkro:' + rt + ':cweScan]';
3631
+
3632
+ if (rt === 'skip') {
3633
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 local CWE grader unavailable, skipped' });
3397
3634
  return;
3398
3635
  }
3399
3636
 
3400
- const cweTag = '[synkro:' + rt + ':cweScan]';
3637
+ if (rt === 'byok') {
3638
+ let packageContext: PackageCapability[] | undefined;
3639
+ if (cwd) {
3640
+ const newImports = detectNewImports(scanToolName, scanToolInput, fileExt);
3641
+ if (newImports.length > 0) {
3642
+ const caps = newImports
3643
+ .slice(0, 5)
3644
+ .map(pkg => scanPackageCapabilities(pkg, cwd))
3645
+ .filter((c): c is PackageCapability => c !== null);
3646
+ if (caps.length > 0) packageContext = caps;
3647
+ }
3648
+ }
3649
+ const scanBody: any = { file_path: filePath, content: cweContent };
3650
+ if (packageContext) {
3651
+ scanBody.package_context = packageContext.map(c => ({
3652
+ name: c.name, description: c.description, capabilities: c.capabilities, source_excerpt: c.sourceExcerpt,
3653
+ }));
3654
+ }
3655
+
3656
+ let cweResp: any;
3657
+ try {
3658
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-scan', {
3659
+ method: 'POST',
3660
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3661
+ body: JSON.stringify(scanBody),
3662
+ signal: AbortSignal.timeout(12000),
3663
+ });
3664
+ if (!resp.ok) {
3665
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 cloud CWE scan failed (HTTP ' + resp.status + '), skipped' });
3666
+ return;
3667
+ }
3668
+ cweResp = await resp.json();
3669
+ } catch {
3670
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 cloud CWE scan timeout, skipped' });
3671
+ return;
3672
+ }
3673
+
3674
+ const findings = Array.isArray(cweResp?.findings) ? cweResp.findings : [];
3675
+ if (cweResp?.action === 'deny' && findings.length > 0) {
3676
+ const activeCweIds = findings
3677
+ .filter((f: any) => f.mode === 'blocking' || f.mode === 'ask')
3678
+ .map((f: any) => f.cwe)
3679
+ .filter((id: string) => !exemptedCwes.has(id.toUpperCase()));
3680
+
3681
+ if (activeCweIds.length === 0) {
3682
+ continue;
3683
+ }
3684
+
3685
+ const displayIds = activeCweIds.slice(0, 3).join(', ');
3686
+ const count = activeCweIds.length;
3687
+ const label = count === 1 ? 'match' : 'matches';
3688
+ const cweMsg = cweTag + ' ' + fileShort + ' \u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
3689
+
3690
+ const fixLines = findings
3691
+ .filter((f: any) => activeCweIds.includes(f.cwe) && f.suggested_fix)
3692
+ .map((f: any) => '[' + f.cwe + '] Fix: ' + f.suggested_fix);
3693
+ const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
3694
+ const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
3695
+ 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.';
3696
+
3697
+ emitBlockScanFindings(
3698
+ jwt,
3699
+ config.captureDepth,
3700
+ { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3701
+ activeCweIds.map((cweId) => {
3702
+ const f = findings.find((x: any) => x.cwe === cweId);
3703
+ return {
3704
+ finding_type: 'cwe' as const,
3705
+ finding_id: cweId,
3706
+ severity: f?.severity || 'high',
3707
+ detail: f?.reason || 'code weakness detected',
3708
+ cwe_name: f?.name || undefined,
3709
+ };
3710
+ }),
3711
+ {
3712
+ finding_type: 'cwe',
3713
+ finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
3714
+ severity: findings[0]?.severity || 'high',
3715
+ detail: denyDetail,
3716
+ },
3717
+ );
3718
+
3719
+ dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
3720
+ scanToolName, gitRepo, sessionId, config.captureDepth, {
3721
+ command: (isShellTool(scanToolName) ? 'shell write ' : 'edit ') + filePath,
3722
+ reasoning: denyDetail,
3723
+ violatedRules: activeCweIds,
3724
+ ccModel,
3725
+ });
3726
+
3727
+ outputJson({
3728
+ systemMessage: cweMsg,
3729
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3730
+ });
3731
+ return;
3732
+ }
3733
+
3734
+ dispatchFinding(jwt, {
3735
+ session_id: sessionId,
3736
+ file_path: filePath,
3737
+ finding_type: 'cwe',
3738
+ finding_id: 'pass',
3739
+ status: 'resolved',
3740
+ }, config.captureDepth);
3741
+ continue;
3742
+ }
3401
3743
 
3402
3744
  if (rt === 'local') {
3403
3745
  let cweRules: any[] = [];
@@ -3431,7 +3773,7 @@ async function main() {
3431
3773
 
3432
3774
  let localPkgContext = '';
3433
3775
  if (cwd) {
3434
- const newImports = detectNewImports(toolName, toolInput, fileExt);
3776
+ const newImports = detectNewImports(scanToolName, scanToolInput, fileExt);
3435
3777
  const caps = newImports.slice(0, 5)
3436
3778
  .map(pkg => scanPackageCapabilities(pkg, cwd))
3437
3779
  .filter((c): c is PackageCapability => c !== null);
@@ -3526,8 +3868,7 @@ async function main() {
3526
3868
  const activeCweIds = cweIds.filter(id => !exemptedCwes.has(id.toUpperCase()));
3527
3869
 
3528
3870
  if (activeCweIds.length === 0) {
3529
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 clean (exempted: ' + cweIds.join(', ') + ')' });
3530
- return;
3871
+ continue;
3531
3872
  }
3532
3873
 
3533
3874
  const cweNameMap = new Map<string, string>();
@@ -3566,8 +3907,8 @@ async function main() {
3566
3907
  );
3567
3908
 
3568
3909
  dispatchCapture(jwt, 'cwe', 'block', verdict.severity || 'high', verdict.category || 'security',
3569
- toolName, gitRepo, sessionId, config.captureDepth, {
3570
- command: 'edit ' + filePath,
3910
+ scanToolName, gitRepo, sessionId, config.captureDepth, {
3911
+ command: (isShellTool(scanToolName) ? 'shell write ' : 'edit ') + filePath,
3571
3912
  reasoning: denyDetail,
3572
3913
  violatedRules: activeCweIds,
3573
3914
  ccModel,
@@ -3587,114 +3928,11 @@ async function main() {
3587
3928
  finding_id: 'pass',
3588
3929
  status: 'resolved',
3589
3930
  }, 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;
3594
- }
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;
3931
+ continue;
3625
3932
  }
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
3933
  }
3687
3934
 
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 } });
3935
+ outputEmpty();
3698
3936
  } catch (err) {
3699
3937
  log('cweGuard error: ' + String(err));
3700
3938
  outputEmpty();
@@ -3707,7 +3945,7 @@ main();
3707
3945
  import {
3708
3946
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3709
3947
  reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
3710
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, extractTranscript, emitBlockScanFindings, resolveTranscriptPath, GATEWAY_URL,
3948
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, dispatchScanResult, extractTranscript, emitBlockScanFindings, resolveTranscriptPath, GATEWAY_URL,
3711
3949
  } from './_synkro-common.ts';
3712
3950
  import { basename } from 'node:path';
3713
3951
  import { readFileSync } from 'node:fs';
@@ -3858,6 +4096,12 @@ async function main() {
3858
4096
  violatedRules: violatedIds,
3859
4097
  ccModel,
3860
4098
  });
4099
+ dispatchScanResult(jwt, {
4100
+ session_id: sessionId, file_path: filePath, scan_type: 'pkg',
4101
+ result: 'block', finding_count: violatedIds.length,
4102
+ finding_ids: violatedIds, severity: 'critical',
4103
+ repo: gitRepo || undefined,
4104
+ });
3861
4105
  const tagStr = '[synkro:' + rt + ':pkgScan]';
3862
4106
  const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
3863
4107
  outputJson({
@@ -3936,12 +4180,23 @@ async function main() {
3936
4180
  const cveIds = findings.slice(0, 10).map((f: any) =>
3937
4181
  (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown'
3938
4182
  );
4183
+ let cveCcModel: string | undefined = String(payload.model ?? payload.model_id ?? '') || undefined;
4184
+ if (!cveCcModel && transcriptPath) {
4185
+ try { cveCcModel = extractTranscript(transcriptPath).ccModel || undefined; } catch {}
4186
+ }
3939
4187
  dispatchCapture(jwt, 'cve', 'block', 'critical', 'security',
3940
4188
  toolName, gitRepo, sessionId, config.captureDepth, {
3941
4189
  command: 'edit ' + filePath,
3942
4190
  reasoning: top3,
3943
4191
  violatedRules: cveIds,
4192
+ ccModel: cveCcModel,
3944
4193
  });
4194
+ dispatchScanResult(jwt, {
4195
+ session_id: sessionId, file_path: filePath, scan_type: 'cve',
4196
+ result: 'block', finding_count: findings.length,
4197
+ finding_ids: cveIds, severity: 'critical',
4198
+ repo: gitRepo || undefined,
4199
+ });
3945
4200
 
3946
4201
  outputJson({
3947
4202
  systemMessage: cveMsg,
@@ -3950,6 +4205,11 @@ async function main() {
3950
4205
  return;
3951
4206
  }
3952
4207
 
4208
+ dispatchScanResult(jwt, {
4209
+ session_id: sessionId, file_path: filePath, scan_type: 'cve',
4210
+ result: 'pass', finding_count: 0,
4211
+ repo: gitRepo || undefined,
4212
+ });
3953
4213
  outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \u2192 clean' });
3954
4214
  } catch (err) {
3955
4215
  log('cveGuard error: ' + String(err));
@@ -3962,7 +4222,7 @@ main();
3962
4222
  INSTALL_SCAN_TS = `#!/usr/bin/env bun
3963
4223
  import {
3964
4224
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3965
- readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, hashCommand,
4225
+ readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, dispatchScanResult, hashCommand,
3966
4226
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
3967
4227
  resolveTranscriptPath, isCursorHookFormat,
3968
4228
  } from './_synkro-common.ts';
@@ -4045,6 +4305,12 @@ async function main() {
4045
4305
  violatedRules: scan.violatedIds,
4046
4306
  ccModel: model || undefined,
4047
4307
  });
4308
+ dispatchScanResult(jwt, {
4309
+ session_id: sessionId, file_path: command, scan_type: 'pkg',
4310
+ result: 'block', finding_count: scan.violatedIds?.length || 1,
4311
+ finding_ids: scan.violatedIds, severity: 'critical',
4312
+ repo: repo || undefined,
4313
+ });
4048
4314
  const denyReason = '[synkro:installScan] BLOCKED: ' + scan.summary + '\\nDo not retry this install. Suggest a safe version to the user instead.';
4049
4315
  outputJson({
4050
4316
  systemMessage: denyReason,
@@ -5339,10 +5605,10 @@ import {
5339
5605
  loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
5340
5606
  appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
5341
5607
  countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
5608
+ isLocalStorageMode,
5342
5609
  } from './_synkro-common.ts';
5343
5610
  import { existsSync, readFileSync } from 'node:fs';
5344
5611
  import { basename, dirname, join } from 'node:path';
5345
- import { homedir } from 'node:os';
5346
5612
 
5347
5613
  let hookDone = false;
5348
5614
 
@@ -5391,16 +5657,8 @@ async function main() {
5391
5657
  if (!jwt) finish();
5392
5658
  jwt = await ensureFreshJwt(jwt);
5393
5659
 
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
5660
  let dependencies: Record<string, string> = {};
5661
+ const fullPath = filePath.startsWith('/') ? filePath : (cwd ? join(cwd, filePath) : filePath);
5404
5662
  let pkgDir = cwd || dirname(fullPath);
5405
5663
  while (pkgDir !== '/' && pkgDir !== '.') {
5406
5664
  const pkgPath = join(pkgDir, 'package.json');
@@ -5416,20 +5674,39 @@ async function main() {
5416
5674
  pkgDir = parent;
5417
5675
  }
5418
5676
 
5419
- const captureBody: Record<string, any> = {
5677
+ const localSpoolBody: Record<string, any> = {
5420
5678
  capture_type: 'edit_scan',
5421
- tool_input: { file_path: filePath, content: fileContent },
5679
+ tool_input: { file_path: filePath, lines_added: linesAdded, lines_removed: linesRemoved },
5422
5680
  edit_verdict: { ok: true },
5423
5681
  dependencies,
5424
5682
  cc_model: model,
5425
5683
  model,
5426
5684
  };
5427
- if (sessionId) captureBody.session_id = sessionId;
5428
- if (cwd) captureBody.cwd = cwd;
5429
- if (repo) captureBody.repo = repo;
5685
+ if (sessionId) localSpoolBody.session_id = sessionId;
5686
+ if (cwd) localSpoolBody.cwd = cwd;
5687
+ if (repo) localSpoolBody.repo = repo;
5688
+ appendLocalTelemetry(localSpoolBody);
5430
5689
 
5431
- appendLocalTelemetry(captureBody);
5432
- shipCloud(jwt, '/api/v1/hook/capture', captureBody, 10000);
5690
+ if (!isLocalStorageMode()) {
5691
+ let fileContent = '';
5692
+ try {
5693
+ if (existsSync(fullPath)) {
5694
+ fileContent = readFileSync(fullPath).slice(0, 50000).toString('utf-8');
5695
+ }
5696
+ } catch {}
5697
+ const cloudBody: Record<string, any> = {
5698
+ capture_type: 'edit_scan',
5699
+ tool_input: { file_path: filePath, content: fileContent },
5700
+ edit_verdict: { ok: true },
5701
+ dependencies,
5702
+ cc_model: model,
5703
+ model,
5704
+ };
5705
+ if (sessionId) cloudBody.session_id = sessionId;
5706
+ if (cwd) cloudBody.cwd = cwd;
5707
+ if (repo) cloudBody.repo = repo;
5708
+ shipCloud(jwt, '/api/v1/hook/capture', cloudBody, 10000);
5709
+ }
5433
5710
 
5434
5711
  if (sessionId) {
5435
5712
  dispatchCapture(jwt, 'edit', 'pass', 'clean', 'trivial_edit', 'Edit', repo, sessionId, 'full', {
@@ -5497,6 +5774,21 @@ main();
5497
5774
  });
5498
5775
 
5499
5776
  // cli/auth/stub.ts
5777
+ var stub_exports = {};
5778
+ __export(stub_exports, {
5779
+ authenticate: () => authenticate,
5780
+ clearCredentials: () => clearCredentials,
5781
+ ensureValidToken: () => ensureValidToken,
5782
+ getAccessToken: () => getAccessToken,
5783
+ getCurrentUserId: () => getCurrentUserId,
5784
+ getSecrets: () => getSecrets,
5785
+ getUserInfo: () => getUserInfo,
5786
+ isAuthenticated: () => isAuthenticated,
5787
+ isTokenExpired: () => isTokenExpired,
5788
+ loadCredentials: () => loadCredentials,
5789
+ refreshToken: () => refreshToken,
5790
+ saveCredentials: () => saveCredentials
5791
+ });
5500
5792
  import { createServer } from "http";
5501
5793
  import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "fs";
5502
5794
  import { homedir as homedir4, platform } from "os";
@@ -5703,6 +5995,17 @@ function isAuthenticated() {
5703
5995
  return true;
5704
5996
  }
5705
5997
  }
5998
+ function getCurrentUserId() {
5999
+ const creds = loadCredentials();
6000
+ if (!creds) {
6001
+ throw new Error("Not authenticated");
6002
+ }
6003
+ const decoded = jwt.decode(creds.access_token);
6004
+ if (!decoded?.sub) {
6005
+ throw new Error("Invalid token");
6006
+ }
6007
+ return decoded.sub;
6008
+ }
5706
6009
  function getUserInfo() {
5707
6010
  const creds = loadCredentials();
5708
6011
  if (!creds) {
@@ -5787,6 +6090,15 @@ function clearCredentials() {
5787
6090
  unlinkSync2(AUTH_FILE);
5788
6091
  }
5789
6092
  }
6093
+ async function getSecrets(userId, integrationId) {
6094
+ return {
6095
+ AWS_ACCESS_KEY_ID: process.env.USER_AWS_KEY || "",
6096
+ AWS_SECRET_ACCESS_KEY: process.env.USER_AWS_SECRET || "",
6097
+ AWS_REGION: process.env.USER_AWS_REGION || "us-east-1",
6098
+ HF_TOKEN: process.env.USER_HF_TOKEN || "",
6099
+ LANGSMITH_API_KEY: process.env.USER_LANGSMITH_KEY || ""
6100
+ };
6101
+ }
5790
6102
  var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, RAW_API_URL, SYNKRO_API_URL, ERROR_HTML, refreshPromise;
5791
6103
  var init_stub = __esm({
5792
6104
  "cli/auth/stub.ts"() {
@@ -6032,12 +6344,13 @@ var init_githubSetup = __esm({
6032
6344
  });
6033
6345
 
6034
6346
  // cli/commands/repoConnect.ts
6035
- import { execSync as execSync3 } from "child_process";
6347
+ import { execSync as execSync3, execFileSync } from "child_process";
6348
+ import { readdirSync } from "fs";
6036
6349
  import { createServer as createServer2 } from "http";
6037
6350
  import { createInterface } from "readline";
6038
6351
  function detectGitRepo() {
6039
6352
  try {
6040
- const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
6353
+ const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
6041
6354
  const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
6042
6355
  const httpMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
6043
6356
  const match = sshMatch || httpMatch;
@@ -6048,6 +6361,33 @@ function detectGitRepo() {
6048
6361
  return null;
6049
6362
  }
6050
6363
  }
6364
+ function detectSubdirRepos() {
6365
+ try {
6366
+ const entries = readdirSync(".", { withFileTypes: true });
6367
+ const repos = [];
6368
+ for (const entry of entries) {
6369
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
6370
+ try {
6371
+ const remoteUrl = execFileSync("git", ["-C", entry.name, "remote", "get-url", "origin"], {
6372
+ encoding: "utf-8",
6373
+ timeout: 5e3,
6374
+ stdio: ["pipe", "pipe", "pipe"]
6375
+ }).trim();
6376
+ const sshMatch = remoteUrl.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
6377
+ const httpMatch = remoteUrl.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
6378
+ const match = sshMatch || httpMatch;
6379
+ if (match) {
6380
+ const fullName = match[1];
6381
+ repos.push({ fullName, shortName: fullName.split("/").pop() || fullName });
6382
+ }
6383
+ } catch {
6384
+ }
6385
+ }
6386
+ return repos;
6387
+ } catch {
6388
+ return [];
6389
+ }
6390
+ }
6051
6391
  function ask(rl, question) {
6052
6392
  return new Promise((resolve3) => rl.question(question, resolve3));
6053
6393
  }
@@ -6149,6 +6489,7 @@ async function connectGithubAndSelectRepos() {
6149
6489
  }
6150
6490
  async function promptRepoConnection(opts) {
6151
6491
  const localRepo = detectGitRepo();
6492
+ const subdirRepos = !localRepo ? detectSubdirRepos() : [];
6152
6493
  if (opts?.linkRepo && localRepo) {
6153
6494
  console.log("Connect repos to Synkro:\n");
6154
6495
  try {
@@ -6171,46 +6512,35 @@ async function promptRepoConnection(opts) {
6171
6512
  const rl = createInterface({ input: process.stdin, output: process.stdout });
6172
6513
  try {
6173
6514
  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) {
6515
+ let existingFullNames = /* @__PURE__ */ new Set();
6516
+ if (subdirRepos.length > 0) {
6192
6517
  try {
6193
6518
  const existing = await listProjects();
6194
- const alreadyLinked = existing.some(
6195
- (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
6519
+ existingFullNames = new Set(
6520
+ existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
6196
6521
  );
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}`);
6522
+ } catch {
6205
6523
  }
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
- );
6524
+ }
6525
+ if (subdirRepos.length > 0) {
6526
+ console.log(` Found ${subdirRepos.length} repo(s) in this directory:
6527
+ `);
6528
+ subdirRepos.forEach((r, i) => {
6529
+ const linked = existingFullNames.has(r.fullName);
6530
+ console.log(` ${String(i + 1).padStart(3)}. ${r.fullName}${linked ? " \u2713 linked" : ""}`);
6531
+ });
6532
+ const ghIdx = subdirRepos.length + 1;
6533
+ const skipIdx = subdirRepos.length + 2;
6534
+ console.log(` ${String(ghIdx).padStart(3)}. Connect GitHub to select repos`);
6535
+ console.log(` ${String(skipIdx).padStart(3)}. Skip for now`);
6536
+ console.log();
6537
+ const selection = await ask(rl, " Select repos to link (comma-separated numbers, e.g. 1,3): ");
6538
+ const nums = selection.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
6539
+ console.log();
6540
+ rl.close();
6541
+ if (nums.includes(ghIdx)) {
6542
+ const selectedRepos = await connectGithubAndSelectRepos();
6543
+ if (selectedRepos.length > 0) {
6214
6544
  const newRepos = selectedRepos.filter((r) => !existingFullNames.has(r.full_name));
6215
6545
  if (newRepos.length === 0) {
6216
6546
  console.log(" \u2713 All selected repos are already linked.");
@@ -6219,14 +6549,83 @@ async function promptRepoConnection(opts) {
6219
6549
  await createProject(projectName, newRepos);
6220
6550
  console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
6221
6551
  }
6222
- } catch (err) {
6223
- console.warn(` \u26A0 Could not link repos: ${err.message}`);
6552
+ }
6553
+ } else if (nums.includes(skipIdx) || nums.length === 0) {
6554
+ console.log(" Skipped. Run `synkro link` later to connect repos.");
6555
+ } else {
6556
+ const repoIndices = nums.map((n) => n - 1).filter((n) => n >= 0 && n < subdirRepos.length);
6557
+ const toLink = repoIndices.map((i) => subdirRepos[i]).filter((r) => !existingFullNames.has(r.fullName));
6558
+ if (toLink.length === 0) {
6559
+ console.log(" \u2713 All selected repos are already linked.");
6560
+ } else {
6561
+ for (const repo of toLink) {
6562
+ try {
6563
+ await createProject(repo.shortName, [{ full_name: repo.fullName }]);
6564
+ console.log(` \u2713 Created project "${repo.shortName}" linked to ${repo.fullName}`);
6565
+ } catch (err) {
6566
+ console.warn(` \u26A0 Could not link ${repo.fullName}: ${err.message}`);
6567
+ }
6568
+ }
6224
6569
  }
6225
6570
  }
6226
- } else if (choiceNum === skipIdx) {
6227
- console.log(" Skipped. Run `synkro link` later to connect repos.");
6228
6571
  } else {
6229
- console.log(" Invalid choice. Skipping repo connection.");
6572
+ const options = [];
6573
+ if (localRepo) {
6574
+ options.push(`Link this repo (${localRepo.fullName})`);
6575
+ }
6576
+ options.push("Connect GitHub to select repos");
6577
+ options.push("Skip for now");
6578
+ options.forEach((opt, i) => {
6579
+ console.log(` ${i + 1}. ${opt}`);
6580
+ });
6581
+ console.log();
6582
+ const choice = await ask(rl, " Choose (number): ");
6583
+ const choiceNum = parseInt(choice.trim(), 10);
6584
+ console.log();
6585
+ rl.close();
6586
+ const localIdx = localRepo ? 1 : -1;
6587
+ const githubIdx = localRepo ? 2 : 1;
6588
+ const skipIdx = localRepo ? 3 : 2;
6589
+ if (choiceNum === localIdx && localRepo) {
6590
+ try {
6591
+ const existing = await listProjects();
6592
+ const alreadyLinked = existing.some(
6593
+ (p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
6594
+ );
6595
+ if (!alreadyLinked) {
6596
+ await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
6597
+ console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
6598
+ } else {
6599
+ console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
6600
+ }
6601
+ } catch (err) {
6602
+ console.warn(` \u26A0 Could not link repo: ${err.message}`);
6603
+ }
6604
+ } else if (choiceNum === githubIdx) {
6605
+ const selectedRepos = await connectGithubAndSelectRepos();
6606
+ if (selectedRepos.length > 0) {
6607
+ try {
6608
+ const existing = await listProjects();
6609
+ const linkedNames = new Set(
6610
+ existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
6611
+ );
6612
+ const newRepos = selectedRepos.filter((r) => !linkedNames.has(r.full_name));
6613
+ if (newRepos.length === 0) {
6614
+ console.log(" \u2713 All selected repos are already linked.");
6615
+ } else {
6616
+ const projectName = newRepos.length === 1 ? newRepos[0].full_name.split("/").pop() || "Project" : "Multi-Repo Project";
6617
+ await createProject(projectName, newRepos);
6618
+ console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
6619
+ }
6620
+ } catch (err) {
6621
+ console.warn(` \u26A0 Could not link repos: ${err.message}`);
6622
+ }
6623
+ }
6624
+ } else if (choiceNum === skipIdx) {
6625
+ console.log(" Skipped. Run `synkro link` later to connect repos.");
6626
+ } else {
6627
+ console.log(" Invalid choice. Skipping repo connection.");
6628
+ }
6230
6629
  }
6231
6630
  } catch {
6232
6631
  rl.close();
@@ -6477,7 +6876,7 @@ __export(dockerInstall_exports, {
6477
6876
  splitWorkers: () => splitWorkers,
6478
6877
  waitForContainerReady: () => waitForContainerReady
6479
6878
  });
6480
- import { copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync } from "fs";
6879
+ import { copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync2 } from "fs";
6481
6880
  import { homedir as homedir6 } from "os";
6482
6881
  import { join as join6 } from "path";
6483
6882
  import { spawnSync as spawnSync2 } from "child_process";
@@ -6849,7 +7248,7 @@ async function dockerSafeStart() {
6849
7248
  return { ok: false, pgdataState: "no_container", error: "No synkro-server container found. Run `synkro install` first." };
6850
7249
  }
6851
7250
  const pgCheck = checkPgdata();
6852
- if (existsSync7(PGDATA_PATH) && readdirSync(PGDATA_PATH).length > 0) {
7251
+ if (existsSync7(PGDATA_PATH) && readdirSync2(PGDATA_PATH).length > 0) {
6853
7252
  if (pgCheck.healthy) {
6854
7253
  console.log(` pgdata: existing data found \u2014 ${pgCheck.details}`);
6855
7254
  } else {
@@ -6890,7 +7289,7 @@ async function dockerSafeRestart() {
6890
7289
  }
6891
7290
  function checkPgdata() {
6892
7291
  if (!existsSync7(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
6893
- const entries = readdirSync(PGDATA_PATH);
7292
+ const entries = readdirSync2(PGDATA_PATH);
6894
7293
  if (entries.length === 0) return { healthy: true, details: "empty (fresh start)" };
6895
7294
  const hasPidFile = entries.includes("postmaster.pid");
6896
7295
  const hasWalDir = entries.includes("pg_wal");
@@ -7299,7 +7698,7 @@ __export(install_exports, {
7299
7698
  installCommand: () => installCommand,
7300
7699
  parseArgs: () => parseArgs
7301
7700
  });
7302
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
7701
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
7303
7702
  import { homedir as homedir8 } from "os";
7304
7703
  import { join as join8 } from "path";
7305
7704
  import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
@@ -7409,6 +7808,19 @@ async function promptStorageMode() {
7409
7808
  );
7410
7809
  });
7411
7810
  }
7811
+ async function promptTranscriptConsent() {
7812
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
7813
+ return new Promise((resolve3) => {
7814
+ rl.question(
7815
+ "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) ",
7816
+ (answer) => {
7817
+ rl.close();
7818
+ const trimmed = answer.trim().toLowerCase();
7819
+ resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
7820
+ }
7821
+ );
7822
+ });
7823
+ }
7412
7824
  function ensureSynkroDir() {
7413
7825
  mkdirSync8(SYNKRO_DIR4, { recursive: true });
7414
7826
  mkdirSync8(HOOKS_DIR, { recursive: true });
@@ -7532,7 +7944,7 @@ function writeConfigEnv(opts) {
7532
7944
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
7533
7945
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
7534
7946
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
7535
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.28")}`
7947
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.30")}`
7536
7948
  ];
7537
7949
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
7538
7950
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7603,7 +8015,7 @@ function collectLocalMetadata() {
7603
8015
  }
7604
8016
  try {
7605
8017
  const sessionsDir = join8(claudeDir, "sessions");
7606
- const files = readdirSync2(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
8018
+ const files = readdirSync3(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
7607
8019
  for (const f of files) {
7608
8020
  const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
7609
8021
  if (s.version) {
@@ -7756,7 +8168,15 @@ async function installCommand(opts = {}) {
7756
8168
  } catch {
7757
8169
  }
7758
8170
  }
7759
- const transcriptConsent = false;
8171
+ let transcriptConsent = true;
8172
+ if (process.stdin.isTTY) {
8173
+ transcriptConsent = await promptTranscriptConsent();
8174
+ if (transcriptConsent) {
8175
+ console.log(" \u2713 Session import enabled\n");
8176
+ } else {
8177
+ console.log(" \u2717 Session import skipped\n");
8178
+ }
8179
+ }
7760
8180
  let hasClaudeCode = false;
7761
8181
  let hasCursor = false;
7762
8182
  for (const agent of agents) {
@@ -7955,6 +8375,23 @@ async function installCommand(opts = {}) {
7955
8375
  const ready = await waitForContainerReady(6e4);
7956
8376
  if (ready) {
7957
8377
  console.log(" \u2713 container ready");
8378
+ const mcpJwt = readFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8379
+ try {
8380
+ const ingestResp = await fetch(`http://127.0.0.1:${hostMcpPort}/api/ingest`, {
8381
+ method: "POST",
8382
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${mcpJwt}` },
8383
+ body: JSON.stringify({ capture_type: "healthcheck", event_id: `healthcheck_${Date.now()}` }),
8384
+ signal: AbortSignal.timeout(5e3)
8385
+ });
8386
+ if (ingestResp.ok) {
8387
+ console.log(" \u2713 ingest endpoint verified");
8388
+ } else {
8389
+ console.warn(` \u26A0 ingest endpoint returned ${ingestResp.status} \u2014 telemetry spool may not drain.`);
8390
+ console.warn(" The .mcp-jwt token may not match the container. Try: synkro uninstall && synkro install");
8391
+ }
8392
+ } catch {
8393
+ console.warn(" \u26A0 ingest endpoint unreachable \u2014 telemetry spool may not drain.");
8394
+ }
7958
8395
  } else {
7959
8396
  console.error(" \u2717 container did not become healthy within 60s");
7960
8397
  console.error(" Run `docker logs synkro-server` to debug.");
@@ -7963,31 +8400,68 @@ async function installCommand(opts = {}) {
7963
8400
  console.log();
7964
8401
  }
7965
8402
  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");
8403
+ const repo = detectGitRepo2();
8404
+ if (repo) {
8405
+ if (storageMode === "local") {
8406
+ try {
8407
+ let mcpToken = "";
8408
+ try {
8409
+ mcpToken = readFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8410
+ } catch {
8411
+ }
8412
+ if (mcpToken) {
8413
+ const mcpPort = parseInt(process.env.SYNKRO_MCP_PORT || "18931", 10);
8414
+ const result = await syncTranscriptsLocal(mcpPort, mcpToken, repo);
8415
+ if (result.messages > 0) {
8416
+ console.log(` \u2713 Imported ${result.sessions} sessions (${result.messages} messages) into local store.`);
8417
+ console.log(" Embeddings generated. Analyzing for rule suggestions...");
8418
+ try {
8419
+ const suggestResp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/suggest-rules`, {
8420
+ method: "POST",
8421
+ headers: { "Content-Type": "application/json" },
8422
+ signal: AbortSignal.timeout(9e4)
8423
+ });
8424
+ if (suggestResp.ok) {
8425
+ const suggestResult = await suggestResp.json();
8426
+ if (suggestResult.suggested && suggestResult.suggested > 0) {
8427
+ console.log(` \u2713 Generated ${suggestResult.suggested} rule suggestions from your session history.`);
8428
+ console.log(' Ask Claude: "show me suggested rules" to review them.\n');
8429
+ } else {
8430
+ console.log(" No rule suggestions generated yet \u2014 more session data needed.\n");
8431
+ }
8432
+ }
8433
+ } catch {
8434
+ console.log(" Rule analysis will run automatically as more sessions are recorded.\n");
8435
+ }
8436
+ }
8437
+ } else {
8438
+ console.warn(" \u26A0 Session import skipped \u2014 container auth token not found.\n");
8439
+ }
8440
+ } catch (err) {
8441
+ console.warn(` \u26A0 Local session import failed: ${err.message}
8442
+ `);
7973
8443
  }
7974
- }
7975
- } catch (err) {
7976
- console.warn(` \u26A0 Session indexing skipped: ${err.message}
8444
+ } else {
8445
+ try {
8446
+ const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
8447
+ if (ingested > 0) {
8448
+ console.log(` \u2713 Indexed ${ingested} session insights from Claude Code history.`);
8449
+ }
8450
+ } catch (err) {
8451
+ console.warn(` \u26A0 Session indexing skipped: ${err.message}
7977
8452
  `);
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
8453
  }
7987
- }
7988
- } catch (err) {
7989
- console.warn(` \u26A0 Transcript sync skipped: ${err.message}
8454
+ try {
8455
+ const result = await syncTranscriptsBulk(gatewayUrl, token, repo);
8456
+ if (result.messages > 0) {
8457
+ console.log(` \u2713 Synced ${result.sessions} sessions (${result.messages} messages) to cloud.`);
8458
+ console.log(" Embeddings use your configured inference provider.\n");
8459
+ }
8460
+ } catch (err) {
8461
+ console.warn(` \u26A0 Transcript sync skipped: ${err.message}
7990
8462
  `);
8463
+ }
8464
+ }
7991
8465
  }
7992
8466
  }
7993
8467
  if (ghToken) {
@@ -8019,7 +8493,7 @@ function getClaudeProjectsFolder() {
8019
8493
  }
8020
8494
  function extractSessionInsights(projectsDir) {
8021
8495
  const insights = [];
8022
- const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
8496
+ const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
8023
8497
  for (const file of files) {
8024
8498
  const sessionId = file.replace(".jsonl", "");
8025
8499
  const filePath = join8(projectsDir, file);
@@ -8137,10 +8611,52 @@ function parseTranscriptFile(filePath) {
8137
8611
  }
8138
8612
  return messages;
8139
8613
  }
8614
+ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
8615
+ const projectsDir = getClaudeProjectsFolder();
8616
+ if (!projectsDir) return { sessions: 0, messages: 0 };
8617
+ const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
8618
+ if (files.length === 0) return { sessions: 0, messages: 0 };
8619
+ console.log(` Found ${files.length} CC session transcripts, importing + embedding...`);
8620
+ let totalSessions = 0;
8621
+ let totalMessages = 0;
8622
+ for (let i = 0; i < files.length; i++) {
8623
+ const file = files[i];
8624
+ const sessionId = file.replace(".jsonl", "");
8625
+ const filePath = join8(projectsDir, file);
8626
+ try {
8627
+ const allMessages = parseTranscriptFile(filePath);
8628
+ const messages = allMessages.length > 500 ? allMessages.slice(-500) : allMessages;
8629
+ if (messages.length === 0) continue;
8630
+ const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/conversation-sync`, {
8631
+ method: "POST",
8632
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${mcpToken}` },
8633
+ body: JSON.stringify({ session_id: sessionId, repo, messages }),
8634
+ signal: AbortSignal.timeout(15e3)
8635
+ });
8636
+ if (resp.ok) {
8637
+ const result = await resp.json();
8638
+ totalSessions++;
8639
+ totalMessages += result.ingested ?? messages.length;
8640
+ }
8641
+ } catch {
8642
+ }
8643
+ if ((i + 1) % 10 === 0 || i === files.length - 1) {
8644
+ process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
8645
+ }
8646
+ try {
8647
+ const content = readFileSync7(join8(projectsDir, file), "utf-8");
8648
+ const lineCount = content.split("\n").filter(Boolean).length;
8649
+ writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
8650
+ } catch {
8651
+ }
8652
+ }
8653
+ if (totalSessions > 0) process.stdout.write("\n");
8654
+ return { sessions: totalSessions, messages: totalMessages };
8655
+ }
8140
8656
  async function syncTranscriptsBulk(gatewayUrl, token, repo) {
8141
8657
  const projectsDir = getClaudeProjectsFolder();
8142
8658
  if (!projectsDir) return { sessions: 0, messages: 0 };
8143
- const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
8659
+ const files = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl"));
8144
8660
  if (files.length === 0) return { sessions: 0, messages: 0 };
8145
8661
  console.log(`Found ${files.length} CC session transcripts, syncing...`);
8146
8662
  const maxMessagesPerSession = 500;
@@ -8751,7 +9267,7 @@ var disconnect_exports = {};
8751
9267
  __export(disconnect_exports, {
8752
9268
  disconnectCommand: () => disconnectCommand
8753
9269
  });
8754
- import { existsSync as existsSync11, rmSync, readdirSync as readdirSync3 } from "fs";
9270
+ import { existsSync as existsSync11, rmSync, readdirSync as readdirSync4 } from "fs";
8755
9271
  import { homedir as homedir10 } from "os";
8756
9272
  import { join as join10 } from "path";
8757
9273
  import { spawnSync as spawnSync5 } from "child_process";
@@ -8834,7 +9350,7 @@ async function disconnectCommand(args2 = []) {
8834
9350
  } else {
8835
9351
  const keep = /* @__PURE__ */ new Set([join10(SYNKRO_DIR5, "pgdata"), join10(SYNKRO_DIR5, "pgdata-backups")]);
8836
9352
  const preserved = [];
8837
- for (const entry of readdirSync3(SYNKRO_DIR5)) {
9353
+ for (const entry of readdirSync4(SYNKRO_DIR5)) {
8838
9354
  const full = join10(SYNKRO_DIR5, entry);
8839
9355
  if (keep.has(full)) {
8840
9356
  preserved.push(entry);
@@ -9140,7 +9656,7 @@ var init_grade = __esm({
9140
9656
  });
9141
9657
 
9142
9658
  // cli/local-cc/pueue.ts
9143
- import { execFileSync, spawnSync as spawnSync6, spawn } from "child_process";
9659
+ import { execFileSync as execFileSync2, spawnSync as spawnSync6, spawn } from "child_process";
9144
9660
  import { homedir as homedir12 } from "os";
9145
9661
  import { join as join12 } from "path";
9146
9662
  import { connect as connect2 } from "net";
@@ -10180,7 +10696,6 @@ var init_config = __esm({
10180
10696
  import { readFileSync as readFileSync13, existsSync as existsSync16 } from "fs";
10181
10697
  import { resolve as resolve2 } from "path";
10182
10698
  var envCandidates = [
10183
- resolve2(process.cwd(), ".env"),
10184
10699
  resolve2(process.env.HOME ?? "", ".synkro", "config.env")
10185
10700
  ];
10186
10701
  for (const envPath of envCandidates) {
@@ -10200,7 +10715,7 @@ var args = process.argv.slice(2);
10200
10715
  var cmd = args[0] || "";
10201
10716
  var subArgs = args.slice(1);
10202
10717
  function printVersion() {
10203
- console.log("1.6.28");
10718
+ console.log("1.6.30");
10204
10719
  }
10205
10720
  function printHelp2() {
10206
10721
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -10246,6 +10761,23 @@ async function main() {
10246
10761
  disconnectCommand2(subArgs);
10247
10762
  break;
10248
10763
  }
10764
+ case "login": {
10765
+ const { authenticate: authenticate2, isAuthenticated: isAuthenticated2 } = await Promise.resolve().then(() => (init_stub(), stub_exports));
10766
+ if (isAuthenticated2()) {
10767
+ console.log("Already authenticated.");
10768
+ } else {
10769
+ console.log("Opening browser for Synkro auth...");
10770
+ const result = await authenticate2((status) => {
10771
+ if (status.phase === "success") console.log(" \u2713 Authenticated");
10772
+ else if (status.phase === "error") console.error(" \u2717 " + status.message);
10773
+ });
10774
+ if (!result) {
10775
+ console.error("Authentication failed.");
10776
+ process.exit(1);
10777
+ }
10778
+ }
10779
+ break;
10780
+ }
10249
10781
  case "grade": {
10250
10782
  const { gradeCommand: gradeCommand2 } = await Promise.resolve().then(() => (init_grade(), grade_exports));
10251
10783
  await gradeCommand2(subArgs);