@synkro-sh/cli 1.5.2 → 1.5.4

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
@@ -1854,6 +1854,28 @@ function cursorHookExit(): never {
1854
1854
  process.exit(0);
1855
1855
  }
1856
1856
 
1857
+ // \u2500\u2500\u2500 Grader-unavailable diagnostic log \u2500\u2500\u2500
1858
+ // Records every time a hook tried to call the local grader and fell open
1859
+ // because the call failed. JSONL at ~/.synkro/grader-unavailable.log so the
1860
+ // user can pinpoint cause (timeout vs ECONNREFUSED vs HTTP 5xx vs sick pool)
1861
+ // instead of guessing from a one-shot system message in the CC UI.
1862
+
1863
+ const UNAVAIL_LOG = join(HOME, '.synkro', 'grader-unavailable.log');
1864
+
1865
+ export function logGraderUnavailable(hook: string, target: string, errorMessage: string): void {
1866
+ try {
1867
+ const entry = {
1868
+ ts: new Date().toISOString(),
1869
+ hook,
1870
+ target,
1871
+ error: errorMessage.slice(0, 500),
1872
+ };
1873
+ appendFileSync(UNAVAIL_LOG, JSON.stringify(entry) + '\\n', 'utf-8');
1874
+ } catch {
1875
+ // best-effort \u2014 never let logging failure cascade into a hook failure
1876
+ }
1877
+ }
1878
+
1857
1879
  // \u2500\u2500\u2500 Output Helpers \u2500\u2500\u2500
1858
1880
 
1859
1881
  export function outputJson(obj: any): void {
@@ -1916,6 +1938,7 @@ import {
1916
1938
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
1917
1939
  appendSessionAction, readSessionLog, compressSessionLog, log,
1918
1940
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
1941
+ logGraderUnavailable,
1919
1942
  type HookConfig, type Rule,
1920
1943
  } from './_synkro-common.ts';
1921
1944
  import { existsSync, readFileSync } from 'node:fs';
@@ -2010,7 +2033,8 @@ async function main() {
2010
2033
  let gradeResp: string;
2011
2034
  try {
2012
2035
  gradeResp = await localGrade('edit', graderPrompt);
2013
- } catch {
2036
+ } catch (err) {
2037
+ logGraderUnavailable('editGuard', fileShort, (err as Error).message || String(err));
2014
2038
  outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 local grader unavailable, skipped' });
2015
2039
  return;
2016
2040
  }
@@ -2133,6 +2157,7 @@ import {
2133
2157
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
2134
2158
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
2135
2159
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, dispatchFinding, dispatchCapture, GATEWAY_URL,
2160
+ logGraderUnavailable,
2136
2161
  } from './_synkro-common.ts';
2137
2162
  import { basename, extname, resolve, join, dirname } from 'node:path';
2138
2163
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
@@ -2403,6 +2428,7 @@ async function main() {
2403
2428
  gradeResponses = [resp1, resp2];
2404
2429
  } catch (gradeErr: any) {
2405
2430
  const reason = gradeErr?.message || String(gradeErr);
2431
+ logGraderUnavailable('cweGuard', fileShort, reason);
2406
2432
  outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 grader unavailable (' + reason + '), skipped' });
2407
2433
  return;
2408
2434
  }
@@ -2411,6 +2437,7 @@ async function main() {
2411
2437
  gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent))];
2412
2438
  } catch (gradeErr: any) {
2413
2439
  const reason = gradeErr?.message || String(gradeErr);
2440
+ logGraderUnavailable('cweGuard', fileShort, reason);
2414
2441
  outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 grader unavailable (' + reason + '), skipped' });
2415
2442
  return;
2416
2443
  }
@@ -2777,6 +2804,7 @@ import {
2777
2804
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
2778
2805
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
2779
2806
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
2807
+ logGraderUnavailable,
2780
2808
  type HookConfig, type Rule,
2781
2809
  } from './_synkro-common.ts';
2782
2810
 
@@ -2821,6 +2849,59 @@ async function main() {
2821
2849
  const cmdShort = command.slice(0, 80);
2822
2850
  log('bashGuard checking: ' + cmdShort);
2823
2851
 
2852
+ // ─── Hook-side short-circuit for safe in-repo reads ───
2853
+ // The judge primer already deterministically allows these, but the round
2854
+ // trip + batch queue still costs 1–25s per call. Skipping the grade for
2855
+ // unambiguously read-only operations removes that latency for ~half of
2856
+ // typical commands (cat/grep/git status/ls/etc.) and unblocks the worker
2857
+ // pool to grade the operations that actually need judgment.
2858
+ function isSafeInRepoRead(tName: string, cmd: string): boolean {
2859
+ // CC's native read tools are inherently safe.
2860
+ if (tName === 'Read' || tName === 'Grep' || tName === 'Glob') return true;
2861
+ if (tName !== 'Bash' && tName !== 'Shell' && tName !== 'terminal' &&
2862
+ tName !== 'run_terminal_cmd' && tName !== 'execute_command') return false;
2863
+ // Reject any shell metacharacter that could turn a "read" into a write
2864
+ // or chain to an unsafe consumer: redirects, pipes, sequencing, command
2865
+ // substitution, sudo/su.
2866
+ if (/[>;&|\\\`]|\\$\\(|<<|\\bsudo\\b|\\bsu\\b|\\brm\\b|\\bmv\\b|\\bcp\\b|\\bchmod\\b|\\bchown\\b|\\btee\\b|\\bsed\\s+-i\\b|\\bkill\\b/.test(cmd)) return false;
2867
+ const SAFE_VERBS = new Set([
2868
+ 'cat','head','tail','less','more','grep','egrep','fgrep','rg','ag',
2869
+ 'find','fd','ls','wc','cmp','diff','file','stat','which','whereis','type',
2870
+ 'pwd','whoami','id','date','echo','printf','env','true','false',
2871
+ 'jq','yq','awk','sort','uniq','cut','tr','xxd','hexdump','od','column',
2872
+ 'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
2873
+ 'git',
2874
+ ]);
2875
+ const tokens = cmd.trim().split(/\\s+/);
2876
+ const verb = tokens[0] || '';
2877
+ if (!SAFE_VERBS.has(verb)) return false;
2878
+ // For multi-mode tools, only allow read subcommands / version flags.
2879
+ if (verb === 'git') {
2880
+ const SAFE_GIT = new Set(['log','show','diff','blame','status','branch','tag','remote','config','rev-parse','ls-files','ls-tree','cat-file','shortlog','reflog','describe','symbolic-ref']);
2881
+ const sub = tokens[1] || '';
2882
+ return SAFE_GIT.has(sub);
2883
+ }
2884
+ if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
2885
+ // Only allow plain version/info/list/why probes — block install/add/update/run/exec.
2886
+ const sub = tokens[1] || '';
2887
+ const SAFE_PKG = new Set(['--version','-v','version','list','ls','why','view','show','info','outdated','-h','--help','help']);
2888
+ return SAFE_PKG.has(sub);
2889
+ }
2890
+ if (['node','python','python3','ruby','rustc'].includes(verb)) {
2891
+ const sub = tokens[1] || '';
2892
+ return sub === '--version' || sub === '-v' || sub === '-V';
2893
+ }
2894
+ // sed without -i flag is read-only by definition; we already excluded
2895
+ // sed -i above. Anything else with a SAFE_VERB and no metachars is fine.
2896
+ return true;
2897
+ }
2898
+
2899
+ if (isSafeInRepoRead(toolName, command)) {
2900
+ log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
2901
+ outputJson({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro: safe in-repo read, deterministic allow.' } });
2902
+ return;
2903
+ }
2904
+
2824
2905
  let jwt = loadJwt();
2825
2906
  if (!jwt) { outputEmpty(); return; }
2826
2907
  jwt = await ensureFreshJwt(jwt);
@@ -2961,7 +3042,8 @@ async function main() {
2961
3042
  let gradeResp: string;
2962
3043
  try {
2963
3044
  gradeResp = await localGrade('bash', graderPrompt);
2964
- } catch {
3045
+ } catch (err) {
3046
+ logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', (err as Error).message || String(err));
2965
3047
  outputJson({ systemMessage: tagStr + ' bashGuard → local grader unavailable, skipped' });
2966
3048
  return;
2967
3049
  }
@@ -2973,8 +3055,12 @@ async function main() {
2973
3055
  const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
2974
3056
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
2975
3057
 
3058
+ const auditRulesInReason = (config.rules || [])
3059
+ .filter((r: any) => r.mode === 'audit' && r.rule_id && verdict.reason && verdict.reason.includes(r.rule_id))
3060
+ .map((r: any) => r.rule_id);
3061
+
2976
3062
  if (mode === 'audit') {
2977
- const reason = tagStr + ' bashGuard → warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
3063
+ const reason = tagStr + ' bashGuard → warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before proceeding — do not ask the user.';
2978
3064
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
2979
3065
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
2980
3066
  dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
@@ -2983,11 +3069,14 @@ async function main() {
2983
3069
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2984
3070
  });
2985
3071
  } else {
2986
- const reason = tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
2987
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3072
+ let blockMsg = tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3073
+ if (auditRulesInReason.length > 0) {
3074
+ blockMsg += '\\nAudit violations (' + auditRulesInReason.join(', ') + '): fix these automatically before retrying — do not ask the user, just resolve them (e.g. run the required precondition).';
3075
+ }
3076
+ const combined = (installScanMsg ? installScanMsg + '\\n' : '') + blockMsg;
2988
3077
  outputJson({
2989
3078
  systemMessage: combined,
2990
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: combined },
3079
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: combined },
2991
3080
  });
2992
3081
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
2993
3082
  toolName, gitRepo, sessionId, config.captureDepth, {
@@ -3078,6 +3167,7 @@ import {
3078
3167
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
3079
3168
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
3080
3169
  outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
3170
+ logGraderUnavailable,
3081
3171
  type HookConfig, type Rule,
3082
3172
  } from './_synkro-common.ts';
3083
3173
 
@@ -3149,7 +3239,8 @@ async function main() {
3149
3239
  let gradeResp: string;
3150
3240
  try {
3151
3241
  gradeResp = await localGrade('bash', graderPrompt);
3152
- } catch {
3242
+ } catch (err) {
3243
+ logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), (err as Error).message || String(err));
3153
3244
  outputJson({ systemMessage: tagStr + ' agentGuard \u2192 local grader unavailable, skipped' });
3154
3245
  return;
3155
3246
  }
@@ -3599,7 +3690,7 @@ async function main() {
3599
3690
  let isError = payload.tool_result?.is_error === true;
3600
3691
  try {
3601
3692
  const out = JSON.parse(payload.tool_output || '{}');
3602
- if (out.exitCode !== 0 || out.is_error === true) isError = true;
3693
+ if ((typeof out.exitCode === 'number' && out.exitCode !== 0) || out.is_error === true) isError = true;
3603
3694
  } catch {}
3604
3695
  const cmd = shellCmd;
3605
3696
  const cmdHash = cmd ? hashCommand(cmd) : '';
@@ -5532,6 +5623,9 @@ async function dockerInstall(opts = {}) {
5532
5623
  `${CLAUDE_HOST_STATE_DIR}:/data/claude-host-state:ro`,
5533
5624
  "-e",
5534
5625
  `WORKERS_PER_POOL=${workers}`,
5626
+ // Pass through the batch-size lever if the operator set it. Defaults
5627
+ // inside the container to 5; clamped to [1, 20] by synkro-server.ts.
5628
+ ...process.env.SYNKRO_MAX_BATCH_SIZE ? ["-e", `SYNKRO_MAX_BATCH_SIZE=${process.env.SYNKRO_MAX_BATCH_SIZE}`] : [],
5535
5629
  image
5536
5630
  ];
5537
5631
  const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
@@ -5758,7 +5852,7 @@ function writeConfigEnv(opts) {
5758
5852
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5759
5853
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5760
5854
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5761
- `SYNKRO_VERSION=${shellQuoteSingle("1.5.2")}`
5855
+ `SYNKRO_VERSION=${shellQuoteSingle("1.5.4")}`
5762
5856
  ];
5763
5857
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5764
5858
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -6165,7 +6259,7 @@ async function installCommand(opts = {}) {
6165
6259
  process.exit(1);
6166
6260
  }
6167
6261
  console.log("Installing Synkro server container...");
6168
- const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "4", 10);
6262
+ const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
6169
6263
  const { image, hostMcpPort, hostGraderPort, hostCwePort } = await dockerInstall({ workersPerPool });
6170
6264
  console.log(` \u2713 pulled ${image}`);
6171
6265
  console.log(` container started \u2014 MCP=${hostMcpPort} general=${hostGraderPort} CWE=${hostCwePort}`);
@@ -7148,7 +7242,7 @@ var args = process.argv.slice(2);
7148
7242
  var cmd = args[0] || "";
7149
7243
  var subArgs = args.slice(1);
7150
7244
  function printVersion() {
7151
- console.log("1.5.2");
7245
+ console.log("1.5.4");
7152
7246
  }
7153
7247
  function printHelp() {
7154
7248
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents