fullcourtdefense-cli 1.35.3 → 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/commandNarrative.js +3 -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/devConfirm.d.ts +30 -13
- package/dist/devConfirm.js +187 -36
- package/dist/version.json +1 -1
- package/package.json +3 -1
package/dist/commandNarrative.js
CHANGED
|
@@ -265,6 +265,9 @@ function describeOne(segmentIn) {
|
|
|
265
265
|
if (storage)
|
|
266
266
|
return step(`Sets variable ${name} to a location in storage bucket ${storage[1]}`);
|
|
267
267
|
const interpolates = /\$\w+|\$\{|\$\(/.test(bare);
|
|
268
|
+
if (interpolates && /(?:\$(?:env:)?(?:TEMP|TMP|LOCALAPPDATA|HOME|PWD|PSScriptRoot|USERPROFILE)\b|%TEMP%|%TMP%|%LOCALAPPDATA%|%USERPROFILE%)/i.test(bare)) {
|
|
269
|
+
return step(`Sets variable ${name} to a path`);
|
|
270
|
+
}
|
|
268
271
|
return step(`Sets variable ${name}${interpolates ? ' from other values' : ` to ${shortValue(bare, 60)}`}`);
|
|
269
272
|
}
|
|
270
273
|
const inner = rhs.replace(/^\(\s*/, '').replace(/\)\s*(?:\.\w+\(\))*$/, '');
|
|
@@ -106,6 +106,12 @@ export interface SharedCommandRule {
|
|
|
106
106
|
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
107
107
|
*/
|
|
108
108
|
export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
109
|
+
/**
|
|
110
|
+
* Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
|
|
111
|
+
* space. Detectors that ask "does this EXECUTE" must look here; the raw line
|
|
112
|
+
* still carries fixture strings and docs.
|
|
113
|
+
*/
|
|
114
|
+
export declare function unquotedCode(text: string): string;
|
|
109
115
|
export declare function stripInertDataSegments(text: string): string;
|
|
110
116
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
111
117
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -126,6 +132,7 @@ export declare function scanDeterministicTextResponse(text: string, options?: Lo
|
|
|
126
132
|
* command works would be stopped for typing its name. (action-only decision path)
|
|
127
133
|
*/
|
|
128
134
|
export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
135
|
+
export declare function referencedScripts(command: string, cwd?: string): string[];
|
|
129
136
|
export interface DeterministicScanOutcome {
|
|
130
137
|
/** The finding that stops the action, if any. */
|
|
131
138
|
blockingFinding?: DeterministicFinding;
|
|
@@ -35,10 +35,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
|
|
37
37
|
exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
|
|
38
|
+
exports.unquotedCode = unquotedCode;
|
|
38
39
|
exports.stripInertDataSegments = stripInertDataSegments;
|
|
39
40
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
40
41
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
41
42
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
43
|
+
exports.referencedScripts = referencedScripts;
|
|
42
44
|
exports.resolveDeterministicOutcome = resolveDeterministicOutcome;
|
|
43
45
|
exports.resolveDeterministicTextResponse = resolveDeterministicTextResponse;
|
|
44
46
|
const fs = __importStar(require("fs"));
|
|
@@ -698,9 +700,14 @@ exports.CREDENTIAL_COMMAND_RULES = [
|
|
|
698
700
|
pattern: String.raw `\bvault\s+(?:kv\s+get|read)\b` },
|
|
699
701
|
];
|
|
700
702
|
const CREDENTIAL_COMMAND_REGEXES = exports.CREDENTIAL_COMMAND_RULES.map(rule => ({ rule, re: new RegExp(rule.pattern, 'i') }));
|
|
701
|
-
function credentialCommandReason(value) {
|
|
703
|
+
function credentialCommandReason(value, options) {
|
|
702
704
|
return matchCommand(value, text => {
|
|
703
705
|
for (const { rule, re } of CREDENTIAL_COMMAND_REGEXES) {
|
|
706
|
+
// Skip suppressed / org-disabled items so a second credential command on
|
|
707
|
+
// the same line is still found after resolveDeterministicOutcome hides
|
|
708
|
+
// the first ask (gh then gcloud, not "gh forever").
|
|
709
|
+
if (!isEnabled(rule.itemId, options))
|
|
710
|
+
continue;
|
|
704
711
|
if (re.test(text))
|
|
705
712
|
return { itemId: rule.itemId, reason: rule.reason };
|
|
706
713
|
}
|
|
@@ -724,6 +731,11 @@ function destructiveCommandReason(value) {
|
|
|
724
731
|
// (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
|
|
725
732
|
if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
|
|
726
733
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
734
|
+
// Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
|
|
735
|
+
// A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
|
|
736
|
+
if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
|
|
737
|
+
return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
|
|
738
|
+
}
|
|
727
739
|
// Bare `*` deletes the current directory tree — destructive on the HOST,
|
|
728
740
|
// but inside a container exec/run it is the container workdir (cleanup).
|
|
729
741
|
const bareStar = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/.exec(lower);
|
|
@@ -746,6 +758,10 @@ function destructiveCommandReason(value) {
|
|
|
746
758
|
&& /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
|
|
747
759
|
return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
|
|
748
760
|
}
|
|
761
|
+
if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
|
|
762
|
+
&& /remove-item\b[^;|&]{0,80}?["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:[\\/]\*?)?["']?(?=\s|$|[;&|])/i.test(lower)) {
|
|
763
|
+
return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
|
|
764
|
+
}
|
|
749
765
|
if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
|
|
750
766
|
return { itemId: 'fork_bomb', reason: 'fork bomb' };
|
|
751
767
|
if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
|
|
@@ -875,8 +891,12 @@ function isWebLookupTarget(toolName, candidate) {
|
|
|
875
891
|
* must not block — the unconditional match over-blocked ordinary development work.
|
|
876
892
|
*/
|
|
877
893
|
function isMetadataRequestContext(toolName, candidate) {
|
|
878
|
-
if (isCommandContext(toolName, candidate))
|
|
894
|
+
if (isCommandContext(toolName, candidate)) {
|
|
895
|
+
// `rg "169.254.169.254" scripts/` searches for the address; it does not request it.
|
|
896
|
+
if (commandOnlySearchesMetadata(candidate.value))
|
|
897
|
+
return false;
|
|
879
898
|
return true;
|
|
899
|
+
}
|
|
880
900
|
if (isWebLookupTarget(toolName, candidate))
|
|
881
901
|
return true;
|
|
882
902
|
if (OUTBOUND_TOOL_HINT.test(toolName))
|
|
@@ -884,6 +904,76 @@ function isMetadataRequestContext(toolName, candidate) {
|
|
|
884
904
|
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
885
905
|
return /(?:url|uri|endpoint|host|address|webhook)/i.test(lastKey);
|
|
886
906
|
}
|
|
907
|
+
const METADATA_SEARCH_PROGRAMS = new Set([
|
|
908
|
+
'rg', 'grep', 'egrep', 'fgrep', 'findstr', 'ack', 'ag', 'select-string', 'sls',
|
|
909
|
+
]);
|
|
910
|
+
/**
|
|
911
|
+
* Split on unquoted `;` / `&&` / `||` / `|` / newline so a pipe cannot hide a
|
|
912
|
+
* later client (`rg … | curl http://169.254…`) while `rg "a|b"` stays one segment.
|
|
913
|
+
*/
|
|
914
|
+
function splitUnquotedCommandSegments(command) {
|
|
915
|
+
const out = [];
|
|
916
|
+
let cur = '';
|
|
917
|
+
let quote = null;
|
|
918
|
+
for (let i = 0; i < command.length; i++) {
|
|
919
|
+
const c = command[i];
|
|
920
|
+
if (quote) {
|
|
921
|
+
cur += c;
|
|
922
|
+
if (c === '\\' && i + 1 < command.length) {
|
|
923
|
+
cur += command[++i];
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (c === quote)
|
|
927
|
+
quote = null;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
931
|
+
quote = c;
|
|
932
|
+
cur += c;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (c === '\n' || c === ';') {
|
|
936
|
+
out.push(cur);
|
|
937
|
+
cur = '';
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (c === '&' && command[i + 1] === '&') {
|
|
941
|
+
out.push(cur);
|
|
942
|
+
cur = '';
|
|
943
|
+
i += 1;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (c === '|') {
|
|
947
|
+
if (command[i + 1] === '|')
|
|
948
|
+
i += 1;
|
|
949
|
+
out.push(cur);
|
|
950
|
+
cur = '';
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
cur += c;
|
|
954
|
+
}
|
|
955
|
+
if (cur)
|
|
956
|
+
out.push(cur);
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
/** True when every segment that mentions a metadata endpoint is a searcher, not a client. */
|
|
960
|
+
function commandOnlySearchesMetadata(command) {
|
|
961
|
+
if (!containsMetadataEndpoint(command))
|
|
962
|
+
return false;
|
|
963
|
+
const segments = splitUnquotedCommandSegments(command);
|
|
964
|
+
let saw = false;
|
|
965
|
+
for (const raw of segments) {
|
|
966
|
+
const seg = raw.trim();
|
|
967
|
+
if (!seg || !containsMetadataEndpoint(seg))
|
|
968
|
+
continue;
|
|
969
|
+
saw = true;
|
|
970
|
+
const token = (seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/) || [''])[0];
|
|
971
|
+
const program = token.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
|
|
972
|
+
if (!METADATA_SEARCH_PROGRAMS.has(program))
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
return saw;
|
|
976
|
+
}
|
|
887
977
|
/**
|
|
888
978
|
* Sensitive credential PATHS only matter where the string is used AS a path (a file access,
|
|
889
979
|
* a path-typed argument) or inside a command. File CONTENTS being written/edited routinely
|
|
@@ -978,8 +1068,36 @@ const INERT_DATA = ' fcd_inert_data ';
|
|
|
978
1068
|
// Code position immediately before a quoted span: interpreter code flags or a
|
|
979
1069
|
// remote/nested executor earlier in the same statement (no ; & | between).
|
|
980
1070
|
const CODE_CONTEXT_BEFORE = /(?:(?:^|[;&|(]|\s)(?:-c|\/c|-e|-command|-scriptblock|-filter)[= ]\s*|(?:^|[;&|(]\s*)(?:\S*[\\/])?(?:sudo\s+|doas\s+)?(?:ssh|wsl(?:\.exe)?|chroot|su|screen|tmux)(?:\s[^;&|"']*)?\s|(?:\S*[\\/])?(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b[^;&|"']*\s)$/i;
|
|
1071
|
+
/**
|
|
1072
|
+
* Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
|
|
1073
|
+
* space. Detectors that ask "does this EXECUTE" must look here; the raw line
|
|
1074
|
+
* still carries fixture strings and docs.
|
|
1075
|
+
*/
|
|
1076
|
+
function unquotedCode(text) {
|
|
1077
|
+
let out = '';
|
|
1078
|
+
for (let i = 0; i < text.length; i++) {
|
|
1079
|
+
const c = text[i];
|
|
1080
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
1081
|
+
const q = c;
|
|
1082
|
+
i += 1;
|
|
1083
|
+
while (i < text.length) {
|
|
1084
|
+
if (text[i] === '\\') {
|
|
1085
|
+
i += 2;
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (text[i] === q)
|
|
1089
|
+
break;
|
|
1090
|
+
i += 1;
|
|
1091
|
+
}
|
|
1092
|
+
out += ' ';
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
out += c;
|
|
1096
|
+
}
|
|
1097
|
+
return out;
|
|
1098
|
+
}
|
|
981
1099
|
function stripInertDataSegments(text) {
|
|
982
|
-
if (DATA_TO_INTERPRETER.test(text))
|
|
1100
|
+
if (DATA_TO_INTERPRETER.test(unquotedCode(text)))
|
|
983
1101
|
return text;
|
|
984
1102
|
let out = text;
|
|
985
1103
|
// PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
|
|
@@ -1201,22 +1319,27 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1201
1319
|
// stripInertDataSegments invariants). This is the generic FP guard for
|
|
1202
1320
|
// "wrote ABOUT a dangerous command" vs "ran one".
|
|
1203
1321
|
const value = stripInertDataSegments(rawValue);
|
|
1322
|
+
// Script files: path/metadata facts are judged on RAW lines, then
|
|
1323
|
+
// `scriptActsOnFinding` keeps only unquoted access (fetch/http.get/curl).
|
|
1324
|
+
// Shell-prose strip would eat `execSync('curl -s http://169.254…')` and miss
|
|
1325
|
+
// a real call. Command-shaped rules below still use the stripped view.
|
|
1326
|
+
const actingView = toolName === 'script_file' ? rawValue : value;
|
|
1204
1327
|
// NOTE: a disabled item must FALL THROUGH to the later rules, not end the
|
|
1205
1328
|
// scan — otherwise turning off (or gating) a path rule would mask a real
|
|
1206
1329
|
// reverse shell / destructive command in the same value.
|
|
1207
1330
|
const sensitivePath = containsSensitiveCredentialPath(commandContext
|
|
1208
|
-
? `${credentialAccessCommandText(
|
|
1331
|
+
? `${credentialAccessCommandText(actingView)}\n${interpreterFileArguments(rawValue)}` : actingView);
|
|
1209
1332
|
if (sensitivePath) {
|
|
1210
1333
|
const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path',
|
|
1211
1334
|
// Verdict-neutral: the same finding is a warning on a monitor machine and
|
|
1212
1335
|
// a block on an enforcing one — the console shows the verdict, not this text.
|
|
1213
|
-
`Local agent access to ${sensitivePath.label}.`,
|
|
1336
|
+
`Local agent access to ${sensitivePath.label}.`, actingView, sensitivePath.label, options);
|
|
1214
1337
|
if (finding)
|
|
1215
1338
|
return finding;
|
|
1216
1339
|
}
|
|
1217
|
-
const metadata = containsMetadataEndpoint(
|
|
1340
|
+
const metadata = containsMetadataEndpoint(actingView);
|
|
1218
1341
|
if (metadata) {
|
|
1219
|
-
const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`,
|
|
1342
|
+
const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, actingView, metadata.value, options);
|
|
1220
1343
|
if (finding)
|
|
1221
1344
|
return finding;
|
|
1222
1345
|
}
|
|
@@ -1224,7 +1347,7 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1224
1347
|
if (reverseShell) {
|
|
1225
1348
|
return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-reverse-shell', `Blocked ${reverseShell.reason}.`, value, reverseShell.reason, options);
|
|
1226
1349
|
}
|
|
1227
|
-
const credentialCmd = credentialCommandReason(value);
|
|
1350
|
+
const credentialCmd = credentialCommandReason(value, options);
|
|
1228
1351
|
if (credentialCmd) {
|
|
1229
1352
|
const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-credential-command', `Blocked credential-revealing command: ${credentialCmd.reason}.`, value, credentialCmd.reason, options);
|
|
1230
1353
|
if (finding)
|
|
@@ -1434,13 +1557,23 @@ function referencedScripts(command, cwd) {
|
|
|
1434
1557
|
// Collision-hardened forms: `ssh` must be a command start (NOT the `.ssh` of the
|
|
1435
1558
|
// flagged path itself), `type` the cmd.exe read form (`type file`, not `type:` in
|
|
1436
1559
|
// JSON/TS fixtures), `open` a call (`open(`), `requests.*` a python HTTP call.
|
|
1437
|
-
const SCRIPT_ACCESS_VERB = /\b(?:cat|gc|get-content|get-item|copy(?:-item)?|cp|xcopy|robocopy|move(?:-item)?|mv|scp|sftp|rsync|curl|wget|invoke-webrequest|invoke-restmethod|iwr|irm|openssl|tar|zip|7z|certutil|base64|readfile(?:sync)?|createreadstream|read_text|read_bytes|fopen|urlopen|fetch|upload|download)\b|\btype\s+\S|(?:^|[^.\w])ssh(?:-add|-keygen|\.exe)?\s|\bopen\s*\(|\brequests\.\w+\s*\(/i;
|
|
1560
|
+
const SCRIPT_ACCESS_VERB = /\b(?:cat|gc|get-content|get-item|copy(?:-item)?|cp|xcopy|robocopy|move(?:-item)?|mv|scp|sftp|rsync|curl|wget|invoke-webrequest|invoke-restmethod|iwr|irm|openssl|tar|zip|7z|certutil|base64|readfile(?:sync)?|createreadstream|read_text|read_bytes|fopen|urlopen|fetch|upload|download|exec(?:sync|file(?:sync)?)?|spawn(?:sync)?)\b|\btype\s+\S|(?:^|[^.\w])ssh(?:-add|-keygen|\.exe)?\s|\bopen\s*\(|\brequests\.\w+\s*\(/i;
|
|
1561
|
+
/** `http.get(` / `require(…).get(` — the call is code; the URL may be a literal. */
|
|
1562
|
+
const SCRIPT_ACCESS_CALL = /\.\s*(?:get|request|post|put|patch|delete|fetch|exec(?:sync|file(?:sync)?)?|spawn(?:sync)?)\s*\(/i;
|
|
1438
1563
|
/**
|
|
1439
1564
|
* A path/metadata finding inside a referenced script only counts when some LINE both
|
|
1440
|
-
* contains the flagged pattern AND acts on it
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1565
|
+
* contains the flagged pattern AND acts on it in CODE. The entire matched span
|
|
1566
|
+
* sitting inside one string literal (`'curl http://169.254…'`) is data — that
|
|
1567
|
+
* was the live IDE false positive on `node scripts/test-execution-view.js`.
|
|
1568
|
+
* `fetch('http://169.254…')` / `http.get('…')` still fire: the CALL is unquoted.
|
|
1443
1569
|
*/
|
|
1570
|
+
function lineActsOnFinding(line) {
|
|
1571
|
+
const code = unquotedCode(line)
|
|
1572
|
+
.replace(/\/\/[^\n]*/g, ' ')
|
|
1573
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
1574
|
+
.replace(/(^|[^$\w])#[^\n]*/g, '$1 ');
|
|
1575
|
+
return SCRIPT_ACCESS_VERB.test(code) || SCRIPT_ACCESS_CALL.test(code);
|
|
1576
|
+
}
|
|
1444
1577
|
function scriptActsOnFinding(content, category) {
|
|
1445
1578
|
const matcher = category === 'sensitive_file'
|
|
1446
1579
|
? (line) => Boolean(containsSensitiveCredentialPath(line))
|
|
@@ -1448,7 +1581,7 @@ function scriptActsOnFinding(content, category) {
|
|
|
1448
1581
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
1449
1582
|
if (!rawLine || rawLine.length > 4000)
|
|
1450
1583
|
continue;
|
|
1451
|
-
if (matcher(rawLine) &&
|
|
1584
|
+
if (matcher(rawLine) && lineActsOnFinding(rawLine))
|
|
1452
1585
|
return true;
|
|
1453
1586
|
}
|
|
1454
1587
|
return false;
|
|
@@ -198,7 +198,7 @@ const BUILTIN_RULES = [
|
|
|
198
198
|
compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root',
|
|
199
199
|
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT] }),
|
|
200
200
|
compileSpec({ id: 'rm_rf_home', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory',
|
|
201
|
-
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME)(
|
|
201
|
+
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)(?:[\\/]\*?)?["']?(?:\s|$|[;&|])`] }),
|
|
202
202
|
compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive delete',
|
|
203
203
|
command: ['del', 'erase'], flags: [String.raw `/s\b`], target: [T_WIN_DRIVE] }),
|
|
204
204
|
compileSpec({ id: 'windows_drive_rmdir', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive remove directory',
|
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
|
|
@@ -776,8 +778,45 @@ function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PEN
|
|
|
776
778
|
// ---------------------------------------------------------------------------
|
|
777
779
|
/** Shell words that run the program that follows them, not a program of their own. */
|
|
778
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
|
+
]);
|
|
779
818
|
/** Control-flow words (sh / bash / PowerShell): structure, never a program of their own. */
|
|
780
|
-
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;
|
|
781
820
|
/** Words a body follows directly (`then rm x`, `do echo`, PowerShell `else { … }`). */
|
|
782
821
|
const SHELL_BODY_LEADS = /^(then|do|else)$/i;
|
|
783
822
|
/** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
|
|
@@ -814,14 +853,28 @@ function splitShellSegments(command) {
|
|
|
814
853
|
segments.push(current);
|
|
815
854
|
return segments;
|
|
816
855
|
}
|
|
817
|
-
/**
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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;
|
|
825
878
|
for (const raw of splitShellSegments(command)) {
|
|
826
879
|
const words = raw.trim().split(/\s+/).filter(Boolean);
|
|
827
880
|
let i = 0;
|
|
@@ -876,19 +929,94 @@ function shellPrograms(command) {
|
|
|
876
929
|
const lead = words[i];
|
|
877
930
|
if (!lead || /^[$@(){}\]\[|&;<>#'"`-]/.test(lead) || SHELL_CONTROL_WORDS.test(lead))
|
|
878
931
|
continue;
|
|
879
|
-
const name = lead
|
|
880
|
-
if (name)
|
|
881
|
-
|
|
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);
|
|
882
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);
|
|
883
956
|
return [...out].sort();
|
|
884
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
|
+
}
|
|
885
1011
|
function grantKey(scope, policyId, matchedRule, policyHash) {
|
|
886
|
-
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]));
|
|
887
1013
|
}
|
|
888
1014
|
/**
|
|
889
|
-
* Does a live grant cover this action under this
|
|
890
|
-
*
|
|
891
|
-
*
|
|
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.
|
|
892
1020
|
*
|
|
893
1021
|
* `limits` are the caps in force NOW (rule + org ceiling). A grant older or wider than
|
|
894
1022
|
* they allow is ignored — tightening the org setting takes effect on the next action,
|
|
@@ -903,9 +1031,7 @@ function findDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_MS
|
|
|
903
1031
|
const ttlCap = Math.min(exports.GRANT_TTL_MS, limits.ttlMs);
|
|
904
1032
|
return mutateLedger(undefined, () => {
|
|
905
1033
|
const ledger = readLedger();
|
|
906
|
-
const hit = ledger.grants.find(g => g
|
|
907
|
-
&& (g.policyId || null) === (rule.policyId || null)
|
|
908
|
-
&& (g.matchedRule || null) === (rule.matchedRule || null)
|
|
1034
|
+
const hit = ledger.grants.find(g => grantRuleApplies(g, rule)
|
|
909
1035
|
&& g.scope.event === scope.event
|
|
910
1036
|
&& g.scope.toolName === scope.toolName
|
|
911
1037
|
&& g.scope.operation === scope.operation
|
|
@@ -917,7 +1043,9 @@ function findDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_MS
|
|
|
917
1043
|
&& now.getTime() - Date.parse(g.grantedAt) < ttlCap
|
|
918
1044
|
&& !grantExhausted(g, limits)
|
|
919
1045
|
&& (scope.event !== 'shell'
|
|
920
|
-
|| (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)))));
|
|
921
1049
|
if (!hit)
|
|
922
1050
|
return undefined;
|
|
923
1051
|
hit.lastUsedAt = now.toISOString();
|
|
@@ -935,26 +1063,31 @@ function recordDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_M
|
|
|
935
1063
|
return undefined;
|
|
936
1064
|
const ttlMs = Math.max(60_000, Math.min(exports.GRANT_TTL_MS, limits.ttlMs));
|
|
937
1065
|
const maxUses = positiveCap(limits.maxUses, exports.GRANT_MAX_USES_LIMIT);
|
|
938
|
-
const
|
|
939
|
-
key: grantKey(scope, rule.policyId, rule.matchedRule, rule.policyHash),
|
|
940
|
-
scope,
|
|
941
|
-
policyId: rule.policyId,
|
|
942
|
-
policyName: rule.policyName,
|
|
943
|
-
matchedRule: rule.matchedRule,
|
|
944
|
-
policyHash: rule.policyHash,
|
|
945
|
-
grantedAt: now.toISOString(),
|
|
946
|
-
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
947
|
-
uses: 0,
|
|
948
|
-
...(maxUses !== undefined ? { maxUses } : {}),
|
|
949
|
-
};
|
|
1066
|
+
const identities = expandGrantRuleIdentities(rule);
|
|
950
1067
|
return mutateLedger(undefined, () => {
|
|
951
1068
|
const ledger = readLedger();
|
|
952
|
-
|
|
953
|
-
|
|
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
|
+
}
|
|
954
1087
|
if (ledger.grants.length > MAX_GRANTS)
|
|
955
1088
|
ledger.grants = ledger.grants.slice(-MAX_GRANTS);
|
|
956
1089
|
writeLedger(ledger);
|
|
957
|
-
return
|
|
1090
|
+
return last;
|
|
958
1091
|
});
|
|
959
1092
|
}
|
|
960
1093
|
/** Live grants: not expired and not spent. */
|
|
@@ -975,7 +1108,9 @@ function revokeDeveloperGrants(key) {
|
|
|
975
1108
|
/** One line a person can read on the button and in the grant list: what "Always allow" covers. */
|
|
976
1109
|
function describeGrantScope(scope) {
|
|
977
1110
|
const what = scope.event === 'shell'
|
|
978
|
-
?
|
|
1111
|
+
? (scope.scripts && scope.scripts.length
|
|
1112
|
+
? `running ${scope.scripts.map(s => `\`${s}\``).join(', ')}`
|
|
1113
|
+
: `\`${(scope.programs || []).join('`, `')}\` commands`)
|
|
979
1114
|
: scope.event === 'mcp'
|
|
980
1115
|
? `${scope.toolName.replace(/^mcp__/, '').replace(/__/g, ' / ')} calls`
|
|
981
1116
|
: `${scope.toolName} ${scope.event} actions`;
|
|
@@ -984,11 +1119,27 @@ function describeGrantScope(scope) {
|
|
|
984
1119
|
return `${what}${op}${where}`;
|
|
985
1120
|
}
|
|
986
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
|
+
}
|
|
987
1134
|
function grantScopeFor(event, toolName, toolArgs, operation, context) {
|
|
988
1135
|
const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
|
|
989
1136
|
if (event === 'shell') {
|
|
990
1137
|
const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
|
|
991
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;
|
|
992
1143
|
}
|
|
993
1144
|
const domain = context?.['destination.domain'];
|
|
994
1145
|
if (typeof domain === 'string' && domain.trim())
|
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",
|