@synkro-sh/cli 1.6.4 → 1.6.6
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 +105 -117
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -733,7 +733,7 @@ var init_hookScriptsTs = __esm({
|
|
|
733
733
|
"use strict";
|
|
734
734
|
SYNKRO_COMMON_TS = `
|
|
735
735
|
// Shared Synkro hook utilities \u2014 imported by all hook scripts.
|
|
736
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
736
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
737
737
|
import { join, dirname, basename, extname, resolve as resolvePath } from 'node:path';
|
|
738
738
|
import { homedir } from 'node:os';
|
|
739
739
|
import { execSync } from 'node:child_process';
|
|
@@ -1002,6 +1002,10 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
|
|
|
1002
1002
|
scanExemptions: [],
|
|
1003
1003
|
};
|
|
1004
1004
|
|
|
1005
|
+
// Kick the telemetry spool drainer. Fire-and-forget: it runs concurrently
|
|
1006
|
+
// with the grade that follows this call, so it adds no latency to the hook.
|
|
1007
|
+
drainSpool().catch(() => {});
|
|
1008
|
+
|
|
1005
1009
|
// Local-first: fetch from the local MCP server (PGLite-backed) \u2014 zero network egress.
|
|
1006
1010
|
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
1007
1011
|
try {
|
|
@@ -1596,18 +1600,104 @@ export function dispatchCapture(
|
|
|
1596
1600
|
}).catch(() => {});
|
|
1597
1601
|
}
|
|
1598
1602
|
|
|
1603
|
+
// \u2500\u2500\u2500 Durable Telemetry Spool \u2500\u2500\u2500
|
|
1604
|
+
// Telemetry must survive process death, container restarts, and ingest-server
|
|
1605
|
+
// backpressure. Instead of a fire-and-forget POST (which silently dropped
|
|
1606
|
+
// captures under parallel load), every event is appended synchronously to a
|
|
1607
|
+
// local JSONL spool \u2014 a write that completes before the function returns and
|
|
1608
|
+
// outlives the hook process. A background drainer (kicked from loadConfig, so
|
|
1609
|
+
// it overlaps the multi-second grade) batch-ships the spool to the ingest
|
|
1610
|
+
// server and only deletes events after a confirmed write. Ingest is idempotent
|
|
1611
|
+
// (ON CONFLICT DO NOTHING on event_id), so a retried or double-drained event
|
|
1612
|
+
// is harmless.
|
|
1613
|
+
|
|
1614
|
+
const TELEMETRY_SPOOL = join(HOME, '.synkro', 'telemetry-spool.jsonl');
|
|
1615
|
+
const SPOOL_DRAIN_PREFIX = 'telemetry-spool.jsonl.draining.';
|
|
1616
|
+
|
|
1617
|
+
// appendLocalTelemetry \u2014 durably records one telemetry event. The synchronous
|
|
1618
|
+
// append IS the durability guarantee; nothing here can drop the event.
|
|
1599
1619
|
export function appendLocalTelemetry(body: Record<string, any>): void {
|
|
1600
1620
|
const event = { ...body, _ts: new Date().toISOString() };
|
|
1621
|
+
try {
|
|
1622
|
+
appendFileSync(TELEMETRY_SPOOL, JSON.stringify(event) + '\\n');
|
|
1623
|
+
} catch {}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// drainSpool \u2014 claims the spool via atomic rename, batch-ships it to the
|
|
1627
|
+
// ingest server, deletes on success, re-spools on failure. Fire-and-forget:
|
|
1628
|
+
// callers kick it and let it run concurrently with the grade.
|
|
1629
|
+
export async function drainSpool(): Promise<void> {
|
|
1630
|
+
const dir = join(HOME, '.synkro');
|
|
1631
|
+
const claimed: string[] = [];
|
|
1632
|
+
|
|
1633
|
+
// 1. Claim the live spool by atomic rename \u2014 a fresh spool takes new writes.
|
|
1634
|
+
try {
|
|
1635
|
+
if (existsSync(TELEMETRY_SPOOL) && statSync(TELEMETRY_SPOOL).size > 0) {
|
|
1636
|
+
const claim = join(dir, SPOOL_DRAIN_PREFIX + process.pid + '.' + Date.now());
|
|
1637
|
+
renameSync(TELEMETRY_SPOOL, claim);
|
|
1638
|
+
claimed.push(claim);
|
|
1639
|
+
}
|
|
1640
|
+
} catch {}
|
|
1641
|
+
|
|
1642
|
+
// 2. Recover orphaned claim files \u2014 a previous hook died mid-drain. Only
|
|
1643
|
+
// adopt claims older than 30s so we never steal another hook's in-flight drain.
|
|
1644
|
+
try {
|
|
1645
|
+
for (const f of readdirSync(dir)) {
|
|
1646
|
+
if (!f.startsWith(SPOOL_DRAIN_PREFIX)) continue;
|
|
1647
|
+
const full = join(dir, f);
|
|
1648
|
+
if (claimed.indexOf(full) !== -1) continue;
|
|
1649
|
+
try {
|
|
1650
|
+
if (Date.now() - statSync(full).mtimeMs > 30000) claimed.push(full);
|
|
1651
|
+
} catch {}
|
|
1652
|
+
}
|
|
1653
|
+
} catch {}
|
|
1654
|
+
if (claimed.length === 0) return;
|
|
1655
|
+
|
|
1656
|
+
// 3. Read every event out of the claimed files.
|
|
1657
|
+
const events: any[] = [];
|
|
1658
|
+
for (const f of claimed) {
|
|
1659
|
+
try {
|
|
1660
|
+
for (const line of readFileSync(f, 'utf-8').split('\\n')) {
|
|
1661
|
+
const t = line.trim();
|
|
1662
|
+
if (!t) continue;
|
|
1663
|
+
try { events.push(JSON.parse(t)); } catch {}
|
|
1664
|
+
}
|
|
1665
|
+
} catch {}
|
|
1666
|
+
}
|
|
1667
|
+
if (events.length === 0) {
|
|
1668
|
+
for (const f of claimed) { try { unlinkSync(f); } catch {} }
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// 4. Ship to /api/ingest/batch in chunks. A token is required by the server.
|
|
1601
1673
|
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
1602
1674
|
let mcpToken = '';
|
|
1603
1675
|
try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
|
|
1604
|
-
if (!mcpToken) return;
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1676
|
+
if (!mcpToken) return; // leave claim files; a later drain retries them
|
|
1677
|
+
|
|
1678
|
+
let allOk = true;
|
|
1679
|
+
for (let i = 0; i < events.length; i += 200) {
|
|
1680
|
+
try {
|
|
1681
|
+
const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/ingest/batch', {
|
|
1682
|
+
method: 'POST',
|
|
1683
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
|
|
1684
|
+
body: JSON.stringify({ events: events.slice(i, i + 200) }),
|
|
1685
|
+
signal: AbortSignal.timeout(8000),
|
|
1686
|
+
});
|
|
1687
|
+
if (!resp.ok) { allOk = false; break; }
|
|
1688
|
+
} catch { allOk = false; break; }
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// 5. Success \u2192 drop the claim files. Failure \u2192 re-spool for the next drain.
|
|
1692
|
+
if (allOk) {
|
|
1693
|
+
for (const f of claimed) { try { unlinkSync(f); } catch {} }
|
|
1694
|
+
} else {
|
|
1695
|
+
try {
|
|
1696
|
+
appendFileSync(TELEMETRY_SPOOL, events.map(e => JSON.stringify(e)).join('\\n') + '\\n');
|
|
1697
|
+
for (const f of claimed) { try { unlinkSync(f); } catch {} }
|
|
1698
|
+
} catch {}
|
|
1699
|
+
// if re-spool failed, claim files remain and are recovered as orphans later
|
|
1700
|
+
}
|
|
1611
1701
|
}
|
|
1612
1702
|
|
|
1613
1703
|
// \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
|
|
@@ -2232,7 +2322,7 @@ async function main() {
|
|
|
2232
2322
|
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
2233
2323
|
'Org rules: ' + JSON.stringify(relevantRules),
|
|
2234
2324
|
'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
|
|
2235
|
-
'
|
|
2325
|
+
'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.',
|
|
2236
2326
|
].join('\\n');
|
|
2237
2327
|
|
|
2238
2328
|
let gradeResp: string;
|
|
@@ -3000,7 +3090,7 @@ import {
|
|
|
3000
3090
|
parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
|
|
3001
3091
|
extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
|
|
3002
3092
|
outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
|
|
3003
|
-
logGraderUnavailable,
|
|
3093
|
+
logGraderUnavailable, filterRules, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
|
|
3004
3094
|
type HookConfig, type Rule,
|
|
3005
3095
|
} from './_synkro-common.ts';
|
|
3006
3096
|
|
|
@@ -3061,108 +3151,6 @@ async function main() {
|
|
|
3061
3151
|
return;
|
|
3062
3152
|
}
|
|
3063
3153
|
|
|
3064
|
-
// ─── Hook-side short-circuit for safe in-repo reads ───
|
|
3065
|
-
// The judge primer already deterministically allows these, but the round
|
|
3066
|
-
// trip + batch queue still costs 1–25s per call. Skipping the grade for
|
|
3067
|
-
// unambiguously read-only operations removes that latency for ~half of
|
|
3068
|
-
// typical commands (cat/grep/git status/ls/etc.) and unblocks the worker
|
|
3069
|
-
// pool to grade the operations that actually need judgment.
|
|
3070
|
-
// Returning FALSE just means "don't short-circuit — let the LLM grade it."
|
|
3071
|
-
// Never blocks. The judge sees the command, applies rules, returns its
|
|
3072
|
-
// own verdict. Path scoping below: STRICT, only short-circuit when every
|
|
3073
|
-
// absolute path is under the linked repo root.
|
|
3074
|
-
function isSafeBashSegment(seg: string, repoRoot: string): boolean {
|
|
3075
|
-
const UNSAFE_CHARS = ['>', ';', '&', '\`'];
|
|
3076
|
-
for (const ch of UNSAFE_CHARS) { if (seg.indexOf(ch) !== -1) return false; }
|
|
3077
|
-
const padded = ' ' + seg + ' ';
|
|
3078
|
-
const UNSAFE_WORDS = [
|
|
3079
|
-
' sudo ', ' su ', ' rm ', ' mv ', ' cp ', ' chmod ', ' chown ',
|
|
3080
|
-
' tee ', ' kill ', ' sed -i', ' sed --in-place',
|
|
3081
|
-
' sh -c', ' bash -c', ' zsh -c', ' eval ', ' exec ',
|
|
3082
|
-
'\$(',
|
|
3083
|
-
];
|
|
3084
|
-
for (const w of UNSAFE_WORDS) { if (padded.indexOf(w) !== -1) return false; }
|
|
3085
|
-
|
|
3086
|
-
// Narrowed verb set. Removed:
|
|
3087
|
-
// awk: has system() / |& shell-spawn
|
|
3088
|
-
// env: \`env FOO=bar evil_cmd\` runs evil_cmd
|
|
3089
|
-
// sed: scripting + -i write capability; not worth parsing
|
|
3090
|
-
const SAFE_VERBS = new Set([
|
|
3091
|
-
'cat','head','tail','less','more','grep','egrep','fgrep','rg','ag',
|
|
3092
|
-
'find','fd','ls','wc','cmp','diff','file','stat','which','whereis','type',
|
|
3093
|
-
'pwd','whoami','id','date','echo','printf','true','false',
|
|
3094
|
-
'jq','yq','sort','uniq','cut','tr','xxd','hexdump','od','column',
|
|
3095
|
-
'node','npm','pnpm','yarn','bun','python','python3','ruby','go','rustc','cargo',
|
|
3096
|
-
'git',
|
|
3097
|
-
]);
|
|
3098
|
-
const tokens = seg.trim().split(' ').filter(t => t.length > 0);
|
|
3099
|
-
const verb = tokens[0] || '';
|
|
3100
|
-
if (!SAFE_VERBS.has(verb)) return false;
|
|
3101
|
-
|
|
3102
|
-
// find/fd: reject any execution / mutation action flag.
|
|
3103
|
-
if (verb === 'find' || verb === 'fd') {
|
|
3104
|
-
const BAD = new Set([
|
|
3105
|
-
'-exec','-execdir','-ok','-okdir','-delete',
|
|
3106
|
-
'-fprint','-fprintf','-fprint0','-fls',
|
|
3107
|
-
'--exec','--exec-batch',
|
|
3108
|
-
]);
|
|
3109
|
-
for (const t of tokens) { if (BAD.has(t)) return false; }
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
// git: only pure-read subcommands. branch/tag/remote/config dropped —
|
|
3113
|
-
// each has flag combinations that mutate state.
|
|
3114
|
-
if (verb === 'git') {
|
|
3115
|
-
const SAFE_GIT = new Set([
|
|
3116
|
-
'log','show','diff','blame','status','rev-parse',
|
|
3117
|
-
'ls-files','ls-tree','cat-file','shortlog','reflog',
|
|
3118
|
-
'describe','symbolic-ref','--version',
|
|
3119
|
-
]);
|
|
3120
|
-
const sub = tokens[1] || '';
|
|
3121
|
-
if (!SAFE_GIT.has(sub)) return false;
|
|
3122
|
-
} else if (['npm','pnpm','yarn','bun','cargo','go'].includes(verb)) {
|
|
3123
|
-
const sub = tokens[1] || '';
|
|
3124
|
-
const SAFE_PKG = new Set([
|
|
3125
|
-
'--version','-v','version','list','ls','why','view','show','info','outdated',
|
|
3126
|
-
'-h','--help','help',
|
|
3127
|
-
]);
|
|
3128
|
-
if (!SAFE_PKG.has(sub)) return false;
|
|
3129
|
-
} else if (['node','python','python3','ruby','rustc'].includes(verb)) {
|
|
3130
|
-
const sub = tokens[1] || '';
|
|
3131
|
-
if (sub !== '--version' && sub !== '-v' && sub !== '-V') return false;
|
|
3132
|
-
}
|
|
3133
|
-
|
|
3134
|
-
// STRICT path scoping. Absolute paths MUST resolve under repoRoot.
|
|
3135
|
-
// Home-relative (~/...) paths fall through to the LLM. Relative paths
|
|
3136
|
-
// are implicitly under cwd which is the repo root for the agent session.
|
|
3137
|
-
if (!repoRoot) return false;
|
|
3138
|
-
for (let i = 1; i < tokens.length; i++) {
|
|
3139
|
-
const t = tokens[i];
|
|
3140
|
-
const stripped = t.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
3141
|
-
if (stripped.startsWith('~')) return false;
|
|
3142
|
-
if (stripped.startsWith('/')) {
|
|
3143
|
-
if (!isPathUnder(stripped, repoRoot)) return false;
|
|
3144
|
-
}
|
|
3145
|
-
}
|
|
3146
|
-
return true;
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
function isSafeInRepoRead(tName: string, cmd: string, repoRoot: string): boolean {
|
|
3150
|
-
if (tName === 'Read' || tName === 'Grep' || tName === 'Glob') return true;
|
|
3151
|
-
if (tName !== 'Bash' && tName !== 'Shell' && tName !== 'terminal' &&
|
|
3152
|
-
tName !== 'run_terminal_cmd' && tName !== 'execute_command') return false;
|
|
3153
|
-
if (!cmd || !repoRoot) return false;
|
|
3154
|
-
// Allow pipes only if EVERY segment is safe on its own. Catches
|
|
3155
|
-
// \`grep ... | head\`, \`cat foo | wc -l\`, \`git log | less\`, etc.
|
|
3156
|
-
// Empty segments (from \`||\`) cause rejection.
|
|
3157
|
-
const segments = cmd.split('|');
|
|
3158
|
-
for (const seg of segments) {
|
|
3159
|
-
const t = seg.trim();
|
|
3160
|
-
if (t.length === 0) return false;
|
|
3161
|
-
if (!isSafeBashSegment(t, repoRoot)) return false;
|
|
3162
|
-
}
|
|
3163
|
-
return true;
|
|
3164
|
-
}
|
|
3165
|
-
|
|
3166
3154
|
if (isSafeInRepoRead(toolName, command, cwd)) {
|
|
3167
3155
|
log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
|
|
3168
3156
|
appendLocalTelemetry({
|
|
@@ -3309,7 +3297,7 @@ async function main() {
|
|
|
3309
3297
|
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
3310
3298
|
'Org rules: ' + JSON.stringify(relevantRules),
|
|
3311
3299
|
'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix — your job is only to detect violations.',
|
|
3312
|
-
'
|
|
3300
|
+
'The rules shown were pre-selected as the ones relevant to this command — 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 secrets in grep args. R005: in-repo path only." Cover every rule shown.',
|
|
3313
3301
|
'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
|
|
3314
3302
|
].filter(Boolean).join('\\n');
|
|
3315
3303
|
|
|
@@ -4363,7 +4351,7 @@ async function main() {
|
|
|
4363
4351
|
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
4364
4352
|
'Org rules: ' + JSON.stringify(relevantRules),
|
|
4365
4353
|
'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
|
|
4366
|
-
'
|
|
4354
|
+
'The rules shown were pre-selected as the ones relevant to this command \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 secrets in grep args. R005: in-repo path only." Cover every rule shown.',
|
|
4367
4355
|
'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
|
|
4368
4356
|
].filter(Boolean).join('\\n');
|
|
4369
4357
|
|
|
@@ -4398,7 +4386,7 @@ async function main() {
|
|
|
4398
4386
|
});
|
|
4399
4387
|
} else {
|
|
4400
4388
|
dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
|
|
4401
|
-
'Bash',
|
|
4389
|
+
'Bash', repo, sessionId, config.captureDepth, {
|
|
4402
4390
|
command, reasoning: verdict.reason || 'no policy violations detected',
|
|
4403
4391
|
rulesChecked: config.rules, violatedRules: [],
|
|
4404
4392
|
ccModel: model,
|
|
@@ -6429,7 +6417,7 @@ function writeConfigEnv(opts) {
|
|
|
6429
6417
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
6430
6418
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
6431
6419
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
6432
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
6420
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.6")}`
|
|
6433
6421
|
];
|
|
6434
6422
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
6435
6423
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -7916,7 +7904,7 @@ var args = process.argv.slice(2);
|
|
|
7916
7904
|
var cmd = args[0] || "";
|
|
7917
7905
|
var subArgs = args.slice(1);
|
|
7918
7906
|
function printVersion() {
|
|
7919
|
-
console.log("1.6.
|
|
7907
|
+
console.log("1.6.6");
|
|
7920
7908
|
}
|
|
7921
7909
|
function printHelp() {
|
|
7922
7910
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|