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
|
@@ -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,40 @@ 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;
|
|
54
|
+
/**
|
|
55
|
+
* Leading command names that anchor a built-in rule — what a first-token hook
|
|
56
|
+
* (cmd.exe doskey) can key on. Derived from the shared tables so a new rule
|
|
57
|
+
* cannot be added without its command name reaching the cmd guard's index.
|
|
58
|
+
*/
|
|
59
|
+
/** Every built-in terminal rule id, ignoring console toggles (parity tests, docs). */
|
|
60
|
+
export declare function builtinRuleIds(): string[];
|
|
61
|
+
/** Every built-in terminal rule, all switched ON with default action (parity tests). */
|
|
62
|
+
export declare function builtinRulesAllOn(): ShellGuardRule[];
|
|
63
|
+
export declare function builtinRuleCommandNames(): string[];
|
|
35
64
|
interface ShellGuardRulesFile {
|
|
36
65
|
updatedAt: string;
|
|
37
66
|
mode: 'block' | 'monitor';
|