@synkro-sh/cli 1.6.55 → 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 +423 -75
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -193,6 +193,19 @@ function installCCHooks(settingsPath, config) {
|
|
|
193
193
|
],
|
|
194
194
|
[SYNKRO_MARKER]: true
|
|
195
195
|
});
|
|
196
|
+
if (config.mcpGateScriptPath) {
|
|
197
|
+
settings.hooks.PreToolUse.push({
|
|
198
|
+
matcher: "mcp__.*",
|
|
199
|
+
hooks: [
|
|
200
|
+
{
|
|
201
|
+
type: "command",
|
|
202
|
+
command: config.mcpGateScriptPath,
|
|
203
|
+
timeout: 30
|
|
204
|
+
}
|
|
205
|
+
],
|
|
206
|
+
[SYNKRO_MARKER]: true
|
|
207
|
+
});
|
|
208
|
+
}
|
|
196
209
|
if (config.taskActivateIntentScriptPath) {
|
|
197
210
|
settings.hooks.PreToolUse.push({
|
|
198
211
|
matcher: "mcp__synkro-guardrails__activate_standard",
|
|
@@ -427,6 +440,7 @@ function installCursorHooks(hooksJsonPath, config) {
|
|
|
427
440
|
timeout: 10,
|
|
428
441
|
matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command|delete_file"
|
|
429
442
|
});
|
|
443
|
+
pushCcHook(h, "beforeMCPExecution", config.mcpGateScriptPath, { timeout: 30 });
|
|
430
444
|
h.afterAgentThought = h.afterAgentThought ?? [];
|
|
431
445
|
h.afterAgentThought.push({
|
|
432
446
|
command: bunRunCmd(config.agentCaptureScriptPath),
|
|
@@ -484,6 +498,7 @@ var init_cursorHookConfig = __esm({
|
|
|
484
498
|
"preToolUse",
|
|
485
499
|
"afterFileEdit",
|
|
486
500
|
"postToolUse",
|
|
501
|
+
"beforeMCPExecution",
|
|
487
502
|
"afterAgentThought",
|
|
488
503
|
"afterAgentResponse"
|
|
489
504
|
];
|
|
@@ -820,7 +835,7 @@ synkro_post_with_retry() {
|
|
|
820
835
|
function stubHook(surface, optsLiteral) {
|
|
821
836
|
return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
|
|
822
837
|
}
|
|
823
|
-
var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
|
|
838
|
+
var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_MCP_GATE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
|
|
824
839
|
var init_hookScriptsTs = __esm({
|
|
825
840
|
"cli/installer/hookScriptsTs.ts"() {
|
|
826
841
|
"use strict";
|
|
@@ -6432,6 +6447,7 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
6432
6447
|
STUB_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true }");
|
|
6433
6448
|
STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
|
|
6434
6449
|
STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
|
|
6450
|
+
STUB_MCP_GATE_TS = stubHook("mcp-gate", "{ needsTranscript: true }");
|
|
6435
6451
|
STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
|
|
6436
6452
|
STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true }");
|
|
6437
6453
|
STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
|
|
@@ -7364,9 +7380,9 @@ __export(claudeDesktopTap_exports, {
|
|
|
7364
7380
|
runClaudeDesktopTap: () => runClaudeDesktopTap
|
|
7365
7381
|
});
|
|
7366
7382
|
import { spawn } from "child_process";
|
|
7367
|
-
import { writeFileSync as writeFileSync6, mkdtempSync, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
|
|
7383
|
+
import { writeFileSync as writeFileSync6, mkdtempSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, existsSync as existsSync7 } from "fs";
|
|
7368
7384
|
import { join as join5 } from "path";
|
|
7369
|
-
import {
|
|
7385
|
+
import { homedir as homedir5 } from "os";
|
|
7370
7386
|
function claudeDesktopInstalled() {
|
|
7371
7387
|
return process.platform === "darwin" && existsSync7("/Applications/Claude.app/Contents/MacOS/Claude");
|
|
7372
7388
|
}
|
|
@@ -7451,7 +7467,7 @@ fi
|
|
|
7451
7467
|
echo "Starting proxy on :$PROXY_PORT..."
|
|
7452
7468
|
# stdin from /dev/null so mitmdump can't grab the TTY in raw mode \u2014 otherwise it
|
|
7453
7469
|
# captures keypresses (incl. Ctrl+C) instead of letting them become a signal.
|
|
7454
|
-
mitmdump --set confdir="$MITM_CA_DIR" --listen-port $PROXY_PORT --mode regular -s "$TAP_SCRIPT" </dev/null
|
|
7470
|
+
mitmdump --set confdir="$MITM_CA_DIR" --listen-port $PROXY_PORT --mode regular -s "$TAP_SCRIPT" </dev/null &>"$(dirname "$TAP_SCRIPT")/mitm.log" &
|
|
7455
7471
|
MITM_PID=$!
|
|
7456
7472
|
sleep 2
|
|
7457
7473
|
|
|
@@ -7489,7 +7505,9 @@ async function runClaudeDesktopTap() {
|
|
|
7489
7505
|
console.log(" Could not read local auth token (~/.synkro/.mcp-jwt). Run `synkro install` first.");
|
|
7490
7506
|
return;
|
|
7491
7507
|
}
|
|
7492
|
-
const
|
|
7508
|
+
const cdRoot = join5(homedir5(), ".synkro", "cd-sessions");
|
|
7509
|
+
mkdirSync6(cdRoot, { recursive: true });
|
|
7510
|
+
const sessionDir = mkdtempSync(join5(cdRoot, "synkro-cd-"));
|
|
7493
7511
|
writeFileSync6(join5(sessionDir, "tap.py"), ADDON_PY, "utf-8");
|
|
7494
7512
|
writeFileSync6(join5(sessionDir, "mcp_proxy.py"), MCP_PROXY_PY, { mode: 493 });
|
|
7495
7513
|
writeFileSync6(join5(sessionDir, "mcp_patch.py"), MCP_PATCH_PY, "utf-8");
|
|
@@ -7498,7 +7516,7 @@ async function runClaudeDesktopTap() {
|
|
|
7498
7516
|
await new Promise((resolve4) => {
|
|
7499
7517
|
const child = spawn("bash", [runnerPath], {
|
|
7500
7518
|
stdio: "inherit",
|
|
7501
|
-
env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_TAP_TOKEN: token }
|
|
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 }
|
|
7502
7520
|
});
|
|
7503
7521
|
const forward = (sig) => {
|
|
7504
7522
|
try {
|
|
@@ -7515,17 +7533,55 @@ async function runClaudeDesktopTap() {
|
|
|
7515
7533
|
});
|
|
7516
7534
|
});
|
|
7517
7535
|
}
|
|
7518
|
-
var CAPTURE_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;
|
|
7519
7537
|
var init_claudeDesktopTap = __esm({
|
|
7520
7538
|
"cli/installer/claudeDesktopTap.ts"() {
|
|
7521
7539
|
"use strict";
|
|
7522
7540
|
CAPTURE_URL = "http://127.0.0.1:18931/api/local/capture";
|
|
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";
|
|
7523
7544
|
JWT_PATH = join5(homedir5(), ".synkro", ".mcp-jwt");
|
|
7524
|
-
ADDON_PY = `import os, json, threading, urllib.request, re
|
|
7545
|
+
ADDON_PY = `import os, json, threading, urllib.request, urllib.error, re, base64
|
|
7525
7546
|
from mitmproxy import http
|
|
7526
7547
|
|
|
7527
7548
|
CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
|
|
7528
7549
|
TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
|
|
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", "")
|
|
7553
|
+
# Token file path \u2014 the SERVER_TOKEN rotates whenever the container is recreated,
|
|
7554
|
+
# so we re-read it from disk on a 401 instead of needing a tap restart.
|
|
7555
|
+
TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "")
|
|
7556
|
+
_token_lock = threading.Lock()
|
|
7557
|
+
|
|
7558
|
+
def _reload_token():
|
|
7559
|
+
global TOKEN
|
|
7560
|
+
if not TOKEN_FILE: return
|
|
7561
|
+
try:
|
|
7562
|
+
with open(TOKEN_FILE) as f:
|
|
7563
|
+
t = f.read().strip()
|
|
7564
|
+
if t:
|
|
7565
|
+
with _token_lock: TOKEN = t
|
|
7566
|
+
print("[synkro] auth token reloaded after 401 (container likely recreated)")
|
|
7567
|
+
except Exception:
|
|
7568
|
+
pass
|
|
7569
|
+
|
|
7570
|
+
def _post_json(url, payload_bytes, timeout):
|
|
7571
|
+
# POST JSON with auto-reauth: on a 401 (token rotated by a container recreate),
|
|
7572
|
+
# re-read the token file and retry ONCE. CWE-362: TOKEN is always snapshotted
|
|
7573
|
+
# under the lock before use, never read bare.
|
|
7574
|
+
for attempt in range(2):
|
|
7575
|
+
with _token_lock: tok = TOKEN
|
|
7576
|
+
try:
|
|
7577
|
+
req = urllib.request.Request(url, data=payload_bytes, headers={"Content-Type": "application/json", "Authorization": "Bearer " + tok})
|
|
7578
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
7579
|
+
return resp.read()
|
|
7580
|
+
except urllib.error.HTTPError as e:
|
|
7581
|
+
if e.code == 401 and attempt == 0:
|
|
7582
|
+
_reload_token(); continue
|
|
7583
|
+
raise
|
|
7584
|
+
return None
|
|
7529
7585
|
|
|
7530
7586
|
# Governance signals parsed from the completion SSE (usage window + web-search
|
|
7531
7587
|
# sources), keyed by conversation id. The completion POST always precedes the
|
|
@@ -7538,7 +7594,206 @@ _sse_lock = threading.Lock()
|
|
|
7538
7594
|
_mcp_registry = {}
|
|
7539
7595
|
_mcp_lock = threading.Lock()
|
|
7540
7596
|
|
|
7597
|
+
def _fmt_tool_name(raw):
|
|
7598
|
+
# Claude Desktop names connector tool_use blocks as "<ServerName><tool>" with
|
|
7599
|
+
# no delimiter (e.g. "Linearlist_issues"). Split it back to "Server:tool" using
|
|
7600
|
+
# the captured MCP registry so the dashboard can resolve the server favicon +
|
|
7601
|
+
# render a clean label. Exact server+tool match only \u2014 never a fuzzy guess.
|
|
7602
|
+
if not raw or ":" in raw: return raw
|
|
7603
|
+
try:
|
|
7604
|
+
with _mcp_lock:
|
|
7605
|
+
servers = list(_mcp_registry.values())
|
|
7606
|
+
for s in servers:
|
|
7607
|
+
sname = s.get("name", "")
|
|
7608
|
+
if not sname or not raw.startswith(sname): continue
|
|
7609
|
+
rest = raw[len(sname):]
|
|
7610
|
+
for t in s.get("tools", []):
|
|
7611
|
+
tn = t.get("name", "")
|
|
7612
|
+
if tn and rest == tn:
|
|
7613
|
+
return sname + ":" + tn
|
|
7614
|
+
except Exception:
|
|
7615
|
+
pass
|
|
7616
|
+
return raw
|
|
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
|
+
|
|
7541
7734
|
class SynkroTap:
|
|
7735
|
+
def request(self, flow):
|
|
7736
|
+
# \u2500\u2500 Upload DLP gate \u2500\u2500 Scan file uploads BEFORE they reach claude.ai and
|
|
7737
|
+
# refuse a blocked one (the file never enters the model). SYNCHRONOUS: this
|
|
7738
|
+
# upload waits for the verdict. Fail-OPEN \u2014 a scan error never breaks an
|
|
7739
|
+
# upload. CWE-200: never print the filename/content (size/rule only).
|
|
7740
|
+
r = flow.request
|
|
7741
|
+
if not SCAN_URL or not TOKEN: return
|
|
7742
|
+
if "claude.ai" not in (r.pretty_host or ""): return
|
|
7743
|
+
if r.method != "POST": return
|
|
7744
|
+
ct = r.headers.get("content-type", "")
|
|
7745
|
+
path = r.path or ""
|
|
7746
|
+
is_upload = ("multipart/form-data" in ct) or bool(re.search(r"/(files|upload|convert)(/|$|\\?)", path))
|
|
7747
|
+
if not is_upload: return
|
|
7748
|
+
# CWE-400: bound everything. Oversized uploads scan on name/type only \u2014 we
|
|
7749
|
+
# never pull a multi-MB body into memory or forward it to the scanner.
|
|
7750
|
+
MAX_RAW = 8 * 1024 * 1024
|
|
7751
|
+
try: clen = int(r.headers.get("content-length", "0") or "0")
|
|
7752
|
+
except Exception: clen = 0
|
|
7753
|
+
if clen and clen > MAX_RAW:
|
|
7754
|
+
payload = {"file_name": "", "size": clen, "content_b64": ""}
|
|
7755
|
+
else:
|
|
7756
|
+
try: body = r.get_content() or b""
|
|
7757
|
+
except Exception: return
|
|
7758
|
+
if not body or len(body) > MAX_RAW: return
|
|
7759
|
+
fname = ""
|
|
7760
|
+
fm = re.search(rb'filename="([^"]*)"', body)
|
|
7761
|
+
if fm:
|
|
7762
|
+
try: fname = fm.group(1).decode("utf-8", "replace")[:300]
|
|
7763
|
+
except Exception: fname = ""
|
|
7764
|
+
# Extract the uploaded file's RAW bytes from the multipart body so the
|
|
7765
|
+
# grader Reads the real file (a PDF/image must be intact, not the
|
|
7766
|
+
# multipart envelope). Fall back to the whole body if no part is found.
|
|
7767
|
+
file_bytes = None
|
|
7768
|
+
bm = re.search(r"boundary=([^;]+)", ct)
|
|
7769
|
+
if bm and fm:
|
|
7770
|
+
boundary = ("--" + bm.group(1).strip().strip('"')).encode()
|
|
7771
|
+
hdr_end = body.find(b"\\r\\n\\r\\n", fm.start())
|
|
7772
|
+
if hdr_end != -1:
|
|
7773
|
+
s = hdr_end + 4
|
|
7774
|
+
e = body.find(b"\\r\\n" + boundary, s)
|
|
7775
|
+
if e != -1: file_bytes = body[s:e]
|
|
7776
|
+
if file_bytes is None: file_bytes = body
|
|
7777
|
+
try: b64 = base64.b64encode(file_bytes).decode("ascii")
|
|
7778
|
+
except Exception: return
|
|
7779
|
+
payload = {"file_name": fname, "size": len(file_bytes), "content_b64": b64}
|
|
7780
|
+
try:
|
|
7781
|
+
respb = _post_json(SCAN_URL, json.dumps(payload).encode(), 55)
|
|
7782
|
+
verdict = json.loads((respb or b"{}").decode("utf-8", "replace"))
|
|
7783
|
+
except Exception as e:
|
|
7784
|
+
print("[synkro] upload scan error (fail-open): " + str(e))
|
|
7785
|
+
return
|
|
7786
|
+
if verdict.get("block"):
|
|
7787
|
+
reason = str(verdict.get("reason", "Blocked by Synkro policy"))[:300]
|
|
7788
|
+
flow.response = http.Response.make(
|
|
7789
|
+
403,
|
|
7790
|
+
json.dumps({"error": {"type": "blocked_by_synkro", "message": "Blocked by Synkro: " + reason}}).encode(),
|
|
7791
|
+
{"Content-Type": "application/json"},
|
|
7792
|
+
)
|
|
7793
|
+
print("[synkro] BLOCKED upload (size=" + str(payload.get("size", 0)) + " rule=" + str(verdict.get("ruleId", "")) + ")")
|
|
7794
|
+
else:
|
|
7795
|
+
print("[synkro] upload scanned -> allow (size=" + str(payload.get("size", 0)) + ")")
|
|
7796
|
+
|
|
7542
7797
|
def responseheaders(self, flow):
|
|
7543
7798
|
# Buffer the small conversation-tree GET (parsed for the dashboard).
|
|
7544
7799
|
# For the completion POST, TAP the SSE chunk-by-chunk and pass each
|
|
@@ -7552,34 +7807,19 @@ class SynkroTap:
|
|
|
7552
7807
|
if r.method == "POST" and "completion" in r.path:
|
|
7553
7808
|
cm = re.search(r"chat_conversations/([0-9a-fA-F-]+)", r.path)
|
|
7554
7809
|
cid = cm.group(1) if cm else ""
|
|
7555
|
-
|
|
7556
|
-
|
|
7557
|
-
#
|
|
7558
|
-
#
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
if d and d not in _st["seen"] and len(_st["sources"]) < 50:
|
|
7569
|
-
_st["seen"].add(d)
|
|
7570
|
-
_st["sources"].append({"domain": d, "name": sm.group(2)})
|
|
7571
|
-
# Citations: the specific pages the model cited (title + url +
|
|
7572
|
-
# domain) \u2014 the real "tool result" for web search.
|
|
7573
|
-
if "citation_start_delta" in line and line.startswith("data: ") and len(_st["citations"]) < 50:
|
|
7574
|
-
try:
|
|
7575
|
-
cit = json.loads(line[6:]).get("delta", {}).get("citation", {})
|
|
7576
|
-
url = cit.get("url", "")
|
|
7577
|
-
if url and url not in _st["cseen"]:
|
|
7578
|
-
_st["cseen"].add(url)
|
|
7579
|
-
md = cit.get("metadata") or {}
|
|
7580
|
-
_st["citations"].append({"title": cit.get("title", ""), "url": url, "domain": md.get("site_domain", "")})
|
|
7581
|
-
except Exception:
|
|
7582
|
-
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()
|
|
7583
7823
|
def _tap(data, _st=st, _cid=cid):
|
|
7584
7824
|
if data:
|
|
7585
7825
|
try:
|
|
@@ -7589,9 +7829,9 @@ class SynkroTap:
|
|
|
7589
7829
|
lines = txt.split("\\n")
|
|
7590
7830
|
_st["carry"] = lines[-1][-200000:] # bound the carried partial line (CWE-400)
|
|
7591
7831
|
for ln in lines[:-1]:
|
|
7592
|
-
|
|
7832
|
+
_scan_sse_line(ln, _st)
|
|
7593
7833
|
else: # end of stream \u2014 flush carry + publish
|
|
7594
|
-
|
|
7834
|
+
_scan_sse_line(_st["carry"], _st)
|
|
7595
7835
|
if (_st["usage"] or _st["sources"] or _st["citations"]) and _cid:
|
|
7596
7836
|
with _sse_lock:
|
|
7597
7837
|
_sse_meta[_cid] = {"usage": _st["usage"], "sources": _st["sources"], "citations": _st["citations"]}
|
|
@@ -7605,6 +7845,36 @@ class SynkroTap:
|
|
|
7605
7845
|
def response(self, flow):
|
|
7606
7846
|
r = flow.request
|
|
7607
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
|
|
7608
7878
|
# MCP server registry from the bootstrap SSE stream.
|
|
7609
7879
|
if "mcp" in r.path and "bootstrap" in r.path and flow.response:
|
|
7610
7880
|
try:
|
|
@@ -7668,7 +7938,7 @@ class SynkroTap:
|
|
|
7668
7938
|
elif bt == "thinking":
|
|
7669
7939
|
thinking += b.get("thinking", "") or b.get("text", "")
|
|
7670
7940
|
elif bt == "tool_use" or bt == "server_tool_use":
|
|
7671
|
-
tool_calls.append({"name": b.get("name", ""), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
|
|
7941
|
+
tool_calls.append({"name": _fmt_tool_name(b.get("name", "")), "id": b.get("id", ""), "arguments": json.dumps(b.get("input", {}))})
|
|
7672
7942
|
elif bt.endswith("_tool_result") or bt == "tool_result":
|
|
7673
7943
|
rc = b.get("content", "")
|
|
7674
7944
|
if isinstance(rc, list):
|
|
@@ -7690,7 +7960,7 @@ class SynkroTap:
|
|
|
7690
7960
|
# web_search results are server-side (content:[]); we surface the
|
|
7691
7961
|
# sources via sse_meta instead. Only record a non-empty result.
|
|
7692
7962
|
if rc and str(rc).strip():
|
|
7693
|
-
tool_results.append({"name": b.get("name", ""), "content": str(rc)})
|
|
7963
|
+
tool_results.append({"name": _fmt_tool_name(b.get("name", "")), "content": str(rc)})
|
|
7694
7964
|
elif isinstance(blocks, str):
|
|
7695
7965
|
text_parts.append(blocks)
|
|
7696
7966
|
text = " ".join(p for p in text_parts if p).strip()
|
|
@@ -7721,23 +7991,92 @@ class SynkroTap:
|
|
|
7721
7991
|
ntools += len(mm.get("tool_calls") or [])
|
|
7722
7992
|
nsrc = len((sm or {}).get("sources") or [])
|
|
7723
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.
|
|
7724
8023
|
def send(_d=data):
|
|
7725
8024
|
try:
|
|
7726
|
-
|
|
7727
|
-
urllib.request.urlopen(req, timeout=5)
|
|
8025
|
+
_post_json(CAPTURE_URL, json.dumps(_d).encode(), 5)
|
|
7728
8026
|
except Exception as e:
|
|
7729
8027
|
print("[synkro] capture error: " + str(e))
|
|
7730
8028
|
threading.Thread(target=send, daemon=True).start()
|
|
7731
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
|
+
|
|
7732
8044
|
addons = [SynkroTap()]
|
|
7733
8045
|
`;
|
|
7734
8046
|
MCP_PROXY_PY = `#!/usr/bin/env python3
|
|
7735
8047
|
"""Synkro MCP Proxy (local) \u2014 stdio passthrough that taps MCP tool calls."""
|
|
7736
|
-
import sys, os, json, subprocess, threading, urllib.request, time
|
|
8048
|
+
import sys, os, json, subprocess, threading, urllib.request, urllib.error, time
|
|
7737
8049
|
|
|
7738
8050
|
CAPTURE_URL = os.environ.get("SYNKRO_CAPTURE_URL", "")
|
|
7739
8051
|
TOKEN = os.environ.get("SYNKRO_TAP_TOKEN", "")
|
|
7740
8052
|
SERVER_NAME = os.environ.get("SYNKRO_MCP_SERVER", "unknown")
|
|
8053
|
+
# The SERVER_TOKEN rotates on container recreate; re-read it from disk on a 401.
|
|
8054
|
+
TOKEN_FILE = os.environ.get("SYNKRO_TAP_TOKEN_FILE", "") or os.path.expanduser("~/.synkro/.mcp-jwt")
|
|
8055
|
+
_token_lock = threading.Lock()
|
|
8056
|
+
|
|
8057
|
+
def _reload_token():
|
|
8058
|
+
global TOKEN
|
|
8059
|
+
try:
|
|
8060
|
+
with open(TOKEN_FILE) as f:
|
|
8061
|
+
t = f.read().strip()
|
|
8062
|
+
if t:
|
|
8063
|
+
with _token_lock: TOKEN = t
|
|
8064
|
+
except Exception:
|
|
8065
|
+
pass
|
|
8066
|
+
|
|
8067
|
+
def _post_json(url, payload_bytes, timeout):
|
|
8068
|
+
# CWE-362: TOKEN snapshotted under the lock. Auto-reauth retry once on 401.
|
|
8069
|
+
for attempt in range(2):
|
|
8070
|
+
with _token_lock: tok = TOKEN
|
|
8071
|
+
try:
|
|
8072
|
+
req = urllib.request.Request(url, data=payload_bytes, headers={"Content-Type": "application/json", "Authorization": "Bearer " + tok})
|
|
8073
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
8074
|
+
return resp.read()
|
|
8075
|
+
except urllib.error.HTTPError as e:
|
|
8076
|
+
if e.code == 401 and attempt == 0:
|
|
8077
|
+
_reload_token(); continue
|
|
8078
|
+
raise
|
|
8079
|
+
return None
|
|
7741
8080
|
|
|
7742
8081
|
pending = {}
|
|
7743
8082
|
pending_lock = threading.Lock()
|
|
@@ -7755,8 +8094,7 @@ def send_capture(tool_name, tool_args, tool_result, latency_ms):
|
|
|
7755
8094
|
},
|
|
7756
8095
|
"mcp": {"server": SERVER_NAME, "tool": tool_name, "latency_ms": latency_ms}
|
|
7757
8096
|
}).encode()
|
|
7758
|
-
|
|
7759
|
-
urllib.request.urlopen(req, timeout=5)
|
|
8097
|
+
_post_json(CAPTURE_URL, payload, 5)
|
|
7760
8098
|
except Exception as e:
|
|
7761
8099
|
print("[synkro-mcp] capture error: " + str(e), file=sys.stderr)
|
|
7762
8100
|
|
|
@@ -7883,7 +8221,7 @@ __export(macKeychain_exports, {
|
|
|
7883
8221
|
writeCursorApiKey: () => writeCursorApiKey,
|
|
7884
8222
|
writeRefreshAgent: () => writeRefreshAgent
|
|
7885
8223
|
});
|
|
7886
|
-
import { existsSync as existsSync8, mkdirSync as
|
|
8224
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
|
|
7887
8225
|
import { homedir as homedir6, platform as platform2 } from "os";
|
|
7888
8226
|
import { join as join6 } from "path";
|
|
7889
8227
|
import { spawnSync } from "child_process";
|
|
@@ -7903,7 +8241,7 @@ function readKeychainCreds() {
|
|
|
7903
8241
|
function exportKeychainCreds() {
|
|
7904
8242
|
const blob = readKeychainCreds();
|
|
7905
8243
|
if (!blob) return null;
|
|
7906
|
-
|
|
8244
|
+
mkdirSync7(CLAUDE_CREDS_DIR, { recursive: true });
|
|
7907
8245
|
chmodSync(CLAUDE_CREDS_DIR, 448);
|
|
7908
8246
|
writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
|
|
7909
8247
|
chmodSync(CLAUDE_CREDS_FILE, 384);
|
|
@@ -7919,7 +8257,7 @@ function cursorApiKeyConfigured() {
|
|
|
7919
8257
|
function writeCursorApiKey(key) {
|
|
7920
8258
|
const trimmed = key.trim();
|
|
7921
8259
|
if (!trimmed) return;
|
|
7922
|
-
|
|
8260
|
+
mkdirSync7(CURSOR_CREDS_DIR, { recursive: true });
|
|
7923
8261
|
chmodSync(CURSOR_CREDS_DIR, 448);
|
|
7924
8262
|
writeFileSync7(CURSOR_API_KEY_FILE, trimmed, "utf-8");
|
|
7925
8263
|
chmodSync(CURSOR_API_KEY_FILE, 384);
|
|
@@ -7958,7 +8296,7 @@ function writeRefreshAgent(synkroBinPath) {
|
|
|
7958
8296
|
if (platform2() !== "darwin") {
|
|
7959
8297
|
throw new KeychainExportError("writeRefreshAgent is darwin-only");
|
|
7960
8298
|
}
|
|
7961
|
-
|
|
8299
|
+
mkdirSync7(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
|
|
7962
8300
|
const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
|
|
7963
8301
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
7964
8302
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -8074,7 +8412,7 @@ __export(dockerInstall_exports, {
|
|
|
8074
8412
|
waitForContainerReady: () => waitForContainerReady,
|
|
8075
8413
|
waitForWorkersReady: () => waitForWorkersReady
|
|
8076
8414
|
});
|
|
8077
|
-
import { copyFileSync, existsSync as existsSync9, mkdirSync as
|
|
8415
|
+
import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
|
|
8078
8416
|
import { homedir as homedir7 } from "os";
|
|
8079
8417
|
import { join as join7 } from "path";
|
|
8080
8418
|
import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
|
|
@@ -8301,9 +8639,9 @@ async function dockerInstall(opts = {}) {
|
|
|
8301
8639
|
const claudeWorkers = opts.claudeWorkers ?? opts.workersPerPool ?? 8;
|
|
8302
8640
|
const cursorWorkers = opts.cursorWorkers ?? 0;
|
|
8303
8641
|
const totalWorkers = claudeWorkers + cursorWorkers;
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8642
|
+
mkdirSync8(PGDATA_PATH, { recursive: true });
|
|
8643
|
+
mkdirSync8(BACKUP_DIR, { recursive: true });
|
|
8644
|
+
mkdirSync8(CLAUDE_HOST_STATE_DIR, { recursive: true });
|
|
8307
8645
|
const hostClaudeJson = join7(homedir7(), ".claude.json");
|
|
8308
8646
|
if (existsSync9(hostClaudeJson)) {
|
|
8309
8647
|
copyFileSync(hostClaudeJson, CLAUDE_HOST_STATE_FILE);
|
|
@@ -8313,7 +8651,7 @@ async function dockerInstall(opts = {}) {
|
|
|
8313
8651
|
`MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
|
|
8314
8652
|
);
|
|
8315
8653
|
}
|
|
8316
|
-
|
|
8654
|
+
mkdirSync8(CURSOR_CREDS_DIR, { recursive: true });
|
|
8317
8655
|
if (needsKeychainBridge()) {
|
|
8318
8656
|
if (claudeWorkers > 0) {
|
|
8319
8657
|
const path = exportKeychainCreds();
|
|
@@ -8336,7 +8674,7 @@ async function dockerInstall(opts = {}) {
|
|
|
8336
8674
|
console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
|
|
8337
8675
|
}
|
|
8338
8676
|
} else {
|
|
8339
|
-
|
|
8677
|
+
mkdirSync8(join7(homedir7(), ".claude"), { recursive: true });
|
|
8340
8678
|
}
|
|
8341
8679
|
const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
|
|
8342
8680
|
const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
|
|
@@ -8578,7 +8916,7 @@ async function dockerSafeStart() {
|
|
|
8578
8916
|
}
|
|
8579
8917
|
} else {
|
|
8580
8918
|
console.log(" pgdata: no existing data \u2014 fresh start.");
|
|
8581
|
-
|
|
8919
|
+
mkdirSync8(PGDATA_PATH, { recursive: true });
|
|
8582
8920
|
}
|
|
8583
8921
|
console.log(" Starting container...");
|
|
8584
8922
|
const start = spawnSync2("docker", ["start", CONTAINER_NAME], {
|
|
@@ -9022,7 +9360,7 @@ __export(install_exports, {
|
|
|
9022
9360
|
syncSkillFiles: () => syncSkillFiles,
|
|
9023
9361
|
writeHookScripts: () => writeHookScripts
|
|
9024
9362
|
});
|
|
9025
|
-
import { existsSync as existsSync11, mkdirSync as
|
|
9363
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, chmodSync as chmodSync2, readFileSync as readFileSync9, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
|
|
9026
9364
|
import { homedir as homedir9 } from "os";
|
|
9027
9365
|
import { join as join9 } from "path";
|
|
9028
9366
|
import { execSync as execSync6 } from "child_process";
|
|
@@ -9180,11 +9518,11 @@ coding patterns and the dashboard shows full history. (Y/n) `
|
|
|
9180
9518
|
}
|
|
9181
9519
|
}
|
|
9182
9520
|
function ensureSynkroDir() {
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9521
|
+
mkdirSync9(SYNKRO_DIR4, { recursive: true });
|
|
9522
|
+
mkdirSync9(HOOKS_DIR, { recursive: true });
|
|
9523
|
+
mkdirSync9(BIN_DIR, { recursive: true });
|
|
9524
|
+
mkdirSync9(OFFSETS_DIR, { recursive: true });
|
|
9525
|
+
mkdirSync9(join9(SYNKRO_DIR4, "sessions"), { recursive: true });
|
|
9188
9526
|
}
|
|
9189
9527
|
function writeHookScripts(mode = "stub") {
|
|
9190
9528
|
const installExtractCorePath = join9(HOOKS_DIR, "installExtractCore.ts");
|
|
@@ -9207,6 +9545,7 @@ function writeHookScripts(mode = "stub") {
|
|
|
9207
9545
|
const cursorAgentCapturePath = join9(HOOKS_DIR, "cursor-agent-capture.ts");
|
|
9208
9546
|
const mcpStdioProxyPath = join9(HOOKS_DIR, "mcp-stdio-proxy.ts");
|
|
9209
9547
|
const taskActivateIntentScriptPath = join9(HOOKS_DIR, "cc-task-activate-intent.ts");
|
|
9548
|
+
const mcpGateScriptPath = join9(HOOKS_DIR, "cc-mcp-gate.ts");
|
|
9210
9549
|
if (mode === "stub") {
|
|
9211
9550
|
const stubCommonPath = join9(HOOKS_DIR, "_synkro-stub-common.ts");
|
|
9212
9551
|
const stubFiles = [
|
|
@@ -9224,6 +9563,7 @@ function writeHookScripts(mode = "stub") {
|
|
|
9224
9563
|
[userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
|
|
9225
9564
|
[installScanScriptPath, STUB_INSTALL_SCAN_TS],
|
|
9226
9565
|
[taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS],
|
|
9566
|
+
[mcpGateScriptPath, STUB_MCP_GATE_TS],
|
|
9227
9567
|
[cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
|
|
9228
9568
|
[cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
|
|
9229
9569
|
[cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
|
|
@@ -9256,7 +9596,8 @@ function writeHookScripts(mode = "stub") {
|
|
|
9256
9596
|
cursorBashJudgeScript: cursorBashJudgePath,
|
|
9257
9597
|
cursorEditCaptureScript: cursorEditCapturePath,
|
|
9258
9598
|
cursorAgentCaptureScript: cursorAgentCapturePath,
|
|
9259
|
-
taskActivateIntentScript: taskActivateIntentScriptPath
|
|
9599
|
+
taskActivateIntentScript: taskActivateIntentScriptPath,
|
|
9600
|
+
mcpGateScript: mcpGateScriptPath
|
|
9260
9601
|
};
|
|
9261
9602
|
}
|
|
9262
9603
|
writeFileSync8(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
@@ -9279,6 +9620,7 @@ function writeHookScripts(mode = "stub") {
|
|
|
9279
9620
|
writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
|
|
9280
9621
|
writeFileSync8(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
|
|
9281
9622
|
writeFileSync8(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
|
|
9623
|
+
writeFileSync8(mcpGateScriptPath, STUB_MCP_GATE_TS, "utf-8");
|
|
9282
9624
|
chmodSync2(bashScriptPath, 493);
|
|
9283
9625
|
chmodSync2(bashFollowupScriptPath, 493);
|
|
9284
9626
|
chmodSync2(editPrecheckScriptPath, 493);
|
|
@@ -9299,6 +9641,7 @@ function writeHookScripts(mode = "stub") {
|
|
|
9299
9641
|
chmodSync2(mcpStdioProxyPath, 493);
|
|
9300
9642
|
chmodSync2(installExtractCorePath, 493);
|
|
9301
9643
|
chmodSync2(taskActivateIntentScriptPath, 493);
|
|
9644
|
+
chmodSync2(mcpGateScriptPath, 493);
|
|
9302
9645
|
try {
|
|
9303
9646
|
unlinkSync4(join9(HOOKS_DIR, "_synkro-stub-common.ts"));
|
|
9304
9647
|
} catch {
|
|
@@ -9319,7 +9662,8 @@ function writeHookScripts(mode = "stub") {
|
|
|
9319
9662
|
cursorBashJudgeScript: cursorBashJudgePath,
|
|
9320
9663
|
cursorEditCaptureScript: cursorEditCapturePath,
|
|
9321
9664
|
cursorAgentCaptureScript: cursorAgentCapturePath,
|
|
9322
|
-
taskActivateIntentScript: taskActivateIntentScriptPath
|
|
9665
|
+
taskActivateIntentScript: taskActivateIntentScriptPath,
|
|
9666
|
+
mcpGateScript: mcpGateScriptPath
|
|
9323
9667
|
};
|
|
9324
9668
|
}
|
|
9325
9669
|
function sanitizeConfigValue(raw, maxLen = 256) {
|
|
@@ -9351,7 +9695,7 @@ function writeConfigEnv(opts) {
|
|
|
9351
9695
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
9352
9696
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
9353
9697
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
9354
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
9698
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.57")}`
|
|
9355
9699
|
];
|
|
9356
9700
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
9357
9701
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -9630,6 +9974,7 @@ async function installCommand(opts = {}) {
|
|
|
9630
9974
|
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
9631
9975
|
installScanScriptPath: scripts.installScanScript,
|
|
9632
9976
|
taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
|
|
9977
|
+
mcpGateScriptPath: scripts.mcpGateScript,
|
|
9633
9978
|
skipTranscriptSync: !transcriptConsent
|
|
9634
9979
|
});
|
|
9635
9980
|
console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
|
|
@@ -9649,7 +9994,8 @@ async function installCommand(opts = {}) {
|
|
|
9649
9994
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
9650
9995
|
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
9651
9996
|
transcriptSyncScriptPath: scripts.transcriptSyncScript,
|
|
9652
|
-
installScanScriptPath: scripts.installScanScript
|
|
9997
|
+
installScanScriptPath: scripts.installScanScript,
|
|
9998
|
+
mcpGateScriptPath: scripts.mcpGateScript
|
|
9653
9999
|
});
|
|
9654
10000
|
console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
|
|
9655
10001
|
}
|
|
@@ -10101,7 +10447,8 @@ function reconcileHarness() {
|
|
|
10101
10447
|
transcriptSyncScriptPath: scripts.transcriptSyncScript,
|
|
10102
10448
|
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
10103
10449
|
installScanScriptPath: scripts.installScanScript,
|
|
10104
|
-
taskActivateIntentScriptPath: scripts.taskActivateIntentScript
|
|
10450
|
+
taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
|
|
10451
|
+
mcpGateScriptPath: scripts.mcpGateScript
|
|
10105
10452
|
});
|
|
10106
10453
|
console.log(" \u2713 Claude Code hooks registered");
|
|
10107
10454
|
try {
|
|
@@ -10132,7 +10479,8 @@ function reconcileHarness() {
|
|
|
10132
10479
|
sessionStartScriptPath: scripts.sessionStartScript,
|
|
10133
10480
|
userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
|
|
10134
10481
|
transcriptSyncScriptPath: scripts.transcriptSyncScript,
|
|
10135
|
-
installScanScriptPath: scripts.installScanScript
|
|
10482
|
+
installScanScriptPath: scripts.installScanScript,
|
|
10483
|
+
mcpGateScriptPath: scripts.mcpGateScript
|
|
10136
10484
|
});
|
|
10137
10485
|
console.log(" \u2713 Cursor hooks registered");
|
|
10138
10486
|
try {
|
|
@@ -10645,14 +10993,14 @@ rl.on('line', async (line) => {
|
|
|
10645
10993
|
});
|
|
10646
10994
|
|
|
10647
10995
|
// cli/local-cc/install.ts
|
|
10648
|
-
import { existsSync as existsSync12, mkdirSync as
|
|
10996
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, readFileSync as readFileSync10, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
|
|
10649
10997
|
import { join as join10 } from "path";
|
|
10650
10998
|
import { homedir as homedir10 } from "os";
|
|
10651
10999
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
10652
11000
|
function writePluginFiles() {
|
|
10653
11001
|
for (const c of CHANNELS) {
|
|
10654
|
-
|
|
10655
|
-
|
|
11002
|
+
mkdirSync10(c.sessionDir, { recursive: true });
|
|
11003
|
+
mkdirSync10(c.pluginSettingsDir, { recursive: true });
|
|
10656
11004
|
writeFileSync9(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
|
|
10657
11005
|
writeFileSync9(
|
|
10658
11006
|
c.pluginSettingsPath,
|
|
@@ -11254,7 +11602,7 @@ var init_disconnect = __esm({
|
|
|
11254
11602
|
});
|
|
11255
11603
|
|
|
11256
11604
|
// cli/local-cc/turnLog.ts
|
|
11257
|
-
import { appendFileSync, existsSync as existsSync14, mkdirSync as
|
|
11605
|
+
import { appendFileSync, existsSync as existsSync14, mkdirSync as mkdirSync11, openSync as openSync2, readFileSync as readFileSync11, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
|
|
11258
11606
|
import { dirname as dirname6, join as join12 } from "path";
|
|
11259
11607
|
import { homedir as homedir12 } from "os";
|
|
11260
11608
|
function truncate(s, max = PREVIEW_MAX) {
|
|
@@ -11276,7 +11624,7 @@ function extractSeverity(result) {
|
|
|
11276
11624
|
}
|
|
11277
11625
|
function appendTurn(args2) {
|
|
11278
11626
|
try {
|
|
11279
|
-
|
|
11627
|
+
mkdirSync11(dirname6(TURN_LOG_PATH), { recursive: true });
|
|
11280
11628
|
const entry = {
|
|
11281
11629
|
ts: new Date(args2.startedAt).toISOString(),
|
|
11282
11630
|
role: args2.role,
|
|
@@ -11312,7 +11660,7 @@ function readRecentTurns(n = 20) {
|
|
|
11312
11660
|
}
|
|
11313
11661
|
function followTurns(onEntry) {
|
|
11314
11662
|
try {
|
|
11315
|
-
|
|
11663
|
+
mkdirSync11(dirname6(TURN_LOG_PATH), { recursive: true });
|
|
11316
11664
|
if (!existsSync14(TURN_LOG_PATH)) {
|
|
11317
11665
|
appendFileSync(TURN_LOG_PATH, "", "utf-8");
|
|
11318
11666
|
}
|
|
@@ -12605,7 +12953,7 @@ var args = process.argv.slice(2);
|
|
|
12605
12953
|
var cmd = args[0] || "";
|
|
12606
12954
|
var subArgs = args.slice(1);
|
|
12607
12955
|
function printVersion() {
|
|
12608
|
-
console.log("1.6.
|
|
12956
|
+
console.log("1.6.57");
|
|
12609
12957
|
}
|
|
12610
12958
|
function printHelp2() {
|
|
12611
12959
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|