fullcourtdefense-cli 1.35.2 → 1.35.5
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/actionPolicyEngine.js +88 -8
- package/dist/approvalPrompt.d.ts +3 -0
- package/dist/approvalPrompt.js +20 -1
- package/dist/blockExplanation.js +17 -0
- package/dist/commandNarrative.d.ts +29 -0
- package/dist/commandNarrative.js +605 -0
- package/dist/commands/deterministicGuard.d.ts +7 -0
- package/dist/commands/deterministicGuard.js +146 -13
- package/dist/commands/shellGuard.js +1 -1
- package/dist/commands/workloadProtect.d.ts +10 -0
- package/dist/commands/workloadProtect.js +27 -4
- package/dist/detectorBaseline.d.ts +2 -2
- package/dist/detectorBaseline.js +2 -2
- package/dist/devConfirm.d.ts +30 -13
- package/dist/devConfirm.js +194 -38
- package/dist/index.js +4 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
package/dist/devConfirm.d.ts
CHANGED
|
@@ -95,19 +95,30 @@ export interface ConfirmedAction {
|
|
|
95
95
|
}
|
|
96
96
|
/**
|
|
97
97
|
* WHAT an "Always allow" covers. Structural, parsed from the action — never the
|
|
98
|
-
* command text or a pattern over it: the programs a shell line runs (
|
|
99
|
-
*
|
|
98
|
+
* command text or a pattern over it: the real programs a shell line runs (so
|
|
99
|
+
* `git push && gh pr create` and `gh pr view` are different sets), the MCP
|
|
100
100
|
* server + tool, the operation the engine derived, and the destination host when
|
|
101
|
-
* the action reaches one.
|
|
102
|
-
* `
|
|
101
|
+
* the action reaches one. Pipeline cmdlets and generic shells (`cmd`,
|
|
102
|
+
* `powershell`, `bash -c`) are not programs — a grant for `gcloud` never
|
|
103
|
+
* becomes a grant for every command. A grant for `gh` writing to `github.com`
|
|
104
|
+
* never covers `curl` writing to `github.com`, nor `gh` writing to `gitlab.com`.
|
|
103
105
|
*/
|
|
104
106
|
export interface GrantScope {
|
|
105
107
|
/** Hook event: shell | mcp | file | read | prompt. */
|
|
106
108
|
event: string;
|
|
107
109
|
/** Shell: the IDE tool (Bash / shell). MCP: `mcp__<server>__<tool>`. Files: the tool. */
|
|
108
110
|
toolName: string;
|
|
109
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Shell only: sorted, de-duplicated real programs. Wrappers (`foreach-object`,
|
|
113
|
+
* `write-output`) and generic shells (`cmd`, `powershell`) are stripped; `cmd /c gcloud`
|
|
114
|
+
* is `{gcloud}`.
|
|
115
|
+
*/
|
|
110
116
|
programs?: string[];
|
|
117
|
+
/**
|
|
118
|
+
* Shell only: referenced script paths this line executes (`node scripts/x.js`).
|
|
119
|
+
* When present, "Always allow" is bound to these files, not every `node`.
|
|
120
|
+
*/
|
|
121
|
+
scripts?: string[];
|
|
111
122
|
/** Engine-derived operation (read / write / SHELL / DELETE / …). */
|
|
112
123
|
operation: string;
|
|
113
124
|
/** Host the action reaches (engine `destination.domain`), when any. */
|
|
@@ -277,16 +288,23 @@ export declare function rejectDeveloperConfirmation(event: string, toolName: str
|
|
|
277
288
|
*/
|
|
278
289
|
export declare function sweepExpiredConfirmations(now?: Date, ttlMs?: number): PendingConfirmation[];
|
|
279
290
|
/**
|
|
280
|
-
* The programs a shell line runs — one per segment,
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
291
|
+
* The real programs a shell line runs — one per segment, wrappers (`sudo`,
|
|
292
|
+
* `env`) and `$var =` peeled off. Pipeline cmdlets and generic shells are not
|
|
293
|
+
* programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
|
|
294
|
+
* `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
|
|
295
|
+
* Always allow is the real app (`gcloud` / `gh` / `aws` / …), not that
|
|
296
|
+
* wrapper mix. `exit` is control flow. Trailing junk (`out-string).trim()`)
|
|
297
|
+
* is stripped. Sorted and unique.
|
|
284
298
|
*/
|
|
285
299
|
export declare function shellPrograms(command: string): string[];
|
|
300
|
+
/** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
|
|
301
|
+
export declare function splitLocalSafetyRuleIds(matchedRule?: string): string[] | undefined;
|
|
286
302
|
/**
|
|
287
|
-
* Does a live grant cover this action under this
|
|
288
|
-
*
|
|
289
|
-
*
|
|
303
|
+
* Does a live grant cover this action under this rule? Combined Local Safety
|
|
304
|
+
* asks (`a+b`) match a grant for `a` or `b`. Shell programs match by INCLUSION:
|
|
305
|
+
* a grant for `{gh, git}` covers a line that only runs `gh`, never a line that
|
|
306
|
+
* also runs `curl`. Generic shells and pipeline cmdlets are not in the set.
|
|
307
|
+
* Touch counts a use.
|
|
290
308
|
*
|
|
291
309
|
* `limits` are the caps in force NOW (rule + org ceiling). A grant older or wider than
|
|
292
310
|
* they allow is ignored — tightening the org setting takes effect on the next action,
|
|
@@ -314,7 +332,6 @@ export declare function listDeveloperGrants(now?: Date): DeveloperGrant[];
|
|
|
314
332
|
export declare function revokeDeveloperGrants(key?: string): number;
|
|
315
333
|
/** One line a person can read on the button and in the grant list: what "Always allow" covers. */
|
|
316
334
|
export declare function describeGrantScope(scope: GrantScope): string;
|
|
317
|
-
/** The scope a grant would have for this action — from engine facts, never from text. */
|
|
318
335
|
export declare function grantScopeFor(event: string, toolName: string, toolArgs: Record<string, unknown> | undefined, operation: string, context: Record<string, unknown> | undefined): GrantScope;
|
|
319
336
|
/**
|
|
320
337
|
* Is this exact action covered in the same session and policy context?
|
package/dist/devConfirm.js
CHANGED
|
@@ -55,6 +55,7 @@ exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
|
|
|
55
55
|
exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
|
|
56
56
|
exports.sweepExpiredConfirmations = sweepExpiredConfirmations;
|
|
57
57
|
exports.shellPrograms = shellPrograms;
|
|
58
|
+
exports.splitLocalSafetyRuleIds = splitLocalSafetyRuleIds;
|
|
58
59
|
exports.findDeveloperGrant = findDeveloperGrant;
|
|
59
60
|
exports.recordDeveloperGrant = recordDeveloperGrant;
|
|
60
61
|
exports.listDeveloperGrants = listDeveloperGrants;
|
|
@@ -91,6 +92,7 @@ const fs = __importStar(require("fs"));
|
|
|
91
92
|
const os = __importStar(require("os"));
|
|
92
93
|
const path = __importStar(require("path"));
|
|
93
94
|
const approvalPrompt_1 = require("./approvalPrompt");
|
|
95
|
+
const deterministicGuard_1 = require("./commands/deterministicGuard");
|
|
94
96
|
/**
|
|
95
97
|
* Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
|
|
96
98
|
* the dialog, read at least the tool name and press a button; MCP clients that
|
|
@@ -163,7 +165,8 @@ function dialogCardHtml(view, options) {
|
|
|
163
165
|
h1{margin:0 0 4px;font-size:22px}.sub{color:#64748b;margin:0 0 14px}
|
|
164
166
|
.box{border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px;margin:0 0 10px;background:#f8fafc}
|
|
165
167
|
h2{margin:0 0 6px;font-size:13px}.k{color:#64748b;font-size:11px;font-weight:700}
|
|
166
|
-
.action{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;white-space:pre-wrap;word-break:break-word;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:8px 10px}
|
|
168
|
+
.action{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;white-space:pre-wrap;word-break:break-word;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:8px 10px;max-height:180px;overflow:auto}
|
|
169
|
+
.steps{margin:0 0 8px}.steps li{margin:3px 0}.steps .n{color:#64748b;font-weight:700;min-width:18px}
|
|
167
170
|
ul{list-style:none;margin:0;padding:0}li{display:flex;gap:8px;margin:6px 0}
|
|
168
171
|
.dot{width:8px;height:8px;border-radius:50%;margin-top:6px;flex:none}
|
|
169
172
|
.row{display:flex;justify-content:flex-end;gap:8px;align-items:center;margin-top:14px;flex-wrap:wrap}
|
|
@@ -180,6 +183,7 @@ button{font:600 13px/1 -apple-system,Segoe UI,sans-serif;border-radius:8px;paddi
|
|
|
180
183
|
</div></div>
|
|
181
184
|
<div class="box"><h2>Action</h2>
|
|
182
185
|
${view.kind ? `<div class="k">${htmlEsc(view.kind)}</div>` : ''}
|
|
186
|
+
${view.steps.length ? `<div class="k" style="margin-top:6px">What it does</div><ol class="steps">${view.steps.map((step, index) => `<li><span class="n">${index + 1}.</span>${htmlEsc(step)}</li>`).join('')}</ol><div class="k">Full script</div>` : ''}
|
|
183
187
|
${view.action ? `<div class="action">${htmlEsc(view.action)}</div>` : '<p class="sub">The action is described in the IDE message.</p>'}
|
|
184
188
|
</div>
|
|
185
189
|
${view.policy ? `<div class="box"><h2>Matched policy</h2><div><span class="k">Policy</span> ${htmlEsc(view.policy)}</div>${view.scope ? `<div><span class="k">Scope</span> ${htmlEsc(view.scope)}</div>` : ''}</div>` : ''}
|
|
@@ -224,7 +228,10 @@ function windowsDialogScript(message, timeoutSeconds, options, title, view) {
|
|
|
224
228
|
"$s=AddLabel $root ([string]$v.subtitle) (New-Object Drawing.Font('Segoe UI',9.75)) $muted ($h.Bottom+4) $hw $hx; $y=[Math]::Max($icon.Bottom,$s.Bottom)+18",
|
|
225
229
|
"AddLabel $root 'Action' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22",
|
|
226
230
|
"if($v.kind){ AddLabel $root ([string]$v.kind) (New-Object Drawing.Font('Segoe UI',8.25,[Drawing.FontStyle]::Bold)) $muted $y $w | Out-Null; $y+=18 }",
|
|
227
|
-
|
|
231
|
+
// What it does: one numbered plain-words line per step, then the FULL script in a
|
|
232
|
+
// read-only scrolling box (fixed height, so a long script never pushes the buttons away).
|
|
233
|
+
"$steps=@($v.steps); if($steps.Count -gt 0){ AddLabel $root 'What it does' (New-Object Drawing.Font('Segoe UI',8.25,[Drawing.FontStyle]::Bold)) $muted $y $w | Out-Null; $y+=18; $n=0; foreach($st in $steps){ $n++; $row=AddLabel $root (\"$n. $st\") (New-Object Drawing.Font('Segoe UI',9.75)) $ink $y $w; $y=$row.Bottom+2 }; $y+=6; AddLabel $root 'Full script' (New-Object Drawing.Font('Segoe UI',8.25,[Drawing.FontStyle]::Bold)) $muted $y $w | Out-Null; $y+=18 }",
|
|
234
|
+
"if($v.action){ $a=New-Object Windows.Forms.TextBox; $a.Multiline=$true; $a.ReadOnly=$true; $a.WordWrap=$true; $a.ScrollBars='Vertical'; $a.BorderStyle='FixedSingle'; $a.BackColor=[Drawing.Color]::FromArgb(248,250,252); $a.ForeColor=$ink; $a.Font=New-Object Drawing.Font('Consolas',9.75); $a.Text=[string]$v.action; $lines=[Math]::Max(1,[Math]::Min(9,([string]$v.action).Length/80+([string]$v.action).Split([char]10).Count)); $a.Size=New-Object Drawing.Size($w,[int](18*$lines+12)); $a.Location=New-Object Drawing.Point($x0,$y); $a.TabStop=$false; $root.Controls.Add($a); $y=$a.Bottom+12 } else { $y+=8 }",
|
|
228
235
|
"if($v.policy){ AddLabel $root 'Matched policy' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22; $pn=AddLabel $root ([string]$v.policy) (New-Object Drawing.Font('Segoe UI',9.75)) $ink $y $w; $y=$pn.Bottom+2; if($v.scope){ $ps=AddLabel $root ([string]$v.scope) (New-Object Drawing.Font('Segoe UI',9)) $muted $y $w; $y=$ps.Bottom+12 } }",
|
|
229
236
|
"AddLabel $root 'Why it triggered' (New-Object Drawing.Font('Segoe UI',9.75,[Drawing.FontStyle]::Bold)) $ink $y $w | Out-Null; $y+=22",
|
|
230
237
|
"foreach($item in @($v.why)){ $row=AddLabel $root ('• '+$item) (New-Object Drawing.Font('Segoe UI',9.75)) $ink $y $w; $y=$row.Bottom+2 }",
|
|
@@ -771,8 +778,45 @@ function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PEN
|
|
|
771
778
|
// ---------------------------------------------------------------------------
|
|
772
779
|
/** Shell words that run the program that follows them, not a program of their own. */
|
|
773
780
|
const SHELL_LEAD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'nice', 'nohup', 'time', 'command', 'builtin', 'exec', 'xargs', 'timeout', 'stdbuf', 'chronic', 'busybox', 'call', 'start', '&']);
|
|
781
|
+
/**
|
|
782
|
+
* Interpreters that can run *any* command. Never a grant program of their own —
|
|
783
|
+
* Always allow on `cmd` / `powershell` / `bash` would cover the next curl.
|
|
784
|
+
* `cmd /c gcloud` peels to `gcloud` via the nested `-c` / `/c` body.
|
|
785
|
+
*/
|
|
786
|
+
const GENERIC_SHELLS = new Set(['cmd', 'powershell', 'pwsh', 'bash', 'sh', 'zsh', 'fish', 'wsl', 'dash']);
|
|
787
|
+
/**
|
|
788
|
+
* Pipeline / formatting cmdlets. They are not the app the rule asked about.
|
|
789
|
+
* Binding Always allow to `foreach-object` made every differently-wrapped
|
|
790
|
+
* `gcloud` line ask again (assaf-desktop / this machine, 2026-09-18..20).
|
|
791
|
+
*/
|
|
792
|
+
const SHELL_PIPELINE_NOISE = new Set([
|
|
793
|
+
'foreach-object', 'where-object', 'select-object', 'sort-object', 'measure-object',
|
|
794
|
+
'group-object', 'tee-object', 'compare-object', 'foreach',
|
|
795
|
+
'format-table', 'format-list', 'format-wide', 'format-custom',
|
|
796
|
+
'write-output', 'write-host', 'write-error', 'write-warning', 'write-verbose', 'write-debug', 'write-information',
|
|
797
|
+
'out-string', 'out-null', 'out-host', 'out-default', 'out-gridview',
|
|
798
|
+
'%', '?',
|
|
799
|
+
]);
|
|
800
|
+
/**
|
|
801
|
+
* Local inspect — listing, presence, identity, conversion. Not the app the
|
|
802
|
+
* rule asked about and not an exfil path. Dropped so Always allow on
|
|
803
|
+
* `gcloud …; Get-ChildItem; Test-Path` is `{gcloud}` and covers the next
|
|
804
|
+
* `gh` / `aws` / `kubectl` line the same way. Readers (`Get-Content`) and
|
|
805
|
+
* script/exfil hosts (`node`, `curl`, `Invoke-RestMethod`) stay in the set.
|
|
806
|
+
*/
|
|
807
|
+
const SHELL_INCIDENTAL = new Set([
|
|
808
|
+
'get-childitem', 'get-item', 'get-itemproperty', 'get-nettcpconnection', 'get-netadapter',
|
|
809
|
+
'get-netipaddress', 'get-netroute', 'get-process', 'get-service', 'get-command',
|
|
810
|
+
'get-help', 'get-alias', 'get-member', 'get-variable', 'get-location', 'get-host',
|
|
811
|
+
'get-date', 'get-history', 'get-psdrive', 'get-psprovider', 'get-module', 'get-job',
|
|
812
|
+
'get-computerinfo', 'test-path', 'resolve-path', 'split-path', 'join-path',
|
|
813
|
+
'convertfrom-json', 'convertto-json', 'convertfrom-csv', 'convertto-csv',
|
|
814
|
+
'ls', 'dir', 'pwd', 'cd', 'set-location', 'push-location', 'pop-location',
|
|
815
|
+
'hostname', 'whoami', 'uname', 'id', 'true', 'false', 'clear', 'cls',
|
|
816
|
+
'sleep', 'start-sleep', 'which', 'where.exe', 'where',
|
|
817
|
+
]);
|
|
774
818
|
/** Control-flow words (sh / bash / PowerShell): structure, never a program of their own. */
|
|
775
|
-
const SHELL_CONTROL_WORDS = /^(if|then|else|elif|elseif|fi|do|done|for|while|until|case|esac|try|catch|finally|foreach|function|return|switch|break|continue)$/i;
|
|
819
|
+
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;
|
|
776
820
|
/** Words a body follows directly (`then rm x`, `do echo`, PowerShell `else { … }`). */
|
|
777
821
|
const SHELL_BODY_LEADS = /^(then|do|else)$/i;
|
|
778
822
|
/** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
|
|
@@ -809,14 +853,28 @@ function splitShellSegments(command) {
|
|
|
809
853
|
segments.push(current);
|
|
810
854
|
return segments;
|
|
811
855
|
}
|
|
812
|
-
/**
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
856
|
+
/** `out-string).trim()` is not a program — take the leading identifier only. */
|
|
857
|
+
function programToken(lead) {
|
|
858
|
+
const unquoted = lead.replace(/^["']|["']$/g, '').split(/[\\/]/).pop() || '';
|
|
859
|
+
const stripped = unquoted.replace(/\.(exe|cmd|bat|ps1|com)$/i, '');
|
|
860
|
+
const ident = stripped.match(/^[A-Za-z%][A-Za-z0-9_.%-]*/);
|
|
861
|
+
return ident ? ident[0].toLowerCase() : undefined;
|
|
862
|
+
}
|
|
863
|
+
/** Body of `cmd /c …` / `powershell -Command …` / `bash -c …`. Encoded payloads are unreadable — no grant. */
|
|
864
|
+
function nestedShellBody(segment) {
|
|
865
|
+
if (/\s--?encodedcommand(?:\s|$)/i.test(segment) || /\s-ec(?:\s|$)/i.test(segment))
|
|
866
|
+
return undefined;
|
|
867
|
+
const match = segment.match(/\s(?:\/c|--?c|--?command)\s+/i);
|
|
868
|
+
if (!match || match.index === undefined)
|
|
869
|
+
return undefined;
|
|
870
|
+
let rest = segment.slice(match.index + match[0].length).trim();
|
|
871
|
+
if ((rest.startsWith('"') && rest.endsWith('"')) || (rest.startsWith("'") && rest.endsWith("'")))
|
|
872
|
+
rest = rest.slice(1, -1);
|
|
873
|
+
return rest.trim() || undefined;
|
|
874
|
+
}
|
|
875
|
+
function collectPrograms(command, out, depth) {
|
|
876
|
+
if (depth > 3 || !command.trim())
|
|
877
|
+
return;
|
|
820
878
|
for (const raw of splitShellSegments(command)) {
|
|
821
879
|
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
822
880
|
let i = 0;
|
|
@@ -871,19 +929,94 @@ function shellPrograms(command) {
|
|
|
871
929
|
const lead = words[i];
|
|
872
930
|
if (!lead || /^[$@(){}\]\[|&;<>#'"`-]/.test(lead) || SHELL_CONTROL_WORDS.test(lead))
|
|
873
931
|
continue;
|
|
874
|
-
const name = lead
|
|
875
|
-
if (name)
|
|
876
|
-
|
|
932
|
+
const name = programToken(lead);
|
|
933
|
+
if (!name || SHELL_PIPELINE_NOISE.has(name) || SHELL_INCIDENTAL.has(name))
|
|
934
|
+
continue;
|
|
935
|
+
if (GENERIC_SHELLS.has(name)) {
|
|
936
|
+
const nested = nestedShellBody(raw);
|
|
937
|
+
if (nested)
|
|
938
|
+
collectPrograms(nested, out, depth + 1);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
out.add(name);
|
|
877
942
|
}
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* The real programs a shell line runs — one per segment, wrappers (`sudo`,
|
|
946
|
+
* `env`) and `$var =` peeled off. Pipeline cmdlets and generic shells are not
|
|
947
|
+
* programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
|
|
948
|
+
* `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
|
|
949
|
+
* Always allow is the real app (`gcloud` / `gh` / `aws` / …), not that
|
|
950
|
+
* wrapper mix. `exit` is control flow. Trailing junk (`out-string).trim()`)
|
|
951
|
+
* is stripped. Sorted and unique.
|
|
952
|
+
*/
|
|
953
|
+
function shellPrograms(command) {
|
|
954
|
+
const out = new Set();
|
|
955
|
+
collectPrograms(command, out, 0);
|
|
878
956
|
return [...out].sort();
|
|
879
957
|
}
|
|
958
|
+
/** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
|
|
959
|
+
function splitLocalSafetyRuleIds(matchedRule) {
|
|
960
|
+
if (!matchedRule || !matchedRule.includes('+'))
|
|
961
|
+
return undefined;
|
|
962
|
+
const parts = matchedRule.split('+').map(part => part.trim()).filter(Boolean);
|
|
963
|
+
if (parts.length < 2 || !parts.every(part => /^[a-z][a-z0-9_]*$/.test(part)))
|
|
964
|
+
return undefined;
|
|
965
|
+
return parts;
|
|
966
|
+
}
|
|
967
|
+
function localSafetyItemIds(value) {
|
|
968
|
+
if (!value)
|
|
969
|
+
return [];
|
|
970
|
+
const prefix = 'local-safety:';
|
|
971
|
+
const raw = value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
972
|
+
return splitLocalSafetyRuleIds(raw) || (raw ? [raw] : []);
|
|
973
|
+
}
|
|
974
|
+
function grantRuleApplies(grant, rule) {
|
|
975
|
+
const grantItems = localSafetyItemIds(grant.matchedRule);
|
|
976
|
+
const liveItems = localSafetyItemIds(rule.matchedRule);
|
|
977
|
+
const namesOverlap = grantItems.length > 0 && liveItems.length > 0 && liveItems.some(id => grantItems.includes(id));
|
|
978
|
+
const exactName = (grant.matchedRule || null) === (rule.matchedRule || null);
|
|
979
|
+
const exactId = (grant.policyId || null) === (rule.policyId || null);
|
|
980
|
+
const grantPolicyItems = localSafetyItemIds(grant.policyId);
|
|
981
|
+
const livePolicyItems = localSafetyItemIds(rule.policyId);
|
|
982
|
+
const policyOverlap = grantPolicyItems.length > 0 && livePolicyItems.length > 0
|
|
983
|
+
&& livePolicyItems.some(id => grantPolicyItems.includes(id));
|
|
984
|
+
if (!exactId && !policyOverlap)
|
|
985
|
+
return false;
|
|
986
|
+
if (grant.policyHash === rule.policyHash && (exactName || namesOverlap))
|
|
987
|
+
return true;
|
|
988
|
+
if (!namesOverlap)
|
|
989
|
+
return false;
|
|
990
|
+
// Per-item grant (new) vs a combined live finding (joined hash).
|
|
991
|
+
if (grantItems.length === 1 && liveItems.length > 1) {
|
|
992
|
+
return grant.policyHash === localSafetyRuleHash({ itemId: grantItems[0], source: 'builtin', action: 'ask' });
|
|
993
|
+
}
|
|
994
|
+
// Combined leftover grant (joined id + joined hash) vs a later single-rule ask.
|
|
995
|
+
if (grantItems.length > 1 && liveItems.length === 1)
|
|
996
|
+
return true;
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
function expandGrantRuleIdentities(rule) {
|
|
1000
|
+
const parts = splitLocalSafetyRuleIds(rule.matchedRule);
|
|
1001
|
+
if (!parts)
|
|
1002
|
+
return [rule];
|
|
1003
|
+
const fromPolicy = rule.policyId?.startsWith('local-safety:') ? 'local-safety:' : undefined;
|
|
1004
|
+
return parts.map(itemId => ({
|
|
1005
|
+
policyId: fromPolicy ? `${fromPolicy}${itemId}` : rule.policyId,
|
|
1006
|
+
policyName: rule.policyName?.includes('+') ? `Local Safety · ${itemId}` : rule.policyName,
|
|
1007
|
+
matchedRule: itemId,
|
|
1008
|
+
policyHash: localSafetyRuleHash({ itemId, source: 'builtin', action: 'ask' }),
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
880
1011
|
function grantKey(scope, policyId, matchedRule, policyHash) {
|
|
881
|
-
return sha256(JSON.stringify([scope.event, scope.toolName, scope.programs || null, scope.operation, scope.destinationDomain || null, policyId || null, matchedRule || null, policyHash]));
|
|
1012
|
+
return sha256(JSON.stringify([scope.event, scope.toolName, scope.programs || null, scope.scripts || null, scope.operation, scope.destinationDomain || null, policyId || null, matchedRule || null, policyHash]));
|
|
882
1013
|
}
|
|
883
1014
|
/**
|
|
884
|
-
* Does a live grant cover this action under this
|
|
885
|
-
*
|
|
886
|
-
*
|
|
1015
|
+
* Does a live grant cover this action under this rule? Combined Local Safety
|
|
1016
|
+
* asks (`a+b`) match a grant for `a` or `b`. Shell programs match by INCLUSION:
|
|
1017
|
+
* a grant for `{gh, git}` covers a line that only runs `gh`, never a line that
|
|
1018
|
+
* also runs `curl`. Generic shells and pipeline cmdlets are not in the set.
|
|
1019
|
+
* Touch counts a use.
|
|
887
1020
|
*
|
|
888
1021
|
* `limits` are the caps in force NOW (rule + org ceiling). A grant older or wider than
|
|
889
1022
|
* they allow is ignored — tightening the org setting takes effect on the next action,
|
|
@@ -898,9 +1031,7 @@ function findDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_MS
|
|
|
898
1031
|
const ttlCap = Math.min(exports.GRANT_TTL_MS, limits.ttlMs);
|
|
899
1032
|
return mutateLedger(undefined, () => {
|
|
900
1033
|
const ledger = readLedger();
|
|
901
|
-
const hit = ledger.grants.find(g => g
|
|
902
|
-
&& (g.policyId || null) === (rule.policyId || null)
|
|
903
|
-
&& (g.matchedRule || null) === (rule.matchedRule || null)
|
|
1034
|
+
const hit = ledger.grants.find(g => grantRuleApplies(g, rule)
|
|
904
1035
|
&& g.scope.event === scope.event
|
|
905
1036
|
&& g.scope.toolName === scope.toolName
|
|
906
1037
|
&& g.scope.operation === scope.operation
|
|
@@ -912,7 +1043,9 @@ function findDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_MS
|
|
|
912
1043
|
&& now.getTime() - Date.parse(g.grantedAt) < ttlCap
|
|
913
1044
|
&& !grantExhausted(g, limits)
|
|
914
1045
|
&& (scope.event !== 'shell'
|
|
915
|
-
|| (Array.isArray(g.scope.programs) && (scope.programs || []).length > 0 && (scope.programs || []).every(p => g.scope.programs.includes(p))))
|
|
1046
|
+
|| (Array.isArray(g.scope.programs) && (scope.programs || []).length > 0 && (scope.programs || []).every(p => g.scope.programs.includes(p))))
|
|
1047
|
+
&& (!(g.scope.scripts && g.scope.scripts.length)
|
|
1048
|
+
|| ((scope.scripts || []).length > 0 && (scope.scripts || []).every(s => g.scope.scripts.includes(s)))));
|
|
916
1049
|
if (!hit)
|
|
917
1050
|
return undefined;
|
|
918
1051
|
hit.lastUsedAt = now.toISOString();
|
|
@@ -930,26 +1063,31 @@ function recordDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_M
|
|
|
930
1063
|
return undefined;
|
|
931
1064
|
const ttlMs = Math.max(60_000, Math.min(exports.GRANT_TTL_MS, limits.ttlMs));
|
|
932
1065
|
const maxUses = positiveCap(limits.maxUses, exports.GRANT_MAX_USES_LIMIT);
|
|
933
|
-
const
|
|
934
|
-
key: grantKey(scope, rule.policyId, rule.matchedRule, rule.policyHash),
|
|
935
|
-
scope,
|
|
936
|
-
policyId: rule.policyId,
|
|
937
|
-
policyName: rule.policyName,
|
|
938
|
-
matchedRule: rule.matchedRule,
|
|
939
|
-
policyHash: rule.policyHash,
|
|
940
|
-
grantedAt: now.toISOString(),
|
|
941
|
-
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
942
|
-
uses: 0,
|
|
943
|
-
...(maxUses !== undefined ? { maxUses } : {}),
|
|
944
|
-
};
|
|
1066
|
+
const identities = expandGrantRuleIdentities(rule);
|
|
945
1067
|
return mutateLedger(undefined, () => {
|
|
946
1068
|
const ledger = readLedger();
|
|
947
|
-
|
|
948
|
-
|
|
1069
|
+
let last;
|
|
1070
|
+
for (const ident of identities) {
|
|
1071
|
+
const grant = {
|
|
1072
|
+
key: grantKey(scope, ident.policyId, ident.matchedRule, ident.policyHash),
|
|
1073
|
+
scope,
|
|
1074
|
+
policyId: ident.policyId,
|
|
1075
|
+
policyName: ident.policyName,
|
|
1076
|
+
matchedRule: ident.matchedRule,
|
|
1077
|
+
policyHash: ident.policyHash,
|
|
1078
|
+
grantedAt: now.toISOString(),
|
|
1079
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
1080
|
+
uses: 0,
|
|
1081
|
+
...(maxUses !== undefined ? { maxUses } : {}),
|
|
1082
|
+
};
|
|
1083
|
+
ledger.grants = ledger.grants.filter(g => g.key !== grant.key && Date.parse(g.expiresAt) > now.getTime() && !grantExhausted(g));
|
|
1084
|
+
ledger.grants.push(grant);
|
|
1085
|
+
last = grant;
|
|
1086
|
+
}
|
|
949
1087
|
if (ledger.grants.length > MAX_GRANTS)
|
|
950
1088
|
ledger.grants = ledger.grants.slice(-MAX_GRANTS);
|
|
951
1089
|
writeLedger(ledger);
|
|
952
|
-
return
|
|
1090
|
+
return last;
|
|
953
1091
|
});
|
|
954
1092
|
}
|
|
955
1093
|
/** Live grants: not expired and not spent. */
|
|
@@ -970,7 +1108,9 @@ function revokeDeveloperGrants(key) {
|
|
|
970
1108
|
/** One line a person can read on the button and in the grant list: what "Always allow" covers. */
|
|
971
1109
|
function describeGrantScope(scope) {
|
|
972
1110
|
const what = scope.event === 'shell'
|
|
973
|
-
?
|
|
1111
|
+
? (scope.scripts && scope.scripts.length
|
|
1112
|
+
? `running ${scope.scripts.map(s => `\`${s}\``).join(', ')}`
|
|
1113
|
+
: `\`${(scope.programs || []).join('`, `')}\` commands`)
|
|
974
1114
|
: scope.event === 'mcp'
|
|
975
1115
|
? `${scope.toolName.replace(/^mcp__/, '').replace(/__/g, ' / ')} calls`
|
|
976
1116
|
: `${scope.toolName} ${scope.event} actions`;
|
|
@@ -979,11 +1119,27 @@ function describeGrantScope(scope) {
|
|
|
979
1119
|
return `${what}${op}${where}`;
|
|
980
1120
|
}
|
|
981
1121
|
/** The scope a grant would have for this action — from engine facts, never from text. */
|
|
1122
|
+
function scriptGrantKeys(command, cwd) {
|
|
1123
|
+
const base = cwd && cwd.trim() ? cwd : process.cwd();
|
|
1124
|
+
const keys = (0, deterministicGuard_1.referencedScripts)(command, base)
|
|
1125
|
+
.filter(script => !script.startsWith('npm:'))
|
|
1126
|
+
.map(script => {
|
|
1127
|
+
const rel = path.relative(base, script);
|
|
1128
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel))
|
|
1129
|
+
return rel.replace(/\\/g, '/');
|
|
1130
|
+
return path.basename(script);
|
|
1131
|
+
});
|
|
1132
|
+
return [...new Set(keys)].sort();
|
|
1133
|
+
}
|
|
982
1134
|
function grantScopeFor(event, toolName, toolArgs, operation, context) {
|
|
983
1135
|
const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
|
|
984
1136
|
if (event === 'shell') {
|
|
985
1137
|
const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
|
|
986
1138
|
scope.programs = shellPrograms(command);
|
|
1139
|
+
const cwd = typeof toolArgs?.cwd === 'string' ? toolArgs.cwd : undefined;
|
|
1140
|
+
const scripts = scriptGrantKeys(command, cwd);
|
|
1141
|
+
if (scripts.length)
|
|
1142
|
+
scope.scripts = scripts;
|
|
987
1143
|
}
|
|
988
1144
|
const domain = context?.['destination.domain'];
|
|
989
1145
|
if (typeof domain === 'string' && domain.trim())
|
package/dist/index.js
CHANGED
|
@@ -197,6 +197,9 @@ function printHelp() {
|
|
|
197
197
|
fleet expires it automatically after --ttl-hours of silence.
|
|
198
198
|
Add --http-gateway true to also gate the agent's direct API calls
|
|
199
199
|
(starts http-gateway; the env file gains HTTP_PROXY / HTTPS_PROXY).
|
|
200
|
+
--enrolled-via <heroku|kubernetes|aws|gcp> (or FCD_ENROLLED_VIA)
|
|
201
|
+
files the row under that connection in the console; Heroku dynos
|
|
202
|
+
are detected from DYNO / HEROKU_APP_NAME.
|
|
200
203
|
Auth: FCD_API_KEY secret (org API key).
|
|
201
204
|
approve Resolve a taint-guard "approve once" request from YOUR terminal.
|
|
202
205
|
When the hook holds an agent action (unrequested network/git
|
|
@@ -737,6 +740,7 @@ async function main() {
|
|
|
737
740
|
force: flags.force,
|
|
738
741
|
httpGateway: flags['http-gateway'],
|
|
739
742
|
agentName: flags['agent-name'],
|
|
743
|
+
enrolledVia: flags['enrolled-via'],
|
|
740
744
|
};
|
|
741
745
|
const { workloadProtectCommand } = await Promise.resolve().then(() => __importStar(require('./commands/workloadProtect')));
|
|
742
746
|
await workloadProtectCommand(args, config);
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.35.
|
|
3
|
+
"version": "1.35.5",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"test:endpoint-approvals": "npm run build && node scripts/audit-endpoint-mcp-shell.js",
|
|
21
21
|
"test:endpoint-clients": "npm run build && node scripts/test-endpoint-client-matrix.js && node scripts/test-copilot-hook-install.js",
|
|
22
22
|
"build": "tsc && node scripts/bundle-policy-engine.js && node scripts/copy-attack-corpus.js && node scripts/bundle-containment-runtime.js",
|
|
23
|
+
"test:ask-dialog": "npm run build && node scripts/test-ask-dialog.js",
|
|
23
24
|
"test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
|
|
24
25
|
"test:drive-delete-guard": "npm run build && node scripts/test-drive-delete-guard.js",
|
|
25
26
|
"test:msi-stop-filter": "node scripts/test-msi-stop-filter.js",
|
|
@@ -99,6 +100,7 @@
|
|
|
99
100
|
"test:qa-loop": "node scripts/test-qa-command-generator.js && node scripts/test-qa-loop-offline.js && node scripts/test-qa-loop-server.js",
|
|
100
101
|
"qa-loop": "node scripts/run-machine-qa-loop.js --offline",
|
|
101
102
|
"test:ide-fp-corpus": "npm run build && node scripts/test-ide-fp-corpus.js",
|
|
103
|
+
"test:web-dev-policy-eval": "npm run build && node scripts/test-web-dev-policy-eval.js",
|
|
102
104
|
"test:secret-shapes": "npm run build && node scripts/test-secret-locator-shapes.js",
|
|
103
105
|
"test:ide-fp-corpus:hook": "npm run build && node scripts/test-ide-fp-corpus.js --hook",
|
|
104
106
|
"test:msi-payload-deps": "npm run build && node scripts/test-msi-payload-deps.js",
|