fullcourtdefense-cli 1.35.0 → 1.35.2
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 +59 -9
- package/dist/approvalPrompt.d.ts +25 -0
- package/dist/approvalPrompt.js +51 -0
- package/dist/blockExplanation.js +4 -1
- package/dist/commands/deterministicGuard.js +124 -6
- package/dist/commands/hook.js +8 -0
- package/dist/detectorBaseline.d.ts +2 -2
- package/dist/detectorBaseline.js +2 -2
- package/dist/devConfirm.d.ts +3 -1
- package/dist/devConfirm.js +176 -30
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -6814,11 +6814,13 @@ function shellVerbWords(command) {
|
|
|
6814
6814
|
let lead = "";
|
|
6815
6815
|
let first = true;
|
|
6816
6816
|
let scriptArgs = false;
|
|
6817
|
+
let previousBare = "";
|
|
6817
6818
|
return shellCommandWords(command).split(/\s+/).filter(Boolean).map((token) => {
|
|
6818
6819
|
if (token === ";") {
|
|
6819
6820
|
lead = "";
|
|
6820
6821
|
first = true;
|
|
6821
6822
|
scriptArgs = false;
|
|
6823
|
+
previousBare = "";
|
|
6822
6824
|
return token;
|
|
6823
6825
|
}
|
|
6824
6826
|
if (scriptArgs) return "";
|
|
@@ -6838,12 +6840,19 @@ function shellVerbWords(command) {
|
|
|
6838
6840
|
}
|
|
6839
6841
|
const dot = word.startsWith(".") ? "." : "";
|
|
6840
6842
|
if (dot && !DOT_COMMAND_PROGRAMS.has(lead)) return "";
|
|
6843
|
+
if (lead === "gh" && GH_CONFIG_RESOURCES.has(previousBare) && /^(?:delete|remove)$/i.test(word)) {
|
|
6844
|
+
const glued = `${previousBare}${word.toLowerCase()}`;
|
|
6845
|
+
previousBare = "";
|
|
6846
|
+
return glued;
|
|
6847
|
+
}
|
|
6848
|
+
if (!flag) previousBare = word.toLowerCase();
|
|
6841
6849
|
const [head, ...rest] = word.replace(/^\./, "").split(/[-_]+/).filter(Boolean);
|
|
6842
6850
|
if (!head) return "";
|
|
6843
6851
|
return `${flag ? flag[1] : dot}${head}${rest.length ? ` ${rest.join("")}` : ""}`;
|
|
6844
6852
|
}).filter(Boolean).join(" ");
|
|
6845
6853
|
}
|
|
6846
6854
|
var VCS_PROGRAMS = /* @__PURE__ */ new Set(["git", "gh", "hg", "svn", "glab"]);
|
|
6855
|
+
var GH_CONFIG_RESOURCES = /* @__PURE__ */ new Set(["secret", "variable", "cache", "alias", "label", "ssh-key", "gpg-key"]);
|
|
6847
6856
|
var SCRIPT_RUNNER_LEADS = /* @__PURE__ */ new Set([
|
|
6848
6857
|
"bash",
|
|
6849
6858
|
"sh",
|
|
@@ -6916,6 +6925,13 @@ function plausibleTokenBody(body) {
|
|
|
6916
6925
|
}
|
|
6917
6926
|
var PLACEHOLDER_WORD_RE = /example|sample|placeholder|redacted|dummy|changeme|fake|xxxx/i;
|
|
6918
6927
|
var REPEATED_CHUNK_RE = /(.{3,})\1{2,}/;
|
|
6928
|
+
var PUBLISHED_EXAMPLE_VALUES = /* @__PURE__ */ new Set([
|
|
6929
|
+
"JBSWY3DPEHPK3PXP",
|
|
6930
|
+
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
|
|
6931
|
+
"12345678901234567890",
|
|
6932
|
+
["AKIA", "IOSFODNN7", "EXAMPLE"].join(""),
|
|
6933
|
+
["wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "EXAMPLEKEY"].join("")
|
|
6934
|
+
]);
|
|
6919
6935
|
function isHighEntropySecretToken(token, allowWordSlug = false) {
|
|
6920
6936
|
if (token.length < 24 || token.length > 512) return false;
|
|
6921
6937
|
const assignment = /^-{0,2}[A-Za-z_][\w.-]*=(.+)$/.exec(token);
|
|
@@ -6924,7 +6940,7 @@ function isHighEntropySecretToken(token, allowWordSlug = false) {
|
|
|
6924
6940
|
if (!/^[A-Za-z0-9._~+/=-]+$/.test(token)) return false;
|
|
6925
6941
|
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(token)) return false;
|
|
6926
6942
|
if (/^[0-9a-f]+$/i.test(token)) return false;
|
|
6927
|
-
if (
|
|
6943
|
+
if (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token) && (token.length === 36 || /[^0-9a-f]/i.test(token[token.length - 37] || ""))) return false;
|
|
6928
6944
|
if (/^sha(?:1|256|384|512)-/i.test(token)) return false;
|
|
6929
6945
|
if (/^data:/i.test(token) || /^[A-Za-z0-9+/]{200,}={0,2}$/.test(token)) return false;
|
|
6930
6946
|
if (/^(?:https?|s3|gs|file):\/\//i.test(token) || /[\\/]{1}[\w.-]+[\\/]/.test(token)) return false;
|
|
@@ -6950,6 +6966,7 @@ function looksLikeSecretValue(raw) {
|
|
|
6950
6966
|
if (value.length < 8) return false;
|
|
6951
6967
|
if (/^[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]/.test(value)) return false;
|
|
6952
6968
|
if (/^--?[A-Za-z]/.test(value)) return false;
|
|
6969
|
+
if (PUBLISHED_EXAMPLE_VALUES.has(value)) return false;
|
|
6953
6970
|
if (SECRET_PLACEHOLDER_RE.test(value)) return false;
|
|
6954
6971
|
if (SECRET_CODE_EXPR_RE.test(value)) return false;
|
|
6955
6972
|
if (/^\/(?:[\[(^\\.]|.*\/[gimsuy]*$)/.test(value) || /[\[({]/.test(value)) return false;
|
|
@@ -7346,13 +7363,14 @@ function temporaryFileSyntax(text) {
|
|
|
7346
7363
|
return text.replace(/"\$env:(?:TEMP|TMP)([\\/][A-Za-z0-9_ ./\\-]+)"/gi, '"fcd-temporary$1"').replace(/(?<=^|\s)\$env:(?:TEMP|TMP)([\\/][A-Za-z0-9_./\\-]+)(?=\s|$)/gi, '"fcd-temporary$1"').replace(/\(Join-Path\s+\$env:(?:TEMP|TMP)\s+'([A-Za-z0-9_ ./\\-]+)'\)/gi, '"fcd-temporary/$1"');
|
|
7347
7364
|
}
|
|
7348
7365
|
var GH_FLAG_VALUE_RE = /^(?:'[^'$`\r\n]*'|"[^"$`\r\n]*"|[^\s'"$`|;&(){}<>]+)$/;
|
|
7366
|
+
var GH_PROSE_VALUE_RE = /^(?:'[^'$`]*'|"[^"$`]*"|[^\s'"$`|;&(){}<>]+)$/;
|
|
7349
7367
|
function ghMetadataWriteBodyFile(segment) {
|
|
7350
7368
|
return parseGhMetadataWrite(segment)?.bodyFile;
|
|
7351
7369
|
}
|
|
7352
7370
|
function parseGhMetadataWrite(segment) {
|
|
7353
|
-
const ghBody = /^(gh\s+(?:pr\s+create|issue\s+create|pr\s+(?:comment|edit)\s+\d+|issue\s+(?:comment|edit)\s+\d+)\s+)(
|
|
7371
|
+
const ghBody = /^(gh\s+(?:pr\s+create|issue\s+create|pr\s+(?:comment|edit)\s+\d+|issue\s+(?:comment|edit)\s+\d+)\s+)([\s\S]+?)((?:\s+2>&1)?(?:\s*\|\s*Select-Object\s+-Last\s+\d{1,3})?)$/i.exec(segment);
|
|
7354
7372
|
if (!ghBody) return void 0;
|
|
7355
|
-
const tokens = ghBody[2].match(/'[^'
|
|
7373
|
+
const tokens = ghBody[2].match(/'[^']*'|"[^"]*"|[^\s'"]+/g) || [];
|
|
7356
7374
|
if (tokens.join("").replace(/\s/g, "") !== ghBody[2].replace(/\s/g, "")) return void 0;
|
|
7357
7375
|
let bodyFile;
|
|
7358
7376
|
let bodySeen = false;
|
|
@@ -7360,7 +7378,10 @@ function parseGhMetadataWrite(segment) {
|
|
|
7360
7378
|
for (let i2 = 0; i2 < tokens.length; i2 += 2) {
|
|
7361
7379
|
const flag = tokens[i2];
|
|
7362
7380
|
const value = tokens[i2 + 1];
|
|
7363
|
-
if (!/^--(?:base|head|title|label|assignee|milestone|reviewer|body-file|body)$/i.test(flag) || !value
|
|
7381
|
+
if (!/^--(?:base|head|title|label|assignee|milestone|reviewer|body-file|body)$/i.test(flag) || !value) return void 0;
|
|
7382
|
+
const literal2 = (/^--(?:body|title)$/i.test(flag) ? GH_PROSE_VALUE_RE : GH_FLAG_VALUE_RE).test(value);
|
|
7383
|
+
const selectorVariable = !/^--(?:body|title|body-file)$/i.test(flag) && /^\$[A-Za-z_]\w*$/.test(value);
|
|
7384
|
+
if (!literal2 && !selectorVariable) return void 0;
|
|
7364
7385
|
if (/^--body-file$/i.test(flag)) {
|
|
7365
7386
|
if (bodySeen) return void 0;
|
|
7366
7387
|
bodySeen = true;
|
|
@@ -7376,6 +7397,11 @@ function parseGhMetadataWrite(segment) {
|
|
|
7376
7397
|
return { bodyFile, masked: `${ghBody[1]}${kept.join(" ")}${ghBody[3]}` };
|
|
7377
7398
|
}
|
|
7378
7399
|
function withoutGhMetadataProse(segment) {
|
|
7400
|
+
const assigned = /^(\s*\$[A-Za-z_]\w*\s*=\s*)([\s\S]+)$/.exec(segment);
|
|
7401
|
+
if (assigned) {
|
|
7402
|
+
const masked = parseGhMetadataWrite(assigned[2])?.masked;
|
|
7403
|
+
return masked === void 0 ? segment : `${assigned[1]}${masked}`;
|
|
7404
|
+
}
|
|
7379
7405
|
return parseGhMetadataWrite(segment)?.masked ?? segment;
|
|
7380
7406
|
}
|
|
7381
7407
|
function shellUrlActionText(command) {
|
|
@@ -7954,7 +7980,8 @@ function provenCompoundDownload(command, detectedUrl) {
|
|
|
7954
7980
|
return segments.every((segment) => {
|
|
7955
7981
|
const statusRead = /^\(((?:Invoke-WebRequest|iwr)\s+[^\r\n]+)\)\.(?:StatusCode|StatusDescription|RawContentLength)$/i.exec(segment);
|
|
7956
7982
|
if (statusRead) segment = statusRead[1];
|
|
7957
|
-
|
|
7983
|
+
const withoutWriteOut = segment.replace(/(\s(?:-w|--write-out)\s+)(?:"[^"\r\n$`@]*"|'[^'\r\n$`@]*')/g, '$1"fcd-write-out"');
|
|
7984
|
+
if (/[$`{}()<>;&]/.test(withoutWriteOut)) return false;
|
|
7958
7985
|
const pipeline = splitShellSegments(segment, true);
|
|
7959
7986
|
if (pipeline.slice(1).some((part) => !isPowerShellReadFormatter(part))) return false;
|
|
7960
7987
|
const raw = pipeline[0];
|
|
@@ -7968,7 +7995,13 @@ function provenCompoundDownload(command, detectedUrl) {
|
|
|
7968
7995
|
let urls = 0;
|
|
7969
7996
|
for (let i2 = 0; i2 < tokens.length; i2++) {
|
|
7970
7997
|
const token = tokens[i2];
|
|
7971
|
-
if (isCurl && /^(
|
|
7998
|
+
if (isCurl && /^(?:-m|--max-time|--connect-timeout)$/.test(token) && /^\d+(?:\.\d+)?$/.test(tokens[i2 + 1] || "")) {
|
|
7999
|
+
i2++;
|
|
8000
|
+
continue;
|
|
8001
|
+
}
|
|
8002
|
+
if (isCurl && /^(?:-w|--write-out)$/.test(token)) {
|
|
8003
|
+
const format = tokens[i2 + 1] || "";
|
|
8004
|
+
if (!format || /^@/.test(format) || /[$`]/.test(format)) return false;
|
|
7972
8005
|
i2++;
|
|
7973
8006
|
continue;
|
|
7974
8007
|
}
|
|
@@ -8068,6 +8101,16 @@ function provenPowerShellReadSequence(command) {
|
|
|
8068
8101
|
}
|
|
8069
8102
|
return requests > 0;
|
|
8070
8103
|
}
|
|
8104
|
+
function isLoopbackHost(hostname) {
|
|
8105
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
8106
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
8107
|
+
}
|
|
8108
|
+
var RAW_NETWORK_PROGRAM_RE = /(?:^|[\s;&|(`])(?:nc|ncat|netcat|socat|ssh|scp|sftp|rsync|telnet|ftp|tftp|mail|mailx|sendmail|msmtp|openssl\s+s_client)\b/i;
|
|
8109
|
+
function commandStaysOnLoopback(command) {
|
|
8110
|
+
if (!command || RAW_NETWORK_PROGRAM_RE.test(command)) return false;
|
|
8111
|
+
const hosts = [...command.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/(?:[^/\s@'"]*@)?([^/\s:'"?#]+)/gi)].map((match) => match[1]);
|
|
8112
|
+
return hosts.length > 0 && hosts.every(isLoopbackHost);
|
|
8113
|
+
}
|
|
8071
8114
|
function isInternalHost(hostname) {
|
|
8072
8115
|
const host = hostname.toLowerCase();
|
|
8073
8116
|
if (!host || host === "localhost" || host === "::1" || host.endsWith(".local") || host.endsWith(".internal")) return true;
|
|
@@ -8108,6 +8151,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
8108
8151
|
const matchText = buildOperationMatchText(toolName, context);
|
|
8109
8152
|
const toolCaps = toolCapabilities(toolName || context.toolName || "");
|
|
8110
8153
|
const localFileContentAction = isLocalFileContentAction(toolName, operation, context);
|
|
8154
|
+
const loopbackContentAction = context["destination.loopback"] === "true";
|
|
8155
|
+
const ruleNamesDestination = (rule) => (rule.constraints || []).some((c) => c.field.startsWith("destination."));
|
|
8156
|
+
const contentCeilingApplies = (rule) => ruleNeedsContentFact(rule) && (localFileContentAction || loopbackContentAction && !ruleNamesDestination(rule));
|
|
8111
8157
|
const isBareInvocation = !!operation && operation.toLowerCase() === (toolName || "").toLowerCase();
|
|
8112
8158
|
const isCodeExecTool = toolCaps.has("code_execution");
|
|
8113
8159
|
const candidateOperations = isBareInvocation && declaredOperations.length > 0 && !isCodeExecTool ? [operation, ...declaredOperations] : [operation];
|
|
@@ -8162,7 +8208,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
8162
8208
|
const constraintDesc = rule.constraints?.map((c) => `${c.field} ${c.operator} ${c.value}`).join(", ");
|
|
8163
8209
|
const matchedRule = `${rule.operations.join("/")} \u2192 ${rule.verdict}${constraintDesc ? ` (${constraintDesc})` : ""}`;
|
|
8164
8210
|
if (isMonitorStage) {
|
|
8165
|
-
const wouldOnlyWarn = rule.verdict === "block" && policy.enforcement !== "hard" &&
|
|
8211
|
+
const wouldOnlyWarn = rule.verdict === "block" && policy.enforcement !== "hard" && contentCeilingApplies(rule);
|
|
8166
8212
|
if ((rule.verdict === "block" || rule.verdict === "require_approval") && !wouldOnlyWarn) {
|
|
8167
8213
|
const match = { policyId: policy.id, policyName: policy.name, verdict: rule.verdict, matchedRule };
|
|
8168
8214
|
monitorMatches.push(match);
|
|
@@ -8180,13 +8226,13 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
8180
8226
|
reason: `Action policy "${policy.name}": ${rule.operations.join("/")} ${rule.verdict === "block" ? "blocked" : rule.verdict === "require_approval" ? "requires approval" : rule.verdict}`,
|
|
8181
8227
|
...rule.verdict === "require_approval" ? { approval: resolveApprovalOptions(rule.approval) } : {}
|
|
8182
8228
|
};
|
|
8183
|
-
if (rule.verdict === "block" && policy.enforcement !== "hard" &&
|
|
8229
|
+
if (rule.verdict === "block" && policy.enforcement !== "hard" && contentCeilingApplies(rule)) {
|
|
8184
8230
|
matched = {
|
|
8185
8231
|
...matched,
|
|
8186
8232
|
allowed: true,
|
|
8187
8233
|
verdict: "log",
|
|
8188
8234
|
loggedInsteadOfBlocked: true,
|
|
8189
|
-
reason: `${matched.reason} \u2014 recorded as a warning: this only touches a file on this machine and nothing leaves it (the same content leaving the machine still blocks; mark the policy Hard block to stop file edits too)`
|
|
8235
|
+
reason: localFileContentAction ? `${matched.reason} \u2014 recorded as a warning: this only touches a file on this machine and nothing leaves it (the same content leaving the machine still blocks; mark the policy Hard block to stop file edits too)` : `${matched.reason} \u2014 recorded as a warning: the destination is this machine (${context["destination.domain"]}) and nothing leaves it (the same content sent to an outside host still blocks; add a destination.type constraint to the rule to enforce on loopback too)`
|
|
8190
8236
|
};
|
|
8191
8237
|
}
|
|
8192
8238
|
if (matched.verdict === "require_approval") {
|
|
@@ -8363,6 +8409,10 @@ function inferBundledToolContext(toolName, args) {
|
|
|
8363
8409
|
context.amount = String(args.amount ?? args.price ?? args.total);
|
|
8364
8410
|
}
|
|
8365
8411
|
context["action.kind"] = isCodeExecTool || toolNameLower === "shell" ? "shell" : context.query !== void 0 && operation !== toolName && /^(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)$/.test(operation) ? "database" : ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(methodArg) ? "http" : context.path !== void 0 && (operation === "read" || operation === "write" || LOCAL_FILE_ACTION_RE.test(toolName) || FILE_REMOVAL_TOOL_RE.test(toolName)) ? "file" : "tool";
|
|
8412
|
+
delete context["destination.loopback"];
|
|
8413
|
+
if (context["destination.domain"] && isLoopbackHost(context["destination.domain"]) && (!isCodeExecTool || commandStaysOnLoopback(commandText))) {
|
|
8414
|
+
context["destination.loopback"] = "true";
|
|
8415
|
+
}
|
|
8366
8416
|
delete context["destination.repository"];
|
|
8367
8417
|
if (isCodeExecTool) {
|
|
8368
8418
|
const repository = explicitGitHubWriteRepository(String(args.command || args.cmd || args.code || args.script || args.input || ""));
|
package/dist/approvalPrompt.d.ts
CHANGED
|
@@ -5,7 +5,32 @@ export interface ApprovalMessageOptions {
|
|
|
5
5
|
alwaysScopeLabel?: string;
|
|
6
6
|
/** How long it holds: "30 days" (default) or "7 days or 20 uses". */
|
|
7
7
|
alwaysLimitsLabel?: string;
|
|
8
|
+
/** Visual variant. Ask is the interactive dialog; approval/block share the card, not extra buttons. */
|
|
9
|
+
tone?: DialogTone;
|
|
8
10
|
}
|
|
11
|
+
/** Which policy outcome this window is showing. Icons and copy follow this; buttons do not. */
|
|
12
|
+
export type DialogTone = 'ask' | 'approval' | 'block';
|
|
13
|
+
export interface NativeDialogView {
|
|
14
|
+
tone: DialogTone;
|
|
15
|
+
badge: string;
|
|
16
|
+
headline: string;
|
|
17
|
+
subtitle: string;
|
|
18
|
+
kind?: string;
|
|
19
|
+
action?: string;
|
|
20
|
+
policy?: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
agent?: string;
|
|
23
|
+
ide?: string;
|
|
24
|
+
tool?: string;
|
|
25
|
+
why: string[];
|
|
26
|
+
next: string[];
|
|
27
|
+
footer: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Layout model for the branded OS dialog. Same facts as the compact prompt;
|
|
31
|
+
* skips mockup-only chrome (other-outcome chips, Request approval, project tags).
|
|
32
|
+
*/
|
|
33
|
+
export declare function nativeDialogView(reason: string, options?: ApprovalMessageOptions): NativeDialogView;
|
|
9
34
|
/** Window title: the kind of action and the policy that stopped it, by name. */
|
|
10
35
|
export declare function nativeApprovalTitle(reason: string): string;
|
|
11
36
|
export declare function nativeApprovalMessage(reason: string, options?: ApprovalMessageOptions): string;
|
package/dist/approvalPrompt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nativeDialogView = nativeDialogView;
|
|
3
4
|
exports.nativeApprovalTitle = nativeApprovalTitle;
|
|
4
5
|
exports.nativeApprovalMessage = nativeApprovalMessage;
|
|
5
6
|
/** Compact OS prompt. The complete decision remains in the IDE response and audit. */
|
|
@@ -54,6 +55,56 @@ function promptFacts(reason) {
|
|
|
54
55
|
}
|
|
55
56
|
return facts;
|
|
56
57
|
}
|
|
58
|
+
function sentences(text) {
|
|
59
|
+
return text.split(/(?<=\.)\s+/).map(part => part.trim()).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Layout model for the branded OS dialog. Same facts as the compact prompt;
|
|
63
|
+
* skips mockup-only chrome (other-outcome chips, Request approval, project tags).
|
|
64
|
+
*/
|
|
65
|
+
function nativeDialogView(reason, options = {}) {
|
|
66
|
+
const f = promptFacts(reason);
|
|
67
|
+
const tone = options.tone || 'ask';
|
|
68
|
+
const why = [
|
|
69
|
+
...(f.because ? sentences(line(f.because, 400)) : []),
|
|
70
|
+
f.note ? line(f.note, 180) : '',
|
|
71
|
+
f.fallback ? line(f.fallback, 400) : '',
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
const next = tone === 'block'
|
|
74
|
+
? ['This action is stopped.', 'An admin can change the rule in the console.']
|
|
75
|
+
: tone === 'approval'
|
|
76
|
+
? ['An admin or manager can approve it in the console.', 'Closing this window does not approve it.']
|
|
77
|
+
: [
|
|
78
|
+
'Allow once lets only this action continue.',
|
|
79
|
+
'Deny or close keeps it blocked.',
|
|
80
|
+
options.allowAlways && options.alwaysScopeLabel
|
|
81
|
+
? `Always allow covers ${line(options.alwaysScopeLabel, 180)} for ${options.alwaysLimitsLabel || '30 days'} on this machine, under this rule as it is written today.`
|
|
82
|
+
: '',
|
|
83
|
+
].filter(Boolean);
|
|
84
|
+
const footer = f.policy && !f.fallback
|
|
85
|
+
? `Full details: the IDE message and this machine’s Activity. Console → Action Policies → ${f.localSafety ? 'Source: Local Safety → ' : ''}"${line(f.policy, 80)}".`
|
|
86
|
+
: 'Full details are in the IDE message and this machine’s Activity in FullCourtDefense.';
|
|
87
|
+
return {
|
|
88
|
+
tone,
|
|
89
|
+
badge: tone === 'block' ? 'Blocked' : tone === 'approval' ? 'Approval required' : 'Ask the developer',
|
|
90
|
+
headline: tone === 'block' ? 'Action blocked' : tone === 'approval' ? 'Action requires approval' : 'Action needs your OK',
|
|
91
|
+
subtitle: tone === 'block'
|
|
92
|
+
? 'A security policy stopped this action. It did not run.'
|
|
93
|
+
: tone === 'approval'
|
|
94
|
+
? 'A security policy paused this action for an admin or manager.'
|
|
95
|
+
: 'A security policy paused this action. Review the details and choose what to do next.',
|
|
96
|
+
kind: f.kind,
|
|
97
|
+
action: f.action ? line(f.action, 280) : undefined,
|
|
98
|
+
policy: f.policy ? line(f.policy, 160) : undefined,
|
|
99
|
+
scope: f.scope ? line(f.scope, 220) : undefined,
|
|
100
|
+
agent: f.agent ? line(f.agent, 80) : undefined,
|
|
101
|
+
ide: f.ide ? line(f.ide, 40) : undefined,
|
|
102
|
+
tool: f.tool ? line(f.tool, 230) : undefined,
|
|
103
|
+
why: why.length ? why : ['A policy matched this action.'],
|
|
104
|
+
next,
|
|
105
|
+
footer,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
57
108
|
/** Window title: the kind of action and the policy that stopped it, by name. */
|
|
58
109
|
function nativeApprovalTitle(reason) {
|
|
59
110
|
const f = promptFacts(reason);
|
package/dist/blockExplanation.js
CHANGED
|
@@ -170,7 +170,10 @@ function buildBlockExplanation(input) {
|
|
|
170
170
|
// "Blocked ..." reason must not turn a pending approval into a final denial.
|
|
171
171
|
const reason = src.source === 'builtin' && (input.outcome === 'ask' || input.outcome === 'approval')
|
|
172
172
|
? src.reason.replace(/^Blocked\b/, 'Matched') : src.reason;
|
|
173
|
-
|
|
173
|
+
// Catalog findings often carry the same words as reason and explanation
|
|
174
|
+
// ("…: gcloud access-token print." / "gcloud access-token print"); say it once.
|
|
175
|
+
const explanation = src.explanation?.trim().replace(/\.$/, '');
|
|
176
|
+
why = explanation && !reason.toLowerCase().includes(explanation.toLowerCase()) ? `${reason} — ${src.explanation}` : reason;
|
|
174
177
|
unblock = [
|
|
175
178
|
`Open the rule in the console: ${consoleLink}`,
|
|
176
179
|
`An admin can set "${label}" to Ask the developer, Warn only, or Off — for everyone or only for ${input.developerName || 'this machine'}.`,
|
|
@@ -610,19 +610,53 @@ function containsSensitiveCredentialPath(value) {
|
|
|
610
610
|
// is not a file read. Traversal and the file must sit in the SAME path token:
|
|
611
611
|
// `../.github/x.yml` on one argument and `.env` on another are two unrelated
|
|
612
612
|
// arguments, not a reach outside the workspace (recorded IDE false positive).
|
|
613
|
-
|
|
614
|
-
|
|
613
|
+
// `=` ends a token too: `--env-file=.env` and `DOTENV_CONFIG_PATH=.env` name
|
|
614
|
+
// the file as much as a bare operand does.
|
|
615
|
+
for (const token of text.split(/[\s'"=]+/)) {
|
|
616
|
+
const fileClass = envFileClass(token);
|
|
617
|
+
if (fileClass === 'template')
|
|
618
|
+
continue;
|
|
619
|
+
if (fileClass !== 'secret-store')
|
|
620
|
+
continue;
|
|
615
621
|
// Traversal to a .env outside the workspace stays a hard block
|
|
616
622
|
// (env_traversal); a plain workspace .env is a separate warn-by-default
|
|
617
623
|
// item (env_workspace) — agents legitimately read it during development,
|
|
618
624
|
// but the fleet should still see every touch.
|
|
619
|
-
const reachesOutside = /(?:^|\/)\.\.(?:\/|$)/.test(
|
|
625
|
+
const reachesOutside = /(?:^|\/)\.\.(?:\/|$)/.test(token) || /%2e/i.test(token);
|
|
620
626
|
return reachesOutside
|
|
621
627
|
? { itemId: 'env_traversal', label: 'environment file via path traversal' }
|
|
622
628
|
: { itemId: 'env_workspace', label: 'workspace environment file' };
|
|
623
629
|
}
|
|
624
630
|
return undefined;
|
|
625
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* file.class of an environment file, decided by the filename's structure — not
|
|
634
|
+
* by the text around it.
|
|
635
|
+
*
|
|
636
|
+
* secret-store `.env`, `.env.local`, `.env.production`, `HEAD:.env`
|
|
637
|
+
* template `.env.example`, `.env.sample`, `.env.template`, `.env.dist`,
|
|
638
|
+
* `.env.defaults`, `.env.schema` — committed on purpose, keys
|
|
639
|
+
* with empty or placeholder values, the file a README says to
|
|
640
|
+
* copy. Reading, editing or staging a template is not a
|
|
641
|
+
* credential access, whatever program does it.
|
|
642
|
+
*
|
|
643
|
+
* A git object path (`HEAD:.env`, `origin/main:.env`, `:.env` for the index)
|
|
644
|
+
* names the same file inside a commit — `git show HEAD:.env` prints it — so the
|
|
645
|
+
* revision prefix is stripped before the filename is classified. A Windows
|
|
646
|
+
* drive letter (`c:/repo/.env`) survives the same strip as a path.
|
|
647
|
+
*/
|
|
648
|
+
const ENV_TEMPLATE_SUFFIXES = new Set(['example', 'examples', 'sample', 'template', 'tpl', 'dist', 'default', 'defaults', 'schema', 'skeleton', 'skel']);
|
|
649
|
+
const ENV_FILE_RE = /(?:^|\/)\.env((?:\.[a-z0-9_-]+)*)(?:$|[?#])/;
|
|
650
|
+
function envFileClass(token) {
|
|
651
|
+
if (!token.includes('.env'))
|
|
652
|
+
return undefined;
|
|
653
|
+
const filePath = token.replace(/^[^\s:]*:(?=[./~\w])/, '');
|
|
654
|
+
const match = ENV_FILE_RE.exec(filePath) || ENV_FILE_RE.exec(token);
|
|
655
|
+
if (!match)
|
|
656
|
+
return undefined;
|
|
657
|
+
const suffixes = match[1].split('.').filter(Boolean);
|
|
658
|
+
return suffixes.some(suffix => ENV_TEMPLATE_SUFFIXES.has(suffix)) ? 'template' : 'secret-store';
|
|
659
|
+
}
|
|
626
660
|
/**
|
|
627
661
|
* Commands that PRINT stored credentials to stdout — the file-path rules are
|
|
628
662
|
* useless against them because the secret never comes from a watched file
|
|
@@ -1027,8 +1061,10 @@ function credentialAccessCommandText(text) {
|
|
|
1027
1061
|
// PowerShell 7 permits a pipeline to continue on a line starting with `|`,
|
|
1028
1062
|
// even across comment lines. A trailing comma also continues an argument list.
|
|
1029
1063
|
// Keep these forms intact so a later consumer cannot lose its source path.
|
|
1064
|
+
// A lone `&` backgrounds or invokes (`& script.ps1`) and disqualifies the
|
|
1065
|
+
// text; `2>&1` / `>&2` merely duplicate a file descriptor and do not.
|
|
1030
1066
|
const allowMetadata = !(/(?:^|[\r\n])[ \t]*\||,[ \t]*(?:#[^\r\n]*)?[\r\n]/.test(text)
|
|
1031
|
-
|| /`|(?:^|[
|
|
1067
|
+
|| /`|(?:^|[^&>])&(?:[^&\d]|$)|@['"]|(?:^|[;&|\r\n])\s*\.\s|\b(?:iex|invoke-expression|invoke-command|eval|function|filter|set-alias|new-alias|sal|nal|set-item|new-item|update-typedata|add-type|import-module|ipmo|PSDefaultParameterValues)\b/i.test(text));
|
|
1032
1068
|
const statements = [];
|
|
1033
1069
|
let current = '', quote = '', depth = 0;
|
|
1034
1070
|
for (let i = 0; i < text.length; i++) {
|
|
@@ -1068,7 +1104,86 @@ function credentialAccessCommandText(text) {
|
|
|
1068
1104
|
return text;
|
|
1069
1105
|
statements.push(current);
|
|
1070
1106
|
// A real shell separator is a boundary of the preceding path, even without spaces.
|
|
1071
|
-
return statements.map(statement => allowMetadata && isFileMetadataQuery(statement) ? 'fcd_file_metadata' : statement).join('\n');
|
|
1107
|
+
return statements.map(statement => allowMetadata && (isFileMetadataQuery(statement) || isMetadataOnlyStatement(statement)) ? 'fcd_file_metadata' : statement).join('\n');
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* access.role of a statement, from its LEADING PROGRAM: what does this program
|
|
1111
|
+
* do with the paths it is given? The programs below answer questions ABOUT a
|
|
1112
|
+
* file — tracked? ignored? present? how big? changed when? — and never print or
|
|
1113
|
+
* move its content. A sensitive path handed to them is the subject of the
|
|
1114
|
+
* question, not an access: `git status .env`, `git check-ignore -v .env`,
|
|
1115
|
+
* `ls -la .env`, `test -f .env`. Anything not listed keeps the conservative
|
|
1116
|
+
* default (the statement may read), so coverage grows without ever losing recall.
|
|
1117
|
+
*
|
|
1118
|
+
* Structural guards: no substitution, grouping or stdin redirection in the
|
|
1119
|
+
* statement (`$(…)`, backticks, parentheses, braces, `<`); a pipe may only feed
|
|
1120
|
+
* a pure text FILTER (`grep`, `wc`, `sort`, `Select-String`…) — metadata in,
|
|
1121
|
+
* metadata out — never a program that turns names into content or runs them
|
|
1122
|
+
* (`xargs cat`, `sh`, `ForEach-Object`). `git log -p` prints patches and
|
|
1123
|
+
* `find -exec` runs a program on the match, so those flags return the
|
|
1124
|
+
* statement to the default.
|
|
1125
|
+
*/
|
|
1126
|
+
// Programs that print a listing. `Get-Item` / `Get-ChildItem` emit FileInfo
|
|
1127
|
+
// OBJECTS and stay with isFileMetadataQuery above, which knows which object
|
|
1128
|
+
// properties are metadata. `ls` / `dir` alias them in PowerShell, but every way
|
|
1129
|
+
// an object becomes content again — an output variable, a script block, a
|
|
1130
|
+
// non-filter consumer — is rejected structurally before this table is consulted.
|
|
1131
|
+
const METADATA_ONLY_PROGRAMS = new Set([
|
|
1132
|
+
'ls', 'dir', 'stat', 'test', '[', 'file', 'du', 'realpath', 'readlink', 'basename', 'dirname', 'test-path',
|
|
1133
|
+
]);
|
|
1134
|
+
const GIT_METADATA_SUBCOMMANDS = new Set(['status', 'check-ignore', 'check-attr', 'ls-files', 'ls-tree', 'log', 'shortlog', 'rev-parse', 'rev-list', 'branch', 'remote', 'tag', 'describe']);
|
|
1135
|
+
// Downstream of a metadata program, only these keep the pipe metadata-only.
|
|
1136
|
+
const TEXT_FILTER_PROGRAMS = new Set([
|
|
1137
|
+
'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'wc', 'sort', 'uniq', 'head', 'tail', 'cut', 'tr', 'column', 'nl',
|
|
1138
|
+
'select-string', 'sls', 'measure-object', 'measure', 'sort-object',
|
|
1139
|
+
'format-table', 'ft', 'format-list', 'fl', 'format-wide', 'fw', 'out-string', 'out-null', 'out-host', 'more',
|
|
1140
|
+
]);
|
|
1141
|
+
// An output/pipeline variable keeps the result for a later statement to open.
|
|
1142
|
+
const CAPTURING_PARAMETER_RE = /^-(?:outvariable|ov|pipelinevariable|pv|outbuffer|ob)$/i;
|
|
1143
|
+
function leadingProgram(tokens) {
|
|
1144
|
+
let i = 0;
|
|
1145
|
+
while (i < tokens.length && (/^[A-Za-z_]\w*=/.test(tokens[i]) || /^(?:sudo|command)$/i.test(tokens[i])))
|
|
1146
|
+
i++;
|
|
1147
|
+
return { program: (tokens[i] || '').toLowerCase().replace(/^.*\//, '').replace(/\.exe$/, ''), args: tokens.slice(i + 1) };
|
|
1148
|
+
}
|
|
1149
|
+
function isMetadataOnlyStatement(statement) {
|
|
1150
|
+
if (/[`(){}<]|\$\(/.test(statement))
|
|
1151
|
+
return false;
|
|
1152
|
+
const tokens = statement.trim().match(/"[^"\r\n]*"|'(?:[^'\r\n]|'')*'|[^\s"']+/g) || [];
|
|
1153
|
+
if (tokens.some(token => CAPTURING_PARAMETER_RE.test(token)))
|
|
1154
|
+
return false;
|
|
1155
|
+
const segments = [[]];
|
|
1156
|
+
for (const token of tokens) {
|
|
1157
|
+
if (token === '|')
|
|
1158
|
+
segments.push([]);
|
|
1159
|
+
else
|
|
1160
|
+
segments[segments.length - 1].push(token);
|
|
1161
|
+
}
|
|
1162
|
+
for (const segment of segments.slice(1)) {
|
|
1163
|
+
if (!TEXT_FILTER_PROGRAMS.has(leadingProgram(segment).program))
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
const { program, args } = leadingProgram(segments[0]);
|
|
1167
|
+
if (!program)
|
|
1168
|
+
return false;
|
|
1169
|
+
if (program === 'git') {
|
|
1170
|
+
let j = 0;
|
|
1171
|
+
while (j < args.length && args[j].startsWith('-')) {
|
|
1172
|
+
if (/^-[cC]$/.test(args[j]))
|
|
1173
|
+
j++;
|
|
1174
|
+
j++;
|
|
1175
|
+
}
|
|
1176
|
+
const subcommand = (args[j] || '').toLowerCase();
|
|
1177
|
+
if (!GIT_METADATA_SUBCOMMANDS.has(subcommand))
|
|
1178
|
+
return false;
|
|
1179
|
+
const rest = args.slice(j + 1);
|
|
1180
|
+
if ((subcommand === 'log' || subcommand === 'rev-list') && rest.some(arg => /^(?:-p|-u|--patch|-L|--pretty|--format|-c|--cc)/.test(arg)))
|
|
1181
|
+
return false;
|
|
1182
|
+
return true;
|
|
1183
|
+
}
|
|
1184
|
+
if (program === 'find')
|
|
1185
|
+
return !args.some(arg => /^-(?:exec|execdir|ok|okdir|delete|fprint\w*|fls)$/i.test(arg));
|
|
1186
|
+
return METADATA_ONLY_PROGRAMS.has(program);
|
|
1072
1187
|
}
|
|
1073
1188
|
function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
1074
1189
|
// Honeypot decoys and org custom patterns scan the RAW value: a decoy path
|
|
@@ -1092,7 +1207,10 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1092
1207
|
const sensitivePath = containsSensitiveCredentialPath(commandContext
|
|
1093
1208
|
? `${credentialAccessCommandText(value)}\n${interpreterFileArguments(rawValue)}` : value);
|
|
1094
1209
|
if (sensitivePath) {
|
|
1095
|
-
const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path',
|
|
1210
|
+
const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path',
|
|
1211
|
+
// Verdict-neutral: the same finding is a warning on a monitor machine and
|
|
1212
|
+
// a block on an enforcing one — the console shows the verdict, not this text.
|
|
1213
|
+
`Local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
|
|
1096
1214
|
if (finding)
|
|
1097
1215
|
return finding;
|
|
1098
1216
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -2111,6 +2111,12 @@ async function enforceActionPolicy(ctx) {
|
|
|
2111
2111
|
(0, policyGateHealth_1.recordGateSuccess)();
|
|
2112
2112
|
}
|
|
2113
2113
|
catch (err) {
|
|
2114
|
+
// A verdict was already written inside the try (respondDegraded / respond →
|
|
2115
|
+
// io.exit). In daemon mode io.exit unwinds with HookExitSignal; catching it
|
|
2116
|
+
// here answered the IDE a SECOND time ("Hook error: {code:0}") and counted
|
|
2117
|
+
// one rejected request as two gate failures, tripping fail-closed early.
|
|
2118
|
+
if (err instanceof HookExitSignal)
|
|
2119
|
+
throw err;
|
|
2114
2120
|
const described = (0, describeError_1.describeError)(err);
|
|
2115
2121
|
(0, hookIo_1.dbg)({ phase: 'policy_exception', event, error: described, failClosed: ctx.failClosed });
|
|
2116
2122
|
const detail = `Hook error: ${described}.`;
|
|
@@ -2281,6 +2287,8 @@ async function enforceShieldText(ctx) {
|
|
|
2281
2287
|
respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
|
|
2282
2288
|
}
|
|
2283
2289
|
catch (err) {
|
|
2290
|
+
if (err instanceof HookExitSignal)
|
|
2291
|
+
throw err; // verdict already written (daemon-mode unwind)
|
|
2284
2292
|
respondDegraded(ctx, `Hook error: ${(0, describeError_1.describeError)(err)}.`, 'prompt');
|
|
2285
2293
|
}
|
|
2286
2294
|
}
|