fullcourtdefense-cli 1.18.6 → 1.18.8
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/commands/daemon.js +75 -1
- package/dist/commands/deterministicGuard.js +111 -2
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -465,6 +465,67 @@ async function runDaemon(args, config) {
|
|
|
465
465
|
log(`Could not report remote action ${actionId} status ${status}; the server will expire it safely.`);
|
|
466
466
|
}
|
|
467
467
|
};
|
|
468
|
+
// ── Honest upgrade_cli completion ─────────────────────────────────────────
|
|
469
|
+
// An MSI/npm upgrade replaces THIS process: the daemon that triggered the
|
|
470
|
+
// update cannot observe the outcome. Persist the action id, let the freshly
|
|
471
|
+
// upgraded daemon (or a stale-marker check) report the REAL result — the
|
|
472
|
+
// dashboard should never show "succeeded" for a version that never arrived.
|
|
473
|
+
const pendingUpgradeFile = () => path.join(daemonDir(), 'pending-upgrade.json');
|
|
474
|
+
const PENDING_UPGRADE_STALE_MS = 15 * 60_000;
|
|
475
|
+
const writePendingUpgradeMarker = (actionId, target) => {
|
|
476
|
+
try {
|
|
477
|
+
fs.writeFileSync(pendingUpgradeFile(), JSON.stringify({
|
|
478
|
+
actionId,
|
|
479
|
+
target,
|
|
480
|
+
fromVersion: cliVersion(),
|
|
481
|
+
startedAt: new Date().toISOString(),
|
|
482
|
+
}), 'utf8');
|
|
483
|
+
}
|
|
484
|
+
catch { /* marker is best-effort; the server TTL expires the action safely */ }
|
|
485
|
+
};
|
|
486
|
+
const verifyPendingUpgrade = async () => {
|
|
487
|
+
let marker;
|
|
488
|
+
try {
|
|
489
|
+
marker = JSON.parse(fs.readFileSync(pendingUpgradeFile(), 'utf8'));
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return; // no pending upgrade
|
|
493
|
+
}
|
|
494
|
+
if (!marker?.actionId || !marker.target) {
|
|
495
|
+
try {
|
|
496
|
+
fs.unlinkSync(pendingUpgradeFile());
|
|
497
|
+
}
|
|
498
|
+
catch { /* ignore */ }
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const current = cliVersion();
|
|
502
|
+
if (compareVersions(current, marker.target) >= 0) {
|
|
503
|
+
try {
|
|
504
|
+
fs.unlinkSync(pendingUpgradeFile());
|
|
505
|
+
}
|
|
506
|
+
catch { /* ignore */ }
|
|
507
|
+
await reportMachineAction(marker.actionId, 'succeeded', {
|
|
508
|
+
resultSummary: `CLI updated to ${current} and the daemon restarted on the new build.`,
|
|
509
|
+
});
|
|
510
|
+
log(`Upgrade verified: CLI ${marker.fromVersion || 'unknown'} -> ${current} is live (action ${marker.actionId}).`);
|
|
511
|
+
await uploadLogTail();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const startedAt = marker.startedAt ? Date.parse(marker.startedAt) : NaN;
|
|
515
|
+
if (!Number.isFinite(startedAt) || Date.now() - startedAt > PENDING_UPGRADE_STALE_MS) {
|
|
516
|
+
try {
|
|
517
|
+
fs.unlinkSync(pendingUpgradeFile());
|
|
518
|
+
}
|
|
519
|
+
catch { /* ignore */ }
|
|
520
|
+
await reportMachineAction(marker.actionId, 'failed', {
|
|
521
|
+
error: `The updater was triggered but the CLI still reports ${current || 'unknown'} after ${Math.round(PENDING_UPGRADE_STALE_MS / 60_000)} minutes (target ${marker.target}). Check %ProgramData%\\FullCourtDefense\\updater.log on the machine.`,
|
|
522
|
+
});
|
|
523
|
+
log(`Upgrade verification failed: still on ${current || 'unknown'}, target was ${marker.target} (action ${marker.actionId}).`);
|
|
524
|
+
await uploadLogTail();
|
|
525
|
+
}
|
|
526
|
+
// Otherwise the install is still in flight — keep the marker and re-check
|
|
527
|
+
// on the next heartbeat; the post-install daemon relaunch settles it.
|
|
528
|
+
};
|
|
468
529
|
const executeMachineAction = async (action) => {
|
|
469
530
|
if (executingActionIds.has(action.id))
|
|
470
531
|
return;
|
|
@@ -547,7 +608,14 @@ async function runDaemon(args, config) {
|
|
|
547
608
|
resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target}.`;
|
|
548
609
|
}
|
|
549
610
|
else if (outcome.started) {
|
|
550
|
-
|
|
611
|
+
// The install replaces this process — DON'T report success yet.
|
|
612
|
+
// Leave the action "running" with a marker; the freshly upgraded
|
|
613
|
+
// daemon confirms the new version (or the check reports failure).
|
|
614
|
+
writePendingUpgradeMarker(action.id, target);
|
|
615
|
+
log(`Upgrade CLI: installer started — success will be confirmed once the new daemon is on ${target}.`);
|
|
616
|
+
executingActionIds.delete(action.id);
|
|
617
|
+
await uploadLogTail();
|
|
618
|
+
return;
|
|
551
619
|
}
|
|
552
620
|
else {
|
|
553
621
|
throw new Error(outcome.detail);
|
|
@@ -660,10 +728,16 @@ async function runDaemon(args, config) {
|
|
|
660
728
|
await uploadLogTail();
|
|
661
729
|
}
|
|
662
730
|
catch { /* spool stays on disk for the next tick */ }
|
|
731
|
+
// Settle any in-flight upgrade_cli action (success once the new build is
|
|
732
|
+
// live, failure when the target never arrived).
|
|
733
|
+
await verifyPendingUpgrade();
|
|
663
734
|
};
|
|
664
735
|
// --- boot ---------------------------------------------------------------
|
|
665
736
|
const watched = refreshWatchTargets();
|
|
666
737
|
log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
|
|
738
|
+
// First: if this boot IS the post-upgrade relaunch, confirm the pending
|
|
739
|
+
// upgrade_cli action before anything else touches the control plane.
|
|
740
|
+
await verifyPendingUpgrade();
|
|
667
741
|
await pollBundle();
|
|
668
742
|
// One protective pass at startup so a machine that drifted while the daemon
|
|
669
743
|
// was down converges immediately. Runs BEFORE the first heartbeat: upgrades
|
|
@@ -52,6 +52,105 @@ const SECRET_PATTERNS = [
|
|
|
52
52
|
{ itemId: 'huggingface_token', label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
|
|
53
53
|
{ itemId: 'database_url_password', label: 'database URL with password', re: /\b(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql):\/\/[^/\s'":]+:[^@\s'"]+@/i },
|
|
54
54
|
];
|
|
55
|
+
// Contextual secret detection: bare high-entropy strings (hex app secrets,
|
|
56
|
+
// random tokens) have no recognizable prefix, so SECRET_PATTERNS misses them.
|
|
57
|
+
// When one sits next to a credential keyword ("secret", "password", "api key"
|
|
58
|
+
// ...), treat it as secret material. Keyword proximity keeps git SHAs, UUIDs
|
|
59
|
+
// in URLs, and hashes in ordinary output from false-positiving.
|
|
60
|
+
// English terms use \b word boundaries (so "tokenizer" does not match "token").
|
|
61
|
+
// Deliberately canonical words only — no typo/shorthand variants (pswd, scrt,
|
|
62
|
+
// ...). A deterministic guard must stay predictable; typo evasion is handled
|
|
63
|
+
// by the cloud tier (meta-safety specialist is typo-augmented).
|
|
64
|
+
const CONTEXT_SECRET_KEYWORD_EN = /\b(?:secret|password|passwd|pwd|credential|passphrase|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|app[ _-]?secret|signing[ _-]?key|auth[ _-]?token|bearer|token)\b/i;
|
|
65
|
+
// Language-agnostic pack: password/secret/key words across common languages.
|
|
66
|
+
// No \b — JS word boundaries break on diacritics and non-Latin scripts. Broad
|
|
67
|
+
// everyday words ("clave", "chiave", "ключ") are safe to include because a
|
|
68
|
+
// keyword NEVER blocks alone: a high-entropy credential-looking token must
|
|
69
|
+
// also sit within the context window.
|
|
70
|
+
const CONTEXT_SECRET_KEYWORD_MULTI = new RegExp([
|
|
71
|
+
// Romance: Romanian (first customer — parola/parolă, cheie/cheia/cheile,
|
|
72
|
+
// credențial; "secret"/"token" are identical to English and covered there),
|
|
73
|
+
// Spanish, French, Italian, Portuguese
|
|
74
|
+
'parol', 'chei', 'credenția', 'credentia', 'contraseñ', 'clave', 'mot de passe', 'clé', 'jeton', 'chiave', 'segret', 'senha', 'chave', 'segredo',
|
|
75
|
+
// Germanic: German, Dutch, Swedish, Norwegian, Danish
|
|
76
|
+
'passwort', 'kennwort', 'schlüssel', 'geheim', 'wachtwoord', 'sleutel', 'lösenord', 'løsenord', 'passord', 'adgangskode', 'nøkkel', 'hemlig',
|
|
77
|
+
// Slavic (Latin + Cyrillic): Polish, Czech, Russian, Ukrainian, Bulgarian, Serbian
|
|
78
|
+
'hasło', 'haslo', 'klucz', 'sekret', 'heslo', 'klíč', 'geslo', 'пароль', 'парол', 'ключ', 'секрет', 'токен',
|
|
79
|
+
// Turkish, Hungarian, Finnish, Greek
|
|
80
|
+
'şifre', 'sifre', 'anahtar', 'gizli', 'jelszó', 'jelszo', 'kulcs', 'titok', 'salasana', 'avain', 'salainen', 'κωδικ', 'μυστικ', 'κλειδ',
|
|
81
|
+
// Hebrew, Arabic, Persian
|
|
82
|
+
'סיסמה', 'סיסמא', 'מפתח', 'סוד', 'كلمة المرور', 'كلمة السر', 'مفتاح', 'رمز سري', 'رمز', 'گذرواژه', 'رمز عبور',
|
|
83
|
+
// Indic: Hindi, Bengali, Tamil
|
|
84
|
+
'पासवर्ड', 'कुंजी', 'गुप्त', 'পাসওয়ার্ড', 'கடவுச்சொல்',
|
|
85
|
+
// CJK: Chinese (simplified + traditional), Japanese, Korean
|
|
86
|
+
'密码', '密碼', '密钥', '密鑰', '口令', '令牌', '秘钥', 'パスワード', 'トークン', '秘密鍵', '비밀번호', '암호', '토큰', '비밀 키',
|
|
87
|
+
// Vietnamese, Indonesian/Malay, Thai
|
|
88
|
+
'mật khẩu', 'bí mật', 'kata sandi', 'kata laluan', 'rahasia', 'rahsia', 'kunci', 'รหัสผ่าน', 'กุญแจ', 'ความลับ',
|
|
89
|
+
].join('|'), 'iu');
|
|
90
|
+
function hasSecretKeyword(text) {
|
|
91
|
+
return CONTEXT_SECRET_KEYWORD_EN.test(text) || CONTEXT_SECRET_KEYWORD_MULTI.test(text);
|
|
92
|
+
}
|
|
93
|
+
const CONTEXT_WINDOW_CHARS = 120;
|
|
94
|
+
function shannonEntropy(value) {
|
|
95
|
+
const counts = new Map();
|
|
96
|
+
for (const ch of value)
|
|
97
|
+
counts.set(ch, (counts.get(ch) || 0) + 1);
|
|
98
|
+
let entropy = 0;
|
|
99
|
+
for (const count of counts.values()) {
|
|
100
|
+
const p = count / value.length;
|
|
101
|
+
entropy -= p * Math.log2(p);
|
|
102
|
+
}
|
|
103
|
+
return entropy;
|
|
104
|
+
}
|
|
105
|
+
function isHighEntropyToken(token) {
|
|
106
|
+
// Pure hex (app secrets, HMAC keys): hex charset caps entropy at 4 bits and
|
|
107
|
+
// excludes most English letters, so a modest bar is already selective.
|
|
108
|
+
if (/^[A-Fa-f0-9]+$/.test(token)) {
|
|
109
|
+
return token.length >= 24 && shannonEntropy(token) >= 3.0;
|
|
110
|
+
}
|
|
111
|
+
// Mixed charset: the 3.8-bit bar keeps camelCase identifiers and English-y
|
|
112
|
+
// names out (they sit ~3.0-3.6) while random tokens land at ~4.1+.
|
|
113
|
+
if (token.length >= 20 && /\d/.test(token) && /[A-Za-z]/.test(token) && shannonEntropy(token) >= 3.8)
|
|
114
|
+
return true;
|
|
115
|
+
// KEY=value style: evaluate the assigned value on its own.
|
|
116
|
+
const assigned = token.includes('=') ? token.split('=').pop() : undefined;
|
|
117
|
+
if (assigned && assigned !== token && assigned.length >= 20)
|
|
118
|
+
return isHighEntropyToken(assigned);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function maskSecretValue(value) {
|
|
122
|
+
return value.length <= 10 ? '***' : `${value.slice(0, 6)}…${value.slice(-4)} (${value.length} chars)`;
|
|
123
|
+
}
|
|
124
|
+
function findContextualSecret(text) {
|
|
125
|
+
if (!hasSecretKeyword(text))
|
|
126
|
+
return undefined;
|
|
127
|
+
for (const match of text.matchAll(/[A-Za-z0-9_\-+/=]{20,}/g)) {
|
|
128
|
+
const token = match[0];
|
|
129
|
+
const index = match.index ?? 0;
|
|
130
|
+
// Skip tokens inside URLs / file paths — path segments and query params
|
|
131
|
+
// routinely look random but are not pasteable credentials.
|
|
132
|
+
const before = text.slice(Math.max(0, index - 2), index);
|
|
133
|
+
if (/[/\\.]/.test(before))
|
|
134
|
+
continue;
|
|
135
|
+
const windowText = text.slice(Math.max(0, index - CONTEXT_WINDOW_CHARS), Math.min(text.length, index + token.length + CONTEXT_WINDOW_CHARS));
|
|
136
|
+
if (!hasSecretKeyword(windowText.replace(token, '')))
|
|
137
|
+
continue;
|
|
138
|
+
if (!isHighEntropyToken(token))
|
|
139
|
+
continue;
|
|
140
|
+
return { itemId: 'contextual_secret', label: 'credential-like string near a secret keyword', value: maskSecretValue(token) };
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
/** toolArgs like { password: "<random>" } — the keyword lives in the key, not the value. */
|
|
145
|
+
function contextualSecretFromKeyPath(keyPath, value) {
|
|
146
|
+
const lastKey = keyPath.split('.').pop() || '';
|
|
147
|
+
if (!hasSecretKeyword(lastKey.replace(/[_-]/g, ' ')))
|
|
148
|
+
return undefined;
|
|
149
|
+
const trimmed = value.trim();
|
|
150
|
+
if (!/^[A-Za-z0-9_\-+/=]+$/.test(trimmed) || !isHighEntropyToken(trimmed))
|
|
151
|
+
return undefined;
|
|
152
|
+
return { itemId: 'contextual_secret', label: `credential-like value in "${lastKey}"`, value: maskSecretValue(trimmed) };
|
|
153
|
+
}
|
|
55
154
|
const METADATA_ENDPOINTS = [
|
|
56
155
|
{ itemId: 'aws_gcp_metadata_ip', value: '169.254.169.254', label: 'cloud metadata endpoint' },
|
|
57
156
|
{ itemId: 'gcp_metadata_host', value: 'metadata.google.internal', label: 'GCP metadata endpoint' },
|
|
@@ -358,7 +457,9 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
|
|
|
358
457
|
}
|
|
359
458
|
if (outbound) {
|
|
360
459
|
for (const candidate of candidates) {
|
|
361
|
-
const secret = findSecret(candidate.value)
|
|
460
|
+
const secret = findSecret(candidate.value)
|
|
461
|
+
|| contextualSecretFromKeyPath(candidate.keyPath, candidate.value)
|
|
462
|
+
|| findContextualSecret(candidate.value);
|
|
362
463
|
if (secret) {
|
|
363
464
|
return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-exfiltration', `Blocked outbound data containing ${secret.label}.`, secret.value, secret.label, options);
|
|
364
465
|
}
|
|
@@ -372,7 +473,7 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
|
|
|
372
473
|
return undefined;
|
|
373
474
|
}
|
|
374
475
|
function scanDeterministicTextResponse(text, options) {
|
|
375
|
-
const secret = findSecret(text);
|
|
476
|
+
const secret = findSecret(text) || findContextualSecret(text);
|
|
376
477
|
if (!secret)
|
|
377
478
|
return undefined;
|
|
378
479
|
return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
|
|
@@ -397,6 +498,14 @@ function scanDeterministicPrompt(text, options) {
|
|
|
397
498
|
if (secret && OUTBOUND_TOOL_HINT.test(trimmed)) {
|
|
398
499
|
return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-prompt-secret-exfiltration', `Blocked prompt asking to send ${secret.label}.`, secret.value, secret.label, options);
|
|
399
500
|
}
|
|
501
|
+
// Pasting a credential next to "secret"/"password"/"api key" wording blocks
|
|
502
|
+
// even without send intent — once it is in the agent context it can leak
|
|
503
|
+
// through any later tool call. Known-prefix secrets without such wording
|
|
504
|
+
// keep the outbound-intent requirement above (devs paste keys to debug).
|
|
505
|
+
const contextualSecret = findContextualSecret(trimmed);
|
|
506
|
+
if (contextualSecret) {
|
|
507
|
+
return builtIn(contextualSecret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-prompt-contextual-secret', `Blocked prompt containing ${contextualSecret.label}.`, contextualSecret.value, contextualSecret.label, options);
|
|
508
|
+
}
|
|
400
509
|
const sensitivePath = containsSensitiveCredentialPath(trimmed);
|
|
401
510
|
if (sensitivePath && bareOrActionIntent(trimmed, /\b(?:read|open|cat|show|print|copy|upload|send|exfiltrate)\b/i)) {
|
|
402
511
|
return builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-prompt-sensitive-credential-path', `Blocked prompt targeting ${sensitivePath.label}.`, trimmed, sensitivePath.label, options);
|
package/dist/version.json
CHANGED