@synkro-sh/cli 1.6.89 → 1.6.91
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 +257 -6
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -1684,7 +1684,11 @@ export function combinedEditGradeEnabled(synkroFile?: SynkroFileConfig): boolean
|
|
|
1684
1684
|
}
|
|
1685
1685
|
|
|
1686
1686
|
async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
|
|
1687
|
-
|
|
1687
|
+
// Defense-in-depth: redact any literal secret from the grade payload so a raw
|
|
1688
|
+
// credential never reaches the model. The grader still sees the SHAPE
|
|
1689
|
+
// (<redacted:...>) so its policy reasoning is unaffected.
|
|
1690
|
+
const safe = scrubSecrets(prompt);
|
|
1691
|
+
const body = JSON.stringify({ role, payload: safe, content: safe, agent_kind: agentKind });
|
|
1688
1692
|
|
|
1689
1693
|
const resp = await fetch('http://127.0.0.1:' + port + '/submit', {
|
|
1690
1694
|
method: 'POST',
|
|
@@ -1734,10 +1738,13 @@ const CONTAINERS_URL = process.env.SYNKRO_CONTAINERS_URL || 'https://containers.
|
|
|
1734
1738
|
// FIRST so the caller's catch fails open cleanly before CC ever force-kills us.
|
|
1735
1739
|
const CLOUD_GRADE_TIMEOUT_MS = 45000;
|
|
1736
1740
|
async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeoutMs = CLOUD_GRADE_TIMEOUT_MS, agentKind: AgentKind = 'claude_code'): Promise<string> {
|
|
1741
|
+
// Defense-in-depth: redact any literal secret before the payload crosses the
|
|
1742
|
+
// network to the cloud grader \u2014 a raw credential never reaches the model.
|
|
1743
|
+
const safe = scrubSecrets(prompt);
|
|
1737
1744
|
const resp = await fetch(CONTAINERS_URL + '/grade/submit', {
|
|
1738
1745
|
method: 'POST',
|
|
1739
1746
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
1740
|
-
body: JSON.stringify({ role, payload:
|
|
1747
|
+
body: JSON.stringify({ role, payload: safe, content: safe, agent_kind: agentKind }),
|
|
1741
1748
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1742
1749
|
});
|
|
1743
1750
|
if (!resp.ok) {
|
|
@@ -2134,6 +2141,15 @@ function sessionLogPath(sessionId: string): string | null {
|
|
|
2134
2141
|
}
|
|
2135
2142
|
|
|
2136
2143
|
export function appendSessionAction(sessionId: string, entry: SessionAction): void {
|
|
2144
|
+
// Redact any secret in the action fields before they are persisted (local log
|
|
2145
|
+
// OR streamed to Timescale) \u2014 session diagnostics must never store a raw
|
|
2146
|
+
// credential. Tool name is a fixed identifier and left as-is.
|
|
2147
|
+
entry = {
|
|
2148
|
+
...entry,
|
|
2149
|
+
summary: entry.summary ? scrubSecrets(entry.summary) : entry.summary,
|
|
2150
|
+
file: entry.file ? scrubSecrets(entry.file) : entry.file,
|
|
2151
|
+
outcome: entry.outcome ? scrubSecrets(entry.outcome) : entry.outcome,
|
|
2152
|
+
};
|
|
2137
2153
|
const logPath = sessionLogPath(sessionId);
|
|
2138
2154
|
if (!logPath) return;
|
|
2139
2155
|
let step = 0;
|
|
@@ -2175,6 +2191,165 @@ export function readSessionLog(sessionId: string): SessionAction[] {
|
|
|
2175
2191
|
} catch { return []; }
|
|
2176
2192
|
}
|
|
2177
2193
|
|
|
2194
|
+
// \u2500\u2500\u2500 Secret/PII firewall \u2500\u2500\u2500
|
|
2195
|
+
// Detect LITERAL credentials in a proposed tool call so the hook can (a) BLOCK it
|
|
2196
|
+
// locally before the tool runs \u2014 the value never reaches a file, shell history,
|
|
2197
|
+
// OR the LLM grader (no leak to Anthropic/Cursor) \u2014 and (b) REDACT any secret
|
|
2198
|
+
// before it is persisted/logged. Patterns use character classes (not \\d / \\.)
|
|
2199
|
+
// to stay readable inside this template literal. HIGH-CONFIDENCE formats only \u2014
|
|
2200
|
+
// a false BLOCK stops the user's work, so we never block on generic entropy.
|
|
2201
|
+
interface SecretHit { type: string; value: string; }
|
|
2202
|
+
const HARD_SECRET_PATTERNS: Array<{ type: string; re: RegExp }> = [
|
|
2203
|
+
{ type: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{24,}/g },
|
|
2204
|
+
{ type: 'openai-key', re: /sk-(?:proj-)?[A-Za-z0-9]{32,}/g },
|
|
2205
|
+
{ type: 'aws-access-key', re: /AKIA[0-9A-Z]{16}/g },
|
|
2206
|
+
{ type: 'github-token', re: /gh[posru]_[A-Za-z0-9]{36,}/g },
|
|
2207
|
+
{ type: 'google-api-key', re: /AIza[0-9A-Za-z_-]{35}/g },
|
|
2208
|
+
{ type: 'slack-token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/g },
|
|
2209
|
+
{ type: 'stripe-key', re: /[rs]k_(?:live|test)_[A-Za-z0-9]{20,}/g },
|
|
2210
|
+
{ type: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}[.][A-Za-z0-9_-]{10,}[.][A-Za-z0-9_-]{10,}/g },
|
|
2211
|
+
{ type: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
2212
|
+
{ type: 'bearer-token', re: /[Bb]earer[ ]+[A-Za-z0-9._-]{20,}/g },
|
|
2213
|
+
];
|
|
2214
|
+
// A match that is obviously a placeholder or env reference is NOT a real secret \u2014
|
|
2215
|
+
// never block on $VAR, <token>, {{ }}, or common dummy values.
|
|
2216
|
+
function isPlaceholderSecret(v: string): boolean {
|
|
2217
|
+
if (/[$<>{}]/.test(v)) return true;
|
|
2218
|
+
const tail = v.replace(/^(sk-ant-|[rs]k_(?:live|test)_|sk-|AKIA|gh[posru]_|AIza|xox[baprs]-|Bearer[ ]+)/, '');
|
|
2219
|
+
return /^(x+|0+|a+|placeholder|example|changeme|your[-_a-z0-9]*|dummy|test|fake|redacted|sample)$/i.test(tail);
|
|
2220
|
+
}
|
|
2221
|
+
// \u2500\u2500 Opaque-secret heuristic (catches the FORMAT-LESS tail the regex can't) \u2500\u2500
|
|
2222
|
+
// A high-entropy value sitting in a SECRET CONTEXT \u2014 assigned to a secret-named
|
|
2223
|
+
// variable, in an auth header, or as a connection-string password. Local +
|
|
2224
|
+
// deterministic, no model. Char-class regexes only (template-literal backslash safe).
|
|
2225
|
+
function shannonEntropy(s: string): number {
|
|
2226
|
+
const m: Record<string, number> = {};
|
|
2227
|
+
for (const ch of s) m[ch] = (m[ch] || 0) + 1;
|
|
2228
|
+
let h = 0;
|
|
2229
|
+
for (const k in m) { const p = m[k] / s.length; h -= p * Math.log2(p); }
|
|
2230
|
+
return h;
|
|
2231
|
+
}
|
|
2232
|
+
function looksOpaqueSecret(v: string): boolean {
|
|
2233
|
+
if (v.length < 16) return false;
|
|
2234
|
+
if (/[$<>{}]/.test(v)) return false; // env ref / placeholder
|
|
2235
|
+
if (/^[0-9a-f]{32,}$/i.test(v)) return false; // hex hash / sha
|
|
2236
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)) return false; // uuid
|
|
2237
|
+
if (!(/[a-zA-Z]/.test(v) && /[0-9]/.test(v))) return false; // real tokens mix letters+digits
|
|
2238
|
+
return shannonEntropy(v) >= 3.2;
|
|
2239
|
+
}
|
|
2240
|
+
const OPAQUE_CTX_PATTERNS: RegExp[] = [
|
|
2241
|
+
/[A-Za-z_][A-Za-z0-9_-]*(?:secret|token|key|password|passwd|api[_-]?key|auth|credential)[A-Za-z0-9_-]*[ ]*[=:][ ]*["']?([^ "'&|;]+)/gi,
|
|
2242
|
+
/(?:authorization|x-api-key|x-auth-token|api-key)[ ]*:[ ]*(?:bearer[ ]+)?([^ "']+)/gi,
|
|
2243
|
+
/[a-z][a-z0-9+.-]*:[/][/][^:/@ ]+:([^@/ ]+)@/gi,
|
|
2244
|
+
];
|
|
2245
|
+
export function detectOpaqueSecrets(text: string): SecretHit[] {
|
|
2246
|
+
if (!text) return [];
|
|
2247
|
+
const hits: SecretHit[] = [];
|
|
2248
|
+
const seen = new Set<string>();
|
|
2249
|
+
for (const re of OPAQUE_CTX_PATTERNS) {
|
|
2250
|
+
re.lastIndex = 0;
|
|
2251
|
+
let m: RegExpExecArray | null;
|
|
2252
|
+
while ((m = re.exec(text)) !== null) {
|
|
2253
|
+
const v = m[1];
|
|
2254
|
+
if (v && !seen.has(v) && looksOpaqueSecret(v)) { seen.add(v); hits.push({ type: 'opaque-secret', value: v }); }
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
return hits;
|
|
2258
|
+
}
|
|
2259
|
+
export function detectHardSecrets(text: string): SecretHit[] {
|
|
2260
|
+
if (!text) return [];
|
|
2261
|
+
const hits: SecretHit[] = [];
|
|
2262
|
+
const seen = new Set<string>();
|
|
2263
|
+
for (const { type, re } of HARD_SECRET_PATTERNS) {
|
|
2264
|
+
re.lastIndex = 0;
|
|
2265
|
+
let m: RegExpExecArray | null;
|
|
2266
|
+
while ((m = re.exec(text)) !== null) {
|
|
2267
|
+
const v = m[0];
|
|
2268
|
+
if (isPlaceholderSecret(v) || seen.has(v)) continue;
|
|
2269
|
+
seen.add(v);
|
|
2270
|
+
hits.push({ type, value: v });
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
// Union the format-less opaque-secret tail (entropy + secret context).
|
|
2274
|
+
for (const h of detectOpaqueSecrets(text)) {
|
|
2275
|
+
if (!seen.has(h.value)) { seen.add(h.value); hits.push(h); }
|
|
2276
|
+
}
|
|
2277
|
+
return hits;
|
|
2278
|
+
}
|
|
2279
|
+
// Shape-preserving redaction for persist/log/grade boundaries: the reader still
|
|
2280
|
+
// sees that a secret of a given KIND was present, never the value. Broader than
|
|
2281
|
+
// the block set (over-redacting a log is harmless; missing one is not).
|
|
2282
|
+
export function scrubSecrets(text: string): string {
|
|
2283
|
+
if (!text) return text;
|
|
2284
|
+
let out = text;
|
|
2285
|
+
for (const { type, re } of HARD_SECRET_PATTERNS) {
|
|
2286
|
+
out = out.replace(re, (m) => isPlaceholderSecret(m) ? m : '<redacted:' + type + '>');
|
|
2287
|
+
}
|
|
2288
|
+
out = out.replace(/((?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)["' ]*[=:][ ]*["']?)([^ "'\\n]{12,})/gi, (full, pfx, val) => {
|
|
2289
|
+
if (/[$<>{}]/.test(val)) return full;
|
|
2290
|
+
return pfx + '<redacted:credential>';
|
|
2291
|
+
});
|
|
2292
|
+
// Format-less opaque secrets (entropy + secret context) the patterns above miss.
|
|
2293
|
+
for (const h of detectOpaqueSecrets(out)) out = out.split(h.value).join('<redacted:opaque-secret>');
|
|
2294
|
+
return out;
|
|
2295
|
+
}
|
|
2296
|
+
function guessEnvName(type: string, i: number): string {
|
|
2297
|
+
const base = type === 'anthropic-key' ? 'ANTHROPIC_API_KEY'
|
|
2298
|
+
: type === 'openai-key' ? 'OPENAI_API_KEY'
|
|
2299
|
+
: type === 'aws-access-key' ? 'AWS_ACCESS_KEY_ID'
|
|
2300
|
+
: type === 'github-token' ? 'GITHUB_TOKEN'
|
|
2301
|
+
: type === 'google-api-key' ? 'GOOGLE_API_KEY'
|
|
2302
|
+
: type === 'slack-token' ? 'SLACK_TOKEN'
|
|
2303
|
+
: type === 'stripe-key' ? 'STRIPE_API_KEY'
|
|
2304
|
+
: (type === 'jwt' || type === 'bearer-token') ? 'AUTH_TOKEN'
|
|
2305
|
+
: 'SECRET';
|
|
2306
|
+
return i === 0 ? base : base + '_' + (i + 1);
|
|
2307
|
+
}
|
|
2308
|
+
// Agent-facing remediation: show the SAME command with each literal secret swapped
|
|
2309
|
+
// for an env reference, so the agent can re-issue it securely without the value
|
|
2310
|
+
// ever appearing in the command, logs, or model context.
|
|
2311
|
+
export function secretRemediation(command: string, hits: SecretHit[]): string {
|
|
2312
|
+
let safe = command;
|
|
2313
|
+
const names: string[] = [];
|
|
2314
|
+
hits.forEach((h, i) => {
|
|
2315
|
+
const envName = guessEnvName(h.type, i);
|
|
2316
|
+
names.push(envName);
|
|
2317
|
+
safe = safe.split(h.value).join('$' + envName);
|
|
2318
|
+
});
|
|
2319
|
+
const typeList = hits.map(h => h.type).join(', ');
|
|
2320
|
+
return 'Synkro blocked this tool call: it contains a literal credential (' + typeList + '). '
|
|
2321
|
+
+ 'A secret value must NEVER be inlined, printed, or echoed \u2014 that leaks it into shell history, logs, and the model context. '
|
|
2322
|
+
+ 'Reference the credential from the environment so its value resolves at runtime and never appears in the command. '
|
|
2323
|
+
+ 'Set it once out-of-band (export ' + names[0] + '=... in an untracked file, or load from your secret manager), then re-issue as:\\n ' + safe + '\\n'
|
|
2324
|
+
+ 'Do NOT print the value to verify it; check the variable is non-empty instead, e.g. test -n "$' + names[0] + '". Re-issue with the literal removed \u2014 do not ask the user to paste it.';
|
|
2325
|
+
}
|
|
2326
|
+
// The NEW text an edit introduces, so we flag a secret the agent is ADDING \u2014 never
|
|
2327
|
+
// a pre-existing one already in the file (which would falsely block any edit to a
|
|
2328
|
+
// file that contains a credential). Write = whole new content; Edit = new_string;
|
|
2329
|
+
// MultiEdit = every edit's new_string.
|
|
2330
|
+
export function editedAddedText(toolName: string, toolInput: any): string {
|
|
2331
|
+
const ti = toolInput || {};
|
|
2332
|
+
if (toolName === 'MultiEdit' && Array.isArray(ti.edits)) {
|
|
2333
|
+
return ti.edits.map((e: any) => (e && typeof e.new_string === 'string') ? e.new_string : '').join('\\n');
|
|
2334
|
+
}
|
|
2335
|
+
return String(ti.content ?? ti.contents ?? ti.new_string ?? ti.new_source ?? ti.code_edit ?? ti.patch ?? '');
|
|
2336
|
+
}
|
|
2337
|
+
// Deny payload when an edit HARDCODES a literal credential (else null). Code-edit
|
|
2338
|
+
// remediation: load from env/secret manager at runtime, never inline the value.
|
|
2339
|
+
export function editSecretDenial(toolName: string, toolInput: any, fileShort: string, tagStr: string): { systemMessage: string; hookSpecificOutput: any } | null {
|
|
2340
|
+
const hits = detectHardSecrets(editedAddedText(toolName, toolInput));
|
|
2341
|
+
if (!hits.length) return null;
|
|
2342
|
+
const types = hits.map(h => h.type).join(', ');
|
|
2343
|
+
const rem = 'Synkro blocked this edit: it hardcodes a literal credential (' + types + ') into ' + fileShort + '. '
|
|
2344
|
+
+ 'NEVER commit a secret value to a file \u2014 it leaks into source control, logs, and the model context. '
|
|
2345
|
+
+ 'Load it at runtime from the environment or a secret manager and reference it instead (e.g. process.env.SECRET_NAME, os.environ["SECRET_NAME"], or your config/secret provider). '
|
|
2346
|
+
+ 'Re-do the edit with the literal removed; do not ask the user to paste it.';
|
|
2347
|
+
return {
|
|
2348
|
+
systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 BLOCKED: hardcoded credential (' + types + '). Not sent to the grader; reference it via the environment.',
|
|
2349
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: rem, additionalContext: rem },
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2178
2353
|
const RETRIEVAL_RECENT_N = 40;
|
|
2179
2354
|
const PROCESS_KW = ['test', 'typecheck', 'tsc', 'lint', 'build', 'migrate', 'migration', 'deploy', 'publish', 'push', 'seed'];
|
|
2180
2355
|
|
|
@@ -2578,6 +2753,11 @@ export function mintEventId(prefix = 'evt'): string {
|
|
|
2578
2753
|
export function appendLocalTelemetry(body: Record<string, any>): void {
|
|
2579
2754
|
if ((process.env.SYNKRO_STORAGE_MODE || 'local') !== 'local') return;
|
|
2580
2755
|
const event = { ...body };
|
|
2756
|
+
// Redact secrets from free-text fields before they reach the spool/dashboard,
|
|
2757
|
+
// for any caller that passes raw tool-call content (idempotent if already scrubbed).
|
|
2758
|
+
for (const k of ['command', 'reasoning', 'summary', 'target', 'content']) {
|
|
2759
|
+
if (typeof event[k] === 'string') event[k] = scrubSecrets(event[k]);
|
|
2760
|
+
}
|
|
2581
2761
|
if (!event.event_id) {
|
|
2582
2762
|
const ct = String(event.capture_type || '');
|
|
2583
2763
|
const prefix = ct === 'usage_tick' ? 'usage' : ct === 'edit_scan' ? 'edit' : 'evt';
|
|
@@ -3953,6 +4133,13 @@ export async function logGraderUnavailable(
|
|
|
3953
4133
|
gradeInput?: string, ctx?: { sessionId?: string; repo?: string },
|
|
3954
4134
|
): Promise<void> {
|
|
3955
4135
|
const { category, why } = classifyGraderError(errorMessage);
|
|
4136
|
+
// Redact any secret from the diagnostic fields before they are persisted
|
|
4137
|
+
// (Timescale in cloud, on-device log in local). The grade input especially can
|
|
4138
|
+
// carry a credential (a .env edit, a bash command) \u2014 debug diagnostics must
|
|
4139
|
+
// never store a raw secret. classifyGraderError ran on the original above.
|
|
4140
|
+
target = scrubSecrets(target || '');
|
|
4141
|
+
errorMessage = scrubSecrets(errorMessage || '');
|
|
4142
|
+
gradeInput = gradeInput ? scrubSecrets(gradeInput) : gradeInput;
|
|
3956
4143
|
|
|
3957
4144
|
if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
|
|
3958
4145
|
try {
|
|
@@ -4060,7 +4247,7 @@ export function outputEmpty(): void {
|
|
|
4060
4247
|
EDIT_PRECHECK_TS = `#!/usr/bin/env bun
|
|
4061
4248
|
import {
|
|
4062
4249
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
4063
|
-
parseVerdict, parseCombinedVerdict, combinedEditGradeEnabled, dispatchCapture, dispatchFinding, ruleMode, reconstructContent, isPathUnder, postWithRetry,
|
|
4250
|
+
parseVerdict, parseCombinedVerdict, combinedEditGradeEnabled, dispatchCapture, dispatchFinding, ruleMode, reconstructContent, editSecretDenial, isPathUnder, postWithRetry,
|
|
4064
4251
|
readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
|
|
4065
4252
|
appendSessionAction, readSessionLog, compressSessionLog, sessionLogForGrade, log,
|
|
4066
4253
|
outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
|
|
@@ -4186,6 +4373,14 @@ async function main() {
|
|
|
4186
4373
|
return;
|
|
4187
4374
|
}
|
|
4188
4375
|
|
|
4376
|
+
// Secret firewall: block an edit that HARDCODES a literal credential before it
|
|
4377
|
+
// reaches the grader/model. Flags only the NEW content (never a pre-existing
|
|
4378
|
+
// secret already in the file).
|
|
4379
|
+
{
|
|
4380
|
+
const _den = editSecretDenial(toolName, toolInput, fileShort, tagStr);
|
|
4381
|
+
if (_den) { outputJson(_den); return; }
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4189
4384
|
if (rt === 'local') {
|
|
4190
4385
|
// \u2500\u2500\u2500 Local grading: org rules ONLY (channel 1, port 18929) \u2500\u2500\u2500
|
|
4191
4386
|
const proposedShort = proposed.slice(0, 4000);
|
|
@@ -4434,7 +4629,7 @@ main();
|
|
|
4434
4629
|
CWE_PRECHECK_TS = String.raw`#!/usr/bin/env bun
|
|
4435
4630
|
import {
|
|
4436
4631
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
|
|
4437
|
-
localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
|
|
4632
|
+
localGradeCwe, parseVerdict, reconstructContent, editSecretDenial, readStdin, log,
|
|
4438
4633
|
outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
|
|
4439
4634
|
extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, cweSnippetLines, dispatchFinding, dispatchCapture, GATEWAY_URL,
|
|
4440
4635
|
logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
|
|
@@ -4697,6 +4892,14 @@ async function main() {
|
|
|
4697
4892
|
const graderLabel = graderPool === 'claude_code' ? 'claude' : graderPool;
|
|
4698
4893
|
const cweTag = '[synkro:' + rt + ':cweScan:' + graderLabel + ']';
|
|
4699
4894
|
|
|
4895
|
+
// Secret firewall: a hardcoded credential in the edit is CWE-798 — block it
|
|
4896
|
+
// locally (deterministic, no model) before the CWE grade, flagging only the
|
|
4897
|
+
// NEW content the edit introduces.
|
|
4898
|
+
{
|
|
4899
|
+
const _den = editSecretDenial(scanToolName, scanToolInput, fileShort, cweTag);
|
|
4900
|
+
if (_den) { outputJson(_den); return; }
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4700
4903
|
if (rt === 'skip') {
|
|
4701
4904
|
outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, 'channel down', graderPool) });
|
|
4702
4905
|
return;
|
|
@@ -5458,6 +5661,7 @@ import {
|
|
|
5458
5661
|
logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
|
|
5459
5662
|
loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
|
|
5460
5663
|
hashCommand, resolveTranscriptPath, isCursorHookFormat,
|
|
5664
|
+
detectHardSecrets, secretRemediation, scrubSecrets,
|
|
5461
5665
|
type HookConfig, type Rule,
|
|
5462
5666
|
} from './_synkro-common.ts';
|
|
5463
5667
|
import { createHash } from 'node:crypto';
|
|
@@ -5597,6 +5801,35 @@ async function main() {
|
|
|
5597
5801
|
return;
|
|
5598
5802
|
}
|
|
5599
5803
|
|
|
5804
|
+
// ─── Secret firewall: BLOCK a literal credential in the command BEFORE it runs
|
|
5805
|
+
// or reaches the grader — the value never lands in a file, shell history, OR the
|
|
5806
|
+
// LLM provider (no leak to Anthropic/Cursor). Deterministic + local, no model
|
|
5807
|
+
// call. High-confidence formats only (a false block stops the user), and
|
|
5808
|
+
// $VAR / placeholder refs never trip it. ───
|
|
5809
|
+
const secretHits = detectHardSecrets(command);
|
|
5810
|
+
if (secretHits.length) {
|
|
5811
|
+
const types = secretHits.map(h => h.type).join(', ');
|
|
5812
|
+
const remediation = secretRemediation(command, secretHits);
|
|
5813
|
+
log('bashGuard ' + cmdShort + ' → BLOCKED: literal credential (' + types + ')');
|
|
5814
|
+
appendLocalTelemetry({
|
|
5815
|
+
capture_type: 'local_verdict',
|
|
5816
|
+
verdict: 'block',
|
|
5817
|
+
hook_type: 'bash',
|
|
5818
|
+
category: 'secret_exposure',
|
|
5819
|
+
tool_name: toolName,
|
|
5820
|
+
command: scrubSecrets(command).slice(0, 200),
|
|
5821
|
+
session_id: sessionId,
|
|
5822
|
+
repo: gitRepo || cwd,
|
|
5823
|
+
cc_model: transcript.ccModel,
|
|
5824
|
+
reasoning: 'Literal credential (' + types + ') in the proposed command — blocked locally before grading; never sent to the model.',
|
|
5825
|
+
});
|
|
5826
|
+
outputJson({
|
|
5827
|
+
systemMessage: tagStr + ' bashGuard → BLOCKED: literal credential in command (' + types + '). Not sent to the grader; use an env var reference instead.',
|
|
5828
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: remediation, additionalContext: remediation },
|
|
5829
|
+
});
|
|
5830
|
+
return;
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5600
5833
|
// ─── Install protection: install-scan runs first and owns block traces ───
|
|
5601
5834
|
let scanConcern = '';
|
|
5602
5835
|
let scanBlockContext = '';
|
|
@@ -6487,6 +6720,7 @@ import {
|
|
|
6487
6720
|
loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
|
|
6488
6721
|
parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules, ruleFilterText,
|
|
6489
6722
|
isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
|
|
6723
|
+
detectHardSecrets, secretRemediation, scrubSecrets,
|
|
6490
6724
|
extractTranscript, readLastPrompt, readSessionLog, compressSessionLog, sessionLogForGrade,
|
|
6491
6725
|
appendLocalTelemetry, logGraderUnavailable, graderUnavailableMessage, log, GATEWAY_URL,
|
|
6492
6726
|
loadSynkroFile, effectiveGraderPool, synkroFilePresent,
|
|
@@ -6643,6 +6877,23 @@ async function main() {
|
|
|
6643
6877
|
const rt = await route(config, synkroFile);
|
|
6644
6878
|
const tagStr = tag(rt, config, graderPool);
|
|
6645
6879
|
|
|
6880
|
+
// Secret firewall: BLOCK a literal credential in the command BEFORE it runs or
|
|
6881
|
+
// reaches the grader \u2014 never leaks to a file, shell history, or the model
|
|
6882
|
+
// (Anthropic/Cursor). Local + deterministic; $VAR / placeholder refs never trip it.
|
|
6883
|
+
const secretHits = detectHardSecrets(command);
|
|
6884
|
+
if (secretHits.length) {
|
|
6885
|
+
const types = secretHits.map(h => h.type).join(', ');
|
|
6886
|
+
const remediation = secretRemediation(command, secretHits);
|
|
6887
|
+
log('bashGuard ' + cmdShort + ' \u2192 BLOCKED: literal credential (' + types + ')');
|
|
6888
|
+
appendLocalTelemetry({
|
|
6889
|
+
capture_type: 'local_verdict', verdict: 'block', hook_type: 'bash',
|
|
6890
|
+
category: 'secret_exposure', tool_name: toolName, command: scrubSecrets(command).slice(0, 200),
|
|
6891
|
+
session_id: sessionId, repo: repo || cwd, cc_model: model,
|
|
6892
|
+
reasoning: 'Literal credential (' + types + ') in the proposed command \u2014 blocked locally before grading; never sent to the model.',
|
|
6893
|
+
});
|
|
6894
|
+
finishWith({ permission: 'deny', user_message: tagStr + ' bashGuard \u2192 BLOCKED: literal credential in command (' + types + '). Use an env var reference instead.', agent_message: remediation });
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6646
6897
|
// Install protection \u2014 install-scan runs first and owns block traces.
|
|
6647
6898
|
let scanConcern = '';
|
|
6648
6899
|
let scanBlockContext = '';
|
|
@@ -10957,7 +11208,7 @@ function writeConfigEnv(opts) {
|
|
|
10957
11208
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
10958
11209
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
10959
11210
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
10960
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
11211
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.91")}`
|
|
10961
11212
|
];
|
|
10962
11213
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
10963
11214
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -15000,7 +15251,7 @@ var args = process.argv.slice(2);
|
|
|
15000
15251
|
var cmd = args[0] || "";
|
|
15001
15252
|
var subArgs = args.slice(1);
|
|
15002
15253
|
function printVersion() {
|
|
15003
|
-
console.log("1.6.
|
|
15254
|
+
console.log("1.6.91");
|
|
15004
15255
|
}
|
|
15005
15256
|
function printHelp2() {
|
|
15006
15257
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|