@synkro-sh/cli 1.0.11 → 1.0.12

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
@@ -360,10 +360,22 @@ esac
360
360
  if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
361
361
 
362
362
  USER_INTENT=""
363
+ RECENT_USER_MESSAGES="[]"
363
364
  RECENT_ACTIONS="[]"
364
365
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
365
- # Last user message text
366
- USER_INTENT=$(tail -200 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "user") | .message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end' 2>/dev/null | tail -1 || echo "")
366
+ # Last 5 user-role messages, oldest first. Lets the grader see consent
367
+ # that carried over from a recent prior turn \u2014 saying "i consent" two
368
+ # turns ago should not require re-prompting on this turn's command.
369
+ RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
370
+ [.[]
371
+ | select(.type == "user")
372
+ | (.message.content
373
+ | if type == "string" then .
374
+ else (map(.text? // "") | join(" "))
375
+ end)
376
+ | select(. != null and . != "")
377
+ ] | .[-5:]' 2>/dev/null || echo "[]")
378
+ USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
367
379
  # Recent agent actions (last 5 tool_use blocks)
368
380
  RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '
369
381
  [.[]
@@ -380,6 +392,7 @@ fi
380
392
  BODY=$(jq -n \\
381
393
  --argjson tool_input "$TOOL_INPUT" \\
382
394
  --arg user_intent "$USER_INTENT" \\
395
+ --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
383
396
  --argjson recent_actions "$RECENT_ACTIONS" \\
384
397
  --arg session_id "$SESSION_ID" \\
385
398
  --arg tool_use_id "$TOOL_USE_ID" \\
@@ -388,6 +401,7 @@ BODY=$(jq -n \\
388
401
  kind: "bash_judge",
389
402
  tool_input: $tool_input,
390
403
  user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
404
+ recent_user_messages: $recent_user_messages,
391
405
  recent_actions: $recent_actions,
392
406
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
393
407
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
@@ -2021,7 +2035,7 @@ function writeConfigEnv(opts) {
2021
2035
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2022
2036
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2023
2037
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2024
- `SYNKRO_VERSION=${shellQuoteSingle("1.0.11")}`
2038
+ `SYNKRO_VERSION=${shellQuoteSingle("1.0.12")}`
2025
2039
  ];
2026
2040
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2027
2041
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2422,31 +2436,17 @@ jobs:
2422
2436
  with:
2423
2437
  fetch-depth: 0
2424
2438
 
2425
- - name: Cache Synkro CLI
2426
- id: cache-synkro
2427
- uses: actions/cache@v4
2428
- with:
2429
- path: ~/.local/bin/synkro
2430
- key: synkro-\${{ runner.os }}-latest
2431
-
2432
- - name: Install Synkro CLI
2433
- if: steps.cache-synkro.outputs.cache-hit != 'true'
2434
- run: |
2435
- mkdir -p ~/.local/bin
2436
- curl -fsSL https://get.synkro.sh | SYNKRO_INSTALL_DIR=~/.local/bin bash
2437
- echo "~/.local/bin" >> $GITHUB_PATH
2438
-
2439
- - name: Cache Claude Code CLI
2440
- id: cache-claude
2439
+ - name: Cache npm globals
2440
+ id: cache-npm-global
2441
2441
  uses: actions/cache@v4
2442
2442
  with:
2443
2443
  path: ~/.npm-global
2444
- key: claude-code-\${{ runner.os }}-v1
2444
+ key: synkro-cli-\${{ runner.os }}-v1
2445
2445
 
2446
- - name: Install Claude Code CLI
2446
+ - name: Install Synkro CLI + Claude Code CLI
2447
2447
  run: |
2448
2448
  npm config set prefix ~/.npm-global
2449
- npm install -g @anthropic-ai/claude-code
2449
+ npm install -g @synkro-sh/cli @anthropic-ai/claude-code
2450
2450
  echo "~/.npm-global/bin" >> $GITHUB_PATH
2451
2451
 
2452
2452
  - name: Run Synkro PR scan
@@ -2628,8 +2628,33 @@ async function setupGithubCommand() {
2628
2628
  process.exit(1);
2629
2629
  }
2630
2630
  const config = readConfig();
2631
- if (!config.SYNKRO_API_KEY) {
2632
- console.error("Missing SYNKRO_API_KEY in ~/.synkro/config.env. Run `synkro install` first.");
2631
+ const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
2632
+ const jwt2 = getAccessToken();
2633
+ if (!jwt2) {
2634
+ console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro login`.");
2635
+ process.exit(1);
2636
+ }
2637
+ console.log("Requesting CI API key from Synkro...");
2638
+ let synkroCiApiKey;
2639
+ try {
2640
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/ci-api-key`, {
2641
+ method: "POST",
2642
+ headers: {
2643
+ "Authorization": `Bearer ${jwt2}`,
2644
+ "Content-Type": "application/json"
2645
+ },
2646
+ body: "{}"
2647
+ });
2648
+ if (!resp.ok) {
2649
+ const errText = await resp.text().catch(() => "");
2650
+ console.error(`Failed to mint CI API key (${resp.status}): ${errText.slice(0, 200)}`);
2651
+ process.exit(1);
2652
+ }
2653
+ const minted = await resp.json();
2654
+ synkroCiApiKey = minted.api_key;
2655
+ console.log(` \u2713 Issued CI key (${synkroCiApiKey.slice(0, 18)}\u2026), expires ${minted.expires_at.slice(0, 10)}`);
2656
+ } catch (err) {
2657
+ console.error(`Failed to mint CI API key: ${err.message}`);
2633
2658
  process.exit(1);
2634
2659
  }
2635
2660
  const rl = createInterface({ input, output });
@@ -2697,7 +2722,7 @@ Will push secrets to ${selected.length} repo(s):`);
2697
2722
  r.repo,
2698
2723
  {
2699
2724
  claudeCodeOauthToken: claudeToken,
2700
- synkroApiKey: config.SYNKRO_API_KEY
2725
+ synkroApiKey: synkroCiApiKey
2701
2726
  }
2702
2727
  );
2703
2728
  console.log("\u2713");
@@ -2740,6 +2765,107 @@ __export(scanPr_exports, {
2740
2765
  scanPrCommand: () => scanPrCommand
2741
2766
  });
2742
2767
  import { execSync as execSync2, spawn } from "child_process";
2768
+ function parseMatchSpec(condition) {
2769
+ if (!condition.startsWith("match_spec:")) return null;
2770
+ try {
2771
+ const parsed = JSON.parse(condition.slice("match_spec:".length));
2772
+ if (typeof parsed?.selector !== "string" || typeof parsed?.requires !== "string" || parsed.position !== "start" && parsed.position !== "anywhere") return null;
2773
+ return {
2774
+ selector: parsed.selector,
2775
+ requires: parsed.requires,
2776
+ position: parsed.position,
2777
+ negate: parsed.negate === true
2778
+ };
2779
+ } catch {
2780
+ return null;
2781
+ }
2782
+ }
2783
+ function selectorMatches(selector, filePath) {
2784
+ const m = selector.match(/^\*\*\/\*\.([a-z0-9]+)$/i);
2785
+ if (!m) return false;
2786
+ return filePath.toLowerCase().endsWith("." + m[1].toLowerCase());
2787
+ }
2788
+ async function fetchOrgRules(gatewayUrl, apiKey) {
2789
+ try {
2790
+ const resp = await fetch(`${gatewayUrl.replace(/\/$/, "")}/api/v1/cli/pr-rules`, {
2791
+ headers: { "x-synkro-api-key": apiKey }
2792
+ });
2793
+ if (!resp.ok) {
2794
+ console.warn(`[scan-pr] failed to fetch org rules: HTTP ${resp.status}`);
2795
+ return [];
2796
+ }
2797
+ const data = await resp.json();
2798
+ return Array.isArray(data?.rules) ? data.rules : [];
2799
+ } catch (err) {
2800
+ console.warn(`[scan-pr] could not fetch org rules: ${err.message}`);
2801
+ return [];
2802
+ }
2803
+ }
2804
+ function applyLiteralMatchNegative(rules, file) {
2805
+ if (!file.patch) return [];
2806
+ const findings = [];
2807
+ const lines = file.patch.split("\n");
2808
+ let currentNewLine = 0;
2809
+ for (const line of lines) {
2810
+ if (line.startsWith("@@")) {
2811
+ const m = line.match(/\+(\d+)(?:,\d+)?/);
2812
+ if (m) currentNewLine = parseInt(m[1], 10);
2813
+ continue;
2814
+ }
2815
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
2816
+ if (!line.startsWith("+")) {
2817
+ if (!line.startsWith("-")) currentNewLine++;
2818
+ continue;
2819
+ }
2820
+ const addedContent = line.slice(1);
2821
+ for (const r of rules) {
2822
+ if (r.mode !== "literal_match") continue;
2823
+ const spec = parseMatchSpec(r.condition);
2824
+ if (!spec || !spec.negate) continue;
2825
+ if (!selectorMatches(spec.selector, file.filename)) continue;
2826
+ if (!addedContent.includes(spec.requires)) continue;
2827
+ findings.push({
2828
+ file: file.filename,
2829
+ line: currentNewLine,
2830
+ severity: r.severity ?? "high",
2831
+ category: r.category || "literal_match",
2832
+ description: r.text,
2833
+ fix: `Remove \`${spec.requires}\` from ${file.filename} (rule ${r.rule_id}).`
2834
+ });
2835
+ }
2836
+ currentNewLine++;
2837
+ }
2838
+ return findings;
2839
+ }
2840
+ function buildPrPrompt(orgAuditRules) {
2841
+ const orgRulesBlock = orgAuditRules.length === 0 ? "" : `
2842
+ ORG-SPECIFIC RULES (these are the customer's policies \u2014 flag any violation found in the diff):
2843
+ ` + orgAuditRules.map((r, i) => ` ${i + 1}. [${r.severity}/${r.category}] ${r.text}`).join("\n") + "\n";
2844
+ return `You are a security code reviewer analyzing a pull request diff for one file. Identify security issues + org-policy violations introduced by this diff.
2845
+
2846
+ Output ONLY a JSON object (no prose, no markdown fences):
2847
+ {
2848
+ "findings": [
2849
+ {
2850
+ "line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
2851
+ "severity": "low" | "medium" | "high" | "critical",
2852
+ "category": "<snake_case>",
2853
+ "description": "<1 sentence>",
2854
+ "fix": "<concrete suggestion>"
2855
+ }
2856
+ ],
2857
+ "summary": "<one-line: 'X findings' or 'clean'>"
2858
+ }
2859
+
2860
+ Baseline security categories: hardcoded_secret, sql_injection, insecure_crypto, eval_exec, unsafe_deserialization, missing_validation, exposed_internal, missing_auth_check, cors_misconfig, path_traversal, command_injection, weak_random, broken_jwt.
2861
+ ${orgRulesBlock}
2862
+ Rules:
2863
+ - Only flag NEW issues (lines starting with +).
2864
+ - Use the L<num> line numbers I prefixed.
2865
+ - Be specific. If clean, return {"findings": [], "summary": "clean"}.
2866
+
2867
+ `;
2868
+ }
2743
2869
  function shouldSkipFile(filename) {
2744
2870
  return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
2745
2871
  }
@@ -2789,13 +2915,13 @@ function getFileDiffWithLines(file) {
2789
2915
  }
2790
2916
  return { hunks: annotated.join("\n"), newFileLineMap: lineMap };
2791
2917
  }
2792
- function spawnClaudeJudge(file, claudeToken) {
2918
+ function spawnClaudeJudge(file, claudeToken, promptHeader) {
2793
2919
  const { hunks } = getFileDiffWithLines(file);
2794
2920
  const userMessage = `File: ${file.filename}
2795
2921
 
2796
2922
  Diff:
2797
2923
  ${hunks}`;
2798
- const fullPrompt = PR_PROMPT_HEADER + userMessage;
2924
+ const fullPrompt = promptHeader + userMessage;
2799
2925
  return new Promise((resolve2) => {
2800
2926
  const t0 = Date.now();
2801
2927
  const proc = spawn(
@@ -2937,6 +3063,14 @@ async function scanPrCommand() {
2937
3063
  }
2938
3064
  console.log(`Synkro scan-pr: ${repo}#${prNumber} @ ${sha.slice(0, 7)}
2939
3065
  `);
3066
+ const orgRules = await fetchOrgRules(gatewayUrl, synkroApiKey);
3067
+ const auditRules = orgRules.filter((r) => r.mode === "audit");
3068
+ const literalNegativeRules = orgRules.filter((r) => {
3069
+ const spec = parseMatchSpec(r.condition);
3070
+ return r.mode === "literal_match" && spec?.negate === true;
3071
+ });
3072
+ console.log(`Loaded ${orgRules.length} org rule(s): ${auditRules.length} audit, ${literalNegativeRules.length} literal_match (negative).`);
3073
+ const promptHeader = buildPrPrompt(auditRules);
2940
3074
  let files;
2941
3075
  try {
2942
3076
  files = getPrFiles(repo, prNumber);
@@ -2971,9 +3105,11 @@ async function scanPrCommand() {
2971
3105
  const t0 = Date.now();
2972
3106
  const results = await processInBatches(eligible, MAX_PARALLEL_FILES, async (file) => {
2973
3107
  process.stdout.write(`Scanning ${file.filename}...`);
2974
- const result = await spawnClaudeJudge(file, claudeToken);
2975
- console.log(` ${result.findings.length} finding(s) (${result.latencyMs}ms)`);
2976
- return result;
3108
+ const literalFindings = applyLiteralMatchNegative(literalNegativeRules, file);
3109
+ const llmResult = await spawnClaudeJudge(file, claudeToken, promptHeader);
3110
+ const merged = [...literalFindings, ...llmResult.findings];
3111
+ console.log(` ${merged.length} finding(s) (${literalFindings.length} literal, ${llmResult.findings.length} llm; ${llmResult.latencyMs}ms)`);
3112
+ return { findings: merged, latencyMs: llmResult.latencyMs };
2977
3113
  });
2978
3114
  const totalLatencyMs = Date.now() - t0;
2979
3115
  const allFindings = results.flatMap((r) => r.findings);
@@ -3001,7 +3137,7 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
3001
3137
  process.exit(1);
3002
3138
  }
3003
3139
  }
3004
- var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES, PR_PROMPT_HEADER;
3140
+ var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES;
3005
3141
  var init_scanPr = __esm({
3006
3142
  "cli/commands/scanPr.ts"() {
3007
3143
  "use strict";
@@ -3022,30 +3158,6 @@ var init_scanPr = __esm({
3022
3158
  ];
3023
3159
  MAX_DIFF_LINES_PER_FILE = 1e3;
3024
3160
  MAX_PARALLEL_FILES = 5;
3025
- PR_PROMPT_HEADER = `You are a security code reviewer analyzing a pull request diff for one file. Identify security issues introduced by this diff.
3026
-
3027
- Output ONLY a JSON object (no prose, no markdown fences):
3028
- {
3029
- "findings": [
3030
- {
3031
- "line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
3032
- "severity": "low" | "medium" | "high" | "critical",
3033
- "category": "<snake_case>",
3034
- "description": "<1 sentence>",
3035
- "fix": "<concrete suggestion>"
3036
- }
3037
- ],
3038
- "summary": "<one-line: 'X findings' or 'clean'>"
3039
- }
3040
-
3041
- Categories: hardcoded_secret, sql_injection, insecure_crypto, eval_exec, unsafe_deserialization, missing_validation, exposed_internal, missing_auth_check, cors_misconfig, path_traversal, command_injection, weak_random, broken_jwt.
3042
-
3043
- Rules:
3044
- - Only flag NEW issues (lines starting with +).
3045
- - Use the L<num> line numbers I prefixed.
3046
- - Be specific. If clean, return {"findings": [], "summary": "clean"}.
3047
-
3048
- `;
3049
3161
  }
3050
3162
  });
3051
3163