@synkro-sh/cli 1.6.73 → 1.6.75
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 +243 -36
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -1272,7 +1272,7 @@ export function findGitRoot(cwd: string): string {
|
|
|
1272
1272
|
export interface SynkroFileConfig {
|
|
1273
1273
|
version: number;
|
|
1274
1274
|
harness: ('claude-code' | 'cursor')[];
|
|
1275
|
-
grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string };
|
|
1275
|
+
grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string; combinedEditGrade?: boolean };
|
|
1276
1276
|
workers: { claude?: number; cursor?: number };
|
|
1277
1277
|
ruleset: string;
|
|
1278
1278
|
skills: string[];
|
|
@@ -1379,6 +1379,7 @@ export function loadSynkroFile(cwd?: string): SynkroFileConfig {
|
|
|
1379
1379
|
grader: {
|
|
1380
1380
|
pool: normalizePoolToken(parsed.grader?.pool),
|
|
1381
1381
|
mode: ['local', 'byok'].includes(parsed.grader?.mode) ? parsed.grader.mode : undefined,
|
|
1382
|
+
combinedEditGrade: parsed.grader?.combined_edit_grade === true,
|
|
1382
1383
|
},
|
|
1383
1384
|
workers: {
|
|
1384
1385
|
// Counts may live under the grader block (unified form) or the legacy
|
|
@@ -1632,7 +1633,7 @@ export function tag(rt: string, config: HookConfig, grader?: string): string {
|
|
|
1632
1633
|
|
|
1633
1634
|
// \u2500\u2500\u2500 Local Grading (direct channel call) \u2500\u2500\u2500
|
|
1634
1635
|
|
|
1635
|
-
type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe';
|
|
1636
|
+
type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe' | 'grade-edit-cwe';
|
|
1636
1637
|
|
|
1637
1638
|
// Which coding agent fired this grade. The dispatcher routes grades to a
|
|
1638
1639
|
// worker pool of the matching kind so a Cursor grade is judged by Cursor and
|
|
@@ -1642,8 +1643,21 @@ export type AgentKind = 'claude_code' | 'cursor';
|
|
|
1642
1643
|
|
|
1643
1644
|
const ROLE_MAP: Record<string, GradeRole> = {
|
|
1644
1645
|
edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
|
|
1646
|
+
'edit-cwe': 'grade-edit-cwe',
|
|
1645
1647
|
};
|
|
1646
1648
|
|
|
1649
|
+
// Combined edit-time grade (org rules + CWE in one inference). OFF by default;
|
|
1650
|
+
// flipped on by env var or synkro.toml [grader] combined_edit_grade = true.
|
|
1651
|
+
// When on: the edit-precheck hook runs ONE grade-edit-cwe call and the
|
|
1652
|
+
// cwe-precheck hook no-ops (the combined call owns CWE) \u2014 halving inferences
|
|
1653
|
+
// per edit. See GRADER_PRIMER_EDIT_CWE.
|
|
1654
|
+
export function combinedEditGradeEnabled(synkroFile?: SynkroFileConfig): boolean {
|
|
1655
|
+
const env = process.env.SYNKRO_COMBINED_EDIT_GRADE;
|
|
1656
|
+
if (env === '1' || env === 'true') return true;
|
|
1657
|
+
if (env === '0' || env === 'false') return false;
|
|
1658
|
+
return synkroFile?.grader?.combinedEditGrade === true;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1647
1661
|
async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
|
|
1648
1662
|
const body = JSON.stringify({ role, payload: prompt, content: prompt, agent_kind: agentKind });
|
|
1649
1663
|
|
|
@@ -2188,6 +2202,67 @@ export function parseVerdict(resp: string): Verdict {
|
|
|
2188
2202
|
return verdict;
|
|
2189
2203
|
}
|
|
2190
2204
|
|
|
2205
|
+
export interface CombinedFinding {
|
|
2206
|
+
id: string;
|
|
2207
|
+
reason: string;
|
|
2208
|
+
suggestedFix: string;
|
|
2209
|
+
severity: string;
|
|
2210
|
+
ruleMode: string;
|
|
2211
|
+
codeSnippet: string;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// Splits a grade-edit-cwe verdict (one <synkro-verdict> whose <violation> blocks
|
|
2215
|
+
// MIX org-rule ids and cwe-### ids) into (a) a rules Verdict the existing rule
|
|
2216
|
+
// enforcement consumes verbatim and (b) a list of CWE findings for the CWE block
|
|
2217
|
+
// path. A block whose rule_id starts with "cwe-" is a CWE finding; anything else
|
|
2218
|
+
// is an org-rule violation (org rules take precedence). Org-rule verdict is ok
|
|
2219
|
+
// UNLESS a non-cwe violation is present \u2014 so a CWE-only failure still passes the
|
|
2220
|
+
// rule gate while blocking on CWE.
|
|
2221
|
+
export function parseCombinedVerdict(resp: string): { ruleVerdict: Verdict; cweFindings: CombinedFinding[] } {
|
|
2222
|
+
const base = parseVerdict(resp);
|
|
2223
|
+
const ruleVerdict: Verdict = { ok: true, reason: '', suggestedFix: '', ruleId: '', ruleMode: '', severity: 'low', category: 'clean' };
|
|
2224
|
+
const cweFindings: CombinedFinding[] = [];
|
|
2225
|
+
const blocks = resp.match(/<violation>[\\s\\S]*?<\\/violation>/g) || [];
|
|
2226
|
+
let firstRule: CombinedFinding | null = null;
|
|
2227
|
+
for (const b of blocks) {
|
|
2228
|
+
const idRaw = (b.match(/<rule_id>([^<]+)<\\/rule_id>/) || [])[1];
|
|
2229
|
+
if (!idRaw) continue;
|
|
2230
|
+
const id = idRaw.trim();
|
|
2231
|
+
const finding: CombinedFinding = {
|
|
2232
|
+
id,
|
|
2233
|
+
reason: ((b.match(/<reason>([\\s\\S]*?)<\\/reason>/) || [])[1] || '').trim(),
|
|
2234
|
+
suggestedFix: ((b.match(/<suggested_fix>([\\s\\S]*?)<\\/suggested_fix>/) || [])[1] || '').trim(),
|
|
2235
|
+
severity: ((b.match(/<severity>([^<]+)<\\/severity>/) || [])[1] || '').trim(),
|
|
2236
|
+
ruleMode: ((b.match(/<rule_mode>([^<]+)<\\/rule_mode>/) || [])[1] || '').trim(),
|
|
2237
|
+
codeSnippet: ((b.match(/<code_snippet>([\\s\\S]*?)<\\/code_snippet>/) || [])[1] || '').replace(/^\\n+|\\n+$/g, ''),
|
|
2238
|
+
};
|
|
2239
|
+
if (/^cwe-/i.test(id)) {
|
|
2240
|
+
finding.id = id.replace(/^cwe-/i, 'CWE-');
|
|
2241
|
+
if (!cweFindings.some(f => f.id === finding.id)) cweFindings.push(finding);
|
|
2242
|
+
} else if (!firstRule) {
|
|
2243
|
+
firstRule = finding;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
if (firstRule) {
|
|
2247
|
+
ruleVerdict.ok = false;
|
|
2248
|
+
ruleVerdict.ruleId = firstRule.id;
|
|
2249
|
+
ruleVerdict.reason = firstRule.reason;
|
|
2250
|
+
ruleVerdict.suggestedFix = firstRule.suggestedFix;
|
|
2251
|
+
ruleVerdict.ruleMode = firstRule.ruleMode;
|
|
2252
|
+
ruleVerdict.severity = firstRule.severity || 'high';
|
|
2253
|
+
ruleVerdict.category = base.category || 'uncategorized';
|
|
2254
|
+
} else if (base.ok === false && base.ruleId && !/^cwe-/i.test(base.ruleId)) {
|
|
2255
|
+
// No <violation> blocks but a top-level rule_id failure (defensive).
|
|
2256
|
+
ruleVerdict.ok = false;
|
|
2257
|
+
ruleVerdict.ruleId = base.ruleId;
|
|
2258
|
+
ruleVerdict.reason = base.reason;
|
|
2259
|
+
ruleVerdict.suggestedFix = base.suggestedFix;
|
|
2260
|
+
ruleVerdict.ruleMode = base.ruleMode;
|
|
2261
|
+
ruleVerdict.severity = base.severity || 'high';
|
|
2262
|
+
}
|
|
2263
|
+
return { ruleVerdict, cweFindings };
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2191
2266
|
// \u2500\u2500\u2500 Telemetry Dispatch \u2500\u2500\u2500
|
|
2192
2267
|
|
|
2193
2268
|
// Gated cloud telemetry POST \u2014 fires only when storage mode is 'cloud'. The
|
|
@@ -3636,7 +3711,7 @@ export function outputEmpty(): void {
|
|
|
3636
3711
|
EDIT_PRECHECK_TS = `#!/usr/bin/env bun
|
|
3637
3712
|
import {
|
|
3638
3713
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
3639
|
-
parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
|
|
3714
|
+
parseVerdict, parseCombinedVerdict, combinedEditGradeEnabled, dispatchCapture, dispatchFinding, ruleMode, reconstructContent, isPathUnder, postWithRetry,
|
|
3640
3715
|
readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
|
|
3641
3716
|
appendSessionAction, readSessionLog, compressSessionLog, log,
|
|
3642
3717
|
outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
|
|
@@ -3786,6 +3861,90 @@ async function main() {
|
|
|
3786
3861
|
'The rules shown were pre-selected as the ones relevant to this edit \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no hardcoded secrets in file. R005: in-repo path only." Cover every rule shown.',
|
|
3787
3862
|
].join('\\n');
|
|
3788
3863
|
|
|
3864
|
+
// \u2500\u2500\u2500 Combined org-rules + CWE in ONE inference (SYNKRO_COMBINED_EDIT_GRADE) \u2500\u2500\u2500
|
|
3865
|
+
// Self-contained early-return branch \u2014 the default two-grade path below is
|
|
3866
|
+
// left untouched. Appends the CWE rule set to the rules prompt, runs one
|
|
3867
|
+
// grade-edit-cwe call, then splits the verdict: org-rule violations drive
|
|
3868
|
+
// the same ask/fix enforcement; cwe-### violations drive a fix-mode block.
|
|
3869
|
+
// The cwe-precheck hook no-ops when this flag is on (see CWE_PRECHECK_TS).
|
|
3870
|
+
if (combinedEditGradeEnabled(synkroFile)) {
|
|
3871
|
+
const fileExt = (filePath.match(/\\.([^.\\/]+)$/) || ['', ''])[1].toLowerCase();
|
|
3872
|
+
let cweRules: any[] = [];
|
|
3873
|
+
try {
|
|
3874
|
+
const cr = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
|
|
3875
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
3876
|
+
signal: AbortSignal.timeout(4000),
|
|
3877
|
+
});
|
|
3878
|
+
if (cr.ok) cweRules = ((await cr.json() as any) || {}).rules || [];
|
|
3879
|
+
} catch { /* CWE rules optional \u2014 rule grading still runs in the combined pass */ }
|
|
3880
|
+
|
|
3881
|
+
const combinedPrompt = graderPrompt
|
|
3882
|
+
+ '\\n\\nCWE rules to ALSO check the proposed content against (emit cwe-### violations with a <code_snippet>): '
|
|
3883
|
+
+ JSON.stringify(cweRules);
|
|
3884
|
+
|
|
3885
|
+
let cResp: string;
|
|
3886
|
+
try {
|
|
3887
|
+
cResp = await localGrade('edit-cwe', combinedPrompt, undefined, graderPool);
|
|
3888
|
+
} catch (err) {
|
|
3889
|
+
const errMsg = (err as Error).message || String(err);
|
|
3890
|
+
logGraderUnavailable('editGuard', fileShort, errMsg);
|
|
3891
|
+
outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
const { ruleVerdict, cweFindings } = parseCombinedVerdict(cResp);
|
|
3896
|
+
const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
|
|
3897
|
+
const violatedRules = ruleVerdict.ruleId ? [ruleVerdict.ruleId] : [];
|
|
3898
|
+
const cweBlock = cweFindings.slice(0, 5)
|
|
3899
|
+
.map(f => '[' + f.id + '] ' + (f.reason || 'weakness') + (f.suggestedFix ? ' Fix: ' + f.suggestedFix : ''))
|
|
3900
|
+
.join('\\n');
|
|
3901
|
+
for (const f of cweFindings) {
|
|
3902
|
+
dispatchFinding(jwt, { session_id: sessionId, file_path: filePath, finding_type: 'cwe', finding_id: f.id, status: 'open' }, config.captureDepth);
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
// Org-rule violation \u2192 ask/fix enforcement (CWE findings appended to the deny).
|
|
3906
|
+
if (!ruleVerdict.ok) {
|
|
3907
|
+
const mode = normalizeMode(ruleVerdict.ruleMode || ruleMode(ruleVerdict.ruleId, config.rules));
|
|
3908
|
+
const guardReason = (ruleVerdict.ruleId ? '(' + ruleVerdict.ruleId + ') ' : '') + (ruleVerdict.reason || 'policy violation');
|
|
3909
|
+
let denyReason = mode === 'fix'
|
|
3910
|
+
? 'Guard: ' + guardReason + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the violation in code yourself.'
|
|
3911
|
+
: 'Guard: ' + guardReason + '\\nAsk the user for explicit consent before retrying.';
|
|
3912
|
+
if (cweBlock) denyReason += '\\nAlso resolve these CWE weaknesses in code:\\n' + cweBlock;
|
|
3913
|
+
dispatchCapture(jwt, 'edit', 'block', ruleVerdict.severity || 'critical', ruleVerdict.category || 'security',
|
|
3914
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3915
|
+
command: editContent, reasoning: guardReason,
|
|
3916
|
+
rulesChecked: relevantRules, violatedRules,
|
|
3917
|
+
ccModel: captureModel, ...lineMetrics,
|
|
3918
|
+
});
|
|
3919
|
+
outputJson({
|
|
3920
|
+
systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason + (cweFindings.length ? ' (+' + cweFindings.length + ' CWE)' : ''),
|
|
3921
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
|
|
3922
|
+
});
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
// Rules clean but CWE weakness(es) \u2192 fix-mode block (resolve in code).
|
|
3927
|
+
if (cweFindings.length > 0) {
|
|
3928
|
+
const ctx = 'CWE: ' + cweBlock + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
|
|
3929
|
+
outputJson({
|
|
3930
|
+
systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked (' + cweFindings.map(f => f.id).join(', ') + ')',
|
|
3931
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
|
|
3932
|
+
});
|
|
3933
|
+
return;
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
// Both clean.
|
|
3937
|
+
dispatchCapture(jwt, 'edit', 'pass', 'clean', ruleVerdict.category || 'trivial_edit',
|
|
3938
|
+
toolName, gitRepo, sessionId, config.captureDepth, {
|
|
3939
|
+
command: editContent, reasoning: ruleVerdict.reason || 'no violations (rules + CWE)',
|
|
3940
|
+
rulesChecked: relevantRules, violatedRules: [],
|
|
3941
|
+
ccModel: captureModel, ...lineMetrics,
|
|
3942
|
+
});
|
|
3943
|
+
const cPassLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (ruleVerdict.reason || 'clean (rules + CWE)');
|
|
3944
|
+
outputJson({ systemMessage: cPassLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro combined edit judge (rules + CWE). ' + (ruleVerdict.reason || 'clean') } });
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3789
3948
|
let gradeResp: string;
|
|
3790
3949
|
try {
|
|
3791
3950
|
gradeResp = await localGrade('edit', graderPrompt, undefined, graderPool);
|
|
@@ -3907,7 +4066,7 @@ import {
|
|
|
3907
4066
|
outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
|
|
3908
4067
|
extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, cweSnippetLines, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
3909
4068
|
logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
|
|
3910
|
-
loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
|
|
4069
|
+
loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage, combinedEditGradeEnabled,
|
|
3911
4070
|
} from './_synkro-common.ts';
|
|
3912
4071
|
import { basename, extname, resolve, join, dirname } from 'node:path';
|
|
3913
4072
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
@@ -4135,6 +4294,11 @@ async function main() {
|
|
|
4135
4294
|
const graderPool = effectiveGraderPool(synkroFile, agentKind);
|
|
4136
4295
|
const rt = await cweRoute(config, synkroFile);
|
|
4137
4296
|
|
|
4297
|
+
// Combined-grade mode: the edit-precheck hook runs CWE inside its single
|
|
4298
|
+
// grade-edit-cwe call, so this standalone CWE scan must no-op — otherwise the
|
|
4299
|
+
// edit would be graded for CWE twice (defeating the inference-merge).
|
|
4300
|
+
if (combinedEditGradeEnabled(synkroFile)) { outputEmpty(); return; }
|
|
4301
|
+
|
|
4138
4302
|
if (config.silent) {
|
|
4139
4303
|
outputJson({ systemMessage: '[synkro:' + rt + ':cweScan:' + (graderPool === 'claude_code' ? 'claude' : graderPool) + '] skipped (silent mode)' });
|
|
4140
4304
|
return;
|
|
@@ -4560,14 +4724,16 @@ async function main() {
|
|
|
4560
4724
|
jwt = await ensureFreshJwt(jwt);
|
|
4561
4725
|
|
|
4562
4726
|
const config = await loadConfig(jwt);
|
|
4563
|
-
const rt = await route(config);
|
|
4564
4727
|
|
|
4565
4728
|
if (config.silent) {
|
|
4566
|
-
outputJson({ systemMessage: '[synkro:
|
|
4729
|
+
outputJson({ systemMessage: '[synkro:cveScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
|
|
4567
4730
|
return;
|
|
4568
4731
|
}
|
|
4569
4732
|
|
|
4570
|
-
|
|
4733
|
+
// CVE scanning has no local/cloud distinction from the user's view \u2014 it's an
|
|
4734
|
+
// OSV database lookup that always runs server-side \u2014 so the tag omits the
|
|
4735
|
+
// location segment that grading/CWE carry. Just [synkro:cveScan].
|
|
4736
|
+
const cveTag = '[synkro:cveScan]';
|
|
4571
4737
|
|
|
4572
4738
|
// Reconstruct proposed content
|
|
4573
4739
|
const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
|
|
@@ -4667,7 +4833,7 @@ async function main() {
|
|
|
4667
4833
|
finding_ids: violatedIds, severity: 'critical',
|
|
4668
4834
|
repo: gitRepo || undefined,
|
|
4669
4835
|
});
|
|
4670
|
-
const tagStr = '[synkro:
|
|
4836
|
+
const tagStr = '[synkro:pkgScan]';
|
|
4671
4837
|
const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
|
|
4672
4838
|
outputJson({
|
|
4673
4839
|
systemMessage: denyReason,
|
|
@@ -9686,7 +9852,10 @@ function captureClaudeSetupToken() {
|
|
|
9686
9852
|
const bin = "script";
|
|
9687
9853
|
const args2 = isMac ? ["-q", tmpFile, "claude", "setup-token"] : ["-qec", "claude setup-token", tmpFile];
|
|
9688
9854
|
return new Promise((resolve4, reject) => {
|
|
9689
|
-
const proc = nodeSpawn(bin, args2, {
|
|
9855
|
+
const proc = nodeSpawn(bin, args2, {
|
|
9856
|
+
stdio: "inherit",
|
|
9857
|
+
env: { ...process.env, FORCE_COLOR: "3", COLORTERM: "truecolor", TERM: "xterm-256color" }
|
|
9858
|
+
});
|
|
9690
9859
|
proc.on("error", (err) => reject(new Error(`Failed to spawn claude setup-token: ${err.message}`)));
|
|
9691
9860
|
proc.on("close", (code) => {
|
|
9692
9861
|
let raw = "";
|
|
@@ -9704,27 +9873,48 @@ function captureClaudeSetupToken() {
|
|
|
9704
9873
|
reject(new Error(`claude setup-token exited with code ${code}`));
|
|
9705
9874
|
return;
|
|
9706
9875
|
}
|
|
9707
|
-
const
|
|
9708
|
-
|
|
9709
|
-
let
|
|
9710
|
-
let
|
|
9711
|
-
while (
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
9721
|
-
|
|
9722
|
-
|
|
9876
|
+
const TOKEN_YELLOW = "2;255;193;7";
|
|
9877
|
+
let fg = "default";
|
|
9878
|
+
let token = "";
|
|
9879
|
+
let i = 0;
|
|
9880
|
+
while (i < raw.length) {
|
|
9881
|
+
if (raw[i] === "\x1B") {
|
|
9882
|
+
const sgr = /^\x1B\[([0-9;?]*)m/.exec(raw.slice(i));
|
|
9883
|
+
if (sgr) {
|
|
9884
|
+
const codes = sgr[1].split(";").map((n) => Number(n) || 0);
|
|
9885
|
+
for (let k = 0; k < codes.length; k++) {
|
|
9886
|
+
const c = codes[k];
|
|
9887
|
+
if (c === 0 || c === 39) fg = "default";
|
|
9888
|
+
else if (c === 38 && codes[k + 1] === 2) {
|
|
9889
|
+
fg = `2;${codes[k + 2]};${codes[k + 3]};${codes[k + 4]}`;
|
|
9890
|
+
k += 4;
|
|
9891
|
+
} else if (c === 38 && codes[k + 1] === 5) {
|
|
9892
|
+
fg = `5;${codes[k + 2]}`;
|
|
9893
|
+
k += 2;
|
|
9894
|
+
} else if (c >= 30 && c <= 37 || c >= 90 && c <= 97) fg = `basic;${c}`;
|
|
9895
|
+
}
|
|
9896
|
+
i += sgr[0].length;
|
|
9897
|
+
continue;
|
|
9898
|
+
}
|
|
9899
|
+
const csi = /^\x1B\[[0-9;?]*[ -/]*[@-~]/.exec(raw.slice(i));
|
|
9900
|
+
const osc = /^\x1B\][^\x07]*(?:\x07|\x1B\\)/.exec(raw.slice(i));
|
|
9901
|
+
if (csi) {
|
|
9902
|
+
i += csi[0].length;
|
|
9903
|
+
continue;
|
|
9904
|
+
}
|
|
9905
|
+
if (osc) {
|
|
9906
|
+
i += osc[0].length;
|
|
9907
|
+
continue;
|
|
9908
|
+
}
|
|
9909
|
+
i += 1;
|
|
9910
|
+
continue;
|
|
9911
|
+
}
|
|
9912
|
+
const ch = raw[i];
|
|
9913
|
+
if (fg === TOKEN_YELLOW && !/[\s\x00-\x1F\x7F]/.test(ch)) token += ch;
|
|
9914
|
+
i += 1;
|
|
9723
9915
|
}
|
|
9724
|
-
const valid = (t) => /^sk-ant-oat01-[A-Za-z0-9_-]{40,}$/.test(t);
|
|
9725
|
-
const token = valid(yToken) ? yToken : valid(lineMatch) ? lineMatch : "";
|
|
9726
9916
|
if (!token) {
|
|
9727
|
-
reject(new Error(`
|
|
9917
|
+
reject(new Error(`Captured no yellow token text from claude setup-token output (raw=${raw.length}b \u2014 is the terminal emitting color?)`));
|
|
9728
9918
|
return;
|
|
9729
9919
|
}
|
|
9730
9920
|
resolve4(token);
|
|
@@ -10255,9 +10445,9 @@ function writeHookScripts(mode = "stub") {
|
|
|
10255
10445
|
const taskActivateIntentScriptPath = join10(HOOKS_DIR, "cc-task-activate-intent.ts");
|
|
10256
10446
|
const mcpGateScriptPath = join10(HOOKS_DIR, "cc-mcp-gate.ts");
|
|
10257
10447
|
if (mode === "stub") {
|
|
10258
|
-
const
|
|
10448
|
+
const stubCommonPath2 = join10(HOOKS_DIR, "_synkro-stub-common.ts");
|
|
10259
10449
|
const stubFiles = [
|
|
10260
|
-
[
|
|
10450
|
+
[stubCommonPath2, STUB_COMMON_TS],
|
|
10261
10451
|
[bashScriptPath, STUB_BASH_JUDGE_TS],
|
|
10262
10452
|
[bashFollowupScriptPath, STUB_BASH_FOLLOWUP_TS],
|
|
10263
10453
|
[editPrecheckScriptPath, STUB_EDIT_PRECHECK_TS],
|
|
@@ -10328,6 +10518,9 @@ function writeHookScripts(mode = "stub") {
|
|
|
10328
10518
|
writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
|
|
10329
10519
|
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");
|
|
10330
10520
|
writeFileSync8(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
|
|
10521
|
+
const stubCommonPath = join10(HOOKS_DIR, "_synkro-stub-common.ts");
|
|
10522
|
+
writeFileSync8(stubCommonPath, STUB_COMMON_TS, "utf-8");
|
|
10523
|
+
chmodSync2(stubCommonPath, 493);
|
|
10331
10524
|
writeFileSync8(mcpGateScriptPath, STUB_MCP_GATE_TS, "utf-8");
|
|
10332
10525
|
chmodSync2(bashScriptPath, 493);
|
|
10333
10526
|
chmodSync2(bashFollowupScriptPath, 493);
|
|
@@ -10350,10 +10543,6 @@ function writeHookScripts(mode = "stub") {
|
|
|
10350
10543
|
chmodSync2(installExtractCorePath, 493);
|
|
10351
10544
|
chmodSync2(taskActivateIntentScriptPath, 493);
|
|
10352
10545
|
chmodSync2(mcpGateScriptPath, 493);
|
|
10353
|
-
try {
|
|
10354
|
-
unlinkSync4(join10(HOOKS_DIR, "_synkro-stub-common.ts"));
|
|
10355
|
-
} catch {
|
|
10356
|
-
}
|
|
10357
10546
|
return {
|
|
10358
10547
|
bashScript: bashScriptPath,
|
|
10359
10548
|
bashFollowupScript: bashFollowupScriptPath,
|
|
@@ -10403,7 +10592,7 @@ function writeConfigEnv(opts) {
|
|
|
10403
10592
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
10404
10593
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
10405
10594
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
10406
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
10595
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.75")}`
|
|
10407
10596
|
];
|
|
10408
10597
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
10409
10598
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -10466,6 +10655,24 @@ async function provisionCloudContainer(opts) {
|
|
|
10466
10655
|
console.error(" Cloud needs a Claude setup-token. Run `claude setup-token` manually, then re-run install.");
|
|
10467
10656
|
process.exit(1);
|
|
10468
10657
|
}
|
|
10658
|
+
console.log(" Validating Claude token...");
|
|
10659
|
+
try {
|
|
10660
|
+
const out = execSync6('claude --print --output-format json "say ok"', {
|
|
10661
|
+
env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: setupToken },
|
|
10662
|
+
encoding: "utf-8",
|
|
10663
|
+
timeout: 3e4,
|
|
10664
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
10665
|
+
});
|
|
10666
|
+
const result = JSON.parse(out);
|
|
10667
|
+
if (result.is_error) throw new Error(result.result || "auth failed");
|
|
10668
|
+
console.log(" \u2713 Claude token validated.\n");
|
|
10669
|
+
} catch (err) {
|
|
10670
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10671
|
+
console.error(` \u2717 Claude token failed validation: ${msg}`);
|
|
10672
|
+
console.error(" The captured token can't authenticate (truncated capture, wrong account, or no API credits).");
|
|
10673
|
+
console.error(" Re-run install to re-authorize \u2014 nothing was stored.");
|
|
10674
|
+
process.exit(1);
|
|
10675
|
+
}
|
|
10469
10676
|
const repo = detectGitRepo2() || void 0;
|
|
10470
10677
|
const sf = readFullSynkroFile();
|
|
10471
10678
|
const harness = [];
|
|
@@ -14055,7 +14262,7 @@ var args = process.argv.slice(2);
|
|
|
14055
14262
|
var cmd = args[0] || "";
|
|
14056
14263
|
var subArgs = args.slice(1);
|
|
14057
14264
|
function printVersion() {
|
|
14058
|
-
console.log("1.6.
|
|
14265
|
+
console.log("1.6.75");
|
|
14059
14266
|
}
|
|
14060
14267
|
function printHelp2() {
|
|
14061
14268
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|