fullcourtdefense-cli 1.34.26 → 1.35.0
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 +92 -7
- package/dist/approvalPrompt.d.ts +2 -0
- package/dist/approvalPrompt.js +58 -11
- package/dist/commands/ephemeralStack.d.ts +36 -0
- package/dist/commands/ephemeralStack.js +126 -1
- package/dist/commands/httpGateway.d.ts +66 -0
- package/dist/commands/httpGateway.js +273 -0
- package/dist/commands/httpGatewayCommand.d.ts +87 -0
- package/dist/commands/httpGatewayCommand.js +450 -0
- package/dist/commands/mcpGateway.d.ts +95 -0
- package/dist/commands/mcpGateway.js +8 -1
- package/dist/commands/workloadProtect.d.ts +8 -0
- package/dist/commands/workloadProtect.js +8 -0
- package/dist/devConfirm.js +15 -8
- package/dist/httpEgress.d.ts +111 -0
- package/dist/httpEgress.js +240 -0
- package/dist/index.js +36 -0
- package/dist/telemetry.d.ts +20 -0
- package/dist/telemetry.js +2 -1
- package/dist/version.json +1 -1
- package/package.json +4 -1
|
@@ -7153,7 +7153,7 @@ function extractUrl(args, _argsText) {
|
|
|
7153
7153
|
const raw = args[field];
|
|
7154
7154
|
if (typeof raw !== "string") continue;
|
|
7155
7155
|
const shellField = ["command", "cmd", "script", "input"].includes(field);
|
|
7156
|
-
const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).join("\n") : raw;
|
|
7156
|
+
const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).map(withoutGhMetadataProse).join("\n") : raw;
|
|
7157
7157
|
const firstUrl = (text) => text.match(/https?:\/\/[^\s"'<>`]+/i)?.[0] || text.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>`]+/i)?.[0];
|
|
7158
7158
|
const outboundUrls = shellField ? splitShellSegments(value).filter((segment) => /^(?:curl(?:\.exe)?|wget(?:\.exe)?|invoke-restmethod|invoke-webrequest|irm|iwr)$/i.test(shellSegmentLead(segment)) && classifyShellSegment(segment) === "write").map(firstUrl).filter((url) => Boolean(url)) : [];
|
|
7159
7159
|
const externalOutbound = outboundUrls.find((url) => {
|
|
@@ -7322,7 +7322,7 @@ function segmentMayReachNetwork(segment) {
|
|
|
7322
7322
|
return !LOCAL_DATA_LEADS.has(lead);
|
|
7323
7323
|
});
|
|
7324
7324
|
}
|
|
7325
|
-
var LITERAL_ASSIGNMENT_RE = /^\s*(?:\$|(?=[A-Za-z_]))([A-Za-z_]\w*)\s*=\s*(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|'(?:[^']|'')*')\s*$/;
|
|
7325
|
+
var LITERAL_ASSIGNMENT_RE = /^\s*(?:\$|(?=[A-Za-z_]))([A-Za-z_]\w*)\s*=\s*(?:@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|'(?:[^']|'')*'|-?\d+(?:\.\d+)?|@\((?:[\s,]|'(?:[^']|'')*'|"[^"$`]*"|@'\r?\n[\s\S]*?\r?\n'@|-?\d+(?:\.\d+)?)*\))\s*$/;
|
|
7326
7326
|
function segmentsThatMayReachNetwork(segments) {
|
|
7327
7327
|
const tempRedefined = segments.some((segment) => /\$env:(?:TEMP|TMP)\s*=/i.test(maskQuotedSpans(segment)));
|
|
7328
7328
|
const remotePath = /(?:^|[\s"'=(])[\\/]{2}[^\s\\/"']+[\\/]/;
|
|
@@ -7347,21 +7347,36 @@ function temporaryFileSyntax(text) {
|
|
|
7347
7347
|
}
|
|
7348
7348
|
var GH_FLAG_VALUE_RE = /^(?:'[^'$`\r\n]*'|"[^"$`\r\n]*"|[^\s'"$`|;&(){}<>]+)$/;
|
|
7349
7349
|
function ghMetadataWriteBodyFile(segment) {
|
|
7350
|
-
|
|
7350
|
+
return parseGhMetadataWrite(segment)?.bodyFile;
|
|
7351
|
+
}
|
|
7352
|
+
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+)(.+?)((?:\s+2>&1)?(?:\s*\|\s*Select-Object\s+-Last\s+\d{1,3})?)$/i.exec(segment);
|
|
7351
7354
|
if (!ghBody) return void 0;
|
|
7352
7355
|
const tokens = ghBody[2].match(/'[^'\r\n]*'|"[^"\r\n]*"|[^\s'"]+/g) || [];
|
|
7353
7356
|
if (tokens.join("").replace(/\s/g, "") !== ghBody[2].replace(/\s/g, "")) return void 0;
|
|
7354
7357
|
let bodyFile;
|
|
7358
|
+
let bodySeen = false;
|
|
7359
|
+
const kept = [];
|
|
7355
7360
|
for (let i2 = 0; i2 < tokens.length; i2 += 2) {
|
|
7356
7361
|
const flag = tokens[i2];
|
|
7357
7362
|
const value = tokens[i2 + 1];
|
|
7358
|
-
if (!/^--(?:base|head|title|label|assignee|milestone|reviewer|body-file)$/i.test(flag) || !value || !GH_FLAG_VALUE_RE.test(value)) return void 0;
|
|
7363
|
+
if (!/^--(?:base|head|title|label|assignee|milestone|reviewer|body-file|body)$/i.test(flag) || !value || !GH_FLAG_VALUE_RE.test(value)) return void 0;
|
|
7359
7364
|
if (/^--body-file$/i.test(flag)) {
|
|
7360
|
-
if (
|
|
7365
|
+
if (bodySeen) return void 0;
|
|
7366
|
+
bodySeen = true;
|
|
7361
7367
|
bodyFile = value;
|
|
7362
7368
|
}
|
|
7369
|
+
if (/^--body$/i.test(flag)) {
|
|
7370
|
+
if (bodySeen) return void 0;
|
|
7371
|
+
bodySeen = true;
|
|
7372
|
+
}
|
|
7373
|
+
const prose = /^--(?:body|title)$/i.test(flag);
|
|
7374
|
+
kept.push(flag, prose ? '"fcd-gh-metadata"' : value);
|
|
7363
7375
|
}
|
|
7364
|
-
return bodyFile;
|
|
7376
|
+
return { bodyFile, masked: `${ghBody[1]}${kept.join(" ")}${ghBody[3]}` };
|
|
7377
|
+
}
|
|
7378
|
+
function withoutGhMetadataProse(segment) {
|
|
7379
|
+
return parseGhMetadataWrite(segment)?.masked ?? segment;
|
|
7365
7380
|
}
|
|
7366
7381
|
function shellUrlActionText(command) {
|
|
7367
7382
|
if (command.length > 65536) return command;
|
|
@@ -7507,6 +7522,22 @@ function splitShellSegments(command, alsoOnPipe = false) {
|
|
|
7507
7522
|
continue;
|
|
7508
7523
|
}
|
|
7509
7524
|
}
|
|
7525
|
+
if (ch === "@" && command[i2 + 1] === "{") {
|
|
7526
|
+
const end = hashtableEnd(command, i2);
|
|
7527
|
+
if (end > i2) {
|
|
7528
|
+
cur += command.slice(i2, end);
|
|
7529
|
+
i2 = end - 1;
|
|
7530
|
+
continue;
|
|
7531
|
+
}
|
|
7532
|
+
}
|
|
7533
|
+
if (ch === "@" && command[i2 + 1] === "(") {
|
|
7534
|
+
const end = literalArrayEnd(command, i2);
|
|
7535
|
+
if (end > i2) {
|
|
7536
|
+
cur += command.slice(i2, end);
|
|
7537
|
+
i2 = end - 1;
|
|
7538
|
+
continue;
|
|
7539
|
+
}
|
|
7540
|
+
}
|
|
7510
7541
|
if (ch === '"' || ch === "'") {
|
|
7511
7542
|
quote = ch;
|
|
7512
7543
|
cur += ch;
|
|
@@ -7547,6 +7578,47 @@ ${q}@`;
|
|
|
7547
7578
|
if (idx < 0) return -1;
|
|
7548
7579
|
return idx + close.length;
|
|
7549
7580
|
}
|
|
7581
|
+
function hashtableEnd(command, start) {
|
|
7582
|
+
let depth = 0;
|
|
7583
|
+
let quote = null;
|
|
7584
|
+
for (let i2 = start + 1; i2 < command.length; i2++) {
|
|
7585
|
+
const ch = command[i2];
|
|
7586
|
+
if (quote) {
|
|
7587
|
+
if (ch === "\\" && quote === '"' && i2 + 1 < command.length) {
|
|
7588
|
+
i2++;
|
|
7589
|
+
continue;
|
|
7590
|
+
}
|
|
7591
|
+
if (ch === quote) quote = null;
|
|
7592
|
+
continue;
|
|
7593
|
+
}
|
|
7594
|
+
if (ch === '"' || ch === "'") {
|
|
7595
|
+
quote = ch;
|
|
7596
|
+
continue;
|
|
7597
|
+
}
|
|
7598
|
+
if (ch === "{") depth++;
|
|
7599
|
+
else if (ch === "}" && --depth === 0) return i2 + 1;
|
|
7600
|
+
}
|
|
7601
|
+
return -1;
|
|
7602
|
+
}
|
|
7603
|
+
var LITERAL_ELEMENT_RE = /^(?:'(?:[^']|'')*'|"[^"$`]*"|@'\r?\n[\s\S]*?\r?\n'@|@"\r?\n[^$`]*?\r?\n"@|-?\d+(?:\.\d+)?)/;
|
|
7604
|
+
function literalArrayEnd(command, start) {
|
|
7605
|
+
let i2 = start + 2;
|
|
7606
|
+
let elements = 0;
|
|
7607
|
+
while (i2 < command.length) {
|
|
7608
|
+
const rest = command.slice(i2);
|
|
7609
|
+
const skip = /^[\s,]+/.exec(rest);
|
|
7610
|
+
if (skip) {
|
|
7611
|
+
i2 += skip[0].length;
|
|
7612
|
+
continue;
|
|
7613
|
+
}
|
|
7614
|
+
if (command[i2] === ")") return elements > 0 ? i2 + 1 : -1;
|
|
7615
|
+
const element = LITERAL_ELEMENT_RE.exec(rest);
|
|
7616
|
+
if (!element || elements >= 256) return -1;
|
|
7617
|
+
i2 += element[0].length;
|
|
7618
|
+
elements++;
|
|
7619
|
+
}
|
|
7620
|
+
return -1;
|
|
7621
|
+
}
|
|
7550
7622
|
var QUOTED_LITERAL_RE = /@'[\s\S]*?\r?\n'@|@"[\s\S]*?\r?\n"@|"((?:[^"\\]|\\.)*)"|'([^']*)'/g;
|
|
7551
7623
|
function quotedSpans(segment) {
|
|
7552
7624
|
const spans = [];
|
|
@@ -7729,6 +7801,16 @@ function shellSegmentLead(segment) {
|
|
|
7729
7801
|
}
|
|
7730
7802
|
var PS_SESSION_VARIABLE_ASSIGN_RE = /^\s*\$(?:psdefaultparametervalues|psmodulepath|profile|executioncontext|env:(?:path|pathext|psmodulepath))\s*(?:\[[^\]]*\]\s*)?(?:\+?=)/i;
|
|
7731
7803
|
var PS_VALUE_EXECUTOR_RE = /\b(?:invoke-expression|iex|invoke-command|icm|start-process|saps|invoke-item|ii)\b|&\s*[$(]|\.\s*invoke\s*\(/i;
|
|
7804
|
+
function isInertValueExpression(raw) {
|
|
7805
|
+
const masked = maskQuotedSpans(raw);
|
|
7806
|
+
if (/\$\(|`|&\s*[$(\w"']|[|<>]/.test(masked) || PS_VALUE_EXECUTOR_RE.test(masked)) return false;
|
|
7807
|
+
if (/(?:::|\.)\s*(?:invoke|start|run|exec|create|load|download|upload|send|open)\w*\s*\(/i.test(masked)) return false;
|
|
7808
|
+
for (const group of masked.matchAll(/\(\s*([A-Za-z][\w-]*)/g)) if (!LOCAL_DATA_LEADS.has(group[1].toLowerCase())) return false;
|
|
7809
|
+
const body = masked.replace(/^\s*\$[\w]+\s*(?:\+|-|\*|\/)?=\s*/, "").trim();
|
|
7810
|
+
if (/^\$\w+\s*(?:\+\+|--)$/.test(body)) return true;
|
|
7811
|
+
if (/^(?:@\{|@\()/.test(body)) return !/[$(]/.test(body.slice(2)) && /^@[{(](?:[\s;,=]|""|-?\d+(?:\.\d+)?|[\w-]+|@\{|\}|\))*$/.test(body);
|
|
7812
|
+
return /^(?:""|-?\d)/.test(body);
|
|
7813
|
+
}
|
|
7732
7814
|
var worstShellOp = (ops, floor = "read") => ops.reduce((w, op) => (SHELL_OP_RANK[op] || 0) > (SHELL_OP_RANK[w] || 0) ? op : w, floor);
|
|
7733
7815
|
function classifyShellSegment(segment, depth = 0) {
|
|
7734
7816
|
const raw = segment.trim();
|
|
@@ -7737,6 +7819,7 @@ function classifyShellSegment(segment, depth = 0) {
|
|
|
7737
7819
|
const lead = shellSegmentLead(segment);
|
|
7738
7820
|
if ((/^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead)) && PS_VALUE_EXECUTOR_RE.test(maskQuotedSpans(raw))) return "SHELL";
|
|
7739
7821
|
if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead)) return "read";
|
|
7822
|
+
if (isInertValueExpression(raw)) return "read";
|
|
7740
7823
|
const rawLower = raw.toLowerCase();
|
|
7741
7824
|
const sqlClient = SHELL_SQL_CLIENTS.has(lead) || /\|\s*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlcmd|sqlite3|mongosh?|clickhouse-client|bq|cockroach|usql)\b/.test(rawLower);
|
|
7742
7825
|
if (sqlClient) {
|
|
@@ -7768,6 +7851,8 @@ function classifyShellWords(masked, raw, lead) {
|
|
|
7768
7851
|
const explicitMethod = /(?:\s-x|\s--request|\s-method)[\s=]+["']?(post|put|patch|delete)\b/i.test(segment);
|
|
7769
7852
|
const hasBody = /\s(?:-d|--data(?:-raw|-binary|-urlencode)?|-F|--form|-T|--upload-file|--json|-body|-infile)\b/i.test(segment);
|
|
7770
7853
|
if (explicitMethod || hasBody) return "write";
|
|
7854
|
+
const credentialArgument = /\s(?:-h|--header|-headers|-u|--user|-token|-credential|-authentication|--oauth2-bearer|-proxycredential)(?:[\s=]|$)/i.exec(segment);
|
|
7855
|
+
if (credentialArgument && /\$\(|`|\$env:|\(\s*[A-Za-z][\w-]*\s/.test(segment.slice(credentialArgument.index))) return "write";
|
|
7771
7856
|
return pipesIntoInterpreter ? "SHELL" : "read";
|
|
7772
7857
|
}
|
|
7773
7858
|
if (SHELL_READ_PROGRAMS.has(lead)) {
|
|
@@ -7924,7 +8009,7 @@ function isPowerShellResponseProjection(segment, responses) {
|
|
|
7924
8009
|
const object = /^\[pscustomobject\]\s*@\{([^{}]+)\}\s*(?:\|\s*(.+))?$/i.exec(segment);
|
|
7925
8010
|
if (!object) return false;
|
|
7926
8011
|
if (object[2] && object[2].split("|").some((part) => !isPowerShellReadFormatter(part.trim()))) return false;
|
|
7927
|
-
const fields = object[1].trim().replace(
|
|
8012
|
+
const fields = object[1].trim().replace(/[;\s]*$/, "").split(/;|\r?\n/).filter((field) => field.trim());
|
|
7928
8013
|
if (fields.length > 32) return false;
|
|
7929
8014
|
const labels = /* @__PURE__ */ new Set();
|
|
7930
8015
|
return fields.every((field) => {
|
package/dist/approvalPrompt.d.ts
CHANGED
|
@@ -6,4 +6,6 @@ export interface ApprovalMessageOptions {
|
|
|
6
6
|
/** How long it holds: "30 days" (default) or "7 days or 20 uses". */
|
|
7
7
|
alwaysLimitsLabel?: string;
|
|
8
8
|
}
|
|
9
|
+
/** Window title: the kind of action and the policy that stopped it, by name. */
|
|
10
|
+
export declare function nativeApprovalTitle(reason: string): string;
|
|
9
11
|
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.nativeApprovalTitle = nativeApprovalTitle;
|
|
3
4
|
exports.nativeApprovalMessage = nativeApprovalMessage;
|
|
4
5
|
/** Compact OS prompt. The complete decision remains in the IDE response and audit. */
|
|
5
6
|
function line(value, limit) {
|
|
@@ -10,7 +11,19 @@ function line(value, limit) {
|
|
|
10
11
|
const head = Math.ceil((limit - 3) * 0.6);
|
|
11
12
|
return `${text.slice(0, head)} … ${text.slice(-(limit - head - 3))}`;
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
const CEILING_NOTE_RE = /\s*This policy is set to Block[.;]\s*(?:Because it is not marked "Hard block", your workstation requires your approval before continuing\.|on a developer workstation it asks you instead\.)?\s*(?:An admin can (?:review the matching rule and its scope in the console|mark it "Hard block" in the console to stop this without asking)\.)?/;
|
|
15
|
+
const CEILING_NOTE_SHORT = 'set to Block; on your workstation it asks you first.';
|
|
16
|
+
/** Parse the `Rule:` line the block explanation writes into the name the console lists and its scope. */
|
|
17
|
+
function ruleParts(rule) {
|
|
18
|
+
const policy = /^Action Policy "(.+?)" \((.+?)\)(?: — rule: (.+))?$/.exec(rule);
|
|
19
|
+
if (policy)
|
|
20
|
+
return { policy: policy[1], scope: [policy[2].replace(/^[^\w]+/, ''), policy[3]].filter(Boolean).join(' · ') };
|
|
21
|
+
const local = /^(built-in Local Safety rule|org custom rule) "(.+?)"(?: in Local Safety)?(?: \((.+)\))?$/.exec(rule);
|
|
22
|
+
if (local)
|
|
23
|
+
return { policy: local[2], localSafety: true, scope: [local[1] === 'org custom rule' ? 'Local Safety — org custom rule' : 'Local Safety — built-in rule', local[3]].filter(Boolean).join(' · ') };
|
|
24
|
+
return { policy: rule };
|
|
25
|
+
}
|
|
26
|
+
function promptFacts(reason) {
|
|
14
27
|
const rows = reason.split(/\r?\n/).map(row => row.trim()).filter(Boolean);
|
|
15
28
|
const field = (name) => rows.find(row => row.startsWith(`${name}:`))?.slice(name.length + 1).trim();
|
|
16
29
|
const what = field('What');
|
|
@@ -20,20 +33,54 @@ function nativeApprovalMessage(reason, options = {}) {
|
|
|
20
33
|
const why = field('Why');
|
|
21
34
|
const agent = field('Agent')
|
|
22
35
|
|| (/^[a-z_ -]+:\s*\S+/i.test(rows[0] || '') && !/^(What|Rule|Why|Tool|Evidence|IDE|Developer):/.test(rows[0]) ? rows[0] : undefined);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
if (!(what || tool || rule || why))
|
|
37
|
+
return { fallback: reason };
|
|
38
|
+
const facts = { agent, ide: field('IDE') };
|
|
39
|
+
if (rule)
|
|
40
|
+
Object.assign(facts, ruleParts(rule));
|
|
41
|
+
if (what) {
|
|
42
|
+
// summarizeAction ends with the kind in parentheses: "… (shell command)".
|
|
43
|
+
const kind = /^(.*)\s\(([a-z][a-z /-]{2,30})\)$/i.exec(what);
|
|
44
|
+
facts.kind = kind?.[2];
|
|
45
|
+
facts.action = kind ? kind[1] : what;
|
|
46
|
+
}
|
|
47
|
+
if (tool)
|
|
48
|
+
facts.tool = [tool, evidence].filter(Boolean).join(' · ');
|
|
49
|
+
if (why) {
|
|
50
|
+
const stripped = why.replace(CEILING_NOTE_RE, ' ').replace(/\s+/g, ' ').trim();
|
|
51
|
+
if (stripped !== why.trim())
|
|
52
|
+
facts.note = CEILING_NOTE_SHORT;
|
|
53
|
+
facts.because = stripped.replace(/([^.])$/, '$1.');
|
|
54
|
+
}
|
|
55
|
+
return facts;
|
|
56
|
+
}
|
|
57
|
+
/** Window title: the kind of action and the policy that stopped it, by name. */
|
|
58
|
+
function nativeApprovalTitle(reason) {
|
|
59
|
+
const f = promptFacts(reason);
|
|
60
|
+
const parts = ['FullCourtDefense', f.kind ? `Approval needed: ${f.kind}` : 'Approval needed', f.policy ? line(f.policy, 60) : ''].filter(Boolean);
|
|
61
|
+
return parts.join(' · ');
|
|
62
|
+
}
|
|
63
|
+
function nativeApprovalMessage(reason, options = {}) {
|
|
64
|
+
const f = promptFacts(reason);
|
|
65
|
+
const details = f.fallback ? [line(f.fallback, 650)] : [
|
|
66
|
+
f.policy ? `Policy: ${line(f.policy, 160)}` : '',
|
|
67
|
+
f.scope ? ` ${line(f.scope, 200)}` : '',
|
|
68
|
+
f.action ? `Action: ${f.kind ? `${f.kind} — ` : ''}${line(f.action, 230)}` : '',
|
|
69
|
+
f.agent ? `Agent: ${line(f.agent, 80)}` : '',
|
|
70
|
+
f.ide ? `IDE: ${line(f.ide, 40)}` : '',
|
|
71
|
+
f.tool ? `Tool: ${line(f.tool, 230)}` : '',
|
|
72
|
+
f.because ? `Because: ${line(f.because, 300)}` : '',
|
|
73
|
+
f.note ? `Note: ${f.note}` : '',
|
|
74
|
+
].filter(Boolean);
|
|
75
|
+
const where = f.policy && !f.fallback
|
|
76
|
+
? `Console → Action Policies → ${f.localSafety ? 'Source: Local Safety → ' : ''}"${line(f.policy, 80)}" · this decision: your machine’s Activity.`
|
|
77
|
+
: 'Full details: the IDE message or this machine’s Activity in FullCourtDefense.';
|
|
31
78
|
if (!options.allowAlways) {
|
|
32
79
|
return [
|
|
33
80
|
'Allow this action once?',
|
|
34
81
|
'', ...details, '',
|
|
35
|
-
'Full details: the IDE message or this machine’s Activity in FullCourtDefense.',
|
|
36
82
|
'Allow once permits only this action. Deny or closing keeps it stopped.',
|
|
83
|
+
where,
|
|
37
84
|
].join('\n');
|
|
38
85
|
}
|
|
39
86
|
return [
|
|
@@ -41,6 +88,6 @@ function nativeApprovalMessage(reason, options = {}) {
|
|
|
41
88
|
'', ...details, '',
|
|
42
89
|
...(options.alwaysScopeLabel ? [`Always allow covers: ${line(options.alwaysScopeLabel, 200)} — for ${options.alwaysLimitsLabel || '30 days'} on this machine, under this rule as it is written today.`, ''] : []),
|
|
43
90
|
'Allow once permits only this action. Deny or closing keeps it stopped.',
|
|
44
|
-
|
|
91
|
+
where,
|
|
45
92
|
].join('\n');
|
|
46
93
|
}
|
|
@@ -53,8 +53,44 @@ export interface EphemeralStackOptions {
|
|
|
53
53
|
hooks?: string;
|
|
54
54
|
/** 'false' => skip wrapping MCP client configs with the gateway. */
|
|
55
55
|
gateway?: string;
|
|
56
|
+
/**
|
|
57
|
+
* HTTP egress gateway (API destinations). OFF unless asked for: 'true' or a
|
|
58
|
+
* port number starts `http-gateway` detached on 127.0.0.1 and the identity
|
|
59
|
+
* env gains HTTP_PROXY / HTTPS_PROXY / NO_PROXY. Absent / 'false' => nothing
|
|
60
|
+
* changes for the workload's network path.
|
|
61
|
+
*/
|
|
62
|
+
httpGateway?: string;
|
|
63
|
+
/** Agent name the HTTP gateway attributes calls to (the workload name). */
|
|
64
|
+
agentName?: string;
|
|
56
65
|
sendLog?: EphemeralLogSink;
|
|
57
66
|
}
|
|
67
|
+
export declare const DEFAULT_HTTP_GATEWAY_PORT = 18790;
|
|
68
|
+
/** Resolve `--http-gateway <true|false|port>`; undefined = off. */
|
|
69
|
+
export declare function resolveHttpGatewayPort(value: string | undefined): number | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Identity-env additions that route the agent's HTTP libraries through the
|
|
72
|
+
* local gateway. NO_PROXY keeps loopback and the FullCourtDefense control plane
|
|
73
|
+
* direct (the gateway would bypass them anyway). Both casings: curl/Python read
|
|
74
|
+
* lower-case, many Java/Go tools upper-case.
|
|
75
|
+
*/
|
|
76
|
+
export declare function httpGatewayIdentityEnv(port: number, apiUrl: string, agentName: string | undefined, env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
77
|
+
export declare function httpGatewayLogPath(): string;
|
|
78
|
+
export declare function httpGatewayReadyPath(): string;
|
|
79
|
+
/**
|
|
80
|
+
* Start `http-gateway` outside our process tree (plain node, no shells) and
|
|
81
|
+
* wait until it reports its port. The workload's entrypoint runs AFTER this
|
|
82
|
+
* command returns, so the proxy must already be listening when the agent starts.
|
|
83
|
+
*/
|
|
84
|
+
export declare function startDetachedHttpGateway(input: {
|
|
85
|
+
port: number;
|
|
86
|
+
agentName?: string;
|
|
87
|
+
apiUrl: string;
|
|
88
|
+
waitMs?: number;
|
|
89
|
+
}): Promise<{
|
|
90
|
+
port: number;
|
|
91
|
+
pid?: number;
|
|
92
|
+
log: string;
|
|
93
|
+
}>;
|
|
58
94
|
/**
|
|
59
95
|
* Install the runtime hook + MCP gateway for an enrolled ephemeral identity.
|
|
60
96
|
* Each step is best-effort and reported: a missing MCP client on the image is
|
|
@@ -33,16 +33,22 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.silentLogSink = void 0;
|
|
36
|
+
exports.DEFAULT_HTTP_GATEWAY_PORT = exports.silentLogSink = void 0;
|
|
37
37
|
exports.ephemeralIdentityMarkerPath = ephemeralIdentityMarkerPath;
|
|
38
38
|
exports.assertNotEnrolledLaptop = assertNotEnrolledLaptop;
|
|
39
39
|
exports.writeEphemeralIdentityMarker = writeEphemeralIdentityMarker;
|
|
40
|
+
exports.resolveHttpGatewayPort = resolveHttpGatewayPort;
|
|
41
|
+
exports.httpGatewayIdentityEnv = httpGatewayIdentityEnv;
|
|
42
|
+
exports.httpGatewayLogPath = httpGatewayLogPath;
|
|
43
|
+
exports.httpGatewayReadyPath = httpGatewayReadyPath;
|
|
44
|
+
exports.startDetachedHttpGateway = startDetachedHttpGateway;
|
|
40
45
|
exports.installEphemeralProtectionStack = installEphemeralProtectionStack;
|
|
41
46
|
exports.exportIdentityEnv = exportIdentityEnv;
|
|
42
47
|
const fs = __importStar(require("fs"));
|
|
43
48
|
const os = __importStar(require("os"));
|
|
44
49
|
const path = __importStar(require("path"));
|
|
45
50
|
const config_1 = require("../config");
|
|
51
|
+
const child_process_1 = require("child_process");
|
|
46
52
|
const installClaudeHook_1 = require("./installClaudeHook");
|
|
47
53
|
const mcpGateway_1 = require("./mcpGateway");
|
|
48
54
|
/**
|
|
@@ -105,6 +111,102 @@ function writeEphemeralIdentityMarker(noun, machineId, markerPath = ephemeralIde
|
|
|
105
111
|
/** No-op sink for callers without a live log stream (workloads today). */
|
|
106
112
|
const silentLogSink = async () => { };
|
|
107
113
|
exports.silentLogSink = silentLogSink;
|
|
114
|
+
exports.DEFAULT_HTTP_GATEWAY_PORT = 18790;
|
|
115
|
+
/** Resolve `--http-gateway <true|false|port>`; undefined = off. */
|
|
116
|
+
function resolveHttpGatewayPort(value) {
|
|
117
|
+
const v = (value || '').trim().toLowerCase();
|
|
118
|
+
if (!v || v === 'false' || v === '0' || v === 'off' || v === 'no')
|
|
119
|
+
return undefined;
|
|
120
|
+
if (v === 'true' || v === 'on' || v === 'yes')
|
|
121
|
+
return exports.DEFAULT_HTTP_GATEWAY_PORT;
|
|
122
|
+
const port = Number(v);
|
|
123
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
124
|
+
throw new Error(`--http-gateway expects true, false or a port (1–65535), got "${value}"`);
|
|
125
|
+
return port;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Identity-env additions that route the agent's HTTP libraries through the
|
|
129
|
+
* local gateway. NO_PROXY keeps loopback and the FullCourtDefense control plane
|
|
130
|
+
* direct (the gateway would bypass them anyway). Both casings: curl/Python read
|
|
131
|
+
* lower-case, many Java/Go tools upper-case.
|
|
132
|
+
*/
|
|
133
|
+
function httpGatewayIdentityEnv(port, apiUrl, agentName, env = process.env) {
|
|
134
|
+
const proxy = `http://127.0.0.1:${port}`;
|
|
135
|
+
let apiHost = '';
|
|
136
|
+
try {
|
|
137
|
+
apiHost = new URL(apiUrl).hostname;
|
|
138
|
+
}
|
|
139
|
+
catch { /* ignore */ }
|
|
140
|
+
const noProxy = ['localhost', '127.0.0.1', '::1', apiHost].filter(Boolean).join(',');
|
|
141
|
+
const out = {
|
|
142
|
+
FCD_HTTP_GATEWAY: proxy,
|
|
143
|
+
HTTP_PROXY: proxy,
|
|
144
|
+
HTTPS_PROXY: proxy,
|
|
145
|
+
http_proxy: proxy,
|
|
146
|
+
https_proxy: proxy,
|
|
147
|
+
NO_PROXY: noProxy,
|
|
148
|
+
no_proxy: noProxy,
|
|
149
|
+
};
|
|
150
|
+
if (agentName && !(env.FCD_AGENT_NAME || '').trim())
|
|
151
|
+
out.FCD_AGENT_NAME = agentName;
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
function httpGatewayLogPath() {
|
|
155
|
+
return path.join(os.homedir(), '.fullcourtdefense', 'http-gateway.log');
|
|
156
|
+
}
|
|
157
|
+
function httpGatewayReadyPath() {
|
|
158
|
+
return path.join(os.homedir(), '.fullcourtdefense', 'http-gateway.json');
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Start `http-gateway` outside our process tree (plain node, no shells) and
|
|
162
|
+
* wait until it reports its port. The workload's entrypoint runs AFTER this
|
|
163
|
+
* command returns, so the proxy must already be listening when the agent starts.
|
|
164
|
+
*/
|
|
165
|
+
async function startDetachedHttpGateway(input) {
|
|
166
|
+
const readyFile = httpGatewayReadyPath();
|
|
167
|
+
const logFile = httpGatewayLogPath();
|
|
168
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
169
|
+
try {
|
|
170
|
+
fs.unlinkSync(readyFile);
|
|
171
|
+
}
|
|
172
|
+
catch { /* none */ }
|
|
173
|
+
const out = fs.openSync(logFile, 'a');
|
|
174
|
+
const cliEntry = path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
|
|
175
|
+
// Credentials are NOT passed on the command line (visible in `ps`): the
|
|
176
|
+
// gateway reads the shield saved by workload-protect from ~/.fullcourtdefense.yml.
|
|
177
|
+
const args = [cliEntry, 'http-gateway', '--port', String(input.port), '--ready-file', readyFile,
|
|
178
|
+
'--api-url', input.apiUrl,
|
|
179
|
+
...(input.agentName ? ['--agent-name', input.agentName] : [])];
|
|
180
|
+
const child = (0, child_process_1.spawn)(process.execPath, args, {
|
|
181
|
+
detached: true,
|
|
182
|
+
stdio: ['ignore', out, out],
|
|
183
|
+
windowsHide: true,
|
|
184
|
+
// The gateway must never route through itself, whatever the agent's env says,
|
|
185
|
+
// and a long-lived process has no business holding the org enrollment key.
|
|
186
|
+
env: Object.fromEntries(Object.entries({ ...process.env, FCD_EPHEMERAL: '1' })
|
|
187
|
+
.filter(([key]) => !/^(https?_proxy|no_proxy|FCD_API_KEY)$/i.test(key))),
|
|
188
|
+
});
|
|
189
|
+
child.unref();
|
|
190
|
+
fs.closeSync(out);
|
|
191
|
+
const deadline = Date.now() + (input.waitMs ?? 10_000);
|
|
192
|
+
while (Date.now() < deadline) {
|
|
193
|
+
try {
|
|
194
|
+
const ready = JSON.parse(fs.readFileSync(readyFile, 'utf8'));
|
|
195
|
+
if (ready.port)
|
|
196
|
+
return { port: ready.port, pid: ready.pid, log: logFile };
|
|
197
|
+
}
|
|
198
|
+
catch { /* not yet */ }
|
|
199
|
+
if (child.exitCode !== null)
|
|
200
|
+
break;
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
202
|
+
}
|
|
203
|
+
let tail = '';
|
|
204
|
+
try {
|
|
205
|
+
tail = fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean).slice(-3).join(' | ');
|
|
206
|
+
}
|
|
207
|
+
catch { /* ignore */ }
|
|
208
|
+
throw new Error(`http-gateway did not start on port ${input.port}${tail ? ` — ${tail}` : ''} (log: ${logFile})`);
|
|
209
|
+
}
|
|
108
210
|
/**
|
|
109
211
|
* Install the runtime hook + MCP gateway for an enrolled ephemeral identity.
|
|
110
212
|
* Each step is best-effort and reported: a missing MCP client on the image is
|
|
@@ -150,6 +252,29 @@ async function installEphemeralProtectionStack(options, config) {
|
|
|
150
252
|
await sendLog([{ level: 'warn', message: `MCP gateway wrap skipped: ${reason}` }]);
|
|
151
253
|
}
|
|
152
254
|
}
|
|
255
|
+
// HTTP egress gateway (API destinations) — opt-in. Nothing about the
|
|
256
|
+
// workload's network path changes unless the caller asked for it.
|
|
257
|
+
const httpPort = resolveHttpGatewayPort(options.httpGateway);
|
|
258
|
+
if (httpPort !== undefined) {
|
|
259
|
+
console.log(`\n\x1b[1mStarting the HTTP egress gateway (API destinations) on 127.0.0.1:${httpPort}…\x1b[0m`);
|
|
260
|
+
try {
|
|
261
|
+
const started = await startDetachedHttpGateway({
|
|
262
|
+
port: httpPort,
|
|
263
|
+
agentName: options.agentName,
|
|
264
|
+
apiUrl: options.apiUrl,
|
|
265
|
+
});
|
|
266
|
+
console.log(` \x1b[32m✓\x1b[0m http-gateway listening (pid ${started.pid ?? '?'}) · log: ${started.log}`);
|
|
267
|
+
console.log(' The identity env file now carries HTTP_PROXY / HTTPS_PROXY — source it before starting the agent.');
|
|
268
|
+
console.log(' HTTPS calls are gated by destination host; plain HTTP by host, method and endpoint shape.');
|
|
269
|
+
await sendLog([{ level: 'success', message: `HTTP egress gateway started on 127.0.0.1:${started.port}` }]);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
273
|
+
console.log(` \x1b[31mHTTP egress gateway failed to start:\x1b[0m ${reason}`);
|
|
274
|
+
await sendLog([{ level: 'warn', message: `HTTP egress gateway failed to start: ${reason}` }]);
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
153
278
|
}
|
|
154
279
|
/**
|
|
155
280
|
* Make the ephemeral identity visible to everything that runs AFTER this
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type HttpEgressRequest } from '../httpEgress';
|
|
2
|
+
export type HttpEgressVerdict = 'allow' | 'block' | 'require_approval' | 'log';
|
|
3
|
+
export interface HttpEgressDecision {
|
|
4
|
+
verdict: HttpEgressVerdict;
|
|
5
|
+
/** Human-readable reason shown to the agent in the blocked response. */
|
|
6
|
+
reason?: string;
|
|
7
|
+
policyId?: string;
|
|
8
|
+
policyName?: string;
|
|
9
|
+
matchedRule?: string;
|
|
10
|
+
/** Learning / monitor: the verdict that WOULD have applied, recorded but not enforced. */
|
|
11
|
+
wouldVerdict?: HttpEgressVerdict;
|
|
12
|
+
/** Set when the decision waited on an approval (id for the record). */
|
|
13
|
+
approvalId?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Resolves an egress request to a final, enforceable verdict (approval waits happen inside). */
|
|
16
|
+
export type HttpEgressDecider = (req: HttpEgressRequest) => Promise<HttpEgressDecision>;
|
|
17
|
+
export interface HttpEgressRecord {
|
|
18
|
+
req: HttpEgressRequest;
|
|
19
|
+
decision: HttpEgressDecision;
|
|
20
|
+
enforced: 'forwarded' | 'blocked' | 'tunnelled' | 'error';
|
|
21
|
+
durationMs: number;
|
|
22
|
+
statusCode?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface HttpGatewayOptions {
|
|
25
|
+
/** Listen port; 0 = ephemeral (reported back). */
|
|
26
|
+
port: number;
|
|
27
|
+
/** Bind address — loopback by default; a container sidecar may bind 0.0.0.0. */
|
|
28
|
+
bind?: string;
|
|
29
|
+
decide: HttpEgressDecider;
|
|
30
|
+
record: (record: HttpEgressRecord) => void;
|
|
31
|
+
/** Process identity for attribution; `allowHeader` lets `x-fcd-agent` override it (off by default). */
|
|
32
|
+
agent: {
|
|
33
|
+
configured?: string;
|
|
34
|
+
workloadName?: string;
|
|
35
|
+
allowHeader?: boolean;
|
|
36
|
+
};
|
|
37
|
+
/** Hosts the gateway forwards without asking (its own backend, loopback). Lower-case. */
|
|
38
|
+
bypassHosts?: ReadonlySet<string>;
|
|
39
|
+
/** Upstream connect timeout for tunnels / forwards. */
|
|
40
|
+
upstreamTimeoutMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Where to actually connect for a destination (`--resolve host=addr:port`,
|
|
43
|
+
* like curl). Demos and tests point real-looking hosts at local fixtures;
|
|
44
|
+
* policy is still evaluated on the ORIGINAL host.
|
|
45
|
+
*/
|
|
46
|
+
resolveUpstream?: (host: string, port: number) => {
|
|
47
|
+
host: string;
|
|
48
|
+
port: number;
|
|
49
|
+
};
|
|
50
|
+
log?: (line: string) => void;
|
|
51
|
+
}
|
|
52
|
+
export interface RunningHttpGateway {
|
|
53
|
+
port: number;
|
|
54
|
+
address: string;
|
|
55
|
+
close: () => Promise<void>;
|
|
56
|
+
/** Live counters for the health endpoint / tests. */
|
|
57
|
+
stats: {
|
|
58
|
+
requests: number;
|
|
59
|
+
blocked: number;
|
|
60
|
+
forwarded: number;
|
|
61
|
+
tunnelled: number;
|
|
62
|
+
errors: number;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export declare function blockedResponseBody(req: HttpEgressRequest, decision: HttpEgressDecision): string;
|
|
66
|
+
export declare function startHttpGateway(options: HttpGatewayOptions): Promise<RunningHttpGateway>;
|