fullcourtdefense-cli 1.27.4 → 1.28.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 +1 -0
- package/dist/commands/cmdGuard.js +23 -6
- 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 +22 -3
- package/dist/commands/shellGuard.js +155 -19
- package/dist/index.js +25 -3
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.buildGuardJs = buildGuardJs;
|
|
36
37
|
exports.isCmdGuardInstalled = isCmdGuardInstalled;
|
|
37
38
|
exports.getCmdGuardStatus = getCmdGuardStatus;
|
|
38
39
|
exports.installCmdGuardCommand = installCmdGuardCommand;
|
|
@@ -127,16 +128,25 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
127
128
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
128
129
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
129
130
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
130
|
-
`
|
|
131
|
+
`const ASK_TIMEOUT_S=${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS};const ASK_REMEMBER_MS=${shellGuard_1.SHELL_ASK_REMEMBER_HOURS}*3600*1000;`,
|
|
132
|
+
`// "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.`,
|
|
133
|
+
`const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
|
|
134
|
+
`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
135
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
132
136
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
133
137
|
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
134
138
|
`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
|
-
|
|
139
|
+
`// Precedence on one line: block outranks ask, ask outranks warn.`,
|
|
140
|
+
`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
141
|
`// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
|
|
137
142
|
`// made the user's terminal wait for a full CLI process INCLUDING the network`,
|
|
138
143
|
`// flush after every finding. Monitor mode must never add synchronous latency.`,
|
|
139
|
-
|
|
144
|
+
`// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
|
|
145
|
+
`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{}}`,
|
|
146
|
+
`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;}}`,
|
|
147
|
+
`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{}}`,
|
|
148
|
+
`// 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.`,
|
|
149
|
+
`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
150
|
`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
151
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
142
152
|
// TOP-LEVEL FAIL-OPEN: any unexpected exception in rule loading/matching
|
|
@@ -144,8 +154,15 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
144
154
|
// delegate. A stack trace or nonzero exit here IS an outage for the shell.
|
|
145
155
|
`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
156
|
`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);}`,
|
|
157
|
+
`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
158
|
`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);}`,
|
|
159
|
+
`if(hit.action==='ask'){`,
|
|
160
|
+
` 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);}`,
|
|
161
|
+
` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
162
|
+
` const a=askDeveloper(hit);`,
|
|
163
|
+
` 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);}`,
|
|
164
|
+
` 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);`,
|
|
165
|
+
`}`,
|
|
149
166
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
150
167
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
151
168
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
|
@@ -263,7 +280,7 @@ async function installCmdGuardCommand(_args = {}) {
|
|
|
263
280
|
}
|
|
264
281
|
const rules = (0, shellGuard_1.writeShellGuardRules)();
|
|
265
282
|
const nodePath = process.execPath;
|
|
266
|
-
const cliEntry =
|
|
283
|
+
const cliEntry = (0, shellGuard_1.guardCliEntry)();
|
|
267
284
|
fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 'utf8');
|
|
268
285
|
// Macro file BEFORE the bat: the bat refuses to register macros without it,
|
|
269
286
|
// so this order can never leave a window where cmd.exe sources a bat whose
|
|
@@ -324,7 +341,7 @@ function refreshCmdGuardRules() {
|
|
|
324
341
|
// registering no macros at all. This is the only path that repairs that.
|
|
325
342
|
if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH) || !fs.existsSync(MACRO_FILE_PATH)) {
|
|
326
343
|
const nodePath = process.execPath;
|
|
327
|
-
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath,
|
|
344
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, (0, shellGuard_1.guardCliEntry)()));
|
|
328
345
|
(0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
|
|
329
346
|
(0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
|
|
330
347
|
}
|
|
@@ -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;
|
|
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ASK_CODE = void 0;
|
|
37
|
+
exports.detectGitBash = detectGitBash;
|
|
38
|
+
exports.posixGuardSupported = posixGuardSupported;
|
|
36
39
|
exports.getPosixShellGuardStatus = getPosixShellGuardStatus;
|
|
37
40
|
exports.isPosixShellGuardInstalled = isPosixShellGuardInstalled;
|
|
38
41
|
exports.installPosixShellGuardCommand = installPosixShellGuardCommand;
|
|
@@ -75,6 +78,18 @@ const shellGuard_1 = require("./shellGuard");
|
|
|
75
78
|
* (checker exit code 97) stops a command. Disable for a session with
|
|
76
79
|
* `export FCD_SHELL_GUARD=off`.
|
|
77
80
|
*
|
|
81
|
+
* "Ask the developer" rules (checker exit code 98): the checker prints one
|
|
82
|
+
* machine-readable `FCD_ASK\t<rule>\t<severity>\t<reason>` line and the SHELL
|
|
83
|
+
* (which owns the tty) shows `Run anyway? [y] / [s] remember for this shell /
|
|
84
|
+
* [N]` with a timeout. y/s run the line, anything else drops it; every answer
|
|
85
|
+
* is reported through the checker's `--record` mode as a developer decision.
|
|
86
|
+
* An ask that cannot be answered (no tty, timeout) is a block — the one place
|
|
87
|
+
* the guard fails STRICT, because the org explicitly asked for a human.
|
|
88
|
+
*
|
|
89
|
+
* Git Bash on Windows (MSYS2 runtime) is supported: same bash guard, wired into
|
|
90
|
+
* %USERPROFILE%\.bashrc, node invoked with MSYS path conversion disabled so
|
|
91
|
+
* typed lines like `/dev/tcp/...` reach the checker untouched.
|
|
92
|
+
*
|
|
78
93
|
* Rules/mode refresh opportunistically on every telemetry flush (offline caches
|
|
79
94
|
* only — never blocks a shell). Blocks are spooled and reported to the fleet
|
|
80
95
|
* dashboard as `shell_terminal` events.
|
|
@@ -87,9 +102,49 @@ const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
|
|
|
87
102
|
const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
|
|
88
103
|
const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
|
|
89
104
|
const BLOCK_CODE = 97;
|
|
105
|
+
/** Checker exit code: the rule asks the developer — the shell must prompt. */
|
|
106
|
+
exports.ASK_CODE = 98;
|
|
90
107
|
function isPosixPlatform() {
|
|
91
108
|
return process.platform === 'darwin' || process.platform === 'linux';
|
|
92
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Git for Windows' bash.exe, if installed (the MSYS2-based "Git Bash"). WSL's
|
|
112
|
+
* System32\bash.exe is deliberately NOT a match — that is a Linux distro with
|
|
113
|
+
* its own home directory and rc files, not this Windows profile.
|
|
114
|
+
*/
|
|
115
|
+
function detectGitBash() {
|
|
116
|
+
if (process.platform !== 'win32')
|
|
117
|
+
return undefined;
|
|
118
|
+
const candidates = [
|
|
119
|
+
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
|
|
120
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
|
|
121
|
+
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
122
|
+
path.join(process.env.ProgramW6432 || '', 'Git', 'bin', 'bash.exe'),
|
|
123
|
+
];
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
try {
|
|
126
|
+
if (candidate && fs.existsSync(candidate))
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
catch { /* keep looking */ }
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Where the bash/zsh guard can be planted: real POSIX hosts, or Windows with
|
|
135
|
+
* Git Bash installed (bash only — no zsh there).
|
|
136
|
+
*/
|
|
137
|
+
function posixGuardSupported() {
|
|
138
|
+
return isPosixPlatform() || Boolean(detectGitBash());
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Path as the planted shell script should spell it. MSYS2 bash resolves
|
|
142
|
+
* `C:/Program Files/nodejs/node.exe` for `[ -x ]` and exec; backslashes would
|
|
143
|
+
* be read as escapes inside the script.
|
|
144
|
+
*/
|
|
145
|
+
function shellPath(p) {
|
|
146
|
+
return process.platform === 'win32' ? p.replace(/\\/g, '/') : p;
|
|
147
|
+
}
|
|
93
148
|
function shSingleQuote(value) {
|
|
94
149
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
95
150
|
}
|
|
@@ -104,27 +159,107 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
104
159
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
105
160
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
106
161
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
107
|
-
`
|
|
162
|
+
`const ASK_CODE=${exports.ASK_CODE};`,
|
|
163
|
+
`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:[]};}}`,
|
|
108
164
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
109
165
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
110
166
|
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
111
167
|
`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;}`,
|
|
112
|
-
|
|
168
|
+
`// Precedence on one line: block outranks ask, ask outranks warn.`,
|
|
169
|
+
`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;}`,
|
|
113
170
|
`function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
|
|
114
|
-
|
|
115
|
-
`
|
|
171
|
+
`// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
|
|
172
|
+
`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 shell (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:'shell_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});triggerFlush();}catch{}}`,
|
|
173
|
+
`let argv=process.argv.slice(2);`,
|
|
174
|
+
`// --record <confirmed|remembered|declined|timeout> <ruleId> -- <line>: the shell answered an ask prompt; report it and exit.`,
|
|
175
|
+
`if(argv[0]==='--record'){const kind=argv[1],ruleId=argv[2];let rest=argv.slice(3);if(rest[0]==='--')rest=rest.slice(1);const rline=rest.join(' ');try{const{rules}=loadRules();const rule=rules.find(r=>r.id===ruleId)||{id:ruleId,reason:'ask rule '+ruleId,category:'',severity:'',source:'builtin'};spoolEvent(rule,rline,(kind==='confirmed'||kind==='remembered')?'allow':'block',kind);}catch{}process.exit(0);}`,
|
|
176
|
+
`if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
|
|
116
177
|
`if(!line.trim())process.exit(0);`,
|
|
117
178
|
`if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
|
|
118
179
|
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
119
180
|
`if(!hit)process.exit(0);`,
|
|
120
|
-
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
181
|
+
`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');process.exit(0);}`,
|
|
121
182
|
`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');process.exit(0);}`,
|
|
183
|
+
`if(hit.action==='ask'){`,
|
|
184
|
+
` // "s = remember for this shell": the shell exports FCD_SHELL_ASK_OK=<ruleId>,<ruleId>... — dies with the shell.`,
|
|
185
|
+
` const ok=String(process.env.FCD_SHELL_ASK_OK||'').split(',').map(s=>s.trim()).filter(Boolean);`,
|
|
186
|
+
` if(ok.includes(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this shell.');spoolEvent(hit,line,'allow','remembered');process.exit(0);}`,
|
|
187
|
+
` // Machine-readable ask line on stdout; the sourcing shell owns the prompt (it has the tty).`,
|
|
188
|
+
` process.stdout.write('FCD_ASK\\t'+hit.id+'\\t'+(hit.severity||'')+'\\t'+String(hit.reason||'').replace(/[\\t\\n\\r]+/g,' ')+'\\n');`,
|
|
189
|
+
` process.exit(ASK_CODE);`,
|
|
190
|
+
`}`,
|
|
122
191
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
123
192
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
124
193
|
`spoolEvent(hit,line,'block');process.exit(BLOCK_CODE);`,
|
|
125
194
|
``,
|
|
126
195
|
].join('\n');
|
|
127
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Shared POSIX-shell prompt for an ask hit (sourced by both the bash and zsh
|
|
199
|
+
* guards — plain POSIX/bash syntax, no ZLE). Args: <meta line> <typed line>.
|
|
200
|
+
* Returns 0 = run the command, 1 = do not. Reads ONE key from /dev/tty with a
|
|
201
|
+
* timeout; y = run once, s = run + export FCD_SHELL_ASK_OK for this shell,
|
|
202
|
+
* anything else / timeout / no tty = not executed. Every answer is reported
|
|
203
|
+
* through the checker's --record mode. Fail-strict: an ask that cannot be
|
|
204
|
+
* answered is a block, never a silent allow.
|
|
205
|
+
*/
|
|
206
|
+
function buildAskShellFunction(shell) {
|
|
207
|
+
// One key, with timeout: bash `read -n 1 -t`, zsh `read -k 1 -t`.
|
|
208
|
+
const readKey = shell === 'zsh'
|
|
209
|
+
? `read -k 1 -t ${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS} __fcd_ans </dev/tty`
|
|
210
|
+
: `IFS= read -r -n 1 -t ${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS} __fcd_ans </dev/tty`;
|
|
211
|
+
return [
|
|
212
|
+
`__fcd_ask_prompt() {`,
|
|
213
|
+
` local __fcd_meta="$1" __fcd_line="$2" __fcd_tag __fcd_id __fcd_sev __fcd_reason __fcd_ans=""`,
|
|
214
|
+
` local __fcd_oldifs="$IFS"; IFS="$(printf '\\t')"; read -r __fcd_tag __fcd_id __fcd_sev __fcd_reason <<< "$__fcd_meta"; IFS="$__fcd_oldifs"`,
|
|
215
|
+
` printf '[FullCourtDefense] ASK [%s]: %s (rule %s)\\n' "$__fcd_sev" "$__fcd_reason" "$__fcd_id" >&2`,
|
|
216
|
+
` # Only a shell with a REAL keyboard may be asked: stdin must be this shell's`,
|
|
217
|
+
` # terminal. A spawned / piped / embedded shell (stdin not a tty) never opens`,
|
|
218
|
+
` # /dev/tty — on MSYS that can be some OTHER window's console — it blocks now.`,
|
|
219
|
+
` if [ -t 0 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then`,
|
|
220
|
+
` printf 'Run anyway? [y] yes [s] yes, remember for this shell [N] no (%ss): ' "${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS}" >/dev/tty`,
|
|
221
|
+
` if ${readKey}; then :; else __fcd_ans="__timeout__"; fi`,
|
|
222
|
+
` printf '\\n' >/dev/tty`,
|
|
223
|
+
` else`,
|
|
224
|
+
` __fcd_ans="__notty__"`,
|
|
225
|
+
` printf '[FullCourtDefense] no terminal to ask on — not executed.\\n' >&2`,
|
|
226
|
+
` fi`,
|
|
227
|
+
` case "$__fcd_ans" in`,
|
|
228
|
+
` y|Y)`,
|
|
229
|
+
` printf '[FullCourtDefense] yes — reported to your security dashboard as allowed by you.\\n' >&2`,
|
|
230
|
+
` __fcd_run_checker --record confirmed "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
|
|
231
|
+
` return 0 ;;`,
|
|
232
|
+
` s|S)`,
|
|
233
|
+
` export FCD_SHELL_ASK_OK="\${FCD_SHELL_ASK_OK:+$FCD_SHELL_ASK_OK,}$__fcd_id"`,
|
|
234
|
+
` printf '[FullCourtDefense] yes — remembered for this shell. Reported to your security dashboard as allowed by you.\\n' >&2`,
|
|
235
|
+
` __fcd_run_checker --record confirmed "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
|
|
236
|
+
` return 0 ;;`,
|
|
237
|
+
` __timeout__)`,
|
|
238
|
+
` printf '[FullCourtDefense] no answer — this command was not executed (timeout). Reported to your security dashboard.\\n' >&2`,
|
|
239
|
+
` __fcd_run_checker --record timeout "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
|
|
240
|
+
` return 1 ;;`,
|
|
241
|
+
` *)`,
|
|
242
|
+
` printf '[FullCourtDefense] no — this command was not executed (declined). Reported to your security dashboard.\\n' >&2`,
|
|
243
|
+
` __fcd_run_checker --record declined "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
|
|
244
|
+
` return 1 ;;`,
|
|
245
|
+
` esac`,
|
|
246
|
+
`}`,
|
|
247
|
+
].join('\n');
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* How the shell scripts invoke the Node checker. On Git Bash (MSYS2 runtime on
|
|
251
|
+
* Windows) node.exe is a NATIVE program, so the runtime would rewrite any
|
|
252
|
+
* argument that looks like a POSIX path (e.g. the typed line "/usr/bin/rm -rf /"
|
|
253
|
+
* or "/dev/tcp/...") into a Windows path before node sees it — disable that for
|
|
254
|
+
* this one call. Both variables are inert on real macOS/Linux shells.
|
|
255
|
+
*/
|
|
256
|
+
function buildRunCheckerFunction() {
|
|
257
|
+
return [
|
|
258
|
+
`__fcd_run_checker() {`,
|
|
259
|
+
` MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$FCD_GUARD_NODE" "$FCD_GUARD_JS" "$@"`,
|
|
260
|
+
`}`,
|
|
261
|
+
].join('\n');
|
|
262
|
+
}
|
|
128
263
|
/** zsh guard — sourced ONLY from .zshrc (contains zsh-specific ZLE syntax). */
|
|
129
264
|
function buildGuardZsh(nodePath) {
|
|
130
265
|
return [
|
|
@@ -135,8 +270,12 @@ function buildGuardZsh(nodePath) {
|
|
|
135
270
|
``,
|
|
136
271
|
`case $- in *i*) ;; *) return 0 2>/dev/null || true ;; esac`,
|
|
137
272
|
``,
|
|
138
|
-
`FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
|
|
139
|
-
`FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
|
|
273
|
+
`FCD_GUARD_NODE=${shSingleQuote(shellPath(nodePath))}`,
|
|
274
|
+
`FCD_GUARD_JS=${shSingleQuote(shellPath(GUARD_JS_PATH))}`,
|
|
275
|
+
``,
|
|
276
|
+
buildRunCheckerFunction(),
|
|
277
|
+
``,
|
|
278
|
+
buildAskShellFunction('zsh'),
|
|
140
279
|
``,
|
|
141
280
|
`# Inspect the WHOLE typed line against every rule before accepting it`,
|
|
142
281
|
`# (command-agnostic: catches any command, pipes, and chained segments).`,
|
|
@@ -145,12 +284,23 @@ function buildGuardZsh(nodePath) {
|
|
|
145
284
|
` local __fcd_line=$BUFFER`,
|
|
146
285
|
` if [[ -n "$__fcd_line" && "\${FCD_SHELL_GUARD:-}" != "off" && -x "$FCD_GUARD_NODE" && -f "$FCD_GUARD_JS" ]]; then`,
|
|
147
286
|
` local __fcd_out`,
|
|
148
|
-
` __fcd_out="$(
|
|
287
|
+
` __fcd_out="$(__fcd_run_checker -- "$__fcd_line" 2>&1)"`,
|
|
149
288
|
` local __fcd_rc=$?`,
|
|
150
|
-
` if [[ $__fcd_rc -eq
|
|
289
|
+
` if [[ $__fcd_rc -eq ${BLOCK_CODE} ]]; then`,
|
|
151
290
|
` zle -M -- "$__fcd_out"`,
|
|
152
291
|
` return`,
|
|
153
292
|
` fi`,
|
|
293
|
+
` if [[ $__fcd_rc -eq ${exports.ASK_CODE} ]]; then`,
|
|
294
|
+
` # Ask the human: leave ZLE for the prompt, then run or drop the line.`,
|
|
295
|
+
` zle -I`,
|
|
296
|
+
` if __fcd_ask_prompt "$__fcd_out" "$__fcd_line"; then`,
|
|
297
|
+
` zle .accept-line`,
|
|
298
|
+
` else`,
|
|
299
|
+
` BUFFER=""`,
|
|
300
|
+
` zle .accept-line`,
|
|
301
|
+
` fi`,
|
|
302
|
+
` return`,
|
|
303
|
+
` fi`,
|
|
154
304
|
` [[ -n "$__fcd_out" ]] && print -u2 -r -- "$__fcd_out"`,
|
|
155
305
|
` fi`,
|
|
156
306
|
` zle .accept-line`,
|
|
@@ -182,10 +332,14 @@ function buildGuardBash(nodePath) {
|
|
|
182
332
|
``,
|
|
183
333
|
`case $- in *i*) ;; *) return 0 2>/dev/null || true ;; esac`,
|
|
184
334
|
``,
|
|
185
|
-
`FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
|
|
186
|
-
`FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
|
|
335
|
+
`FCD_GUARD_NODE=${shSingleQuote(shellPath(nodePath))}`,
|
|
336
|
+
`FCD_GUARD_JS=${shSingleQuote(shellPath(GUARD_JS_PATH))}`,
|
|
187
337
|
`__fcd_last_line=""`,
|
|
188
338
|
``,
|
|
339
|
+
buildRunCheckerFunction(),
|
|
340
|
+
``,
|
|
341
|
+
buildAskShellFunction('bash'),
|
|
342
|
+
``,
|
|
189
343
|
`# Run the shared checker on one full line. Returns 1 to cancel (extdebug).`,
|
|
190
344
|
`__fcd_check_line() {`,
|
|
191
345
|
` local __fcd_line="$1"`,
|
|
@@ -193,10 +347,14 @@ function buildGuardBash(nodePath) {
|
|
|
193
347
|
` [ "\${FCD_SHELL_GUARD:-}" = "off" ] && return 0`,
|
|
194
348
|
` if [ ! -x "$FCD_GUARD_NODE" ] || [ ! -f "$FCD_GUARD_JS" ]; then return 0; fi`,
|
|
195
349
|
` local __fcd_out __fcd_rc`,
|
|
196
|
-
` __fcd_out="$(
|
|
350
|
+
` __fcd_out="$(__fcd_run_checker -- "$__fcd_line" 2>&1)"`,
|
|
197
351
|
` __fcd_rc=$?`,
|
|
352
|
+
` if [ "$__fcd_rc" -eq ${exports.ASK_CODE} ]; then`,
|
|
353
|
+
` __fcd_ask_prompt "$__fcd_out" "$__fcd_line" && return 0`,
|
|
354
|
+
` return 1`,
|
|
355
|
+
` fi`,
|
|
198
356
|
` [ -n "$__fcd_out" ] && printf '%s\\n' "$__fcd_out" >&2`,
|
|
199
|
-
` [ "$__fcd_rc" -eq
|
|
357
|
+
` [ "$__fcd_rc" -eq ${BLOCK_CODE} ] && return 1`,
|
|
200
358
|
` return 0`,
|
|
201
359
|
`}`,
|
|
202
360
|
``,
|
|
@@ -222,13 +380,18 @@ function buildGuardBash(nodePath) {
|
|
|
222
380
|
``,
|
|
223
381
|
].join('\n');
|
|
224
382
|
}
|
|
225
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* rc files we wire the guard into. .bash_profile only when it already exists.
|
|
385
|
+
* Windows (Git Bash) is bash-only: Git for Windows' login shell reads
|
|
386
|
+
* ~/.bash_profile, and auto-creates one that sources ~/.bashrc when only the
|
|
387
|
+
* latter exists — so ~/.bashrc is the right home there too.
|
|
388
|
+
*/
|
|
226
389
|
function resolveRcTargets() {
|
|
227
390
|
const home = os.homedir();
|
|
228
|
-
const out = [
|
|
229
|
-
|
|
230
|
-
{ shell: '
|
|
231
|
-
|
|
391
|
+
const out = [];
|
|
392
|
+
if (process.platform !== 'win32')
|
|
393
|
+
out.push({ shell: 'zsh', rcPath: path.join(home, '.zshrc'), guardPath: GUARD_ZSH_PATH });
|
|
394
|
+
out.push({ shell: 'bash', rcPath: path.join(home, '.bashrc'), guardPath: GUARD_BASH_PATH });
|
|
232
395
|
const bashProfile = path.join(home, '.bash_profile');
|
|
233
396
|
if (fs.existsSync(bashProfile))
|
|
234
397
|
out.push({ shell: 'bash', rcPath: bashProfile, guardPath: GUARD_BASH_PATH });
|
|
@@ -272,7 +435,7 @@ function removeSnippetFromRc(rcPath) {
|
|
|
272
435
|
}
|
|
273
436
|
/** Read-only probe. Never throws. */
|
|
274
437
|
function getPosixShellGuardStatus() {
|
|
275
|
-
if (!
|
|
438
|
+
if (!posixGuardSupported()) {
|
|
276
439
|
return { supported: false, installed: false, rcFiles: [], rulesPresent: false };
|
|
277
440
|
}
|
|
278
441
|
try {
|
|
@@ -310,7 +473,7 @@ function getPosixShellGuardStatus() {
|
|
|
310
473
|
/** Lightweight boolean for the telemetry heartbeat / discovery report. */
|
|
311
474
|
function isPosixShellGuardInstalled() {
|
|
312
475
|
try {
|
|
313
|
-
if (!
|
|
476
|
+
if (!posixGuardSupported())
|
|
314
477
|
return false;
|
|
315
478
|
if (!fs.existsSync(GUARD_ZSH_PATH) && !fs.existsSync(GUARD_BASH_PATH))
|
|
316
479
|
return false;
|
|
@@ -327,17 +490,18 @@ function isPosixShellGuardInstalled() {
|
|
|
327
490
|
return false;
|
|
328
491
|
}
|
|
329
492
|
}
|
|
330
|
-
/** `fullcourtdefense install-shell-guard` on macOS/Linux
|
|
493
|
+
/** `fullcourtdefense install-shell-guard` on macOS/Linux (bash/zsh) or Windows with Git Bash (bash). */
|
|
331
494
|
async function installPosixShellGuardCommand(args = {}) {
|
|
332
|
-
if (!
|
|
333
|
-
console.log('The POSIX shell guard supports macOS and Linux (bash/zsh).');
|
|
495
|
+
if (!posixGuardSupported()) {
|
|
496
|
+
console.log('The POSIX shell guard supports macOS and Linux (bash/zsh), and Git Bash on Windows.');
|
|
334
497
|
return;
|
|
335
498
|
}
|
|
336
499
|
const rules = (0, shellGuard_1.writeShellGuardRules)();
|
|
337
500
|
const nodePath = process.execPath;
|
|
338
|
-
const cliEntry =
|
|
501
|
+
const cliEntry = (0, shellGuard_1.guardCliEntry)();
|
|
339
502
|
fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), { encoding: 'utf8', mode: 0o600 });
|
|
340
|
-
|
|
503
|
+
if (process.platform !== 'win32')
|
|
504
|
+
fs.writeFileSync(GUARD_ZSH_PATH, buildGuardZsh(nodePath), { encoding: 'utf8', mode: 0o600 });
|
|
341
505
|
fs.writeFileSync(GUARD_BASH_PATH, buildGuardBash(nodePath), { encoding: 'utf8', mode: 0o600 });
|
|
342
506
|
console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
|
|
343
507
|
if (args.profiles === 'false') {
|
|
@@ -358,14 +522,14 @@ async function installPosixShellGuardCommand(args = {}) {
|
|
|
358
522
|
console.log(`\x1b[33m⚠ ${target.shell}: could not update ${target.rcPath} (${error instanceof Error ? error.message : String(error)})\x1b[0m`);
|
|
359
523
|
}
|
|
360
524
|
}
|
|
361
|
-
console.log('\x1b[2mTakes effect in NEW terminals (or run: source ~/.zshrc). Dangerous typed commands are blocked
|
|
525
|
+
console.log('\x1b[2mTakes effect in NEW terminals (or run: source ~/.zshrc / ~/.bashrc). Dangerous typed commands are blocked, asked about, or warned (per rule and machine mode) and reported to the fleet dashboard.\x1b[0m');
|
|
362
526
|
if (wired === 0) {
|
|
363
527
|
console.log('\x1b[33m⚠ Guard scripts written but no rc file was newly wired — check the entries above.\x1b[0m');
|
|
364
528
|
}
|
|
365
529
|
}
|
|
366
|
-
/** `fullcourtdefense uninstall-shell-guard` on macOS/Linux — remove the guard. */
|
|
530
|
+
/** `fullcourtdefense uninstall-shell-guard` on macOS/Linux (or Git Bash on Windows) — remove the guard. */
|
|
367
531
|
async function uninstallPosixShellGuardCommand() {
|
|
368
|
-
if (!
|
|
532
|
+
if (!posixGuardSupported()) {
|
|
369
533
|
console.log('Nothing to remove on this OS.');
|
|
370
534
|
return;
|
|
371
535
|
}
|
|
@@ -400,9 +564,10 @@ function refreshPosixShellGuardRules() {
|
|
|
400
564
|
// logic of the build that wrote them — rewrite when the stamp is stale.
|
|
401
565
|
if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
|
|
402
566
|
const nodePath = process.execPath;
|
|
403
|
-
const cliEntry =
|
|
567
|
+
const cliEntry = (0, shellGuard_1.guardCliEntry)();
|
|
404
568
|
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 0o600);
|
|
405
|
-
(
|
|
569
|
+
if (process.platform !== 'win32')
|
|
570
|
+
(0, shellGuard_1.writeGuardArtifact)(GUARD_ZSH_PATH, buildGuardZsh(nodePath), 0o600);
|
|
406
571
|
(0, shellGuard_1.writeGuardArtifact)(GUARD_BASH_PATH, buildGuardBash(nodePath), 0o600);
|
|
407
572
|
}
|
|
408
573
|
}
|
|
@@ -27,11 +27,30 @@ export interface ShellGuardRule {
|
|
|
27
27
|
/**
|
|
28
28
|
* Per-rule enforcement action from the console (Local Safety). Absent =
|
|
29
29
|
* 'block'. 'warn' → yellow notice, the command still runs (like monitor
|
|
30
|
-
* mode, but for this one rule). '
|
|
31
|
-
*
|
|
30
|
+
* mode, but for this one rule). 'ask' → the guard pauses the typed line and
|
|
31
|
+
* asks the human at the keyboard (y / N / s = remember for this shell);
|
|
32
|
+
* declined, timed out, or no answerable terminal → block, never a silent
|
|
33
|
+
* allow. 'mask' is meaningless for a typed command and degrades to block.
|
|
32
34
|
*/
|
|
33
|
-
action?:
|
|
35
|
+
action?: ShellGuardAction;
|
|
34
36
|
}
|
|
37
|
+
export type ShellGuardAction = 'block' | 'warn' | 'ask';
|
|
38
|
+
/**
|
|
39
|
+
* The CLI entry a planted guard should spawn for `flush-spool`. Resolved from
|
|
40
|
+
* THIS module's location (dist/commands -> dist/index.js), never from
|
|
41
|
+
* process.argv[1]: when the guard installer runs embedded in another process
|
|
42
|
+
* (daemon, test drill, host script), argv[1] is the HOST — baking it in would
|
|
43
|
+
* make every spooled event re-launch the host, hidden, recursively (a fork
|
|
44
|
+
* bomb that has frozen a developer machine). argv[1] is only a last resort
|
|
45
|
+
* when the packaged entry is not on disk.
|
|
46
|
+
*/
|
|
47
|
+
export declare function guardCliEntry(): string;
|
|
48
|
+
/** Seconds a terminal "ask" prompt waits for an answer before it counts as declined. */
|
|
49
|
+
export declare const SHELL_ASK_TIMEOUT_SECONDS = 30;
|
|
50
|
+
/** How long "s = remember for this shell" lasts (capped — a forgotten window must not last forever). */
|
|
51
|
+
export declare const SHELL_ASK_REMEMBER_HOURS = 12;
|
|
52
|
+
/** Normalize a console action into what a typed-command guard can enforce. */
|
|
53
|
+
export declare function toShellGuardAction(raw: unknown): ShellGuardAction;
|
|
35
54
|
interface ShellGuardRulesFile {
|
|
36
55
|
updatedAt: string;
|
|
37
56
|
mode: 'block' | 'monitor';
|
|
@@ -33,10 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.GUARD_BUILD_MARKER = void 0;
|
|
36
|
+
exports.SHELL_ASK_REMEMBER_HOURS = exports.SHELL_ASK_TIMEOUT_SECONDS = exports.GUARD_BUILD_MARKER = void 0;
|
|
37
37
|
exports.guardBuildStamp = guardBuildStamp;
|
|
38
38
|
exports.guardArtifactStale = guardArtifactStale;
|
|
39
39
|
exports.writeGuardArtifact = writeGuardArtifact;
|
|
40
|
+
exports.guardCliEntry = guardCliEntry;
|
|
41
|
+
exports.toShellGuardAction = toShellGuardAction;
|
|
40
42
|
exports.writeShellGuardRules = writeShellGuardRules;
|
|
41
43
|
exports.activeShellGuardRules = activeShellGuardRules;
|
|
42
44
|
exports.evaluateShellCommand = evaluateShellCommand;
|
|
@@ -127,6 +129,36 @@ function writeGuardArtifact(filePath, content, mode) {
|
|
|
127
129
|
fs.writeFileSync(tmp, content, mode !== undefined ? { encoding: 'utf8', mode } : 'utf8');
|
|
128
130
|
fs.renameSync(tmp, filePath);
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* The CLI entry a planted guard should spawn for `flush-spool`. Resolved from
|
|
134
|
+
* THIS module's location (dist/commands -> dist/index.js), never from
|
|
135
|
+
* process.argv[1]: when the guard installer runs embedded in another process
|
|
136
|
+
* (daemon, test drill, host script), argv[1] is the HOST — baking it in would
|
|
137
|
+
* make every spooled event re-launch the host, hidden, recursively (a fork
|
|
138
|
+
* bomb that has frozen a developer machine). argv[1] is only a last resort
|
|
139
|
+
* when the packaged entry is not on disk.
|
|
140
|
+
*/
|
|
141
|
+
function guardCliEntry() {
|
|
142
|
+
const packaged = path.join(__dirname, '..', 'index.js');
|
|
143
|
+
try {
|
|
144
|
+
if (fs.existsSync(packaged))
|
|
145
|
+
return packaged;
|
|
146
|
+
}
|
|
147
|
+
catch { /* fall through */ }
|
|
148
|
+
return process.argv[1] || '';
|
|
149
|
+
}
|
|
150
|
+
/** Seconds a terminal "ask" prompt waits for an answer before it counts as declined. */
|
|
151
|
+
exports.SHELL_ASK_TIMEOUT_SECONDS = 30;
|
|
152
|
+
/** How long "s = remember for this shell" lasts (capped — a forgotten window must not last forever). */
|
|
153
|
+
exports.SHELL_ASK_REMEMBER_HOURS = 12;
|
|
154
|
+
/** Normalize a console action into what a typed-command guard can enforce. */
|
|
155
|
+
function toShellGuardAction(raw) {
|
|
156
|
+
if (raw === 'warn')
|
|
157
|
+
return 'warn';
|
|
158
|
+
if (raw === 'ask')
|
|
159
|
+
return 'ask';
|
|
160
|
+
return 'block';
|
|
161
|
+
}
|
|
130
162
|
/** Confine every lookahead to a single command segment (never span `;` `&` `|`). */
|
|
131
163
|
const SEG = String.raw `[^;&|]*`;
|
|
132
164
|
function compileSpec(spec) {
|
|
@@ -337,13 +369,14 @@ function cachedDisabledItemIds() {
|
|
|
337
369
|
return disabled;
|
|
338
370
|
}
|
|
339
371
|
/**
|
|
340
|
-
* Per-item enforcement actions from the cached local-safety snapshots.
|
|
341
|
-
* '
|
|
342
|
-
* block by omission)
|
|
343
|
-
*
|
|
372
|
+
* Per-item enforcement actions from the cached local-safety snapshots. 'warn'
|
|
373
|
+
* and 'ask' survive here; 'mask' cannot rewrite a typed command (degrades to
|
|
374
|
+
* block by omission). Cross-snapshot conflicts resolve fail-strict: an explicit
|
|
375
|
+
* 'block' anywhere wins over 'ask', which wins over 'warn'.
|
|
344
376
|
*/
|
|
345
377
|
function cachedItemActions() {
|
|
346
378
|
const warn = new Set();
|
|
379
|
+
const ask = new Set();
|
|
347
380
|
const blocked = new Set();
|
|
348
381
|
try {
|
|
349
382
|
for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
|
|
@@ -359,6 +392,8 @@ function cachedItemActions() {
|
|
|
359
392
|
continue;
|
|
360
393
|
if (action === 'warn')
|
|
361
394
|
warn.add(itemId);
|
|
395
|
+
else if (action === 'ask')
|
|
396
|
+
ask.add(itemId);
|
|
362
397
|
else
|
|
363
398
|
blocked.add(itemId);
|
|
364
399
|
}
|
|
@@ -368,8 +403,12 @@ function cachedItemActions() {
|
|
|
368
403
|
}
|
|
369
404
|
catch { /* no snapshot cache yet */ }
|
|
370
405
|
const result = new Map();
|
|
371
|
-
for (const itemId of
|
|
406
|
+
for (const itemId of ask) {
|
|
372
407
|
if (!blocked.has(itemId))
|
|
408
|
+
result.set(itemId, 'ask');
|
|
409
|
+
}
|
|
410
|
+
for (const itemId of warn) {
|
|
411
|
+
if (!blocked.has(itemId) && !ask.has(itemId))
|
|
373
412
|
result.set(itemId, 'warn');
|
|
374
413
|
}
|
|
375
414
|
return result;
|
|
@@ -398,8 +437,8 @@ function cachedCustomRules() {
|
|
|
398
437
|
pattern: escapeDotNetRegex(block.pattern.trim()),
|
|
399
438
|
reason: typeof block.explanation === 'string' && block.explanation ? block.explanation : `matched custom safety pattern "${block.pattern.trim()}"`,
|
|
400
439
|
source: 'custom',
|
|
401
|
-
// 'mask' cannot rewrite a typed command — only 'warn'
|
|
402
|
-
...(block.action === 'warn' ? { action:
|
|
440
|
+
// 'mask' cannot rewrite a typed command — only 'warn' / 'ask' relax here.
|
|
441
|
+
...(block.action === 'warn' || block.action === 'ask' ? { action: block.action } : {}),
|
|
403
442
|
});
|
|
404
443
|
}
|
|
405
444
|
}
|
|
@@ -423,7 +462,10 @@ function effectiveBuiltinRules() {
|
|
|
423
462
|
const itemActions = cachedItemActions();
|
|
424
463
|
return BUILTIN_RULES
|
|
425
464
|
.filter(rule => !disabled.has(rule.id))
|
|
426
|
-
.map(rule =>
|
|
465
|
+
.map(rule => {
|
|
466
|
+
const action = itemActions.get(rule.id);
|
|
467
|
+
return action ? { ...rule, action } : rule;
|
|
468
|
+
});
|
|
427
469
|
}
|
|
428
470
|
/** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
|
|
429
471
|
function writeShellGuardRules() {
|
|
@@ -494,7 +536,7 @@ function refreshShellGuardRules() {
|
|
|
494
536
|
// detection logic of the build that WROTE it. Rewrite when stale so new
|
|
495
537
|
// shells load the current guard (rules refreshes alone never carry logic).
|
|
496
538
|
if (guardArtifactStale(GUARD_PS1_PATH)) {
|
|
497
|
-
writeGuardArtifact(GUARD_PS1_PATH, buildGuardPs1(process.execPath,
|
|
539
|
+
writeGuardArtifact(GUARD_PS1_PATH, buildGuardPs1(process.execPath, guardCliEntry()));
|
|
498
540
|
}
|
|
499
541
|
}
|
|
500
542
|
catch { /* best-effort */ }
|
|
@@ -517,6 +559,10 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
517
559
|
`$global:FcdCliEntry = ${psQuote(cliEntry)}`,
|
|
518
560
|
`$global:FcdGuardMode = 'monitor'`,
|
|
519
561
|
`$global:FcdGuardRules = @()`,
|
|
562
|
+
`# "s = remember for this shell" answers: rule id -> expiry. Lives and dies with this PowerShell process.`,
|
|
563
|
+
`if ($null -eq $global:FcdAskRemembered) { $global:FcdAskRemembered = @{} }`,
|
|
564
|
+
`$global:FcdAskTimeoutSeconds = ${exports.SHELL_ASK_TIMEOUT_SECONDS}`,
|
|
565
|
+
`$global:FcdAskRememberHours = ${exports.SHELL_ASK_REMEMBER_HOURS}`,
|
|
520
566
|
``,
|
|
521
567
|
`try {`,
|
|
522
568
|
` if (Test-Path $global:FcdRulesPath) {`,
|
|
@@ -530,7 +576,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
530
576
|
` Severity = [string]$_.severity`,
|
|
531
577
|
` Reason = [string]$_.reason`,
|
|
532
578
|
` Source = [string]$_.source`,
|
|
533
|
-
` Action = $(
|
|
579
|
+
` Action = $(switch ([string]$_.action) { 'warn' { 'warn' } 'ask' { 'ask' } default { 'block' } })`,
|
|
534
580
|
` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
|
|
535
581
|
` }`,
|
|
536
582
|
` } catch { $null }`,
|
|
@@ -571,6 +617,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
571
617
|
` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
|
|
572
618
|
` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
|
|
573
619
|
` $warnHit = $null`,
|
|
620
|
+
` $askHit = $null`,
|
|
574
621
|
` foreach ($rule in $global:FcdGuardRules) {`,
|
|
575
622
|
` try {`,
|
|
576
623
|
` # Built-in rules ask "can this text ACT?" — they see the execution`,
|
|
@@ -580,27 +627,71 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
580
627
|
` $target = if ($isCustom) { $CommandLine } else { $execView }`,
|
|
581
628
|
` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
|
|
582
629
|
` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
|
|
583
|
-
` #
|
|
584
|
-
` if ($rule.Action -
|
|
585
|
-
` if ($null -eq $
|
|
630
|
+
` # Precedence on one line: block outranks ask, ask outranks warn.`,
|
|
631
|
+
` if ($rule.Action -eq 'block') { return $rule }`,
|
|
632
|
+
` if ($rule.Action -eq 'ask') { if ($null -eq $askHit) { $askHit = $rule } }`,
|
|
633
|
+
` elseif ($null -eq $warnHit) { $warnHit = $rule }`,
|
|
586
634
|
` }`,
|
|
587
635
|
` } catch { }`,
|
|
588
636
|
` }`,
|
|
637
|
+
` if ($null -ne $askHit) { return $askHit }`,
|
|
589
638
|
` return $warnHit`,
|
|
590
639
|
`}`,
|
|
591
640
|
``,
|
|
641
|
+
`# Read one key for an "ask" prompt without going through PSReadLine (we are`,
|
|
642
|
+
`# inside its Enter handler, so the console input is ours). y / s / anything`,
|
|
643
|
+
`# else / timeout / no console => n. Never throws.`,
|
|
644
|
+
`function global:Read-FcdAskAnswer {`,
|
|
645
|
+
` param([int]$TimeoutSeconds = 30)`,
|
|
646
|
+
` try {`,
|
|
647
|
+
` if ([Console]::IsInputRedirected) { return 'n' }`,
|
|
648
|
+
` $deadline = (Get-Date).AddSeconds($TimeoutSeconds)`,
|
|
649
|
+
` while ((Get-Date) -lt $deadline) {`,
|
|
650
|
+
` if ([Console]::KeyAvailable) {`,
|
|
651
|
+
` $key = [Console]::ReadKey($true)`,
|
|
652
|
+
` $char = ([string]$key.KeyChar).ToLowerInvariant()`,
|
|
653
|
+
` if ($char -eq 'y') { return 'y' }`,
|
|
654
|
+
` if ($char -eq 's') { return 's' }`,
|
|
655
|
+
` return 'n'`,
|
|
656
|
+
` }`,
|
|
657
|
+
` Start-Sleep -Milliseconds 40`,
|
|
658
|
+
` }`,
|
|
659
|
+
` return 't'`,
|
|
660
|
+
` } catch { }`,
|
|
661
|
+
` return 'n'`,
|
|
662
|
+
`}`,
|
|
663
|
+
``,
|
|
664
|
+
`# Is this ask rule already answered "s" in this shell (and not expired)?`,
|
|
665
|
+
`function global:Test-FcdAskRemembered {`,
|
|
666
|
+
` param([string]$RuleId)`,
|
|
667
|
+
` try {`,
|
|
668
|
+
` $until = $global:FcdAskRemembered[$RuleId]`,
|
|
669
|
+
` if ($null -ne $until -and $until -gt (Get-Date)) { return $true }`,
|
|
670
|
+
` if ($null -ne $until) { $global:FcdAskRemembered.Remove($RuleId) }`,
|
|
671
|
+
` } catch { }`,
|
|
672
|
+
` return $false`,
|
|
673
|
+
`}`,
|
|
674
|
+
``,
|
|
592
675
|
`function global:Write-FcdGuardEvent {`,
|
|
593
|
-
` param($Rule, [string]$CommandLine, [string]$Decision)`,
|
|
676
|
+
` param($Rule, [string]$CommandLine, [string]$Decision, [string]$Developer = '')`,
|
|
594
677
|
` try {`,
|
|
595
678
|
` $evidence = $CommandLine.Trim()`,
|
|
596
679
|
` if ($evidence.Length -gt 180) { $evidence = $evidence.Substring(0, 180) + '...' }`,
|
|
597
|
-
` $
|
|
680
|
+
` # $Developer: '' (none) | 'confirmed' | 'remembered' | 'declined' | 'timeout'`,
|
|
681
|
+
` $reason = 'Shell guard: ' + $Rule.Reason`,
|
|
682
|
+
` switch ($Developer) {`,
|
|
683
|
+
` 'confirmed' { $reason = 'developer confirmed in terminal prompt — ' + $reason }`,
|
|
684
|
+
` 'remembered' { $reason = 'developer confirmed earlier in this shell (session memory) — ' + $reason }`,
|
|
685
|
+
` 'declined' { $reason = 'developer declined in terminal prompt — ' + $reason }`,
|
|
686
|
+
` 'timeout' { $reason = 'developer confirmation not given (terminal prompt timed out) — ' + $reason }`,
|
|
687
|
+
` }`,
|
|
688
|
+
` $event = [ordered]@{`,
|
|
598
689
|
` eventId = [guid]::NewGuid().ToString()`,
|
|
599
690
|
` type = 'verdict'`,
|
|
600
691
|
` decision = $Decision`,
|
|
601
692
|
` toolName = 'powershell_terminal'`,
|
|
602
693
|
` operation = 'shell_command'`,
|
|
603
|
-
` reason =
|
|
694
|
+
` reason = $reason`,
|
|
604
695
|
` ruleId = $Rule.Id`,
|
|
605
696
|
` category = $Rule.Category`,
|
|
606
697
|
` severity = $Rule.Severity`,
|
|
@@ -608,6 +699,11 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
608
699
|
` evidence = $evidence`,
|
|
609
700
|
` occurredAt = (Get-Date).ToUniversalTime().ToString('o')`,
|
|
610
701
|
` }`,
|
|
702
|
+
` if ($Developer -ne '') {`,
|
|
703
|
+
` $event['approvalScope'] = 'developer'`,
|
|
704
|
+
` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered') { $event['approvedBy'] = 'developer' }`,
|
|
705
|
+
` }`,
|
|
706
|
+
` $event = [pscustomobject]$event`,
|
|
611
707
|
` # BOM-free UTF8 append — PS5's Add-Content -Encoding utf8 writes a BOM that breaks JSONL parsing.`,
|
|
612
708
|
` [System.IO.File]::AppendAllText($global:FcdSpoolPath, (($event | ConvertTo-Json -Compress) + "\`n"), (New-Object System.Text.UTF8Encoding($false)))`,
|
|
613
709
|
` if ((Test-Path $global:FcdNodePath) -and (Test-Path $global:FcdCliEntry)) {`,
|
|
@@ -636,13 +732,53 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
636
732
|
` # Cursor/VS Code terminals). InvokePrompt() re-renders the prompt+buffer`,
|
|
637
733
|
` # BELOW the message so the warning stays in the scrollback permanently.`,
|
|
638
734
|
` if ($global:FcdGuardMode -eq 'monitor') {`,
|
|
735
|
+
` $would = if ($hit.Action -eq 'ask') { 'would ask you to confirm' } else { 'would block' }`,
|
|
639
736
|
` Write-Host ''`,
|
|
640
|
-
` Write-Host ('[FullCourtDefense] monitor: would
|
|
737
|
+
` Write-Host ('[FullCourtDefense] monitor: ' + $would + ' [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
|
|
641
738
|
` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
|
|
642
739
|
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
|
|
643
740
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
644
741
|
` return`,
|
|
645
742
|
` }`,
|
|
743
|
+
` # Per-rule 'ask' action from the console: pause the line and ask the`,
|
|
744
|
+
` # human at the keyboard. y = run once, s = run + remember for this`,
|
|
745
|
+
` # shell window, anything else / timeout = not executed. Every answer is`,
|
|
746
|
+
` # reported to the fleet as a developer decision (same facts as the IDE`,
|
|
747
|
+
` # prompt). No answerable console => block, never a silent allow.`,
|
|
748
|
+
` if ($hit.Action -eq 'ask') {`,
|
|
749
|
+
` if (Test-FcdAskRemembered -RuleId ([string]$hit.Id)) {`,
|
|
750
|
+
` Write-Host ''`,
|
|
751
|
+
` Write-Host ('[FullCourtDefense] allowed — you confirmed rule ' + $hit.Id + ' earlier in this shell.') -ForegroundColor DarkGray`,
|
|
752
|
+
` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
|
|
753
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow' -Developer 'remembered'`,
|
|
754
|
+
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
755
|
+
` return`,
|
|
756
|
+
` }`,
|
|
757
|
+
` Write-Host ''`,
|
|
758
|
+
` Write-Host ('[FullCourtDefense] ASK [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
|
|
759
|
+
` Write-Host ('Run anyway? [y] yes [s] yes, remember for this shell [N] no (' + $global:FcdAskTimeoutSeconds + 's): ') -NoNewline`,
|
|
760
|
+
` $answer = Read-FcdAskAnswer -TimeoutSeconds $global:FcdAskTimeoutSeconds`,
|
|
761
|
+
` if ($answer -eq 'y' -or $answer -eq 's') {`,
|
|
762
|
+
` if ($answer -eq 's') {`,
|
|
763
|
+
` $global:FcdAskRemembered[[string]$hit.Id] = (Get-Date).AddHours($global:FcdAskRememberHours)`,
|
|
764
|
+
` Write-Host 'yes — remembered for this shell. Reported to your security dashboard as allowed by you.' -ForegroundColor Green`,
|
|
765
|
+
` } else {`,
|
|
766
|
+
` Write-Host 'yes — reported to your security dashboard as allowed by you.' -ForegroundColor Green`,
|
|
767
|
+
` }`,
|
|
768
|
+
` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
|
|
769
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow' -Developer 'confirmed'`,
|
|
770
|
+
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
771
|
+
` return`,
|
|
772
|
+
` }`,
|
|
773
|
+
` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
|
|
774
|
+
` $outcome = if ($answer -eq 't') { 'timeout' } else { 'declined' }`,
|
|
775
|
+
` Write-Host $(if ($outcome -eq 'timeout') { 'no answer' } else { 'no' }) -ForegroundColor Red`,
|
|
776
|
+
` Write-Host ('This command was not executed (' + $outcome + '). Reported to your security dashboard.') -ForegroundColor DarkGray`,
|
|
777
|
+
` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
|
|
778
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'block' -Developer $outcome`,
|
|
779
|
+
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
780
|
+
` return`,
|
|
781
|
+
` }`,
|
|
646
782
|
` # Per-rule 'warn' action from the console: notice + the command runs.`,
|
|
647
783
|
` if ($hit.Action -eq 'warn') {`,
|
|
648
784
|
` Write-Host ''`,
|
|
@@ -830,7 +966,7 @@ async function installShellGuardCommand(args = {}) {
|
|
|
830
966
|
}
|
|
831
967
|
const rules = writeShellGuardRules();
|
|
832
968
|
const nodePath = process.execPath;
|
|
833
|
-
const cliEntry =
|
|
969
|
+
const cliEntry = guardCliEntry();
|
|
834
970
|
fs.writeFileSync(GUARD_PS1_PATH, buildGuardPs1(nodePath, cliEntry), 'utf8');
|
|
835
971
|
console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
|
|
836
972
|
if (args.profiles === 'false') {
|
package/dist/index.js
CHANGED
|
@@ -822,6 +822,12 @@ async function main() {
|
|
|
822
822
|
if (process.platform === 'win32') {
|
|
823
823
|
const { installShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/shellGuard')));
|
|
824
824
|
await installShellGuardCommand({ profiles: flags.profiles });
|
|
825
|
+
// Git Bash (MSYS2) on Windows gets the bash guard too — same rules file.
|
|
826
|
+
const { installPosixShellGuardCommand, detectGitBash } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
|
|
827
|
+
if (detectGitBash()) {
|
|
828
|
+
console.log('\n\x1b[1mGit Bash detected — installing the bash guard as well…\x1b[0m');
|
|
829
|
+
await installPosixShellGuardCommand({ profiles: flags.profiles });
|
|
830
|
+
}
|
|
825
831
|
}
|
|
826
832
|
else {
|
|
827
833
|
const { installPosixShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
|
|
@@ -833,6 +839,9 @@ async function main() {
|
|
|
833
839
|
if (process.platform === 'win32') {
|
|
834
840
|
const { uninstallShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/shellGuard')));
|
|
835
841
|
await uninstallShellGuardCommand();
|
|
842
|
+
const { uninstallPosixShellGuardCommand, posixGuardSupported } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
|
|
843
|
+
if (posixGuardSupported())
|
|
844
|
+
await uninstallPosixShellGuardCommand();
|
|
836
845
|
}
|
|
837
846
|
else {
|
|
838
847
|
const { uninstallPosixShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
|
|
@@ -850,6 +859,15 @@ async function main() {
|
|
|
850
859
|
}
|
|
851
860
|
if (status.rulesPresent)
|
|
852
861
|
console.log(` Rules: ${status.ruleCount} (mode: ${status.mode || 'block'})`);
|
|
862
|
+
const { getPosixShellGuardStatus, detectGitBash } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
|
|
863
|
+
const gitBash = detectGitBash();
|
|
864
|
+
if (gitBash) {
|
|
865
|
+
const posix = getPosixShellGuardStatus();
|
|
866
|
+
console.log(`\nInteractive Git Bash guard: ${posix.installed ? '\x1b[32minstalled\x1b[0m' : '\x1b[31mnot installed\x1b[0m'} \x1b[2m(${gitBash})\x1b[0m`);
|
|
867
|
+
for (const rc of posix.rcFiles) {
|
|
868
|
+
console.log(` ${rc.shell}: ${rc.installed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} \x1b[2m${rc.rcPath}\x1b[0m`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
853
871
|
console.log('');
|
|
854
872
|
break;
|
|
855
873
|
}
|
|
@@ -898,7 +916,10 @@ async function main() {
|
|
|
898
916
|
const matches = evaluateShellCommand(cmd, rules);
|
|
899
917
|
if (matches.length)
|
|
900
918
|
blocked++;
|
|
901
|
-
|
|
919
|
+
// The action the terminal guard would take: block > ask > warn across matches.
|
|
920
|
+
const actions = matches.map(m => m.rule.action || 'block');
|
|
921
|
+
const action = actions.includes('block') ? 'block' : actions.includes('ask') ? 'ask' : actions.length ? 'warn' : undefined;
|
|
922
|
+
return { command: cmd, blocked: matches.length > 0, action, matches: matches.map(m => ({ ruleId: m.rule.id, severity: m.rule.severity, category: m.rule.category, action: m.rule.action || 'block', reason: m.rule.reason, segment: m.segment })) };
|
|
902
923
|
});
|
|
903
924
|
if (asJson) {
|
|
904
925
|
console.log(JSON.stringify({ total: cmds.length, blocked, ruleCount: rules.length, results }, null, 2));
|
|
@@ -908,8 +929,9 @@ async function main() {
|
|
|
908
929
|
for (const r of results) {
|
|
909
930
|
if (r.blocked) {
|
|
910
931
|
const m = r.matches[0];
|
|
911
|
-
|
|
912
|
-
console.log(`
|
|
932
|
+
const label = r.action === 'ask' ? '\x1b[33mASK \x1b[0m' : r.action === 'warn' ? '\x1b[33mWARN \x1b[0m' : '\x1b[31mBLOCK\x1b[0m';
|
|
933
|
+
console.log(` ${label} [\x1b[1m${m.severity}\x1b[0m] ${r.command}`);
|
|
934
|
+
console.log(` \x1b[2m${m.reason} (rule ${m.ruleId})${r.matches.length > 1 ? ` +${r.matches.length - 1} more` : ''}${r.action === 'ask' ? ' — asks you to confirm in the terminal' : r.action === 'warn' ? ' — runs with a warning' : ''}\x1b[0m`);
|
|
913
935
|
}
|
|
914
936
|
else {
|
|
915
937
|
console.log(` \x1b[32mALLOW\x1b[0m ${r.command}`);
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"test:offline-policy": "npm run build && node scripts/test-offline-policy-enforcement.js",
|
|
79
79
|
"test:developer-confirmation": "npm run build && node scripts/test-developer-confirmation.js",
|
|
80
80
|
"test:local-safety-ask": "npm run build && node scripts/test-local-safety-ask.js",
|
|
81
|
+
"test:terminal-mcp-ask": "npm run build && node scripts/test-terminal-mcp-ask.js",
|
|
81
82
|
"test:demo-actions": "npm run build && node scripts/test-demo-actions.js",
|
|
82
83
|
"test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
|
|
83
84
|
"test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
|