@synkro-sh/cli 1.6.56 → 1.6.57

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
@@ -440,6 +440,7 @@ function installCursorHooks(hooksJsonPath, config) {
440
440
  timeout: 10,
441
441
  matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command|delete_file"
442
442
  });
443
+ pushCcHook(h, "beforeMCPExecution", config.mcpGateScriptPath, { timeout: 30 });
443
444
  h.afterAgentThought = h.afterAgentThought ?? [];
444
445
  h.afterAgentThought.push({
445
446
  command: bunRunCmd(config.agentCaptureScriptPath),
@@ -497,6 +498,7 @@ var init_cursorHookConfig = __esm({
497
498
  "preToolUse",
498
499
  "afterFileEdit",
499
500
  "postToolUse",
501
+ "beforeMCPExecution",
500
502
  "afterAgentThought",
501
503
  "afterAgentResponse"
502
504
  ];
@@ -7514,7 +7516,7 @@ async function runClaudeDesktopTap() {
7514
7516
  await new Promise((resolve4) => {
7515
7517
  const child = spawn("bash", [runnerPath], {
7516
7518
  stdio: "inherit",
7517
- env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH }
7519
+ env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_SCAN_TURN_URL: SCAN_TURN_URL, SYNKRO_DLP_POLICY_URL: DLP_POLICY_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH }
7518
7520
  });
7519
7521
  const forward = (sig) => {
7520
7522
  try {
@@ -7531,12 +7533,14 @@ async function runClaudeDesktopTap() {
7531
7533
  });
7532
7534
  });
7533
7535
  }
7534
- var CAPTURE_URL, SCAN_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7536
+ var CAPTURE_URL, SCAN_URL, SCAN_TURN_URL, DLP_POLICY_URL, JWT_PATH, ADDON_PY, MCP_PROXY_PY, MCP_PATCH_PY;
7535
7537
  var init_claudeDesktopTap = __esm({
7536
7538
  "cli/installer/claudeDesktopTap.ts"() {
7537
7539
  "use strict";
7538
7540
  CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
7539
7541
  SCAN_URL = "http://127.0.0.1:18931/api/local/scan-upload";
7542
+ SCAN_TURN_URL = "http://127.0.0.1:18931/api/local/scan-turn";
7543
+ DLP_POLICY_URL = "http://127.0.0.1:18931/api/local/dlp-policy";
7540
7544
  JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
7541
7545
  ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64
7542
7546
  from mitmproxy import http
@@ -7544,6 +7548,8 @@ from mitmproxy import http
7544
7548
  CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
7545
7549
  TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
7546
7550
  SCAN_URL = os.environ.get("SYNKRO_SCAN_URL", "")
7551
+ SCAN_TURN_URL = os.environ.get("SYNKRO_SCAN_TURN_URL", "")
7552
+ POLICY_URL = os.environ.get("SYNKRO_DLP_POLICY_URL", "")
7547
7553
  # Token file path \u2014 the SERVER_TOKEN rotates whenever the container is recreated,
7548
7554
  # so we re-read it from disk on a 401 instead of needing a tap restart.
7549
7555
  TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "")
@@ -7609,6 +7615,122 @@ def _fmt_tool_name(raw):
7609
7615
  pass
7610
7616
  return raw
7611
7617
 
7618
+ # \u2500\u2500 M3 live-hold state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7619
+ # Whether to HOLD connector turns (buffer the live completion until graded).
7620
+ # Fetched once from the server: True only when a BLOCK-mode rule exists (+ not
7621
+ # silent). Audit-only orgs get False \u2192 stream + async audit, ZERO added latency.
7622
+ _dlp_hold = None
7623
+ _dlp_lock = threading.Lock()
7624
+ # Shown live in place of a held connector turn while the single grader (the tree
7625
+ # GET) runs; replaced by the real content (if clean) or the block notice.
7626
+ REVIEWING_NOTICE = "\\U0001F6E1\\uFE0F Synkro is reviewing this response for policy compliance\\u2026"
7627
+
7628
+ def _should_hold():
7629
+ global _dlp_hold
7630
+ with _dlp_lock:
7631
+ if _dlp_hold is not None:
7632
+ return _dlp_hold
7633
+ val = False
7634
+ if POLICY_URL:
7635
+ try:
7636
+ with urllib.request.urlopen(urllib.request.Request(POLICY_URL), timeout=4) as resp:
7637
+ val = bool(json.loads(resp.read().decode("utf-8", "replace")).get("hold"))
7638
+ except Exception:
7639
+ val = False
7640
+ with _dlp_lock:
7641
+ _dlp_hold = val
7642
+ return val
7643
+
7644
+ def _scan_turn_call(cid, msgs, connector):
7645
+ # Synchronous DLP grade. Returns the block reason (str) on BLOCK, else None.
7646
+ # Fail-OPEN \u2014 a scan error never blocks the turn.
7647
+ if not (SCAN_TURN_URL and TOKEN): return None
7648
+ try:
7649
+ vb = _post_json(SCAN_TURN_URL, json.dumps({"conversation_id": str(cid)[:80], "messages": msgs, "connector": connector}).encode(), 18)
7650
+ vd = json.loads((vb or b"{}").decode("utf-8", "replace"))
7651
+ if vd.get("block"):
7652
+ return str(vd.get("reason", "restricted data"))[:300]
7653
+ except Exception as e:
7654
+ print("[synkro] turn scan error (fail-open): " + str(e))
7655
+ return None
7656
+
7657
+ def _scan_sse_line(line, st):
7658
+ # Extract governance metadata (usage window + web-search sources/citations)
7659
+ # from one completion SSE line. Shared by the streaming + buffered paths.
7660
+ if st["usage"] is None:
7661
+ um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7662
+ if um:
7663
+ st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
7664
+ if len(st["sources"]) < 50:
7665
+ for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
7666
+ d = sm.group(1)
7667
+ if d and d not in st["seen"] and len(st["sources"]) < 50:
7668
+ st["seen"].add(d)
7669
+ st["sources"].append({"domain": d, "name": sm.group(2)})
7670
+ if "citation_start_delta" in line and line.startswith("data: ") and len(st["citations"]) < 50:
7671
+ try:
7672
+ cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
7673
+ url = cit.get("url", "")
7674
+ if url and url not in st["cseen"]:
7675
+ st["cseen"].add(url)
7676
+ md = cit.get("metadata") or {}
7677
+ st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
7678
+ except Exception:
7679
+ pass
7680
+
7681
+ def _new_scan_state():
7682
+ return {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7683
+
7684
+ def _extract_sse_text(body):
7685
+ # Pull all visible text out of a buffered completion SSE (assistant text +
7686
+ # tool_result content) for grading, and detect whether a tool/connector ran.
7687
+ parts = []
7688
+ tool = ("tool_use" in body) or ("tool_result" in body)
7689
+ total = 0
7690
+ for line in body.split("\\n"):
7691
+ if total > 2000000: break
7692
+ if not line.startswith("data: "): continue
7693
+ try: obj = json.loads(line[6:])
7694
+ except Exception: continue
7695
+ d = obj.get("delta")
7696
+ if isinstance(d, dict) and isinstance(d.get("text"), str):
7697
+ parts.append(d["text"]); total += len(d["text"])
7698
+ cb = obj.get("content_block")
7699
+ if isinstance(cb, dict):
7700
+ if isinstance(cb.get("text"), str): parts.append(cb["text"]); total += len(cb["text"])
7701
+ c = cb.get("content")
7702
+ if isinstance(c, str): parts.append(c); total += len(c)
7703
+ elif isinstance(c, list):
7704
+ for x in c:
7705
+ if isinstance(x, dict) and isinstance(x.get("text"), str): parts.append(x["text"]); total += len(x["text"])
7706
+ return ("".join(parts)[:2000000], tool)
7707
+
7708
+ def _neutralize_sse(body, notice):
7709
+ # Rewrite a buffered completion SSE: blank every text/content field (inject the
7710
+ # notice into the FIRST text delta), leaving all structural events intact so
7711
+ # Claude Desktop still renders a valid \u2014 but content-free \u2014 turn.
7712
+ out = []
7713
+ injected = [False]
7714
+ for line in body.split("\\n"):
7715
+ if not line.startswith("data: "):
7716
+ out.append(line); continue
7717
+ try: obj = json.loads(line[6:])
7718
+ except Exception:
7719
+ out.append(line); continue
7720
+ changed = False
7721
+ d = obj.get("delta")
7722
+ if isinstance(d, dict) and isinstance(d.get("text"), str):
7723
+ d["text"] = (notice if not injected[0] else "")
7724
+ injected[0] = True; changed = True
7725
+ cb = obj.get("content_block")
7726
+ if isinstance(cb, dict):
7727
+ if isinstance(cb.get("text"), str) and cb["text"]:
7728
+ cb["text"] = ""; changed = True
7729
+ if cb.get("content"):
7730
+ cb["content"] = ""; changed = True
7731
+ out.append(("data: " + json.dumps(obj)) if changed else line)
7732
+ return "\\n".join(out)
7733
+
7612
7734
  class SynkroTap:
7613
7735
  def request(self, flow):
7614
7736
  # \u2500\u2500 Upload DLP gate \u2500\u2500 Scan file uploads BEFORE they reach claude.ai and
@@ -7662,10 +7784,10 @@ class SynkroTap:
7662
7784
  print("[synkro] upload scan error (fail-open): " + str(e))
7663
7785
  return
7664
7786
  if verdict.get("block"):
7665
- reason = str(verdict.get("reason", "Blocked by Synkro DLP policy"))[:300]
7787
+ reason = str(verdict.get("reason", "Blocked by Synkro policy"))[:300]
7666
7788
  flow.response = http.Response.make(
7667
7789
  403,
7668
- json.dumps({"error": {"type": "blocked_by_synkro_dlp", "message": "Blocked by Synkro DLP: " + reason}}).encode(),
7790
+ json.dumps({"error": {"type": "blocked_by_synkro", "message": "Blocked by Synkro: " + reason}}).encode(),
7669
7791
  {"Content-Type": "application/json"},
7670
7792
  )
7671
7793
  print("[synkro] BLOCKED upload (size=" + str(payload.get("size", 0)) + " rule=" + str(verdict.get("ruleId", "")) + ")")
@@ -7685,34 +7807,19 @@ class SynkroTap:
7685
7807
  if r.method == "POST" and "completion" in r.path:
7686
7808
  cm = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
7687
7809
  cid = cm.group(1) if cm else ""
7688
- # Incremental parse: scan complete SSE lines for the small governance
7689
- # signals AS THEY STREAM and keep only the extracted results \u2014 never
7690
- # buffer the body. The carry holds at most one partial line (bounded),
7691
- # so there's NO cap on total stream length and no source is dropped.
7692
- st = {"carry": "", "usage": None, "sources": [], "seen": set(), "citations": [], "cseen": set()}
7693
- def _scan(line, _st=st):
7694
- if _st["usage"] is None:
7695
- um = re.search(r'"5h":\\{"status":"([^"]*)","resets_at":(\\d+),"utilization":([0-9.]+)', line)
7696
- if um:
7697
- _st["usage"] = {"window": "5h", "status": um.group(1), "resets_at": int(um.group(2)), "utilization": float(um.group(3))}
7698
- if len(_st["sources"]) < 50:
7699
- for sm in re.finditer(r'"webpage_metadata","site_domain":"([^"]*)"(?:,"favicon_url":"[^"]*")?,"site_name":"([^"]*)"', line):
7700
- d = sm.group(1)
7701
- if d and d not in _st["seen"] and len(_st["sources"]) < 50:
7702
- _st["seen"].add(d)
7703
- _st["sources"].append({"domain": d, "name": sm.group(2)})
7704
- # Citations: the specific pages the model cited (title + url +
7705
- # domain) \u2014 the real "tool result" for web search.
7706
- if "citation_start_delta" in line and line.startswith("data: ") and len(_st["citations"]) < 50:
7707
- try:
7708
- cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
7709
- url = cit.get("url", "")
7710
- if url and url not in _st["cseen"]:
7711
- _st["cseen"].add(url)
7712
- md = cit.get("metadata") or {}
7713
- _st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
7714
- except Exception:
7715
- pass
7810
+ with _mcp_lock:
7811
+ has_servers = len(_mcp_registry) > 0
7812
+ # M3 LIVE HOLD: only when a BLOCK-mode rule exists AND connectors are
7813
+ # connected, BUFFER the whole completion (leave .stream unset) so
7814
+ # response() can grade it and neutralize restricted content BEFORE the
7815
+ # user sees a single token. Audit-only / no-connector turns skip this
7816
+ # entirely and stream as before \u2014 zero added latency.
7817
+ if has_servers and _should_hold():
7818
+ flow.metadata["synkro_hold_cid"] = cid
7819
+ return
7820
+ # Streaming path (unchanged behaviour): tap chunk-by-chunk, extract
7821
+ # governance metadata only, never buffer the body. Audit stays async.
7822
+ st = _new_scan_state()
7716
7823
  def _tap(data, _st=st, _cid=cid):
7717
7824
  if data:
7718
7825
  try:
@@ -7722,9 +7829,9 @@ class SynkroTap:
7722
7829
  lines = txt.split("\\n")
7723
7830
  _st["carry"] = lines[-1][-200000:] # bound the carried partial line (CWE-400)
7724
7831
  for ln in lines[:-1]:
7725
- _scan(ln)
7832
+ _scan_sse_line(ln, _st)
7726
7833
  else: # end of stream \u2014 flush carry + publish
7727
- _scan(_st["carry"])
7834
+ _scan_sse_line(_st["carry"], _st)
7728
7835
  if (_st["usage"] or _st["sources"] or _st["citations"]) and _cid:
7729
7836
  with _sse_lock:
7730
7837
  _sse_meta[_cid] = {"usage": _st["usage"], "sources": _st["sources"], "citations": _st["citations"]}
@@ -7738,6 +7845,36 @@ class SynkroTap:
7738
7845
  def response(self, flow):
7739
7846
  r = flow.request
7740
7847
  if "claude.ai" not in (r.pretty_host or ""): return
7848
+ # \u2500\u2500 M3: a HELD completion (buffered for inline grading) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7849
+ # We have the full SSE body now. Re-derive governance metadata (same as the
7850
+ # streaming path), then \u2014 if the turn used a tool/connector \u2014 grade it
7851
+ # SYNCHRONOUSLY and, on a block, NEUTRALIZE the response so the user never
7852
+ # sees the restricted content. Verdict is cached for the tree GET to reuse.
7853
+ hold_cid = (flow.metadata.get("synkro_hold_cid") if hasattr(flow, "metadata") else None)
7854
+ if hold_cid is not None and "completion" in r.path and flow.response:
7855
+ try:
7856
+ body = flow.response.get_text() or ""
7857
+ except Exception:
7858
+ body = ""
7859
+ st = _new_scan_state()
7860
+ for ln in body.split("\\n"):
7861
+ _scan_sse_line(ln, st)
7862
+ if (st["usage"] or st["sources"] or st["citations"]) and hold_cid:
7863
+ with _sse_lock:
7864
+ _sse_meta[hold_cid] = {"usage": st["usage"], "sources": st["sources"], "citations": st["citations"]}
7865
+ text, has_tool = _extract_sse_text(body)
7866
+ if has_tool:
7867
+ # The turn used a connector \u2014 HOLD the live view: replace the streamed
7868
+ # content with a "reviewing" placeholder so the user never sees the raw
7869
+ # response. The tree GET is the SINGLE grader and renders the final
7870
+ # state (real content if clean, block notice if restricted) \u2014 so there
7871
+ # is exactly one full-document grade per turn (no double-grade race).
7872
+ try:
7873
+ flow.response.text = _neutralize_sse(body, REVIEWING_NOTICE)
7874
+ print("[synkro] HELD completion -> reviewing placeholder")
7875
+ except Exception as e:
7876
+ print("[synkro] hold placeholder failed (fail-open): " + str(e))
7877
+ return
7741
7878
  # MCP server registry from the bootstrap SSE stream.
7742
7879
  if "mcp" in r.path and "bootstrap" in r.path and flow.response:
7743
7880
  try:
@@ -7854,6 +7991,35 @@ class SynkroTap:
7854
7991
  ntools += len(mm.get("tool_calls") or [])
7855
7992
  nsrc = len((sm or {}).get("sources") or [])
7856
7993
  print("[synkro] captured chat (msgs=" + str(len(messages)) + " tools=" + str(ntools) + " sources=" + str(nsrc) + " model=" + model + ")")
7994
+
7995
+ # \u2500\u2500 Tier-2 connector DLP gate \u2500\u2500 If this turn pulled content via an MCP
7996
+ # connector, scan it SYNCHRONOUSLY against blocking org rules. On a block
7997
+ # verdict we HOLD this tree GET (the authoritative render) until the verdict
7998
+ # and then SCRUB the turn from the tree the renderer sees \u2014 the employee
7999
+ # never sees the restricted content. The REAL content is still captured
8000
+ # below for audit (record-everything-even-when-blocked). Fail-OPEN.
8001
+ blocked_reason = None
8002
+ has_connector = any(m.get("role") == "tool" and m.get("content") for m in messages)
8003
+ # SINGLE grader: the held completion only shows a "reviewing" placeholder, so
8004
+ # the tree GET is the one and only place a turn is graded \u2014 exactly one
8005
+ # full-document grade per turn (no double-grade). Gated on a BLOCK-mode rule
8006
+ # existing; audit-only orgs (_should_hold False) skip this and stay async.
8007
+ if _should_hold() and SCAN_TURN_URL and TOKEN and has_connector:
8008
+ # CWE-20/CWE-400: bounded, shape-validated payload (<=200 msgs, <=2MB).
8009
+ scan_msgs = []
8010
+ budget = 2000000
8011
+ for m in messages[:200]:
8012
+ if budget <= 0: break
8013
+ c = str(m.get("content", ""))[:min(500000, budget)]
8014
+ budget -= len(c)
8015
+ scan_msgs.append({"role": str(m.get("role", "user"))[:20], "content": c, "name": str(m.get("name", ""))[:200]})
8016
+ connector = ""
8017
+ for m in scan_msgs:
8018
+ if m["role"] == "tool" and m["name"]:
8019
+ nm = m["name"]; connector = (nm.split(":")[0] if ":" in nm else nm); break
8020
+ blocked_reason = _scan_turn_call(convo_id, scan_msgs, connector)
8021
+
8022
+ # Capture the REAL content for audit regardless of the block decision.
7857
8023
  def send(_d=data):
7858
8024
  try:
7859
8025
  _post_json(CAPTURE_URL, json.dumps(_d).encode(), 5)
@@ -7861,6 +8027,20 @@ class SynkroTap:
7861
8027
  print("[synkro] capture error: " + str(e))
7862
8028
  threading.Thread(target=send, daemon=True).start()
7863
8029
 
8030
+ # Block the rendered view: replace this turn's assistant message content in
8031
+ # the tree (everything after the last human turn) with a Synkro notice, so
8032
+ # the restricted content + any model output derived from it is never shown.
8033
+ if blocked_reason is not None:
8034
+ notice = "[Blocked by Synkro] " + blocked_reason
8035
+ for i in range(last_human + 1, len(all_msgs)):
8036
+ if all_msgs[i].get("sender") != "human":
8037
+ all_msgs[i]["content"] = [{"type": "text", "text": notice}]
8038
+ try:
8039
+ flow.response.text = json.dumps(convo)
8040
+ print("[synkro] BLOCKED turn view (reason=" + blocked_reason[:80] + ")")
8041
+ except Exception as e:
8042
+ print("[synkro] block scrub failed (fail-open): " + str(e))
8043
+
7864
8044
  addons = [SynkroTap()]
7865
8045
  `;
7866
8046
  MCP_PROXY_PY = `#!/usr/bin/env python3
@@ -9515,7 +9695,7 @@ function writeConfigEnv(opts) {
9515
9695
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
9516
9696
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
9517
9697
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
9518
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.56")}`
9698
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.57")}`
9519
9699
  ];
9520
9700
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
9521
9701
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -9814,7 +9994,8 @@ async function installCommand(opts = {}) {
9814
9994
  sessionStartScriptPath: scripts.sessionStartScript,
9815
9995
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
9816
9996
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
9817
- installScanScriptPath: scripts.installScanScript
9997
+ installScanScriptPath: scripts.installScanScript,
9998
+ mcpGateScriptPath: scripts.mcpGateScript
9818
9999
  });
9819
10000
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
9820
10001
  }
@@ -10298,7 +10479,8 @@ function reconcileHarness() {
10298
10479
  sessionStartScriptPath: scripts.sessionStartScript,
10299
10480
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
10300
10481
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
10301
- installScanScriptPath: scripts.installScanScript
10482
+ installScanScriptPath: scripts.installScanScript,
10483
+ mcpGateScriptPath: scripts.mcpGateScript
10302
10484
  });
10303
10485
  console.log(" \u2713 Cursor hooks registered");
10304
10486
  try {
@@ -12771,7 +12953,7 @@ var args = process.argv.slice(2);
12771
12953
  var cmd = args[0] || "";
12772
12954
  var subArgs = args.slice(1);
12773
12955
  function printVersion() {
12774
- console.log("1.6.56");
12956
+ console.log("1.6.57");
12775
12957
  }
12776
12958
  function printHelp2() {
12777
12959
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents