fullcourtdefense-cli 1.27.4 → 1.29.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/commands/cmdGuard.d.ts +10 -0
- package/dist/commands/cmdGuard.js +44 -9
- package/dist/commands/deterministicGuard.d.ts +35 -0
- package/dist/commands/deterministicGuard.js +49 -35
- package/dist/commands/installAll.js +6 -4
- package/dist/commands/mcpGateway.js +165 -6
- package/dist/commands/posixShellGuard.d.ts +15 -2
- package/dist/commands/posixShellGuard.js +195 -30
- package/dist/commands/shellGuard.d.ts +32 -3
- package/dist/commands/shellGuard.js +223 -26
- package/dist/index.js +25 -3
- package/dist/version.json +1 -1
- package/package.json +4 -2
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
|
|
3
|
+
* are cheap enough to wrap in cmd: `gh`, `vault`, `security`, … — derived, not
|
|
4
|
+
* hand-listed, so a new shared rule reaches cmd's index automatically unless
|
|
5
|
+
* its command is on the high-frequency diet above. cmd stays PARTIAL by
|
|
6
|
+
* construction (first token only, no `git`/`kubectl`/`aws` wrappers); the
|
|
7
|
+
* console labels it that way.
|
|
8
|
+
*/
|
|
9
|
+
export declare const INTERCEPTED_COMMANDS: string[];
|
|
10
|
+
export declare function buildGuardJs(nodePath: string, cliEntry: string): string;
|
|
1
11
|
export interface CmdGuardStatus {
|
|
2
12
|
supported: boolean;
|
|
3
13
|
installed: boolean;
|
|
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.INTERCEPTED_COMMANDS = void 0;
|
|
37
|
+
exports.buildGuardJs = buildGuardJs;
|
|
36
38
|
exports.isCmdGuardInstalled = isCmdGuardInstalled;
|
|
37
39
|
exports.getCmdGuardStatus = getCmdGuardStatus;
|
|
38
40
|
exports.installCmdGuardCommand = installCmdGuardCommand;
|
|
@@ -74,7 +76,7 @@ const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
|
|
|
74
76
|
* so pipes/`&&` chains where the dangerous command is not first are NOT caught by
|
|
75
77
|
* cmd (they ARE under PowerShell/zsh/bash and `shell-guard-check`).
|
|
76
78
|
*/
|
|
77
|
-
const
|
|
79
|
+
const FIXED_INTERCEPTED_COMMANDS = [
|
|
78
80
|
// Destructive filesystem / disk / permission tampering
|
|
79
81
|
'del', 'erase', 'rd', 'rmdir', 'format', 'cipher', 'diskpart', 'fsutil',
|
|
80
82
|
'takeown', 'icacls', 'attrib', 'compact',
|
|
@@ -96,8 +98,25 @@ const INTERCEPTED_COMMANDS = [
|
|
|
96
98
|
* Coverage is NOT lost where it matters: agent-driven commands go through the
|
|
97
99
|
* IDE hooks (full ruleset, any position in the line), and humans typing in
|
|
98
100
|
* PowerShell are covered in-process by the PSReadLine guard. Interactive
|
|
99
|
-
* cmd.exe keeps only the rare, high-signal destructive/tamper commands above
|
|
101
|
+
* cmd.exe keeps only the rare, high-signal destructive/tamper commands above
|
|
102
|
+
* plus the low-frequency anchors of the shared command rules (below).
|
|
100
103
|
*/
|
|
104
|
+
const CMD_HIGH_FREQUENCY_COMMANDS = new Set([
|
|
105
|
+
'docker', 'git', 'kubectl', 'aws', 'gcloud', 'az', 'terraform', 'pulumi', 'wsl',
|
|
106
|
+
'curl', 'wget', 'taskkill', 'robocopy', 'xcopy', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
|
|
107
|
+
]);
|
|
108
|
+
/**
|
|
109
|
+
* Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
|
|
110
|
+
* are cheap enough to wrap in cmd: `gh`, `vault`, `security`, … — derived, not
|
|
111
|
+
* hand-listed, so a new shared rule reaches cmd's index automatically unless
|
|
112
|
+
* its command is on the high-frequency diet above. cmd stays PARTIAL by
|
|
113
|
+
* construction (first token only, no `git`/`kubectl`/`aws` wrappers); the
|
|
114
|
+
* console labels it that way.
|
|
115
|
+
*/
|
|
116
|
+
exports.INTERCEPTED_COMMANDS = Array.from(new Set([
|
|
117
|
+
...FIXED_INTERCEPTED_COMMANDS,
|
|
118
|
+
...(0, shellGuard_1.builtinRuleCommandNames)().filter(name => !CMD_HIGH_FREQUENCY_COMMANDS.has(name)),
|
|
119
|
+
]));
|
|
101
120
|
function regString(key, value) {
|
|
102
121
|
try {
|
|
103
122
|
const out = (0, child_process_1.execFileSync)('reg', ['query', key, '/v', value], {
|
|
@@ -127,16 +146,25 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
127
146
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
128
147
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
129
148
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
130
|
-
`
|
|
149
|
+
`const ASK_TIMEOUT_S=${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS};const ASK_REMEMBER_MS=${shellGuard_1.SHELL_ASK_REMEMBER_HOURS}*3600*1000;`,
|
|
150
|
+
`// "s = remember for this cmd window": keyed by the parent cmd.exe pid (doskey runs us directly under it); dies with the window, capped in time.`,
|
|
151
|
+
`const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
|
|
152
|
+
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
131
153
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
132
154
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
133
155
|
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
134
156
|
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\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;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
|
|
135
|
-
|
|
157
|
+
`// Precedence on one line: block outranks ask, ask outranks warn.`,
|
|
158
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null,askHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){if(!askHit)askHit=r;}else if(!warnHit)warnHit=r;}}catch{}}return askHit||warnHit;}`,
|
|
136
159
|
`// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
|
|
137
160
|
`// made the user's terminal wait for a full CLI process INCLUDING the network`,
|
|
138
161
|
`// flush after every finding. Monitor mode must never add synchronous latency.`,
|
|
139
|
-
|
|
162
|
+
`// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
|
|
163
|
+
`function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this cmd window (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
|
|
164
|
+
`function askRemembered(ruleId){try{const m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'));const until=m&&m[ruleId];return typeof until==='number'&&until>Date.now();}catch{return false;}}`,
|
|
165
|
+
`function rememberAsk(ruleId){try{let m={};try{m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'))||{};}catch{}m[ruleId]=Date.now()+ASK_REMEMBER_MS;fs.mkdirSync(path.dirname(ASK_MEMORY_PATH),{recursive:true});fs.writeFileSync(ASK_MEMORY_PATH,JSON.stringify(m),{encoding:'utf8',mode:0o600});}catch{}}`,
|
|
166
|
+
`// One key via choice.exe (ships with Windows): Y run once, S run + remember for this window, N / timeout not executed. FCD_ASK_TEST_ANSWER overrides for the drill.`,
|
|
167
|
+
`function askDeveloper(hit){const forced=process.env.FCD_ASK_TEST_ANSWER;if(forced)return forced;try{const r=spawnSync('choice',['/c','YSN','/n','/t',String(ASK_TIMEOUT_S),'/d','N','/m','Run anyway? [Y] yes [S] yes, remember for this cmd window [N] no ('+ASK_TIMEOUT_S+'s):'],{stdio:'inherit',windowsHide:true});if(r.status===1)return 'y';if(r.status===2)return 's';if(r.status===3)return 'n';}catch{}return 'n';}`,
|
|
140
168
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
141
169
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
142
170
|
// TOP-LEVEL FAIL-OPEN: any unexpected exception in rule loading/matching
|
|
@@ -144,8 +172,15 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
144
172
|
// delegate. A stack trace or nonzero exit here IS an outage for the shell.
|
|
145
173
|
`const line=args.join(' ');let mode='monitor';let hit=null;try{const r=loadRules();mode=r.mode;hit=matchLine(line,r.rules);}catch{hit=null;}`,
|
|
146
174
|
`if(!hit)delegate(args);`,
|
|
147
|
-
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
175
|
+
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: '+(hit.action==='ask'?'would ask you to confirm':'would block')+' ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
148
176
|
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
177
|
+
`if(hit.action==='ask'){`,
|
|
178
|
+
` if(askRemembered(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this cmd window.');spoolEvent(hit,line,'allow','remembered');delegate(args);}`,
|
|
179
|
+
` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
180
|
+
` const a=askDeveloper(hit);`,
|
|
181
|
+
` if(a==='y'||a==='s'){if(a==='s')rememberAsk(hit.id);console.error('[FullCourtDefense] yes'+(a==='s'?' — remembered for this cmd window.':'')+' Reported to your security dashboard as allowed by you.');spoolEvent(hit,line,'allow','confirmed');delegate(args);}`,
|
|
182
|
+
` const outcome=a==='t'?'timeout':'declined';console.error('[FullCourtDefense] '+(outcome==='timeout'?'no answer':'no')+' — this command was not executed ('+outcome+'). Reported to your security dashboard.');spoolEvent(hit,line,'block',outcome);process.exit(1);`,
|
|
183
|
+
`}`,
|
|
149
184
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
150
185
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
151
186
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
|
@@ -163,7 +198,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
163
198
|
function buildMacroFile(nodePath) {
|
|
164
199
|
const guardJs = batQuote(GUARD_JS_PATH);
|
|
165
200
|
const node = batQuote(nodePath);
|
|
166
|
-
return `${INTERCEPTED_COMMANDS.map(cmd => `${cmd}=${node} ${guardJs} ${cmd} $*`).join('\r\n')}\r\n`;
|
|
201
|
+
return `${exports.INTERCEPTED_COMMANDS.map(cmd => `${cmd}=${node} ${guardJs} ${cmd} $*`).join('\r\n')}\r\n`;
|
|
167
202
|
}
|
|
168
203
|
function buildAutorunBat(nodePath) {
|
|
169
204
|
const guardJs = batQuote(GUARD_JS_PATH);
|
|
@@ -263,7 +298,7 @@ async function installCmdGuardCommand(_args = {}) {
|
|
|
263
298
|
}
|
|
264
299
|
const rules = (0, shellGuard_1.writeShellGuardRules)();
|
|
265
300
|
const nodePath = process.execPath;
|
|
266
|
-
const cliEntry =
|
|
301
|
+
const cliEntry = (0, shellGuard_1.guardCliEntry)();
|
|
267
302
|
fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 'utf8');
|
|
268
303
|
// Macro file BEFORE the bat: the bat refuses to register macros without it,
|
|
269
304
|
// so this order can never leave a window where cmd.exe sources a bat whose
|
|
@@ -324,7 +359,7 @@ function refreshCmdGuardRules() {
|
|
|
324
359
|
// registering no macros at all. This is the only path that repairs that.
|
|
325
360
|
if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH) || !fs.existsSync(MACRO_FILE_PATH)) {
|
|
326
361
|
const nodePath = process.execPath;
|
|
327
|
-
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath,
|
|
362
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, (0, shellGuard_1.guardCliEntry)()));
|
|
328
363
|
(0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
|
|
329
364
|
(0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
|
|
330
365
|
}
|
|
@@ -69,6 +69,41 @@ export interface LocalSafetyScanOptions {
|
|
|
69
69
|
*/
|
|
70
70
|
trustedScriptPaths?: string[];
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Built-ins that ship OFF until an org switches them on in the console. Shared
|
|
74
|
+
* with the terminal guards so a machine with no snapshot yet (fresh install,
|
|
75
|
+
* wiped cache) enforces the SAME default set in the IDE and in a shell.
|
|
76
|
+
*/
|
|
77
|
+
export declare const DEFAULT_DISABLED_BUILT_INS: ReadonlySet<string>;
|
|
78
|
+
/**
|
|
79
|
+
* One command rule shared by EVERY surface that can see a typed command: the
|
|
80
|
+
* IDE/agent hooks (this module), the PowerShell / bash / zsh profile guards and
|
|
81
|
+
* the cmd.exe doskey guard (shellGuard.ts loads this table into the terminal
|
|
82
|
+
* rule file verbatim). `pattern` is a regex SOURCE that must compile — and
|
|
83
|
+
* mean the same thing — under both JS and .NET with IgnoreCase: `\b`, `\s`,
|
|
84
|
+
* `(?:…)`, lookaheads and `[^\n;|&]` only. No lookbehind, no named groups,
|
|
85
|
+
* no `\d`-class differences. `commands` are the leading binaries a rule can
|
|
86
|
+
* fire on — the only thing a first-token hook (doskey) can key on.
|
|
87
|
+
*/
|
|
88
|
+
export interface SharedCommandRule {
|
|
89
|
+
itemId: string;
|
|
90
|
+
/** Catalog category id (local-safety-catalog.ts). */
|
|
91
|
+
categoryId: 'credential_commands';
|
|
92
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
93
|
+
pattern: string;
|
|
94
|
+
reason: string;
|
|
95
|
+
/** Leading command names this rule anchors on (for name-keyed hooks such as cmd.exe doskey). */
|
|
96
|
+
commands: string[];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Commands that PRINT stored credentials to stdout — the file-path rules are
|
|
100
|
+
* useless against them because the secret never comes from a watched file
|
|
101
|
+
* ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
|
|
102
|
+
* rarely-legitimate-for-agents forms are on by default; cloud token printers
|
|
103
|
+
* that legitimate deploy tooling uses constantly are strict (off by default,
|
|
104
|
+
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
105
|
+
*/
|
|
106
|
+
export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
72
107
|
export declare function stripInertDataSegments(text: string): string;
|
|
73
108
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
74
109
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
|
|
36
37
|
exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
|
|
37
38
|
exports.stripInertDataSegments = stripInertDataSegments;
|
|
38
39
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
@@ -189,7 +190,12 @@ const SQL_KEYS = new Set([
|
|
|
189
190
|
'toolArgs.query',
|
|
190
191
|
'toolArgs.sql',
|
|
191
192
|
]);
|
|
192
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Built-ins that ship OFF until an org switches them on in the console. Shared
|
|
195
|
+
* with the terminal guards so a machine with no snapshot yet (fresh install,
|
|
196
|
+
* wiped cache) enforces the SAME default set in the IDE and in a shell.
|
|
197
|
+
*/
|
|
198
|
+
exports.DEFAULT_DISABLED_BUILT_INS = new Set([
|
|
193
199
|
'git_clean_force',
|
|
194
200
|
'generic_kubectl_delete',
|
|
195
201
|
'truncate_table',
|
|
@@ -413,7 +419,7 @@ function collectStrings(value, keyPath = 'args', out = [], depth = 0) {
|
|
|
413
419
|
return out;
|
|
414
420
|
}
|
|
415
421
|
function disabled(options) {
|
|
416
|
-
return new Set(options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS));
|
|
422
|
+
return new Set(options?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS));
|
|
417
423
|
}
|
|
418
424
|
function isEnabled(itemId, options) {
|
|
419
425
|
return !disabled(options).has(itemId);
|
|
@@ -587,39 +593,47 @@ function containsSensitiveCredentialPath(value) {
|
|
|
587
593
|
* useless against them because the secret never comes from a watched file
|
|
588
594
|
* ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
|
|
589
595
|
* rarely-legitimate-for-agents forms are on by default; cloud token printers
|
|
590
|
-
* that legitimate deploy tooling uses constantly are strict (off by default
|
|
596
|
+
* that legitimate deploy tooling uses constantly are strict (off by default,
|
|
597
|
+
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
591
598
|
*/
|
|
599
|
+
exports.CREDENTIAL_COMMAND_RULES = [
|
|
600
|
+
{ itemId: 'gh_auth_token_cmd', categoryId: 'credential_commands', severity: 'critical', commands: ['gh'],
|
|
601
|
+
reason: 'GitHub CLI token print (gh auth token)',
|
|
602
|
+
pattern: String.raw `\bgh\s+auth\s+token\b|\bgh\s+auth\s+status\b(?=[^\n;|&]*(?:--show-token|\s-t\b))` },
|
|
603
|
+
{ itemId: 'git_credential_fill', categoryId: 'credential_commands', severity: 'critical', commands: ['git'],
|
|
604
|
+
reason: 'git credential helper dump',
|
|
605
|
+
pattern: String.raw `\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b` },
|
|
606
|
+
{ itemId: 'macos_keychain_dump', categoryId: 'credential_commands', severity: 'critical', commands: ['security'],
|
|
607
|
+
reason: 'macOS keychain password dump',
|
|
608
|
+
pattern: String.raw `\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b|\bsecurity\s+dump-keychain\b` },
|
|
609
|
+
{ itemId: 'kubectl_config_raw', categoryId: 'credential_commands', severity: 'high', commands: ['kubectl'],
|
|
610
|
+
reason: 'raw kubeconfig dump (kubectl config view --raw)',
|
|
611
|
+
pattern: String.raw `\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b` },
|
|
612
|
+
{ itemId: 'aws_export_credentials', categoryId: 'credential_commands', severity: 'critical', commands: ['aws'],
|
|
613
|
+
reason: 'AWS secret access key export',
|
|
614
|
+
pattern: String.raw `\baws\s+configure\s+export-credentials\b|\baws\s+configure\s+get\b[^\n;|&]*secret` },
|
|
615
|
+
{ itemId: 'gcloud_access_token_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['gcloud'],
|
|
616
|
+
reason: 'gcloud access-token print',
|
|
617
|
+
pattern: String.raw `\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b` },
|
|
618
|
+
{ itemId: 'gcloud_secret_access_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['gcloud'],
|
|
619
|
+
reason: 'GCP Secret Manager read',
|
|
620
|
+
pattern: String.raw `\bgcloud\s+secrets\s+versions\s+access\b` },
|
|
621
|
+
{ itemId: 'az_access_token_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['az'],
|
|
622
|
+
reason: 'Azure access-token print',
|
|
623
|
+
pattern: String.raw `\baz\s+account\s+get-access-token\b` },
|
|
624
|
+
{ itemId: 'aws_secret_fetch_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['aws'],
|
|
625
|
+
reason: 'AWS secret fetch',
|
|
626
|
+
pattern: String.raw `\baws\s+secretsmanager\s+get-secret-value\b|\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b` },
|
|
627
|
+
{ itemId: 'vault_read_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['vault'],
|
|
628
|
+
reason: 'Vault secret read',
|
|
629
|
+
pattern: String.raw `\bvault\s+(?:kv\s+get|read)\b` },
|
|
630
|
+
];
|
|
631
|
+
const CREDENTIAL_COMMAND_REGEXES = exports.CREDENTIAL_COMMAND_RULES.map(rule => ({ rule, re: new RegExp(rule.pattern, 'i') }));
|
|
592
632
|
function credentialCommandReason(value) {
|
|
593
|
-
return matchCommand(value,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (/\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b/.test(lower)) {
|
|
598
|
-
return { itemId: 'git_credential_fill', reason: 'git credential helper dump' };
|
|
599
|
-
}
|
|
600
|
-
if (/\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b/.test(lower) || /\bsecurity\s+dump-keychain\b/.test(lower)) {
|
|
601
|
-
return { itemId: 'macos_keychain_dump', reason: 'macOS keychain password dump' };
|
|
602
|
-
}
|
|
603
|
-
if (/\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b/.test(lower)) {
|
|
604
|
-
return { itemId: 'kubectl_config_raw', reason: 'raw kubeconfig dump (kubectl config view --raw)' };
|
|
605
|
-
}
|
|
606
|
-
if (/\baws\s+configure\s+export-credentials\b/.test(lower) || /\baws\s+configure\s+get\b[^\n;|&]*secret/.test(lower)) {
|
|
607
|
-
return { itemId: 'aws_export_credentials', reason: 'AWS secret access key export' };
|
|
608
|
-
}
|
|
609
|
-
if (/\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b/.test(lower)) {
|
|
610
|
-
return { itemId: 'gcloud_access_token_cmd', reason: 'gcloud access-token print' };
|
|
611
|
-
}
|
|
612
|
-
if (/\bgcloud\s+secrets\s+versions\s+access\b/.test(lower)) {
|
|
613
|
-
return { itemId: 'gcloud_secret_access_cmd', reason: 'GCP Secret Manager read' };
|
|
614
|
-
}
|
|
615
|
-
if (/\baz\s+account\s+get-access-token\b/.test(lower)) {
|
|
616
|
-
return { itemId: 'az_access_token_cmd', reason: 'Azure access-token print' };
|
|
617
|
-
}
|
|
618
|
-
if (/\baws\s+secretsmanager\s+get-secret-value\b/.test(lower) || /\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b/.test(lower)) {
|
|
619
|
-
return { itemId: 'aws_secret_fetch_cmd', reason: 'AWS secret fetch' };
|
|
620
|
-
}
|
|
621
|
-
if (/\bvault\s+(?:kv\s+get|read)\b/.test(lower)) {
|
|
622
|
-
return { itemId: 'vault_read_cmd', reason: 'Vault secret read' };
|
|
633
|
+
return matchCommand(value, text => {
|
|
634
|
+
for (const { rule, re } of CREDENTIAL_COMMAND_REGEXES) {
|
|
635
|
+
if (re.test(text))
|
|
636
|
+
return { itemId: rule.itemId, reason: rule.reason };
|
|
623
637
|
}
|
|
624
638
|
return undefined;
|
|
625
639
|
});
|
|
@@ -1190,7 +1204,7 @@ function scanReferencedScript(command, cwd, options) {
|
|
|
1190
1204
|
while (finding
|
|
1191
1205
|
&& (finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf')
|
|
1192
1206
|
&& !scriptActsOnFinding(content, finding.category)) {
|
|
1193
|
-
const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
|
|
1207
|
+
const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS);
|
|
1194
1208
|
scanOptions = { ...(scanOptions || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
|
|
1195
1209
|
finding = scanTextValue('script_file', content, scanOptions);
|
|
1196
1210
|
}
|
|
@@ -1212,7 +1226,7 @@ function withRuleSuppressed(options, finding) {
|
|
|
1212
1226
|
customBlocks: (options?.customBlocks || []).filter(block => block.id !== finding.itemId),
|
|
1213
1227
|
};
|
|
1214
1228
|
}
|
|
1215
|
-
const baseDisabled = options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
|
|
1229
|
+
const baseDisabled = options?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS);
|
|
1216
1230
|
return { ...(options || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
|
|
1217
1231
|
}
|
|
1218
1232
|
function findingAction(finding) {
|
|
@@ -126,10 +126,12 @@ async function installAllCommand(args, config) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
// Interactive macOS/Linux guard — same rules, zsh accept-line widget + bash
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
if ((
|
|
132
|
-
console.log(
|
|
129
|
+
// DEBUG-trap hook, from the local cache (no admin, no network hot path). On
|
|
130
|
+
// Windows the same bash guard covers Git Bash (MSYS2) when it is installed.
|
|
131
|
+
if ((0, posixShellGuard_1.posixGuardSupported)() && args.posixGuard !== 'false') {
|
|
132
|
+
console.log(process.platform === 'win32'
|
|
133
|
+
? '\n\x1b[1mGit Bash detected — installing interactive shell guard (bash)…\x1b[0m'
|
|
134
|
+
: '\n\x1b[1mInstalling interactive shell guard (bash + zsh)…\x1b[0m');
|
|
133
135
|
try {
|
|
134
136
|
await (0, posixShellGuard_1.installPosixShellGuardCommand)();
|
|
135
137
|
}
|
|
@@ -67,6 +67,7 @@ const policyGateHealth_1 = require("../policyGateHealth");
|
|
|
67
67
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
68
68
|
const machineIdentity_1 = require("../machineIdentity");
|
|
69
69
|
const backupManifest_1 = require("../backupManifest");
|
|
70
|
+
const devConfirm_1 = require("../devConfirm");
|
|
70
71
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
71
72
|
const MANAGED_SERVER_NAME = 'agentguard-gateway';
|
|
72
73
|
const INSTALL_GATEWAY_ARGS = { includeShieldKey: false };
|
|
@@ -876,6 +877,8 @@ function blockError(message) {
|
|
|
876
877
|
function isBlockError(err) {
|
|
877
878
|
return err instanceof Error && err.fcdDeliberateBlock === true;
|
|
878
879
|
}
|
|
880
|
+
/** How long an MCP elicitation ("Ask the developer" dialog in the client) waits before it counts as declined. */
|
|
881
|
+
const ELICITATION_TIMEOUT_MS = 5 * 60_000;
|
|
879
882
|
class McpGatewayServer {
|
|
880
883
|
gatewayConfig;
|
|
881
884
|
downstreamSpec;
|
|
@@ -883,6 +886,20 @@ class McpGatewayServer {
|
|
|
883
886
|
downstreamReady;
|
|
884
887
|
buffer = '';
|
|
885
888
|
api;
|
|
889
|
+
/**
|
|
890
|
+
* The MCP client declared `capabilities.elicitation` on initialize — it can
|
|
891
|
+
* show the user a server-initiated question. That is the only channel a stdio
|
|
892
|
+
* proxy has to "Ask the developer"; without it an ask rule fails strict.
|
|
893
|
+
*/
|
|
894
|
+
clientSupportsElicitation = false;
|
|
895
|
+
/** Server -> client requests (elicitation) awaiting the client's response, by id. */
|
|
896
|
+
clientPending = new Map();
|
|
897
|
+
clientRequestSeq = 0;
|
|
898
|
+
/**
|
|
899
|
+
* "Remember for this session" scope when the client passes no session id: the
|
|
900
|
+
* gateway process lives exactly as long as the client's MCP session does.
|
|
901
|
+
*/
|
|
902
|
+
gatewaySessionId = `gateway-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
886
903
|
constructor(gatewayConfig, downstreamSpec) {
|
|
887
904
|
this.gatewayConfig = gatewayConfig;
|
|
888
905
|
this.downstreamSpec = downstreamSpec;
|
|
@@ -924,10 +941,29 @@ class McpGatewayServer {
|
|
|
924
941
|
// id may legally be 0 or "" — only a genuinely ABSENT id marks a notification.
|
|
925
942
|
if ((message.id === undefined || message.id === null) && message.method?.startsWith('notifications/'))
|
|
926
943
|
return;
|
|
944
|
+
// A RESPONSE from the client to one of OUR requests (elicitation): no
|
|
945
|
+
// method, an id we issued. Route it to the waiter instead of the -32601 path.
|
|
946
|
+
if (!message.method && message.id !== undefined && message.id !== null && ('result' in message || 'error' in message)) {
|
|
947
|
+
const waiter = this.clientPending.get(String(message.id));
|
|
948
|
+
if (waiter) {
|
|
949
|
+
clearTimeout(waiter.timer);
|
|
950
|
+
this.clientPending.delete(String(message.id));
|
|
951
|
+
waiter.resolve(message);
|
|
952
|
+
}
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
927
955
|
try {
|
|
928
956
|
if (message.method === 'initialize') {
|
|
957
|
+
const caps = message.params?.capabilities;
|
|
958
|
+
this.clientSupportsElicitation = Boolean(caps && typeof caps === 'object' && caps.elicitation !== undefined && caps.elicitation !== null);
|
|
959
|
+
// Echo the client's own protocol version (it necessarily supports it);
|
|
960
|
+
// elicitation is a 2025-06-18+ feature, but the capability flag — not
|
|
961
|
+
// the version string — is what tells us the client can show a dialog.
|
|
962
|
+
const requested = typeof message.params?.protocolVersion === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(message.params.protocolVersion)
|
|
963
|
+
? message.params.protocolVersion
|
|
964
|
+
: '2024-11-05';
|
|
929
965
|
this.respond(message.id, {
|
|
930
|
-
protocolVersion:
|
|
966
|
+
protocolVersion: requested,
|
|
931
967
|
capabilities: { tools: {} },
|
|
932
968
|
serverInfo: { name: 'agentguard-mcp-gateway', version: '0.1.0' },
|
|
933
969
|
});
|
|
@@ -1099,12 +1135,77 @@ class McpGatewayServer {
|
|
|
1099
1135
|
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
|
1100
1136
|
process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
|
|
1101
1137
|
}
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1138
|
+
// "Ask the developer" rule with no hard block on the same call: ask the
|
|
1139
|
+
// human through the MCP client (elicitation) when the client can show a
|
|
1140
|
+
// dialog and a human is present. Session memory reuses the IDE hook's
|
|
1141
|
+
// ledger (same tool + rule => remembered). Decline, cancel, timeout, a
|
|
1142
|
+
// client without elicitation, or no human => block, never a silent allow.
|
|
1143
|
+
let localBlock = requestOutcome.blockingFinding || requestOutcome.askFinding;
|
|
1144
|
+
let askNote = '';
|
|
1145
|
+
if (!requestOutcome.blockingFinding && requestOutcome.askFinding && !reportOnlyMode) {
|
|
1146
|
+
const ask = requestOutcome.askFinding;
|
|
1147
|
+
const origin = ask.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
1148
|
+
const rememberScope = { toolName, operation: operation || (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs).operation, policyId: `local-safety:${ask.itemId}`, matchedRule: ask.ruleId };
|
|
1149
|
+
const sessionId = runtimeSessionId() || this.gatewaySessionId;
|
|
1150
|
+
const remembered = (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
|
|
1151
|
+
if (remembered) {
|
|
1152
|
+
(0, telemetry_1.spoolEvent)({
|
|
1153
|
+
decision: 'allow', toolName, operation: rememberScope.operation,
|
|
1154
|
+
reason: `developer confirmed earlier this session (session memory) — ${ask.reason}`,
|
|
1155
|
+
ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
|
|
1156
|
+
evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
|
|
1157
|
+
approvalScope: 'developer', approvedBy: 'developer',
|
|
1158
|
+
});
|
|
1159
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1160
|
+
localBlock = undefined;
|
|
1161
|
+
}
|
|
1162
|
+
else if (this.clientSupportsElicitation && (0, devConfirm_1.isHumanPresent)()) {
|
|
1163
|
+
const digest = (0, devConfirm_1.actionDigest)(toolArgs);
|
|
1164
|
+
const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
|
|
1165
|
+
event: 'mcp_gateway', toolName, operation: rememberScope.operation, argsDigest: digest,
|
|
1166
|
+
policyId: rememberScope.policyId, policyName: `Local Safety: ${ask.itemId}`, matchedRule: ask.ruleId,
|
|
1167
|
+
sessionId, remember: 'none',
|
|
1168
|
+
});
|
|
1169
|
+
process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}: ${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId})\n`);
|
|
1170
|
+
const answer = await this.elicitDeveloperConfirmation({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence });
|
|
1171
|
+
if (answer.answer === 'accept') {
|
|
1172
|
+
// Settle through the shared ledger so "remember for this session" is
|
|
1173
|
+
// recorded exactly like an IDE approval would be.
|
|
1174
|
+
if (answer.remember)
|
|
1175
|
+
(0, devConfirm_1.raiseDeveloperConfirmation)({ ...marker, remember: 'session' });
|
|
1176
|
+
(0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
|
|
1177
|
+
(0, telemetry_1.spoolEvent)({
|
|
1178
|
+
decision: 'allow', toolName, operation: rememberScope.operation,
|
|
1179
|
+
reason: `developer confirmed in MCP client prompt${answer.remember ? ' (remembered for this session)' : ''} — ${ask.reason}`,
|
|
1180
|
+
ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
|
|
1181
|
+
evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
|
|
1182
|
+
approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
1183
|
+
});
|
|
1184
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1185
|
+
localBlock = undefined;
|
|
1186
|
+
}
|
|
1187
|
+
else {
|
|
1188
|
+
(0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
|
|
1189
|
+
const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
|
|
1190
|
+
(0, telemetry_1.spoolEvent)({
|
|
1191
|
+
decision: 'block', toolName, operation: rememberScope.operation,
|
|
1192
|
+
reason: `${how} — ${ask.reason}`,
|
|
1193
|
+
ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
|
|
1194
|
+
evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
|
|
1195
|
+
approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
1196
|
+
});
|
|
1197
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1198
|
+
throw blockError(`${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId}: ${ask.evidence}) — ${answer.answer === 'timeout' ? 'you did not answer the confirmation prompt in time' : 'you declined the confirmation prompt'}, so this tool call was not executed. Logged to your org's console. This is a security policy decision, not a transient error — do not retry.`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
askNote = this.clientSupportsElicitation
|
|
1203
|
+
? ' This rule asks the developer to confirm, but no human is present on this machine (CI / ephemeral workload), so it blocks here.'
|
|
1204
|
+
: ' This rule asks the developer to confirm; this MCP client cannot show a confirmation dialog (no elicitation support), so it blocks here (the IDE hook can ask).';
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1105
1207
|
if (localBlock) {
|
|
1106
1208
|
const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
1107
|
-
const askNote = !requestOutcome.blockingFinding ? ' This rule asks the developer to confirm; the MCP gateway cannot prompt, so it blocks here (the IDE hook can ask).' : '';
|
|
1108
1209
|
if (reportOnlyMode) {
|
|
1109
1210
|
// MONITOR CONTRACT: a monitor/shadow machine (or one with no bundle
|
|
1110
1211
|
// signal at all) must NEVER hard-block. Online, the server already
|
|
@@ -1112,8 +1213,9 @@ class McpGatewayServer {
|
|
|
1112
1213
|
// the client-side belt for offline/no-cache/stale-cache states,
|
|
1113
1214
|
// where the scan can still yield block-action findings. Record the
|
|
1114
1215
|
// would-block and let the call through.
|
|
1216
|
+
const would = requestOutcome.blockingFinding ? 'would block' : 'would ask you to confirm';
|
|
1115
1217
|
this.spoolLocalFinding({ ...localBlock, reason: `[monitor] ${localBlock.reason}` }, toolName, operation, 'warn');
|
|
1116
|
-
process.stderr.write(`FullCourtDefense (monitor) would
|
|
1218
|
+
process.stderr.write(`FullCourtDefense (monitor) ${would} ${toolName}: ${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
|
|
1117
1219
|
}
|
|
1118
1220
|
else {
|
|
1119
1221
|
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
@@ -1614,6 +1716,63 @@ class McpGatewayServer {
|
|
|
1614
1716
|
error(id, code, message) {
|
|
1615
1717
|
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
|
|
1616
1718
|
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Server -> client JSON-RPC request (MCP lets servers ask the client for
|
|
1721
|
+
* things: elicitation, sampling, roots). Resolves with the client's response
|
|
1722
|
+
* message; a timeout resolves to `undefined`. Never rejects.
|
|
1723
|
+
*/
|
|
1724
|
+
requestClient(method, params, timeoutMs) {
|
|
1725
|
+
const id = `fcd-${++this.clientRequestSeq}`;
|
|
1726
|
+
return new Promise(resolve => {
|
|
1727
|
+
const timer = setTimeout(() => {
|
|
1728
|
+
this.clientPending.delete(id);
|
|
1729
|
+
resolve(undefined);
|
|
1730
|
+
}, timeoutMs);
|
|
1731
|
+
this.clientPending.set(id, { resolve, timer });
|
|
1732
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* "Ask the developer" through the MCP client. Shows the same question the IDE
|
|
1737
|
+
* hook would: what is about to run, which rule asked, yes / no, and an
|
|
1738
|
+
* optional "remember for this session". Returns the human's answer; anything
|
|
1739
|
+
* that is not an explicit accept (decline, cancel, timeout, malformed) is a
|
|
1740
|
+
* decline. The caller decides what a decline means (block).
|
|
1741
|
+
*/
|
|
1742
|
+
async elicitDeveloperConfirmation(input) {
|
|
1743
|
+
const started = Date.now();
|
|
1744
|
+
const evidence = input.evidence ? `\n\nEvidence: ${input.evidence.slice(0, 240)}` : '';
|
|
1745
|
+
const response = await this.requestClient('elicitation/create', {
|
|
1746
|
+
message: `FullCourtDefense — your organization asks you to confirm this action.\n\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nAllow this tool call? Your answer is reported to your security dashboard as your decision.`,
|
|
1747
|
+
requestedSchema: {
|
|
1748
|
+
type: 'object',
|
|
1749
|
+
properties: {
|
|
1750
|
+
confirm: {
|
|
1751
|
+
type: 'boolean',
|
|
1752
|
+
title: 'Allow this tool call',
|
|
1753
|
+
description: 'Yes runs the tool call once. No (or closing this dialog) does not run it.',
|
|
1754
|
+
default: false,
|
|
1755
|
+
},
|
|
1756
|
+
remember: {
|
|
1757
|
+
type: 'boolean',
|
|
1758
|
+
title: 'Remember for this session',
|
|
1759
|
+
description: 'Do not ask again for this tool and rule until this MCP session ends (max 12 hours).',
|
|
1760
|
+
default: false,
|
|
1761
|
+
},
|
|
1762
|
+
},
|
|
1763
|
+
required: ['confirm'],
|
|
1764
|
+
},
|
|
1765
|
+
}, ELICITATION_TIMEOUT_MS);
|
|
1766
|
+
const latencyMs = Date.now() - started;
|
|
1767
|
+
if (!response)
|
|
1768
|
+
return { answer: 'timeout', remember: false, latencyMs };
|
|
1769
|
+
const result = (response.result && typeof response.result === 'object') ? response.result : undefined;
|
|
1770
|
+
const content = (result?.content && typeof result.content === 'object') ? result.content : undefined;
|
|
1771
|
+
if (result?.action === 'accept' && content?.confirm === true) {
|
|
1772
|
+
return { answer: 'accept', remember: content.remember === true, latencyMs };
|
|
1773
|
+
}
|
|
1774
|
+
return { answer: 'decline', remember: false, latencyMs };
|
|
1775
|
+
}
|
|
1617
1776
|
}
|
|
1618
1777
|
async function mcpGatewayCommand(args, config) {
|
|
1619
1778
|
// The gateway is a resident proxy in front of the IDE's MCP servers — if it
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/** Checker exit code: the rule asks the developer — the shell must prompt. */
|
|
2
|
+
export declare const ASK_CODE = 98;
|
|
3
|
+
/**
|
|
4
|
+
* Git for Windows' bash.exe, if installed (the MSYS2-based "Git Bash"). WSL's
|
|
5
|
+
* System32\bash.exe is deliberately NOT a match — that is a Linux distro with
|
|
6
|
+
* its own home directory and rc files, not this Windows profile.
|
|
7
|
+
*/
|
|
8
|
+
export declare function detectGitBash(): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Where the bash/zsh guard can be planted: real POSIX hosts, or Windows with
|
|
11
|
+
* Git Bash installed (bash only — no zsh there).
|
|
12
|
+
*/
|
|
13
|
+
export declare function posixGuardSupported(): boolean;
|
|
1
14
|
export interface PosixShellGuardStatus {
|
|
2
15
|
supported: boolean;
|
|
3
16
|
installed: boolean;
|
|
@@ -18,9 +31,9 @@ export interface InstallPosixShellGuardArgs {
|
|
|
18
31
|
/** 'false' to write the guard scripts + rules without wiring rc files. */
|
|
19
32
|
profiles?: string;
|
|
20
33
|
}
|
|
21
|
-
/** `fullcourtdefense install-shell-guard` on macOS/Linux
|
|
34
|
+
/** `fullcourtdefense install-shell-guard` on macOS/Linux (bash/zsh) or Windows with Git Bash (bash). */
|
|
22
35
|
export declare function installPosixShellGuardCommand(args?: InstallPosixShellGuardArgs): Promise<void>;
|
|
23
|
-
/** `fullcourtdefense uninstall-shell-guard` on macOS/Linux — remove the guard. */
|
|
36
|
+
/** `fullcourtdefense uninstall-shell-guard` on macOS/Linux (or Git Bash on Windows) — remove the guard. */
|
|
24
37
|
export declare function uninstallPosixShellGuardCommand(): Promise<void>;
|
|
25
38
|
/** Refresh the shared rules JSON when the POSIX guard is installed. Never throws. */
|
|
26
39
|
export declare function refreshPosixShellGuardRules(): void;
|