@synkro-sh/cli 1.5.4 → 1.5.6

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');
@@ -2855,48 +2855,103 @@ async function main() {
2855
2855
  // unambiguously read-only operations removes that latency for ~half of
2856
2856
  // typical commands (cat/grep/git status/ls/etc.) and unblocks the worker
2857
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;
2858
+ // Returning FALSE just means "don't short-circuit — let the LLM grade it."
2859
+ // Never blocks. The judge sees the command, applies rules, returns its
2860
+ // own verdict. Path scoping below: STRICT, only short-circuit when every
2861
+ // absolute path is under the linked repo root.
2862
+ function isSafeBashSegment(seg: string, repoRoot: string): boolean {
2863
+ const UNSAFE_CHARS = ['>', ';', '&', '\`'];
2864
+ for (const ch of UNSAFE_CHARS) { if (seg.indexOf(ch) !== -1) return false; }
2865
+ const padded = ' ' + seg + ' ';
2866
+ const UNSAFE_WORDS = [
2867
+ ' sudo ', ' su ', ' rm ', ' mv ', ' cp ', ' chmod ', ' chown ',
2868
+ ' tee ', ' kill ', ' sed -i', ' sed --in-place',
2869
+ ' sh -c', ' bash -c', ' zsh -c', ' eval ', ' exec ',
2870
+ '\$(',
2871
+ ];
2872
+ for (const w of UNSAFE_WORDS) { if (padded.indexOf(w) !== -1) return false; }
2873
+
2874
+ // Narrowed verb set. Removed:
2875
+ // awk: has system() / |& shell-spawn
2876
+ // env: \`env FOO=bar evil_cmd\` runs evil_cmd
2877
+ // sed: scripting + -i write capability; not worth parsing
2867
2878
  const SAFE_VERBS = new Set([
2868
2879
  'cat','head','tail','less','more','grep','egrep','fgrep','rg','ag',
2869
2880
  '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',
2881
+ 'pwd','whoami','id','date','echo','printf','true','false',
2882
+ 'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
2872
2883
  'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
2873
2884
  'git',
2874
2885
  ]);
2875
- const tokens = cmd.trim().split(/\\s+/);
2886
+ const tokens = seg.trim().split(' ').filter(t => t.length > 0);
2876
2887
  const verb = tokens[0] || '';
2877
2888
  if (!SAFE_VERBS.has(verb)) return false;
2878
- // For multi-mode tools, only allow read subcommands / version flags.
2889
+
2890
+ // find/fd: reject any execution / mutation action flag.
2891
+ if (verb === 'find' || verb === 'fd') {
2892
+ const BAD = new Set([
2893
+ '-exec','-execdir','-ok','-okdir','-delete',
2894
+ '-fprint','-fprintf','-fprint0','-fls',
2895
+ '--exec','--exec-batch',
2896
+ ]);
2897
+ for (const t of tokens) { if (BAD.has(t)) return false; }
2898
+ }
2899
+
2900
+ // git: only pure-read subcommands. branch/tag/remote/config dropped —
2901
+ // each has flag combinations that mutate state.
2879
2902
  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']);
2903
+ const SAFE_GIT = new Set([
2904
+ 'log','show','diff','blame','status','rev-parse',
2905
+ 'ls-files','ls-tree','cat-file','shortlog','reflog',
2906
+ 'describe','symbolic-ref','--version',
2907
+ ]);
2881
2908
  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.
2909
+ if (!SAFE_GIT.has(sub)) return false;
2910
+ } else if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
2886
2911
  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)) {
2912
+ const SAFE_PKG = new Set([
2913
+ '--version','-v','version','list','ls','why','view','show','info','outdated',
2914
+ '-h','--help','help',
2915
+ ]);
2916
+ if (!SAFE_PKG.has(sub)) return false;
2917
+ } else if (['node','python','python3','ruby','rustc'].includes(verb)) {
2891
2918
  const sub = tokens[1] || '';
2892
- return sub === '--version' || sub === '-v' || sub === '-V';
2919
+ if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
2920
+ }
2921
+
2922
+ // STRICT path scoping. Absolute paths MUST resolve under repoRoot.
2923
+ // Home-relative (~/...) paths fall through to the LLM. Relative paths
2924
+ // are implicitly under cwd which is the repo root for the agent session.
2925
+ if (!repoRoot) return false;
2926
+ for (let i = 1; i < tokens.length; i++) {
2927
+ const t = tokens[i];
2928
+ const stripped = t.replace(/^['"]/, '').replace(/['"]$/, '');
2929
+ if (stripped.startsWith('~')) return false;
2930
+ if (stripped.startsWith('/')) {
2931
+ if (!isPathUnder(stripped, repoRoot)) return false;
2932
+ }
2933
+ }
2934
+ return true;
2935
+ }
2936
+
2937
+ function isSafeInRepoRead(tName: string, cmd: string, repoRoot: string): boolean {
2938
+ if (tName === 'Read' || tName === 'Grep' || tName === 'Glob') return true;
2939
+ if (tName !== 'Bash' && tName !== 'Shell' && tName !== 'terminal' &&
2940
+ tName !== 'run_terminal_cmd' && tName !== 'execute_command') return false;
2941
+ if (!cmd || !repoRoot) return false;
2942
+ // Allow pipes only if EVERY segment is safe on its own. Catches
2943
+ // \`grep ... | head\`, \`cat foo | wc -l\`, \`git log | less\`, etc.
2944
+ // Empty segments (from \`||\`) cause rejection.
2945
+ const segments = cmd.split('|');
2946
+ for (const seg of segments) {
2947
+ const t = seg.trim();
2948
+ if (t.length === 0) return false;
2949
+ if (!isSafeBashSegment(t, repoRoot)) return false;
2893
2950
  }
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
2951
  return true;
2897
2952
  }
2898
2953
 
2899
- if (isSafeInRepoRead(toolName, command)) {
2954
+ if (isSafeInRepoRead(toolName, command, cwd)) {
2900
2955
  log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
2901
2956
  outputJson({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro: safe in-repo read, deterministic allow.' } });
2902
2957
  return;
@@ -5852,7 +5907,7 @@ function writeConfigEnv(opts) {
5852
5907
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5853
5908
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5854
5909
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5855
- `SYNKRO_VERSION=${shellQuoteSingle("1.5.4")}`
5910
+ `SYNKRO_VERSION=${shellQuoteSingle("1.5.6")}`
5856
5911
  ];
5857
5912
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5858
5913
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7242,7 +7297,7 @@ var args = process.argv.slice(2);
7242
7297
  var cmd = args[0] || "";
7243
7298
  var subArgs = args.slice(1);
7244
7299
  function printVersion() {
7245
- console.log("1.5.4");
7300
+ console.log("1.5.6");
7246
7301
  }
7247
7302
  function printHelp() {
7248
7303
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents