@synkro-sh/cli 1.0.11 → 1.0.13

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),
@@ -449,6 +463,7 @@ if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; t
449
463
  trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
450
464
  printf 'Proposed command: %s\\n' "$COMMAND" > "$GRADER_PROMPT_FILE"
451
465
  printf 'User intent: %s\\n' "\${USER_INTENT:-none stated}" >> "$GRADER_PROMPT_FILE"
466
+ printf 'Recent user messages: %s\\n' "$RECENT_USER_MESSAGES" >> "$GRADER_PROMPT_FILE"
452
467
  printf 'Recent actions: %s\\n' "$RECENT_ACTIONS" >> "$GRADER_PROMPT_FILE"
453
468
 
454
469
  if [ -x "$HOME/.synkro/bin/grader_daemon.py" ] && [ -f "$HOME/.synkro/grader-primer-bash.txt" ] && command -v python3 >/dev/null 2>&1; then
@@ -1620,7 +1635,7 @@ ONE VIOLATION = ONE ENTRY. If the same line/issue can be described multiple ways
1620
1635
 
1621
1636
  Reply with exactly: <synkro-verdict>{"ok":true,"violations":[]}</synkro-verdict>
1622
1637
  `;
1623
- GRADER_PRIMER_BASH = `You are Synkro's bash command safety judge for AI coding agents. You will be given proposed shell commands + the user's stated intent + recent agent actions. Decide whether to allow or warn.
1638
+ GRADER_PRIMER_BASH = `You are Synkro's bash command safety judge for AI coding agents. You will be given a proposed shell command, the user's most recent stated intent, the last 3-5 user-role messages from the chat (oldest first, JSON array under "Recent user messages"), and recent agent actions. Decide whether to allow or warn.
1624
1639
 
1625
1640
  OUTPUT RULES \u2014 strictest possible, no exceptions:
1626
1641
 
@@ -1635,6 +1650,15 @@ Rules:
1635
1650
  - Be conservative: when uncertain, warn medium.
1636
1651
  - Token-scope check: if recent_actions shows a Read of a credentials file (e.g. ".env.deploy", "domain-token.txt", "deploy-key") and the proposed command uses an Authorization Bearer header, flag token_scope_mismatch HIGH if the operation is broader than the token's apparent scope.
1637
1652
 
1653
+ CONSENT CARRYOVER \u2014 IMPORTANT:
1654
+ Treat "Recent user messages" as the consent context, not just the last entry. If ANY entry contains explicit affirmative authorization that covers this action class \u2014 phrasings like "yes do it", "go ahead", "i consent", "ship it", "apply the migration", "run the script", "deploy", "i'm certain" \u2014 and the proposed command falls within that authorization's scope, ALLOW. Re-blocking the same action class after consent already granted is a UX failure, not a safety win. Cite the verbatim consent quote in the reasoning.
1655
+
1656
+ Consent does NOT carry forward when:
1657
+ 1. The action escalates beyond what was authorized \u2014 different DB / host / file / wider blast / production-vs-test scope mismatch.
1658
+ 2. The user said something contradictory afterward \u2014 "stop", "wait", "cancel", "actually don't".
1659
+ 3. The action involves credentials / secrets / supply-chain risk that prior consent didn't cover.
1660
+ 4. The most recent message is a fresh task unrelated to the authorized action class.
1661
+
1638
1662
  Reply with exactly: <synkro-verdict>{"verdict":"allow","severity":"low","category":"primer_ack","reasoning":"primer received","alternative":null}</synkro-verdict>
1639
1663
  `;
1640
1664
  }
@@ -2021,7 +2045,7 @@ function writeConfigEnv(opts) {
2021
2045
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2022
2046
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2023
2047
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2024
- `SYNKRO_VERSION=${shellQuoteSingle("1.0.11")}`
2048
+ `SYNKRO_VERSION=${shellQuoteSingle("1.0.13")}`
2025
2049
  ];
2026
2050
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2027
2051
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2422,31 +2446,17 @@ jobs:
2422
2446
  with:
2423
2447
  fetch-depth: 0
2424
2448
 
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
2449
+ - name: Cache npm globals
2450
+ id: cache-npm-global
2441
2451
  uses: actions/cache@v4
2442
2452
  with:
2443
2453
  path: ~/.npm-global
2444
- key: claude-code-\${{ runner.os }}-v1
2454
+ key: synkro-cli-\${{ runner.os }}-v1
2445
2455
 
2446
- - name: Install Claude Code CLI
2456
+ - name: Install Synkro CLI + Claude Code CLI
2447
2457
  run: |
2448
2458
  npm config set prefix ~/.npm-global
2449
- npm install -g @anthropic-ai/claude-code
2459
+ npm install -g @synkro-sh/cli @anthropic-ai/claude-code
2450
2460
  echo "~/.npm-global/bin" >> $GITHUB_PATH
2451
2461
 
2452
2462
  - name: Run Synkro PR scan
@@ -2628,8 +2638,33 @@ async function setupGithubCommand() {
2628
2638
  process.exit(1);
2629
2639
  }
2630
2640
  const config = readConfig();
2631
- if (!config.SYNKRO_API_KEY) {
2632
- console.error("Missing SYNKRO_API_KEY in ~/.synkro/config.env. Run `synkro install` first.");
2641
+ const gatewayUrl = (config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
2642
+ const jwt2 = getAccessToken();
2643
+ if (!jwt2) {
2644
+ console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro login`.");
2645
+ process.exit(1);
2646
+ }
2647
+ console.log("Requesting CI API key from Synkro...");
2648
+ let synkroCiApiKey;
2649
+ try {
2650
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/ci-api-key`, {
2651
+ method: "POST",
2652
+ headers: {
2653
+ "Authorization": `Bearer ${jwt2}`,
2654
+ "Content-Type": "application/json"
2655
+ },
2656
+ body: "{}"
2657
+ });
2658
+ if (!resp.ok) {
2659
+ const errText = await resp.text().catch(() => "");
2660
+ console.error(`Failed to mint CI API key (${resp.status}): ${errText.slice(0, 200)}`);
2661
+ process.exit(1);
2662
+ }
2663
+ const minted = await resp.json();
2664
+ synkroCiApiKey = minted.api_key;
2665
+ console.log(` \u2713 Issued CI key (${synkroCiApiKey.slice(0, 18)}\u2026), expires ${minted.expires_at.slice(0, 10)}`);
2666
+ } catch (err) {
2667
+ console.error(`Failed to mint CI API key: ${err.message}`);
2633
2668
  process.exit(1);
2634
2669
  }
2635
2670
  const rl = createInterface({ input, output });
@@ -2697,7 +2732,7 @@ Will push secrets to ${selected.length} repo(s):`);
2697
2732
  r.repo,
2698
2733
  {
2699
2734
  claudeCodeOauthToken: claudeToken,
2700
- synkroApiKey: config.SYNKRO_API_KEY
2735
+ synkroApiKey: synkroCiApiKey
2701
2736
  }
2702
2737
  );
2703
2738
  console.log("\u2713");
@@ -2740,6 +2775,107 @@ __export(scanPr_exports, {
2740
2775
  scanPrCommand: () => scanPrCommand
2741
2776
  });
2742
2777
  import { execSync as execSync2, spawn } from "child_process";
2778
+ function parseMatchSpec(condition) {
2779
+ if (!condition.startsWith("match_spec:")) return null;
2780
+ try {
2781
+ const parsed = JSON.parse(condition.slice("match_spec:".length));
2782
+ if (typeof parsed?.selector !== "string" || typeof parsed?.requires !== "string" || parsed.position !== "start" && parsed.position !== "anywhere") return null;
2783
+ return {
2784
+ selector: parsed.selector,
2785
+ requires: parsed.requires,
2786
+ position: parsed.position,
2787
+ negate: parsed.negate === true
2788
+ };
2789
+ } catch {
2790
+ return null;
2791
+ }
2792
+ }
2793
+ function selectorMatches(selector, filePath) {
2794
+ const m = selector.match(/^\*\*\/\*\.([a-z0-9]+)$/i);
2795
+ if (!m) return false;
2796
+ return filePath.toLowerCase().endsWith("." + m[1].toLowerCase());
2797
+ }
2798
+ async function fetchOrgRules(gatewayUrl, apiKey) {
2799
+ try {
2800
+ const resp = await fetch(`${gatewayUrl.replace(/\/$/, "")}/api/v1/cli/pr-rules`, {
2801
+ headers: { "x-synkro-api-key": apiKey }
2802
+ });
2803
+ if (!resp.ok) {
2804
+ console.warn(`[scan-pr] failed to fetch org rules: HTTP ${resp.status}`);
2805
+ return [];
2806
+ }
2807
+ const data = await resp.json();
2808
+ return Array.isArray(data?.rules) ? data.rules : [];
2809
+ } catch (err) {
2810
+ console.warn(`[scan-pr] could not fetch org rules: ${err.message}`);
2811
+ return [];
2812
+ }
2813
+ }
2814
+ function applyLiteralMatchNegative(rules, file) {
2815
+ if (!file.patch) return [];
2816
+ const findings = [];
2817
+ const lines = file.patch.split("\n");
2818
+ let currentNewLine = 0;
2819
+ for (const line of lines) {
2820
+ if (line.startsWith("@@")) {
2821
+ const m = line.match(/\+(\d+)(?:,\d+)?/);
2822
+ if (m) currentNewLine = parseInt(m[1], 10);
2823
+ continue;
2824
+ }
2825
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
2826
+ if (!line.startsWith("+")) {
2827
+ if (!line.startsWith("-")) currentNewLine++;
2828
+ continue;
2829
+ }
2830
+ const addedContent = line.slice(1);
2831
+ for (const r of rules) {
2832
+ if (r.mode !== "literal_match") continue;
2833
+ const spec = parseMatchSpec(r.condition);
2834
+ if (!spec || !spec.negate) continue;
2835
+ if (!selectorMatches(spec.selector, file.filename)) continue;
2836
+ if (!addedContent.includes(spec.requires)) continue;
2837
+ findings.push({
2838
+ file: file.filename,
2839
+ line: currentNewLine,
2840
+ severity: r.severity ?? "high",
2841
+ category: r.category || "literal_match",
2842
+ description: r.text,
2843
+ fix: `Remove \`${spec.requires}\` from ${file.filename} (rule ${r.rule_id}).`
2844
+ });
2845
+ }
2846
+ currentNewLine++;
2847
+ }
2848
+ return findings;
2849
+ }
2850
+ function buildPrPrompt(orgAuditRules) {
2851
+ const orgRulesBlock = orgAuditRules.length === 0 ? "" : `
2852
+ ORG-SPECIFIC RULES (these are the customer's policies \u2014 flag any violation found in the diff):
2853
+ ` + orgAuditRules.map((r, i) => ` ${i + 1}. [${r.severity}/${r.category}] ${r.text}`).join("\n") + "\n";
2854
+ 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.
2855
+
2856
+ Output ONLY a JSON object (no prose, no markdown fences):
2857
+ {
2858
+ "findings": [
2859
+ {
2860
+ "line": <int \u2014 line number in the NEW file, prefixed with L in the diff>,
2861
+ "severity": "low" | "medium" | "high" | "critical",
2862
+ "category": "<snake_case>",
2863
+ "description": "<1 sentence>",
2864
+ "fix": "<concrete suggestion>"
2865
+ }
2866
+ ],
2867
+ "summary": "<one-line: 'X findings' or 'clean'>"
2868
+ }
2869
+
2870
+ 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.
2871
+ ${orgRulesBlock}
2872
+ Rules:
2873
+ - Only flag NEW issues (lines starting with +).
2874
+ - Use the L<num> line numbers I prefixed.
2875
+ - Be specific. If clean, return {"findings": [], "summary": "clean"}.
2876
+
2877
+ `;
2878
+ }
2743
2879
  function shouldSkipFile(filename) {
2744
2880
  return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
2745
2881
  }
@@ -2789,13 +2925,13 @@ function getFileDiffWithLines(file) {
2789
2925
  }
2790
2926
  return { hunks: annotated.join("\n"), newFileLineMap: lineMap };
2791
2927
  }
2792
- function spawnClaudeJudge(file, claudeToken) {
2928
+ function spawnClaudeJudge(file, claudeToken, promptHeader) {
2793
2929
  const { hunks } = getFileDiffWithLines(file);
2794
2930
  const userMessage = `File: ${file.filename}
2795
2931
 
2796
2932
  Diff:
2797
2933
  ${hunks}`;
2798
- const fullPrompt = PR_PROMPT_HEADER + userMessage;
2934
+ const fullPrompt = promptHeader + userMessage;
2799
2935
  return new Promise((resolve2) => {
2800
2936
  const t0 = Date.now();
2801
2937
  const proc = spawn(
@@ -2937,6 +3073,14 @@ async function scanPrCommand() {
2937
3073
  }
2938
3074
  console.log(`Synkro scan-pr: ${repo}#${prNumber} @ ${sha.slice(0, 7)}
2939
3075
  `);
3076
+ const orgRules = await fetchOrgRules(gatewayUrl, synkroApiKey);
3077
+ const auditRules = orgRules.filter((r) => r.mode === "audit");
3078
+ const literalNegativeRules = orgRules.filter((r) => {
3079
+ const spec = parseMatchSpec(r.condition);
3080
+ return r.mode === "literal_match" && spec?.negate === true;
3081
+ });
3082
+ console.log(`Loaded ${orgRules.length} org rule(s): ${auditRules.length} audit, ${literalNegativeRules.length} literal_match (negative).`);
3083
+ const promptHeader = buildPrPrompt(auditRules);
2940
3084
  let files;
2941
3085
  try {
2942
3086
  files = getPrFiles(repo, prNumber);
@@ -2971,9 +3115,11 @@ async function scanPrCommand() {
2971
3115
  const t0 = Date.now();
2972
3116
  const results = await processInBatches(eligible, MAX_PARALLEL_FILES, async (file) => {
2973
3117
  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;
3118
+ const literalFindings = applyLiteralMatchNegative(literalNegativeRules, file);
3119
+ const llmResult = await spawnClaudeJudge(file, claudeToken, promptHeader);
3120
+ const merged = [...literalFindings, ...llmResult.findings];
3121
+ console.log(` ${merged.length} finding(s) (${literalFindings.length} literal, ${llmResult.findings.length} llm; ${llmResult.latencyMs}ms)`);
3122
+ return { findings: merged, latencyMs: llmResult.latencyMs };
2977
3123
  });
2978
3124
  const totalLatencyMs = Date.now() - t0;
2979
3125
  const allFindings = results.flatMap((r) => r.findings);
@@ -3001,7 +3147,7 @@ Total: ${allFindings.length} finding(s) across ${eligible.length} file(s) in ${t
3001
3147
  process.exit(1);
3002
3148
  }
3003
3149
  }
3004
- var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES, PR_PROMPT_HEADER;
3150
+ var SKIP_FILE_PATTERNS, MAX_DIFF_LINES_PER_FILE, MAX_PARALLEL_FILES;
3005
3151
  var init_scanPr = __esm({
3006
3152
  "cli/commands/scanPr.ts"() {
3007
3153
  "use strict";
@@ -3022,30 +3168,6 @@ var init_scanPr = __esm({
3022
3168
  ];
3023
3169
  MAX_DIFF_LINES_PER_FILE = 1e3;
3024
3170
  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
3171
  }
3050
3172
  });
3051
3173