@synkro-sh/cli 1.5.5 → 1.5.7

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
@@ -1110,7 +1110,7 @@ const ROLE_MAP: Record<string, GradeRole> = {
1110
1110
  edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
1111
1111
  };
1112
1112
 
1113
- async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 20000): Promise<string> {
1113
+ async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000): Promise<string> {
1114
1114
  const body = JSON.stringify({ role, payload: prompt, content: prompt });
1115
1115
 
1116
1116
  const resp = await fetch('http://127.0.0.1:' + port + '/submit', {
@@ -1130,7 +1130,7 @@ async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port:
1130
1130
  return String(data.result || '');
1131
1131
  }
1132
1132
 
1133
- export async function localGrade(surface: string, prompt: string, timeoutMs = 20000): Promise<string> {
1133
+ export async function localGrade(surface: string, prompt: string, timeoutMs = 30000): Promise<string> {
1134
1134
  if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1135
1135
  const jwt = loadJwt();
1136
1136
  if (!jwt) throw new Error('NO_JWT');
@@ -2804,7 +2804,7 @@ import {
2804
2804
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
2805
2805
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
2806
2806
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
2807
- logGraderUnavailable,
2807
+ logGraderUnavailable, isPathUnder,
2808
2808
  type HookConfig, type Rule,
2809
2809
  } from './_synkro-common.ts';
2810
2810
 
@@ -2849,65 +2849,138 @@ async function main() {
2849
2849
  const cmdShort = command.slice(0, 80);
2850
2850
  log('bashGuard checking: ' + cmdShort);
2851
2851
 
2852
+ // Load JWT + routing config eagerly so even the short-circuit message
2853
+ // carries the live pack name + local/cloud tag. Cost: ~200-500ms for the
2854
+ // config fetch (network call, no caching). The fetch is unavoidable for
2855
+ // the LLM path anyway — we just pay it sooner so the short-circuit can
2856
+ // produce a properly-tagged system message.
2857
+ let jwt = loadJwt();
2858
+ if (!jwt) { outputEmpty(); return; }
2859
+ jwt = await ensureFreshJwt(jwt);
2860
+
2861
+ const config = await loadConfig(jwt);
2862
+ const rt = await route(config);
2863
+ const tagStr = tag(rt, config);
2864
+
2865
+ if (config.silent) {
2866
+ outputJson({ systemMessage: tagStr + ' bashGuard → skipped (silent mode)' });
2867
+ return;
2868
+ }
2869
+
2852
2870
  // ─── Hook-side short-circuit for safe in-repo reads ───
2853
2871
  // The judge primer already deterministically allows these, but the round
2854
2872
  // trip + batch queue still costs 1–25s per call. Skipping the grade for
2855
2873
  // unambiguously read-only operations removes that latency for ~half of
2856
2874
  // typical commands (cat/grep/git status/ls/etc.) and unblocks the worker
2857
2875
  // 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. Using string includes instead of a
2865
- // regex because escaping inside a String.raw template literal is a
2866
- // footgun (we got bitten by an unbalanced-paren regex once already).
2867
- const UNSAFE_CHARS = ['>', ';', '&', '|', '\`'];
2868
- for (const ch of UNSAFE_CHARS) { if (cmd.indexOf(ch) !== -1) return false; }
2869
- const padded = ' ' + cmd + ' ';
2870
- const UNSAFE_WORDS = [' sudo ', ' su ', ' rm ', ' mv ', ' cp ', ' chmod ', ' chown ', ' tee ', ' kill ', ' sed -i', ' sed --in-place', '\$('];
2876
+ // Returning FALSE just means "don't short-circuit — let the LLM grade it."
2877
+ // Never blocks. The judge sees the command, applies rules, returns its
2878
+ // own verdict. Path scoping below: STRICT, only short-circuit when every
2879
+ // absolute path is under the linked repo root.
2880
+ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
2881
+ const UNSAFE_CHARS = ['>', ';', '&', '\`'];
2882
+ for (const ch of UNSAFE_CHARS) { if (seg.indexOf(ch) !== -1) return false; }
2883
+ const padded = ' ' + seg + ' ';
2884
+ const UNSAFE_WORDS = [
2885
+ ' sudo ', ' su ', ' rm ', ' mv ', ' cp ', ' chmod ', ' chown ',
2886
+ ' tee ', ' kill ', ' sed -i', ' sed --in-place',
2887
+ ' sh -c', ' bash -c', ' zsh -c', ' eval ', ' exec ',
2888
+ '\$(',
2889
+ ];
2871
2890
  for (const w of UNSAFE_WORDS) { if (padded.indexOf(w) !== -1) return false; }
2891
+
2892
+ // Narrowed verb set. Removed:
2893
+ // awk: has system() / |& shell-spawn
2894
+ // env: \`env FOO=bar evil_cmd\` runs evil_cmd
2895
+ // sed: scripting + -i write capability; not worth parsing
2872
2896
  const SAFE_VERBS = new Set([
2873
2897
  'cat','head','tail','less','more','grep','egrep','fgrep','rg','ag',
2874
2898
  'find','fd','ls','wc','cmp','diff','file','stat','which','whereis','type',
2875
- 'pwd','whoami','id','date','echo','printf','env','true','false',
2876
- 'jq','yq','awk','sort','uniq','cut','tr','xxd','hexdump','od','column',
2899
+ 'pwd','whoami','id','date','echo','printf','true','false',
2900
+ 'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
2877
2901
  'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
2878
- 'git','sed',
2902
+ 'git',
2879
2903
  ]);
2880
- const tokens = cmd.trim().split(' ').filter(t => t.length > 0);
2904
+ const tokens = seg.trim().split(' ').filter(t => t.length > 0);
2881
2905
  const verb = tokens[0] || '';
2882
2906
  if (!SAFE_VERBS.has(verb)) return false;
2907
+
2908
+ // find/fd: reject any execution / mutation action flag.
2909
+ if (verb === 'find' || verb === 'fd') {
2910
+ const BAD = new Set([
2911
+ '-exec','-execdir','-ok','-okdir','-delete',
2912
+ '-fprint','-fprintf','-fprint0','-fls',
2913
+ '--exec','--exec-batch',
2914
+ ]);
2915
+ for (const t of tokens) { if (BAD.has(t)) return false; }
2916
+ }
2917
+
2918
+ // git: only pure-read subcommands. branch/tag/remote/config dropped —
2919
+ // each has flag combinations that mutate state.
2883
2920
  if (verb === 'git') {
2884
- 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']);
2921
+ const SAFE_GIT = new Set([
2922
+ 'log','show','diff','blame','status','rev-parse',
2923
+ 'ls-files','ls-tree','cat-file','shortlog','reflog',
2924
+ 'describe','symbolic-ref','--version',
2925
+ ]);
2885
2926
  const sub = tokens[1] || '';
2886
- return SAFE_GIT.has(sub);
2887
- }
2888
- if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
2927
+ if (!SAFE_GIT.has(sub)) return false;
2928
+ } else if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
2889
2929
  const sub = tokens[1] || '';
2890
- const SAFE_PKG = new Set(['--version','-v','version','list','ls','why','view','show','info','outdated','-h','--help','help']);
2891
- return SAFE_PKG.has(sub);
2892
- }
2893
- if (['node','python','python3','ruby','rustc'].includes(verb)) {
2930
+ const SAFE_PKG = new Set([
2931
+ '--version','-v','version','list','ls','why','view','show','info','outdated',
2932
+ '-h','--help','help',
2933
+ ]);
2934
+ if (!SAFE_PKG.has(sub)) return false;
2935
+ } else if (['node','python','python3','ruby','rustc'].includes(verb)) {
2894
2936
  const sub = tokens[1] || '';
2895
- return sub === '--version' || sub === '-v' || sub === '-V';
2937
+ if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
2938
+ }
2939
+
2940
+ // STRICT path scoping. Absolute paths MUST resolve under repoRoot.
2941
+ // Home-relative (~/...) paths fall through to the LLM. Relative paths
2942
+ // are implicitly under cwd which is the repo root for the agent session.
2943
+ if (!repoRoot) return false;
2944
+ for (let i = 1; i < tokens.length; i++) {
2945
+ const t = tokens[i];
2946
+ const stripped = t.replace(/^['"]/, '').replace(/['"]$/, '');
2947
+ if (stripped.startsWith('~')) return false;
2948
+ if (stripped.startsWith('/')) {
2949
+ if (!isPathUnder(stripped, repoRoot)) return false;
2950
+ }
2951
+ }
2952
+ return true;
2953
+ }
2954
+
2955
+ function isSafeInRepoRead(tName: string, cmd: string, repoRoot: string): boolean {
2956
+ if (tName === 'Read' || tName === 'Grep' || tName === 'Glob') return true;
2957
+ if (tName !== 'Bash' && tName !== 'Shell' && tName !== 'terminal' &&
2958
+ tName !== 'run_terminal_cmd' && tName !== 'execute_command') return false;
2959
+ if (!cmd || !repoRoot) return false;
2960
+ // Allow pipes only if EVERY segment is safe on its own. Catches
2961
+ // \`grep ... | head\`, \`cat foo | wc -l\`, \`git log | less\`, etc.
2962
+ // Empty segments (from \`||\`) cause rejection.
2963
+ const segments = cmd.split('|');
2964
+ for (const seg of segments) {
2965
+ const t = seg.trim();
2966
+ if (t.length === 0) return false;
2967
+ if (!isSafeBashSegment(t, repoRoot)) return false;
2896
2968
  }
2897
- // sed: only safe without -i (we filtered that above).
2898
2969
  return true;
2899
2970
  }
2900
2971
 
2901
- if (isSafeInRepoRead(toolName, command)) {
2972
+ if (isSafeInRepoRead(toolName, command, cwd)) {
2902
2973
  log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
2903
- outputJson({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro: safe in-repo read, deterministic allow.' } });
2974
+ outputJson({
2975
+ systemMessage: tagStr + ' bashGuard → pass: safe in-repo read',
2976
+ hookSpecificOutput: {
2977
+ hookEventName: 'PreToolUse',
2978
+ additionalContext: tagStr + ' bashGuard pass: safe in-repo read.',
2979
+ },
2980
+ });
2904
2981
  return;
2905
2982
  }
2906
2983
 
2907
- let jwt = loadJwt();
2908
- if (!jwt) { outputEmpty(); return; }
2909
- jwt = await ensureFreshJwt(jwt);
2910
-
2911
2984
  // ─── Install protection: server-side pkg-scan (CVE + typosquat + tarball + reputation) ───
2912
2985
  let installScanMsg = '';
2913
2986
  if (toolName === 'Bash') {
@@ -3016,15 +3089,9 @@ async function main() {
3016
3089
 
3017
3090
  const lastPrompt = readLastPrompt(sessionId);
3018
3091
 
3019
- const config = await loadConfig(jwt);
3020
- const rt = await route(config);
3021
- const tagStr = tag(rt, config);
3022
-
3023
- if (config.silent) {
3024
- const msg = (installScanMsg ? installScanMsg + '\\n' : '') + tagStr + ' bashGuard → skipped (silent mode)';
3025
- outputJson({ systemMessage: msg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
3026
- return;
3027
- }
3092
+ // jwt + config + rt + tagStr already loaded eagerly at top of main
3093
+ // (so the short-circuit could emit a properly-tagged message). Silent
3094
+ // mode was also checked up there.
3028
3095
 
3029
3096
  if (rt === 'local') {
3030
3097
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
@@ -5854,7 +5921,7 @@ function writeConfigEnv(opts) {
5854
5921
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5855
5922
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5856
5923
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5857
- `SYNKRO_VERSION=${shellQuoteSingle("1.5.5")}`
5924
+ `SYNKRO_VERSION=${shellQuoteSingle("1.5.7")}`
5858
5925
  ];
5859
5926
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5860
5927
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7244,7 +7311,7 @@ var args = process.argv.slice(2);
7244
7311
  var cmd = args[0] || "";
7245
7312
  var subArgs = args.slice(1);
7246
7313
  function printVersion() {
7247
- console.log("1.5.5");
7314
+ console.log("1.5.7");
7248
7315
  }
7249
7316
  function printHelp() {
7250
7317
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents