fullcourtdefense-cli 1.35.5 → 1.35.7
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/hook.js +3 -1
- package/dist/devConfirm.d.ts +36 -3
- package/dist/devConfirm.js +121 -28
- package/dist/telemetry.js +21 -1
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/hook.js
CHANGED
|
@@ -820,7 +820,9 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
820
820
|
// this exact rule (same rule hash). Rules that forbid memory never reach here.
|
|
821
821
|
// The caps in force come from the rule and the org ceiling in the cached bundle;
|
|
822
822
|
// an org that switched "Always allow" off (`null`) honours no grant at all.
|
|
823
|
-
|
|
823
|
+
// The grant binds the facts THIS rule fired on: a credential-command rule is
|
|
824
|
+
// about the program, an egress / SSRF / exfil rule is about the host too.
|
|
825
|
+
const grantScope = (0, devConfirm_1.grantScopeFor)(event, call.toolName, call.toolArgs, operation, context, (0, devConfirm_1.grantRuleFactsFor)(source));
|
|
824
826
|
const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
|
|
825
827
|
const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
|
|
826
828
|
if (grant) {
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -293,8 +293,10 @@ export declare function sweepExpiredConfirmations(now?: Date, ttlMs?: number): P
|
|
|
293
293
|
* programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
|
|
294
294
|
* `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
|
|
295
295
|
* Always allow is the real app (`gcloud` / `gh` / `aws` / …), not that
|
|
296
|
-
* wrapper mix.
|
|
297
|
-
*
|
|
296
|
+
* wrapper mix. Text filters after a pipe (`| Select-String`, `| grep | head`)
|
|
297
|
+
* are output shaping, not programs; the same names leading a statement
|
|
298
|
+
* (`head .env`) stay. `exit` is control flow. Trailing junk
|
|
299
|
+
* (`out-string).trim()`) is stripped. Sorted and unique.
|
|
298
300
|
*/
|
|
299
301
|
export declare function shellPrograms(command: string): string[];
|
|
300
302
|
/** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
|
|
@@ -332,7 +334,38 @@ export declare function listDeveloperGrants(now?: Date): DeveloperGrant[];
|
|
|
332
334
|
export declare function revokeDeveloperGrants(key?: string): number;
|
|
333
335
|
/** One line a person can read on the button and in the grant list: what "Always allow" covers. */
|
|
334
336
|
export declare function describeGrantScope(scope: GrantScope): string;
|
|
335
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Which line-level facts the RULE that asked actually depends on. A grant binds
|
|
339
|
+
* the facts the rule fired on and nothing else: `gcloud_access_token_cmd` fired
|
|
340
|
+
* on `gcloud auth print-access-token`, so where a later `Invoke-RestMethod` in
|
|
341
|
+
* the same line posts (`logging.` vs `monitoring.googleapis.com`) and whether
|
|
342
|
+
* the engine classed the line `write` or `SHELL` must not partition the grant —
|
|
343
|
+
* that is what made every Always allow ask again (this machine, 2026-09-14..20).
|
|
344
|
+
* A rule that IS about the host (egress policy, SSRF, exfiltration, reverse
|
|
345
|
+
* shell) keeps the host in the grant so `evil.example` never rides on it.
|
|
346
|
+
*/
|
|
347
|
+
export interface GrantRuleFacts {
|
|
348
|
+
bindsDestination: boolean;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Derive `GrantRuleFacts` from the source of the ask. Unknown = bound (the
|
|
352
|
+
* narrower grant), so a policy the cache no longer holds never widens anything.
|
|
353
|
+
*/
|
|
354
|
+
export declare function grantRuleFactsFor(source: {
|
|
355
|
+
localFinding?: {
|
|
356
|
+
category?: string;
|
|
357
|
+
};
|
|
358
|
+
policyId?: string;
|
|
359
|
+
policies?: Array<{
|
|
360
|
+
id: string;
|
|
361
|
+
rules?: Array<{
|
|
362
|
+
constraints?: Array<{
|
|
363
|
+
field: string;
|
|
364
|
+
}>;
|
|
365
|
+
}>;
|
|
366
|
+
}>;
|
|
367
|
+
}): GrantRuleFacts;
|
|
368
|
+
export declare function grantScopeFor(event: string, toolName: string, toolArgs: Record<string, unknown> | undefined, operation: string, context: Record<string, unknown> | undefined, facts?: GrantRuleFacts): GrantScope;
|
|
336
369
|
/**
|
|
337
370
|
* Is this exact action covered in the same session and policy context?
|
|
338
371
|
* The 1h choice is a shorter expiry, never authorization for another session.
|
package/dist/devConfirm.js
CHANGED
|
@@ -61,6 +61,7 @@ exports.recordDeveloperGrant = recordDeveloperGrant;
|
|
|
61
61
|
exports.listDeveloperGrants = listDeveloperGrants;
|
|
62
62
|
exports.revokeDeveloperGrants = revokeDeveloperGrants;
|
|
63
63
|
exports.describeGrantScope = describeGrantScope;
|
|
64
|
+
exports.grantRuleFactsFor = grantRuleFactsFor;
|
|
64
65
|
exports.grantScopeFor = grantScopeFor;
|
|
65
66
|
exports.isConfirmationRemembered = isConfirmationRemembered;
|
|
66
67
|
/**
|
|
@@ -819,11 +820,31 @@ const SHELL_INCIDENTAL = new Set([
|
|
|
819
820
|
const SHELL_CONTROL_WORDS = /^(if|then|else|elif|elseif|fi|do|done|for|while|until|case|esac|try|catch|finally|foreach|function|return|exit|switch|break|continue)$/i;
|
|
820
821
|
/** Words a body follows directly (`then rm x`, `do echo`, PowerShell `else { … }`). */
|
|
821
822
|
const SHELL_BODY_LEADS = /^(then|do|else)$/i;
|
|
822
|
-
/**
|
|
823
|
+
/**
|
|
824
|
+
* Text filters. In PIPELINE position (`gcloud … | Select-String x`, `gh … | grep
|
|
825
|
+
* y | head -1`) they shape the previous program's output and are not the app the
|
|
826
|
+
* rule asked about — binding Always allow to `select-string` made the next
|
|
827
|
+
* `gcloud … | Select-Object` line ask again (this machine, 2026-09-20). As a
|
|
828
|
+
* statement LEAD they read the file they are given (`head .env`, `grep x
|
|
829
|
+
* ~/.aws/credentials`) and stay the program.
|
|
830
|
+
*/
|
|
831
|
+
const SHELL_TEXT_FILTERS = new Set([
|
|
832
|
+
'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'sls', 'awk', 'gawk', 'sed',
|
|
833
|
+
'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'jq', 'yq', 'column', 'nl', 'more', 'less', 'cat', 'tee',
|
|
834
|
+
]);
|
|
835
|
+
/**
|
|
836
|
+
* Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not
|
|
837
|
+
* semantics. Separators inside `{ … }` do not split: a control statement's
|
|
838
|
+
* bodies (`if (…) { a; b } else { c }`) stay with their statement so every
|
|
839
|
+
* body is read (`braceBodies`). `piped` = this segment is a pipeline stage
|
|
840
|
+
* (`|`, not `||`).
|
|
841
|
+
*/
|
|
823
842
|
function splitShellSegments(command) {
|
|
824
843
|
const segments = [];
|
|
825
844
|
let current = '';
|
|
845
|
+
let piped = false;
|
|
826
846
|
let quote = null;
|
|
847
|
+
let braces = 0;
|
|
827
848
|
for (let i = 0; i < command.length; i++) {
|
|
828
849
|
const ch = command[i];
|
|
829
850
|
if (quote) {
|
|
@@ -839,18 +860,28 @@ function splitShellSegments(command) {
|
|
|
839
860
|
current += ch;
|
|
840
861
|
continue;
|
|
841
862
|
}
|
|
842
|
-
if (ch === '
|
|
863
|
+
if (ch === '{')
|
|
864
|
+
braces++;
|
|
865
|
+
else if (ch === '}' && braces > 0)
|
|
866
|
+
braces--;
|
|
867
|
+
if (braces === 0 && (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i + 1] === '&'))) {
|
|
868
|
+
let nextPiped = false;
|
|
843
869
|
if (ch === '&')
|
|
844
870
|
i++;
|
|
845
|
-
if (ch === '|'
|
|
846
|
-
i
|
|
847
|
-
|
|
871
|
+
if (ch === '|') {
|
|
872
|
+
if (command[i + 1] === '|')
|
|
873
|
+
i++;
|
|
874
|
+
else
|
|
875
|
+
nextPiped = true;
|
|
876
|
+
}
|
|
877
|
+
segments.push({ text: current, piped });
|
|
848
878
|
current = '';
|
|
879
|
+
piped = nextPiped;
|
|
849
880
|
continue;
|
|
850
881
|
}
|
|
851
882
|
current += ch;
|
|
852
883
|
}
|
|
853
|
-
segments.push(current);
|
|
884
|
+
segments.push({ text: current, piped });
|
|
854
885
|
return segments;
|
|
855
886
|
}
|
|
856
887
|
/** `out-string).trim()` is not a program — take the leading identifier only. */
|
|
@@ -872,10 +903,58 @@ function nestedShellBody(segment) {
|
|
|
872
903
|
rest = rest.slice(1, -1);
|
|
873
904
|
return rest.trim() || undefined;
|
|
874
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* Every top-level `{ … }` body of a control statement, quote-aware. An
|
|
908
|
+
* unterminated body (a segment cut before its `}`) yields what follows the
|
|
909
|
+
* `{` — under-reading a body is what let a branch hide a program.
|
|
910
|
+
*/
|
|
911
|
+
function braceBodies(statement) {
|
|
912
|
+
const bodies = [];
|
|
913
|
+
let quote = null;
|
|
914
|
+
let depth = 0;
|
|
915
|
+
let start = -1;
|
|
916
|
+
let data = false; // `${VAR}` / `@{ k = v }` — an expansion or a hashtable, not a script block
|
|
917
|
+
for (let i = 0; i < statement.length; i++) {
|
|
918
|
+
const ch = statement[i];
|
|
919
|
+
if (quote) {
|
|
920
|
+
if (ch === quote)
|
|
921
|
+
quote = null;
|
|
922
|
+
else if (ch === '\\' && quote === '"')
|
|
923
|
+
i++;
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
927
|
+
quote = ch;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (ch === '{') {
|
|
931
|
+
if (depth++ === 0) {
|
|
932
|
+
data = /[$@]/.test(statement[i - 1] || '');
|
|
933
|
+
start = i + 1;
|
|
934
|
+
}
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (ch === '}' && depth > 0 && --depth === 0) {
|
|
938
|
+
if (!data)
|
|
939
|
+
bodies.push(statement.slice(start, i));
|
|
940
|
+
start = -1;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (start >= 0 && !data)
|
|
944
|
+
bodies.push(statement.slice(start));
|
|
945
|
+
return bodies.map(body => body.trim()).filter(Boolean);
|
|
946
|
+
}
|
|
875
947
|
function collectPrograms(command, out, depth) {
|
|
876
|
-
if (depth >
|
|
948
|
+
if (depth > 6 || !command.trim())
|
|
877
949
|
return;
|
|
878
|
-
for (const raw of splitShellSegments(command)) {
|
|
950
|
+
for (const { text: raw, piped } of splitShellSegments(command)) {
|
|
951
|
+
// Script blocks anywhere in the statement run programs of their own —
|
|
952
|
+
// `if (c) { a } else { b }`, `… | ForEach-Object { curl … }`, `try { … } catch { … }`,
|
|
953
|
+
// `{ git status }`. EVERY body is read: reading only the first let a `{gh}`
|
|
954
|
+
// grant cover `if ($x) { gh auth token } else { curl https://evil… }`.
|
|
955
|
+
if (raw.includes('{'))
|
|
956
|
+
for (const body of braceBodies(raw))
|
|
957
|
+
collectPrograms(body, out, depth + 1);
|
|
879
958
|
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
880
959
|
let i = 0;
|
|
881
960
|
// `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …` / `$t = (gcloud …)`
|
|
@@ -906,23 +985,10 @@ function collectPrograms(command, out, depth) {
|
|
|
906
985
|
i++;
|
|
907
986
|
continue;
|
|
908
987
|
}
|
|
988
|
+
// Control statement: its bodies were read above; the statement itself is not a program.
|
|
909
989
|
if (SHELL_CONTROL_WORDS.test(w)) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const open = words.findIndex((word, index) => index > i && word.includes('{'));
|
|
913
|
-
if (open < 0) {
|
|
914
|
-
i = words.length;
|
|
915
|
-
break;
|
|
916
|
-
}
|
|
917
|
-
const rest = words[open].slice(words[open].indexOf('{') + 1);
|
|
918
|
-
if (rest) {
|
|
919
|
-
words[open] = rest;
|
|
920
|
-
i = open;
|
|
921
|
-
}
|
|
922
|
-
else {
|
|
923
|
-
i = open + 1;
|
|
924
|
-
}
|
|
925
|
-
continue;
|
|
990
|
+
i = words.length;
|
|
991
|
+
break;
|
|
926
992
|
}
|
|
927
993
|
break;
|
|
928
994
|
}
|
|
@@ -932,6 +998,8 @@ function collectPrograms(command, out, depth) {
|
|
|
932
998
|
const name = programToken(lead);
|
|
933
999
|
if (!name || SHELL_PIPELINE_NOISE.has(name) || SHELL_INCIDENTAL.has(name))
|
|
934
1000
|
continue;
|
|
1001
|
+
if (piped && SHELL_TEXT_FILTERS.has(name))
|
|
1002
|
+
continue;
|
|
935
1003
|
if (GENERIC_SHELLS.has(name)) {
|
|
936
1004
|
const nested = nestedShellBody(raw);
|
|
937
1005
|
if (nested)
|
|
@@ -947,8 +1015,10 @@ function collectPrograms(command, out, depth) {
|
|
|
947
1015
|
* programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
|
|
948
1016
|
* `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
|
|
949
1017
|
* Always allow is the real app (`gcloud` / `gh` / `aws` / …), not that
|
|
950
|
-
* wrapper mix.
|
|
951
|
-
*
|
|
1018
|
+
* wrapper mix. Text filters after a pipe (`| Select-String`, `| grep | head`)
|
|
1019
|
+
* are output shaping, not programs; the same names leading a statement
|
|
1020
|
+
* (`head .env`) stay. `exit` is control flow. Trailing junk
|
|
1021
|
+
* (`out-string).trim()`) is stripped. Sorted and unique.
|
|
952
1022
|
*/
|
|
953
1023
|
function shellPrograms(command) {
|
|
954
1024
|
const out = new Set();
|
|
@@ -1131,7 +1201,25 @@ function scriptGrantKeys(command, cwd) {
|
|
|
1131
1201
|
});
|
|
1132
1202
|
return [...new Set(keys)].sort();
|
|
1133
1203
|
}
|
|
1134
|
-
|
|
1204
|
+
/** Local Safety categories whose finding is the destination host itself. */
|
|
1205
|
+
const DESTINATION_BOUND_CATEGORIES = new Set(['metadata_ssrf', 'secret_exfiltration', 'reverse_shell']);
|
|
1206
|
+
/**
|
|
1207
|
+
* Derive `GrantRuleFacts` from the source of the ask. Unknown = bound (the
|
|
1208
|
+
* narrower grant), so a policy the cache no longer holds never widens anything.
|
|
1209
|
+
*/
|
|
1210
|
+
function grantRuleFactsFor(source) {
|
|
1211
|
+
if (source.localFinding) {
|
|
1212
|
+
return { bindsDestination: DESTINATION_BOUND_CATEGORIES.has(String(source.localFinding.category || '')) };
|
|
1213
|
+
}
|
|
1214
|
+
const policy = source.policyId ? source.policies?.find(p => p.id === source.policyId) : undefined;
|
|
1215
|
+
if (!policy)
|
|
1216
|
+
return { bindsDestination: true };
|
|
1217
|
+
const bindsDestination = (policy.rules || []).some(rule => (rule.constraints || []).some(c => typeof c.field === 'string' && c.field.startsWith('destination.')));
|
|
1218
|
+
return { bindsDestination };
|
|
1219
|
+
}
|
|
1220
|
+
/** Grants made without rule facts (MCP gateway, older callers) keep the narrow, host-bound shape. */
|
|
1221
|
+
const DEFAULT_GRANT_RULE_FACTS = { bindsDestination: true };
|
|
1222
|
+
function grantScopeFor(event, toolName, toolArgs, operation, context, facts = DEFAULT_GRANT_RULE_FACTS) {
|
|
1135
1223
|
const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
|
|
1136
1224
|
if (event === 'shell') {
|
|
1137
1225
|
const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
|
|
@@ -1140,9 +1228,14 @@ function grantScopeFor(event, toolName, toolArgs, operation, context) {
|
|
|
1140
1228
|
const scripts = scriptGrantKeys(command, cwd);
|
|
1141
1229
|
if (scripts.length)
|
|
1142
1230
|
scope.scripts = scripts;
|
|
1231
|
+
// A shell line's identity is its programs. The engine verb (`write` for a
|
|
1232
|
+
// POST, `SHELL` otherwise) describes the line, not the app the rule asked
|
|
1233
|
+
// about, and it changed the key between two `gcloud … ; Invoke-RestMethod`
|
|
1234
|
+
// lines that fired the very same rule. One verb for every shell grant.
|
|
1235
|
+
scope.operation = 'SHELL';
|
|
1143
1236
|
}
|
|
1144
1237
|
const domain = context?.['destination.domain'];
|
|
1145
|
-
if (typeof domain === 'string' && domain.trim())
|
|
1238
|
+
if (facts.bindsDestination && typeof domain === 'string' && domain.trim())
|
|
1146
1239
|
scope.destinationDomain = domain.trim().toLowerCase();
|
|
1147
1240
|
return scope;
|
|
1148
1241
|
}
|
package/dist/telemetry.js
CHANGED
|
@@ -84,6 +84,26 @@ const ACTION_EVIDENCE_KEYS = [
|
|
|
84
84
|
];
|
|
85
85
|
/** Noise / secrets — never stamp these as activity evidence. */
|
|
86
86
|
const EVIDENCE_SKIP_KEYS = /^(cwd|pwd|env|environment|headers|cookie|cookies|authorization|password|passwd|secret|token|api[_-]?key|private[_-]?key|session|retryCount|timeoutMs|timeout)$/i;
|
|
87
|
+
/**
|
|
88
|
+
* Payload keys — a file body, a patch, message text — are business content,
|
|
89
|
+
* never evidence. The record carries the ACTION (tool, path, command, verdict,
|
|
90
|
+
* rule); what was written is the customer's. Only the size leaves the machine:
|
|
91
|
+
* a `.env` edit on a fleet machine shipped its whole body, signing secret
|
|
92
|
+
* included, before this (2026-09-18). The suffix is the backend's idempotency
|
|
93
|
+
* marker (`modules/runtime/tool-args.ts`), so re-sanitizing leaves it alone.
|
|
94
|
+
*/
|
|
95
|
+
const EVIDENCE_CONTENT_KEYS = /^(content|contents|body|file_?contents?|stdin|payload|diff|patch|new_?str(ing)?|old_?str(ing)?)$/i;
|
|
96
|
+
/** Text a human or agent TYPED into a field or a page script — count only, the same rule the backend applies on read. */
|
|
97
|
+
const EVIDENCE_TYPED_TEXT_KEYS = /^(text|value|input|keys|type_?text)$/i;
|
|
98
|
+
function minimizedEvidenceValue(key, value) {
|
|
99
|
+
if (typeof value !== 'string')
|
|
100
|
+
return undefined;
|
|
101
|
+
if (EVIDENCE_CONTENT_KEYS.test(key))
|
|
102
|
+
return `[… ${value.length} chars total — content minimized]`;
|
|
103
|
+
if (EVIDENCE_TYPED_TEXT_KEYS.test(key))
|
|
104
|
+
return `[${value.length} character${value.length === 1 ? '' : 's'} typed]`;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
87
107
|
function stringifyEvidenceValue(value) {
|
|
88
108
|
// One value (the command line) may use most of the envelope; keys and quoting take the rest.
|
|
89
109
|
if (typeof value === 'string' && value.trim())
|
|
@@ -128,7 +148,7 @@ function evidenceFromToolArgs(toolArgs) {
|
|
|
128
148
|
continue;
|
|
129
149
|
if (Object.keys(picked).length >= 8)
|
|
130
150
|
break;
|
|
131
|
-
const text = stringifyEvidenceValue(value);
|
|
151
|
+
const text = minimizedEvidenceValue(key, value) || stringifyEvidenceValue(value);
|
|
132
152
|
if (text)
|
|
133
153
|
picked[key] = text;
|
|
134
154
|
}
|
package/dist/version.json
CHANGED