- Created skills/ directory - Moved 272 skills to skills/ subfolder - Kept agents/ at root level - Kept installation scripts and docs at root level Repository structure: - skills/ - All 272 skills from skills.sh - agents/ - Agent definitions - *.sh, *.ps1 - Installation scripts - README.md, etc. - Documentation Co-Authored-By: Claude <noreply@anthropic.com>
2386 lines
65 KiB
JavaScript
2386 lines
65 KiB
JavaScript
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
|
|
// node_modules/shell-quote/quote.js
|
|
var require_quote = __commonJS((exports, module) => {
|
|
module.exports = function quote(xs) {
|
|
return xs.map(function(s) {
|
|
if (s === "") {
|
|
return "''";
|
|
}
|
|
if (s && typeof s === "object") {
|
|
return s.op.replace(/(.)/g, "\\$1");
|
|
}
|
|
if (/["\s\\]/.test(s) && !/'/.test(s)) {
|
|
return "'" + s.replace(/(['])/g, "\\$1") + "'";
|
|
}
|
|
if (/["'\s]/.test(s)) {
|
|
return '"' + s.replace(/(["\\$`!])/g, "\\$1") + '"';
|
|
}
|
|
return String(s).replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
|
|
}).join(" ");
|
|
};
|
|
});
|
|
|
|
// node_modules/shell-quote/parse.js
|
|
var require_parse = __commonJS((exports, module) => {
|
|
var CONTROL = "(?:" + [
|
|
"\\|\\|",
|
|
"\\&\\&",
|
|
";;",
|
|
"\\|\\&",
|
|
"\\<\\(",
|
|
"\\<\\<\\<",
|
|
">>",
|
|
">\\&",
|
|
"<\\&",
|
|
"[&;()|<>]"
|
|
].join("|") + ")";
|
|
var controlRE = new RegExp("^" + CONTROL + "$");
|
|
var META = "|&;()<> \\t";
|
|
var SINGLE_QUOTE = '"((\\\\"|[^"])*?)"';
|
|
var DOUBLE_QUOTE = "'((\\\\'|[^'])*?)'";
|
|
var hash = /^#$/;
|
|
var SQ = "'";
|
|
var DQ = '"';
|
|
var DS = "$";
|
|
var TOKEN = "";
|
|
var mult = 4294967296;
|
|
for (i = 0;i < 4; i++) {
|
|
TOKEN += (mult * Math.random()).toString(16);
|
|
}
|
|
var i;
|
|
var startsWithToken = new RegExp("^" + TOKEN);
|
|
function matchAll(s, r) {
|
|
var origIndex = r.lastIndex;
|
|
var matches = [];
|
|
var matchObj;
|
|
while (matchObj = r.exec(s)) {
|
|
matches.push(matchObj);
|
|
if (r.lastIndex === matchObj.index) {
|
|
r.lastIndex += 1;
|
|
}
|
|
}
|
|
r.lastIndex = origIndex;
|
|
return matches;
|
|
}
|
|
function getVar(env, pre, key) {
|
|
var r = typeof env === "function" ? env(key) : env[key];
|
|
if (typeof r === "undefined" && key != "") {
|
|
r = "";
|
|
} else if (typeof r === "undefined") {
|
|
r = "$";
|
|
}
|
|
if (typeof r === "object") {
|
|
return pre + TOKEN + JSON.stringify(r) + TOKEN;
|
|
}
|
|
return pre + r;
|
|
}
|
|
function parseInternal(string, env, opts) {
|
|
if (!opts) {
|
|
opts = {};
|
|
}
|
|
var BS = opts.escape || "\\";
|
|
var BAREWORD = "(\\" + BS + `['"` + META + `]|[^\\s'"` + META + "])+";
|
|
var chunker = new RegExp([
|
|
"(" + CONTROL + ")",
|
|
"(" + BAREWORD + "|" + SINGLE_QUOTE + "|" + DOUBLE_QUOTE + ")+"
|
|
].join("|"), "g");
|
|
var matches = matchAll(string, chunker);
|
|
if (matches.length === 0) {
|
|
return [];
|
|
}
|
|
if (!env) {
|
|
env = {};
|
|
}
|
|
var commented = false;
|
|
return matches.map(function(match) {
|
|
var s = match[0];
|
|
if (!s || commented) {
|
|
return;
|
|
}
|
|
if (controlRE.test(s)) {
|
|
return { op: s };
|
|
}
|
|
var quote = false;
|
|
var esc = false;
|
|
var out = "";
|
|
var isGlob = false;
|
|
var i2;
|
|
function parseEnvVar() {
|
|
i2 += 1;
|
|
var varend;
|
|
var varname;
|
|
var char = s.charAt(i2);
|
|
if (char === "{") {
|
|
i2 += 1;
|
|
if (s.charAt(i2) === "}") {
|
|
throw new Error("Bad substitution: " + s.slice(i2 - 2, i2 + 1));
|
|
}
|
|
varend = s.indexOf("}", i2);
|
|
if (varend < 0) {
|
|
throw new Error("Bad substitution: " + s.slice(i2));
|
|
}
|
|
varname = s.slice(i2, varend);
|
|
i2 = varend;
|
|
} else if (/[*@#?$!_-]/.test(char)) {
|
|
varname = char;
|
|
i2 += 1;
|
|
} else {
|
|
var slicedFromI = s.slice(i2);
|
|
varend = slicedFromI.match(/[^\w\d_]/);
|
|
if (!varend) {
|
|
varname = slicedFromI;
|
|
i2 = s.length;
|
|
} else {
|
|
varname = slicedFromI.slice(0, varend.index);
|
|
i2 += varend.index - 1;
|
|
}
|
|
}
|
|
return getVar(env, "", varname);
|
|
}
|
|
for (i2 = 0;i2 < s.length; i2++) {
|
|
var c = s.charAt(i2);
|
|
isGlob = isGlob || !quote && (c === "*" || c === "?");
|
|
if (esc) {
|
|
out += c;
|
|
esc = false;
|
|
} else if (quote) {
|
|
if (c === quote) {
|
|
quote = false;
|
|
} else if (quote == SQ) {
|
|
out += c;
|
|
} else {
|
|
if (c === BS) {
|
|
i2 += 1;
|
|
c = s.charAt(i2);
|
|
if (c === DQ || c === BS || c === DS) {
|
|
out += c;
|
|
} else {
|
|
out += BS + c;
|
|
}
|
|
} else if (c === DS) {
|
|
out += parseEnvVar();
|
|
} else {
|
|
out += c;
|
|
}
|
|
}
|
|
} else if (c === DQ || c === SQ) {
|
|
quote = c;
|
|
} else if (controlRE.test(c)) {
|
|
return { op: s };
|
|
} else if (hash.test(c)) {
|
|
commented = true;
|
|
var commentObj = { comment: string.slice(match.index + i2 + 1) };
|
|
if (out.length) {
|
|
return [out, commentObj];
|
|
}
|
|
return [commentObj];
|
|
} else if (c === BS) {
|
|
esc = true;
|
|
} else if (c === DS) {
|
|
out += parseEnvVar();
|
|
} else {
|
|
out += c;
|
|
}
|
|
}
|
|
if (isGlob) {
|
|
return { op: "glob", pattern: out };
|
|
}
|
|
return out;
|
|
}).reduce(function(prev, arg) {
|
|
return typeof arg === "undefined" ? prev : prev.concat(arg);
|
|
}, []);
|
|
}
|
|
module.exports = function parse(s, env, opts) {
|
|
var mapped = parseInternal(s, env, opts);
|
|
if (typeof env !== "function") {
|
|
return mapped;
|
|
}
|
|
return mapped.reduce(function(acc, s2) {
|
|
if (typeof s2 === "object") {
|
|
return acc.concat(s2);
|
|
}
|
|
var xs = s2.split(RegExp("(" + TOKEN + ".*?" + TOKEN + ")", "g"));
|
|
if (xs.length === 1) {
|
|
return acc.concat(xs[0]);
|
|
}
|
|
return acc.concat(xs.filter(Boolean).map(function(x) {
|
|
if (startsWithToken.test(x)) {
|
|
return JSON.parse(x.split(TOKEN)[1]);
|
|
}
|
|
return x;
|
|
}));
|
|
}, []);
|
|
};
|
|
});
|
|
|
|
// src/types.ts
|
|
var MAX_RECURSION_DEPTH = 10;
|
|
var MAX_STRIP_ITERATIONS = 20;
|
|
var NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
var COMMAND_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
var MAX_REASON_LENGTH = 256;
|
|
var SHELL_OPERATORS = new Set(["&&", "||", "|&", "|", "&", ";", `
|
|
`]);
|
|
var SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "ksh", "dash", "fish", "csh", "tcsh"]);
|
|
var INTERPRETERS = new Set(["python", "python3", "python2", "node", "ruby", "perl"]);
|
|
var DANGEROUS_PATTERNS = [
|
|
/\brm\s+.*-[rR].*-f\b/,
|
|
/\brm\s+.*-f.*-[rR]\b/,
|
|
/\brm\s+-rf\b/,
|
|
/\brm\s+-fr\b/,
|
|
/\bgit\s+reset\s+--hard\b/,
|
|
/\bgit\s+checkout\s+--\b/,
|
|
/\bgit\s+clean\s+-f\b/,
|
|
/\bfind\b.*\s-delete\b/
|
|
];
|
|
var PARANOID_INTERPRETERS_SUFFIX = `
|
|
|
|
(Paranoid mode: interpreter one-liners are blocked.)`;
|
|
|
|
// node_modules/shell-quote/index.js
|
|
var $quote = require_quote();
|
|
var $parse = require_parse();
|
|
|
|
// src/core/shell.ts
|
|
var ENV_PROXY = new Proxy({}, {
|
|
get: (_, name) => `$${String(name)}`
|
|
});
|
|
function splitShellCommands(command) {
|
|
if (hasUnclosedQuotes(command)) {
|
|
return [[command]];
|
|
}
|
|
const normalizedCommand = command.replace(/\n/g, " ; ");
|
|
const tokens = $parse(normalizedCommand, ENV_PROXY);
|
|
const segments = [];
|
|
let current = [];
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (token === undefined) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (isOperator(token)) {
|
|
if (current.length > 0) {
|
|
segments.push(current);
|
|
current = [];
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
if (typeof token !== "string") {
|
|
i++;
|
|
continue;
|
|
}
|
|
const nextToken = tokens[i + 1];
|
|
if (token === "$" && nextToken && isParenOpen(nextToken)) {
|
|
if (current.length > 0) {
|
|
segments.push(current);
|
|
current = [];
|
|
}
|
|
const { innerSegments, endIndex } = extractCommandSubstitution(tokens, i + 2);
|
|
for (const seg of innerSegments) {
|
|
segments.push(seg);
|
|
}
|
|
i = endIndex + 1;
|
|
continue;
|
|
}
|
|
const backtickSegments = extractBacktickSubstitutions(token);
|
|
if (backtickSegments.length > 0) {
|
|
for (const seg of backtickSegments) {
|
|
segments.push(seg);
|
|
}
|
|
}
|
|
current.push(token);
|
|
i++;
|
|
}
|
|
if (current.length > 0) {
|
|
segments.push(current);
|
|
}
|
|
return segments;
|
|
}
|
|
function extractBacktickSubstitutions(token) {
|
|
const segments = [];
|
|
let i = 0;
|
|
while (i < token.length) {
|
|
const backtickStart = token.indexOf("`", i);
|
|
if (backtickStart === -1)
|
|
break;
|
|
const backtickEnd = token.indexOf("`", backtickStart + 1);
|
|
if (backtickEnd === -1)
|
|
break;
|
|
const innerCommand = token.slice(backtickStart + 1, backtickEnd);
|
|
if (innerCommand.trim()) {
|
|
const innerSegments = splitShellCommands(innerCommand);
|
|
for (const seg of innerSegments) {
|
|
segments.push(seg);
|
|
}
|
|
}
|
|
i = backtickEnd + 1;
|
|
}
|
|
return segments;
|
|
}
|
|
function isParenOpen(token) {
|
|
return typeof token === "object" && token !== null && "op" in token && token.op === "(";
|
|
}
|
|
function isParenClose(token) {
|
|
return typeof token === "object" && token !== null && "op" in token && token.op === ")";
|
|
}
|
|
function extractCommandSubstitution(tokens, startIndex) {
|
|
const innerSegments = [];
|
|
let currentSegment = [];
|
|
let depth = 1;
|
|
let i = startIndex;
|
|
while (i < tokens.length && depth > 0) {
|
|
const token = tokens[i];
|
|
if (isParenOpen(token)) {
|
|
depth++;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (isParenClose(token)) {
|
|
depth--;
|
|
if (depth === 0)
|
|
break;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (depth === 1 && token && isOperator(token)) {
|
|
if (currentSegment.length > 0) {
|
|
innerSegments.push(currentSegment);
|
|
currentSegment = [];
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
if (typeof token === "string") {
|
|
currentSegment.push(token);
|
|
}
|
|
i++;
|
|
}
|
|
if (currentSegment.length > 0) {
|
|
innerSegments.push(currentSegment);
|
|
}
|
|
return { innerSegments, endIndex: i };
|
|
}
|
|
function hasUnclosedQuotes(command) {
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let escaped = false;
|
|
for (const char of command) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (char === "'" && !inDouble) {
|
|
inSingle = !inSingle;
|
|
} else if (char === '"' && !inSingle) {
|
|
inDouble = !inDouble;
|
|
}
|
|
}
|
|
return inSingle || inDouble;
|
|
}
|
|
var ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
function parseEnvAssignment(token) {
|
|
if (!ENV_ASSIGNMENT_RE.test(token)) {
|
|
return null;
|
|
}
|
|
const eqIdx = token.indexOf("=");
|
|
if (eqIdx < 0) {
|
|
return null;
|
|
}
|
|
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
|
|
}
|
|
function stripEnvAssignmentsWithInfo(tokens) {
|
|
const envAssignments = new Map;
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token) {
|
|
break;
|
|
}
|
|
const assignment = parseEnvAssignment(token);
|
|
if (!assignment) {
|
|
break;
|
|
}
|
|
envAssignments.set(assignment.name, assignment.value);
|
|
i++;
|
|
}
|
|
return { tokens: tokens.slice(i), envAssignments };
|
|
}
|
|
function stripWrappers(tokens) {
|
|
return stripWrappersWithInfo(tokens).tokens;
|
|
}
|
|
function stripWrappersWithInfo(tokens) {
|
|
let result = [...tokens];
|
|
const allEnvAssignments = new Map;
|
|
for (let iteration = 0;iteration < MAX_STRIP_ITERATIONS; iteration++) {
|
|
const before = result.join(" ");
|
|
const { tokens: strippedTokens, envAssignments } = stripEnvAssignmentsWithInfo(result);
|
|
for (const [k, v] of envAssignments) {
|
|
allEnvAssignments.set(k, v);
|
|
}
|
|
result = strippedTokens;
|
|
if (result.length === 0)
|
|
break;
|
|
while (result.length > 0 && result[0]?.includes("=") && !ENV_ASSIGNMENT_RE.test(result[0] ?? "")) {
|
|
result = result.slice(1);
|
|
}
|
|
if (result.length === 0)
|
|
break;
|
|
const head = result[0]?.toLowerCase();
|
|
if (head !== "sudo" && head !== "env" && head !== "command") {
|
|
break;
|
|
}
|
|
if (head === "sudo") {
|
|
result = stripSudo(result);
|
|
}
|
|
if (head === "env") {
|
|
const envResult = stripEnvWithInfo(result);
|
|
result = envResult.tokens;
|
|
for (const [k, v] of envResult.envAssignments) {
|
|
allEnvAssignments.set(k, v);
|
|
}
|
|
}
|
|
if (head === "command") {
|
|
result = stripCommand(result);
|
|
}
|
|
if (result.join(" ") === before)
|
|
break;
|
|
}
|
|
const { tokens: finalTokens, envAssignments: finalAssignments } = stripEnvAssignmentsWithInfo(result);
|
|
for (const [k, v] of finalAssignments) {
|
|
allEnvAssignments.set(k, v);
|
|
}
|
|
return { tokens: finalTokens, envAssignments: allEnvAssignments };
|
|
}
|
|
var SUDO_OPTS_WITH_VALUE = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
|
|
function stripSudo(tokens) {
|
|
let i = 1;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === "--") {
|
|
return tokens.slice(i + 1);
|
|
}
|
|
if (!token.startsWith("-")) {
|
|
break;
|
|
}
|
|
if (SUDO_OPTS_WITH_VALUE.has(token)) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
return tokens.slice(i);
|
|
}
|
|
var ENV_OPTS_NO_VALUE = new Set(["-i", "-0", "--null"]);
|
|
var ENV_OPTS_WITH_VALUE = new Set([
|
|
"-u",
|
|
"--unset",
|
|
"-C",
|
|
"--chdir",
|
|
"-S",
|
|
"--split-string",
|
|
"-P"
|
|
]);
|
|
function stripEnvWithInfo(tokens) {
|
|
const envAssignments = new Map;
|
|
let i = 1;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === "--") {
|
|
return { tokens: tokens.slice(i + 1), envAssignments };
|
|
}
|
|
if (ENV_OPTS_NO_VALUE.has(token)) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ENV_OPTS_WITH_VALUE.has(token)) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (token.startsWith("-u=") || token.startsWith("--unset=")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token.startsWith("-C=") || token.startsWith("--chdir=")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token.startsWith("-P")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token.startsWith("-")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
const assignment = parseEnvAssignment(token);
|
|
if (!assignment) {
|
|
break;
|
|
}
|
|
envAssignments.set(assignment.name, assignment.value);
|
|
i++;
|
|
}
|
|
return { tokens: tokens.slice(i), envAssignments };
|
|
}
|
|
function stripCommand(tokens) {
|
|
let i = 1;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === "-p" || token === "-v" || token === "-V") {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token === "--") {
|
|
return tokens.slice(i + 1);
|
|
}
|
|
if (token.startsWith("-") && !token.startsWith("--") && token.length > 1) {
|
|
const chars = token.slice(1);
|
|
if (!/^[pvV]+$/.test(chars)) {
|
|
break;
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return tokens.slice(i);
|
|
}
|
|
function extractShortOpts(tokens) {
|
|
const opts = new Set;
|
|
let pastDoubleDash = false;
|
|
for (const token of tokens) {
|
|
if (token === "--") {
|
|
pastDoubleDash = true;
|
|
continue;
|
|
}
|
|
if (pastDoubleDash)
|
|
continue;
|
|
if (token.startsWith("-") && !token.startsWith("--") && token.length > 1) {
|
|
for (let i = 1;i < token.length; i++) {
|
|
const char = token[i];
|
|
if (!char || !/[a-zA-Z]/.test(char)) {
|
|
break;
|
|
}
|
|
opts.add(`-${char}`);
|
|
}
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
function normalizeCommandToken(token) {
|
|
return getBasename(token).toLowerCase();
|
|
}
|
|
function getBasename(token) {
|
|
return token.includes("/") ? token.split("/").pop() ?? token : token;
|
|
}
|
|
function isOperator(token) {
|
|
return typeof token === "object" && token !== null && "op" in token && SHELL_OPERATORS.has(token.op);
|
|
}
|
|
|
|
// src/core/analyze/dangerous-text.ts
|
|
function dangerousInText(text) {
|
|
const t = text.toLowerCase();
|
|
const stripped = t.trimStart();
|
|
const isEchoOrRg = stripped.startsWith("echo ") || stripped.startsWith("rg ");
|
|
const patterns = [
|
|
{
|
|
regex: /\brm\s+(-[^\s]*r[^\s]*\s+-[^\s]*f|-[^\s]*f[^\s]*\s+-[^\s]*r|-[^\s]*rf|-[^\s]*fr)\b/,
|
|
reason: "rm -rf"
|
|
},
|
|
{
|
|
regex: /\bgit\s+reset\s+--hard\b/,
|
|
reason: "git reset --hard"
|
|
},
|
|
{
|
|
regex: /\bgit\s+reset\s+--merge\b/,
|
|
reason: "git reset --merge"
|
|
},
|
|
{
|
|
regex: /\bgit\s+clean\s+(-[^\s]*f|-f)\b/,
|
|
reason: "git clean -f"
|
|
},
|
|
{
|
|
regex: /\bgit\s+push\s+[^|;]*(-f\b|--force\b)(?!-with-lease)/,
|
|
reason: "git push --force (use --force-with-lease instead)"
|
|
},
|
|
{
|
|
regex: /\bgit\s+branch\s+-D\b/,
|
|
reason: "git branch -D",
|
|
caseSensitive: true
|
|
},
|
|
{
|
|
regex: /\bgit\s+stash\s+(drop|clear)\b/,
|
|
reason: "git stash drop/clear"
|
|
},
|
|
{
|
|
regex: /\bgit\s+checkout\s+--\s/,
|
|
reason: "git checkout --"
|
|
},
|
|
{
|
|
regex: /\bgit\s+restore\b(?!.*--(staged|help))/,
|
|
reason: "git restore (without --staged)"
|
|
},
|
|
{
|
|
regex: /\bfind\b[^\n;|&]*\s-delete\b/,
|
|
reason: "find -delete",
|
|
skipForEchoRg: true
|
|
}
|
|
];
|
|
for (const { regex, reason, skipForEchoRg, caseSensitive } of patterns) {
|
|
if (skipForEchoRg && isEchoOrRg)
|
|
continue;
|
|
const target = caseSensitive ? text : t;
|
|
if (regex.test(target)) {
|
|
return reason;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// src/core/rules-custom.ts
|
|
function checkCustomRules(tokens, rules) {
|
|
if (tokens.length === 0 || rules.length === 0) {
|
|
return null;
|
|
}
|
|
const command = getBasename(tokens[0] ?? "");
|
|
const subcommand = extractSubcommand(tokens);
|
|
const shortOpts = extractShortOpts(tokens);
|
|
for (const rule of rules) {
|
|
if (!matchesCommand(command, rule.command)) {
|
|
continue;
|
|
}
|
|
if (rule.subcommand && subcommand !== rule.subcommand) {
|
|
continue;
|
|
}
|
|
if (matchesBlockArgs(tokens, rule.block_args, shortOpts)) {
|
|
return `[${rule.name}] ${rule.reason}`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function matchesCommand(command, ruleCommand) {
|
|
return command === ruleCommand;
|
|
}
|
|
var OPTIONS_WITH_VALUES = new Set([
|
|
"-c",
|
|
"-C",
|
|
"--git-dir",
|
|
"--work-tree",
|
|
"--namespace",
|
|
"--config-env"
|
|
]);
|
|
function extractSubcommand(tokens) {
|
|
let skipNext = false;
|
|
for (let i = 1;i < tokens.length; i++) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
continue;
|
|
if (skipNext) {
|
|
skipNext = false;
|
|
continue;
|
|
}
|
|
if (token === "--") {
|
|
const nextToken = tokens[i + 1];
|
|
if (nextToken && !nextToken.startsWith("-")) {
|
|
return nextToken;
|
|
}
|
|
return null;
|
|
}
|
|
if (OPTIONS_WITH_VALUES.has(token)) {
|
|
skipNext = true;
|
|
continue;
|
|
}
|
|
if (token.startsWith("-")) {
|
|
for (const opt of OPTIONS_WITH_VALUES) {
|
|
if (token.startsWith(`${opt}=`)) {
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
return token;
|
|
}
|
|
return null;
|
|
}
|
|
function matchesBlockArgs(tokens, blockArgs, shortOpts) {
|
|
const blockArgsSet = new Set(blockArgs);
|
|
for (const token of tokens) {
|
|
if (blockArgsSet.has(token)) {
|
|
return true;
|
|
}
|
|
}
|
|
for (const opt of shortOpts) {
|
|
if (blockArgsSet.has(opt)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// src/core/rules-git.ts
|
|
var REASON_CHECKOUT_DOUBLE_DASH = "git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
|
|
var REASON_CHECKOUT_REF_PATH = "git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
|
|
var REASON_CHECKOUT_PATHSPEC_FROM_FILE = "git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
|
|
var REASON_CHECKOUT_AMBIGUOUS = "git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
|
|
var REASON_RESTORE = "git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
|
|
var REASON_RESTORE_WORKTREE = "git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
|
|
var REASON_RESET_HARD = "git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
|
|
var REASON_RESET_MERGE = "git reset --merge can lose uncommitted changes. Use 'git stash' first.";
|
|
var REASON_CLEAN = "git clean -f removes untracked files permanently. Use 'git clean -n' to preview first.";
|
|
var REASON_PUSH_FORCE = "git push --force destroys remote history. Use --force-with-lease for safer force push.";
|
|
var REASON_BRANCH_DELETE = "git branch -D force-deletes without merge check. Use -d for safe delete.";
|
|
var REASON_STASH_DROP = "git stash drop permanently deletes stashed changes. Consider 'git stash list' first.";
|
|
var REASON_STASH_CLEAR = "git stash clear deletes ALL stashed changes permanently.";
|
|
var REASON_WORKTREE_REMOVE_FORCE = "git worktree remove --force can delete uncommitted changes. Remove --force flag.";
|
|
var GIT_GLOBAL_OPTS_WITH_VALUE = new Set([
|
|
"-c",
|
|
"-C",
|
|
"--git-dir",
|
|
"--work-tree",
|
|
"--namespace",
|
|
"--super-prefix",
|
|
"--config-env"
|
|
]);
|
|
var CHECKOUT_OPTS_WITH_VALUE = new Set([
|
|
"-b",
|
|
"-B",
|
|
"--orphan",
|
|
"--conflict",
|
|
"--pathspec-from-file",
|
|
"--unified"
|
|
]);
|
|
var CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(["--recurse-submodules", "--track", "-t"]);
|
|
var CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
|
|
"-q",
|
|
"--quiet",
|
|
"-f",
|
|
"--force",
|
|
"-d",
|
|
"--detach",
|
|
"-m",
|
|
"--merge",
|
|
"-p",
|
|
"--patch",
|
|
"--ours",
|
|
"--theirs",
|
|
"--no-track",
|
|
"--overwrite-ignore",
|
|
"--no-overwrite-ignore",
|
|
"--ignore-other-worktrees",
|
|
"--progress",
|
|
"--no-progress"
|
|
]);
|
|
function splitAtDoubleDash(tokens) {
|
|
const index = tokens.indexOf("--");
|
|
if (index === -1) {
|
|
return { index: -1, before: tokens, after: [] };
|
|
}
|
|
return {
|
|
index,
|
|
before: tokens.slice(0, index),
|
|
after: tokens.slice(index + 1)
|
|
};
|
|
}
|
|
function analyzeGit(tokens) {
|
|
const { subcommand, rest } = extractGitSubcommandAndRest(tokens);
|
|
if (!subcommand) {
|
|
return null;
|
|
}
|
|
switch (subcommand.toLowerCase()) {
|
|
case "checkout":
|
|
return analyzeGitCheckout(rest);
|
|
case "restore":
|
|
return analyzeGitRestore(rest);
|
|
case "reset":
|
|
return analyzeGitReset(rest);
|
|
case "clean":
|
|
return analyzeGitClean(rest);
|
|
case "push":
|
|
return analyzeGitPush(rest);
|
|
case "branch":
|
|
return analyzeGitBranch(rest);
|
|
case "stash":
|
|
return analyzeGitStash(rest);
|
|
case "worktree":
|
|
return analyzeGitWorktree(rest);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
function extractGitSubcommandAndRest(tokens) {
|
|
if (tokens.length === 0) {
|
|
return { subcommand: null, rest: [] };
|
|
}
|
|
const firstToken = tokens[0];
|
|
const command = firstToken ? getBasename(firstToken).toLowerCase() : null;
|
|
if (command !== "git") {
|
|
return { subcommand: null, rest: [] };
|
|
}
|
|
let i = 1;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === "--") {
|
|
const nextToken = tokens[i + 1];
|
|
if (nextToken && !nextToken.startsWith("-")) {
|
|
return { subcommand: nextToken, rest: tokens.slice(i + 2) };
|
|
}
|
|
return { subcommand: null, rest: tokens.slice(i + 1) };
|
|
}
|
|
if (token.startsWith("-")) {
|
|
if (GIT_GLOBAL_OPTS_WITH_VALUE.has(token)) {
|
|
i += 2;
|
|
} else if (token.startsWith("-c") && token.length > 2) {
|
|
i++;
|
|
} else if (token.startsWith("-C") && token.length > 2) {
|
|
i++;
|
|
} else {
|
|
i++;
|
|
}
|
|
} else {
|
|
return { subcommand: token, rest: tokens.slice(i + 1) };
|
|
}
|
|
}
|
|
return { subcommand: null, rest: [] };
|
|
}
|
|
function analyzeGitCheckout(tokens) {
|
|
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
|
|
for (const token of tokens) {
|
|
if (token === "-b" || token === "-B" || token === "--orphan") {
|
|
return null;
|
|
}
|
|
if (token === "--pathspec-from-file") {
|
|
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
|
|
}
|
|
if (token.startsWith("--pathspec-from-file=")) {
|
|
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
|
|
}
|
|
}
|
|
if (doubleDashIdx !== -1) {
|
|
const hasRefBeforeDash = beforeDash.some((t) => !t.startsWith("-"));
|
|
if (hasRefBeforeDash) {
|
|
return REASON_CHECKOUT_REF_PATH;
|
|
}
|
|
return REASON_CHECKOUT_DOUBLE_DASH;
|
|
}
|
|
const positionalArgs = getCheckoutPositionalArgs(tokens);
|
|
if (positionalArgs.length >= 2) {
|
|
return REASON_CHECKOUT_AMBIGUOUS;
|
|
}
|
|
return null;
|
|
}
|
|
function getCheckoutPositionalArgs(tokens) {
|
|
const positional = [];
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === "--") {
|
|
break;
|
|
}
|
|
if (token.startsWith("-")) {
|
|
if (CHECKOUT_OPTS_WITH_VALUE.has(token)) {
|
|
i += 2;
|
|
} else if (token.startsWith("--") && token.includes("=")) {
|
|
i++;
|
|
} else if (CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) {
|
|
const nextToken = tokens[i + 1];
|
|
if (nextToken && !nextToken.startsWith("-") && (token === "--recurse-submodules" || token === "--track" || token === "-t")) {
|
|
const validModes = token === "--recurse-submodules" ? ["checkout", "on-demand"] : ["direct", "inherit"];
|
|
if (validModes.includes(nextToken)) {
|
|
i += 2;
|
|
} else {
|
|
i++;
|
|
}
|
|
} else {
|
|
i++;
|
|
}
|
|
} else if (token.startsWith("--") && !CHECKOUT_KNOWN_OPTS_NO_VALUE.has(token) && !CHECKOUT_OPTS_WITH_VALUE.has(token) && !CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) {
|
|
const nextToken = tokens[i + 1];
|
|
if (nextToken && !nextToken.startsWith("-")) {
|
|
i += 2;
|
|
} else {
|
|
i++;
|
|
}
|
|
} else {
|
|
i++;
|
|
}
|
|
} else {
|
|
positional.push(token);
|
|
i++;
|
|
}
|
|
}
|
|
return positional;
|
|
}
|
|
function analyzeGitRestore(tokens) {
|
|
let hasStaged = false;
|
|
for (const token of tokens) {
|
|
if (token === "--help" || token === "--version") {
|
|
return null;
|
|
}
|
|
if (token === "--worktree" || token === "-W") {
|
|
return REASON_RESTORE_WORKTREE;
|
|
}
|
|
if (token === "--staged" || token === "-S") {
|
|
hasStaged = true;
|
|
}
|
|
}
|
|
return hasStaged ? null : REASON_RESTORE;
|
|
}
|
|
function analyzeGitReset(tokens) {
|
|
for (const token of tokens) {
|
|
if (token === "--hard") {
|
|
return REASON_RESET_HARD;
|
|
}
|
|
if (token === "--merge") {
|
|
return REASON_RESET_MERGE;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function analyzeGitClean(tokens) {
|
|
for (const token of tokens) {
|
|
if (token === "-n" || token === "--dry-run") {
|
|
return null;
|
|
}
|
|
}
|
|
const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--"));
|
|
if (tokens.includes("--force") || shortOpts.has("-f")) {
|
|
return REASON_CLEAN;
|
|
}
|
|
return null;
|
|
}
|
|
function analyzeGitPush(tokens) {
|
|
let hasForceWithLease = false;
|
|
const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--"));
|
|
const hasForce = tokens.includes("--force") || shortOpts.has("-f");
|
|
for (const token of tokens) {
|
|
if (token === "--force-with-lease" || token.startsWith("--force-with-lease=")) {
|
|
hasForceWithLease = true;
|
|
}
|
|
}
|
|
if (hasForce && !hasForceWithLease) {
|
|
return REASON_PUSH_FORCE;
|
|
}
|
|
return null;
|
|
}
|
|
function analyzeGitBranch(tokens) {
|
|
const shortOpts = extractShortOpts(tokens.filter((t) => t !== "--"));
|
|
if (shortOpts.has("-D")) {
|
|
return REASON_BRANCH_DELETE;
|
|
}
|
|
return null;
|
|
}
|
|
function analyzeGitStash(tokens) {
|
|
for (const token of tokens) {
|
|
if (token === "drop") {
|
|
return REASON_STASH_DROP;
|
|
}
|
|
if (token === "clear") {
|
|
return REASON_STASH_CLEAR;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function analyzeGitWorktree(tokens) {
|
|
const hasRemove = tokens.includes("remove");
|
|
if (!hasRemove)
|
|
return null;
|
|
const { before } = splitAtDoubleDash(tokens);
|
|
for (const token of before) {
|
|
if (token === "--force" || token === "-f") {
|
|
return REASON_WORKTREE_REMOVE_FORCE;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// src/core/rules-rm.ts
|
|
import { realpathSync } from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { normalize, resolve } from "node:path";
|
|
|
|
// src/core/analyze/rm-flags.ts
|
|
function hasRecursiveForceFlags(tokens) {
|
|
let hasRecursive = false;
|
|
let hasForce = false;
|
|
for (const token of tokens) {
|
|
if (token === "--")
|
|
break;
|
|
if (token === "-r" || token === "-R" || token === "--recursive") {
|
|
hasRecursive = true;
|
|
} else if (token === "-f" || token === "--force") {
|
|
hasForce = true;
|
|
} else if (token.startsWith("-") && !token.startsWith("--")) {
|
|
if (token.includes("r") || token.includes("R"))
|
|
hasRecursive = true;
|
|
if (token.includes("f"))
|
|
hasForce = true;
|
|
}
|
|
}
|
|
return hasRecursive && hasForce;
|
|
}
|
|
|
|
// src/core/rules-rm.ts
|
|
var REASON_RM_RF = "rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.";
|
|
var REASON_RM_RF_ROOT_HOME = "rm -rf targeting root or home directory is extremely dangerous and always blocked.";
|
|
function analyzeRm(tokens, options = {}) {
|
|
const {
|
|
cwd,
|
|
originalCwd,
|
|
paranoid = false,
|
|
allowTmpdirVar = true,
|
|
tmpdirOverridden = false
|
|
} = options;
|
|
const anchoredCwd = originalCwd ?? cwd ?? null;
|
|
const resolvedCwd = cwd ?? null;
|
|
const trustTmpdirVar = allowTmpdirVar && !tmpdirOverridden;
|
|
const ctx = {
|
|
anchoredCwd,
|
|
resolvedCwd,
|
|
paranoid,
|
|
trustTmpdirVar,
|
|
homeDir: getHomeDirForRmPolicy()
|
|
};
|
|
if (!hasRecursiveForceFlags(tokens)) {
|
|
return null;
|
|
}
|
|
const targets = extractTargets(tokens);
|
|
for (const target of targets) {
|
|
const classification = classifyTarget(target, ctx);
|
|
const reason = reasonForClassification(classification, ctx);
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function extractTargets(tokens) {
|
|
const targets = [];
|
|
let pastDoubleDash = false;
|
|
for (let i = 1;i < tokens.length; i++) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
continue;
|
|
if (token === "--") {
|
|
pastDoubleDash = true;
|
|
continue;
|
|
}
|
|
if (pastDoubleDash) {
|
|
targets.push(token);
|
|
continue;
|
|
}
|
|
if (!token.startsWith("-")) {
|
|
targets.push(token);
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
function classifyTarget(target, ctx) {
|
|
if (isDangerousRootOrHomeTarget(target)) {
|
|
return { kind: "root_or_home_target" };
|
|
}
|
|
const anchoredCwd = ctx.anchoredCwd;
|
|
if (anchoredCwd) {
|
|
if (isCwdSelfTarget(target, anchoredCwd)) {
|
|
return { kind: "cwd_self_target" };
|
|
}
|
|
}
|
|
if (isTempTarget(target, ctx.trustTmpdirVar)) {
|
|
return { kind: "temp_target" };
|
|
}
|
|
if (anchoredCwd) {
|
|
if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) {
|
|
return { kind: "root_or_home_target" };
|
|
}
|
|
if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) {
|
|
return { kind: "within_anchored_cwd" };
|
|
}
|
|
}
|
|
return { kind: "outside_anchored_cwd" };
|
|
}
|
|
function reasonForClassification(classification, ctx) {
|
|
switch (classification.kind) {
|
|
case "root_or_home_target":
|
|
return REASON_RM_RF_ROOT_HOME;
|
|
case "cwd_self_target":
|
|
return REASON_RM_RF;
|
|
case "temp_target":
|
|
return null;
|
|
case "within_anchored_cwd":
|
|
if (ctx.paranoid) {
|
|
return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`;
|
|
}
|
|
return null;
|
|
case "outside_anchored_cwd":
|
|
return REASON_RM_RF;
|
|
}
|
|
}
|
|
function isDangerousRootOrHomeTarget(path) {
|
|
const normalized = path.trim();
|
|
if (normalized === "/" || normalized === "/*") {
|
|
return true;
|
|
}
|
|
if (normalized === "~" || normalized === "~/" || normalized.startsWith("~/")) {
|
|
if (normalized === "~" || normalized === "~/" || normalized === "~/*") {
|
|
return true;
|
|
}
|
|
}
|
|
if (normalized === "$HOME" || normalized === "$HOME/" || normalized === "$HOME/*") {
|
|
return true;
|
|
}
|
|
if (normalized === "${HOME}" || normalized === "${HOME}/" || normalized === "${HOME}/*") {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function isTempTarget(path, allowTmpdirVar) {
|
|
const normalized = path.trim();
|
|
if (normalized.includes("..")) {
|
|
return false;
|
|
}
|
|
if (normalized === "/tmp" || normalized.startsWith("/tmp/")) {
|
|
return true;
|
|
}
|
|
if (normalized === "/var/tmp" || normalized.startsWith("/var/tmp/")) {
|
|
return true;
|
|
}
|
|
const systemTmpdir = tmpdir();
|
|
if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) {
|
|
return true;
|
|
}
|
|
if (allowTmpdirVar) {
|
|
if (normalized === "$TMPDIR" || normalized.startsWith("$TMPDIR/")) {
|
|
return true;
|
|
}
|
|
if (normalized === "${TMPDIR}" || normalized.startsWith("${TMPDIR}/")) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
function getHomeDirForRmPolicy() {
|
|
return process.env.HOME ?? homedir();
|
|
}
|
|
function isCwdHomeForRmPolicy(cwd, homeDir) {
|
|
try {
|
|
const normalizedCwd = normalize(cwd);
|
|
const normalizedHome = normalize(homeDir);
|
|
return normalizedCwd === normalizedHome;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function isCwdSelfTarget(target, cwd) {
|
|
if (target === "." || target === "./") {
|
|
return true;
|
|
}
|
|
try {
|
|
const resolved = resolve(cwd, target);
|
|
const realCwd = realpathSync(cwd);
|
|
const realResolved = realpathSync(resolved);
|
|
return realResolved === realCwd;
|
|
} catch {
|
|
try {
|
|
const resolved = resolve(cwd, target);
|
|
const normalizedCwd = normalize(cwd);
|
|
return resolved === normalizedCwd;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
function isTargetWithinCwd(target, originalCwd, effectiveCwd) {
|
|
const resolveCwd = effectiveCwd ?? originalCwd;
|
|
if (target.startsWith("~") || target.startsWith("$HOME") || target.startsWith("${HOME}")) {
|
|
return false;
|
|
}
|
|
if (target.includes("$") || target.includes("`")) {
|
|
return false;
|
|
}
|
|
if (target.startsWith("/")) {
|
|
try {
|
|
const normalizedTarget = normalize(target);
|
|
const normalizedCwd = `${normalize(originalCwd)}/`;
|
|
return normalizedTarget.startsWith(normalizedCwd);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (target.startsWith("./") || !target.includes("/")) {
|
|
try {
|
|
const resolved = resolve(resolveCwd, target);
|
|
const normalizedOriginalCwd = normalize(originalCwd);
|
|
return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (target.startsWith("../")) {
|
|
return false;
|
|
}
|
|
try {
|
|
const resolved = resolve(resolveCwd, target);
|
|
const normalizedCwd = normalize(originalCwd);
|
|
return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function isHomeDirectory(cwd) {
|
|
const home = process.env.HOME ?? homedir();
|
|
try {
|
|
const normalizedCwd = normalize(cwd);
|
|
const normalizedHome = normalize(home);
|
|
return normalizedCwd === normalizedHome;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// src/core/analyze/constants.ts
|
|
var DISPLAY_COMMANDS = new Set([
|
|
"echo",
|
|
"printf",
|
|
"cat",
|
|
"head",
|
|
"tail",
|
|
"less",
|
|
"more",
|
|
"grep",
|
|
"rg",
|
|
"ag",
|
|
"ack",
|
|
"sed",
|
|
"awk",
|
|
"cut",
|
|
"tr",
|
|
"sort",
|
|
"uniq",
|
|
"wc",
|
|
"tee",
|
|
"man",
|
|
"help",
|
|
"info",
|
|
"type",
|
|
"which",
|
|
"whereis",
|
|
"whatis",
|
|
"apropos",
|
|
"file",
|
|
"stat",
|
|
"ls",
|
|
"ll",
|
|
"dir",
|
|
"tree",
|
|
"pwd",
|
|
"date",
|
|
"cal",
|
|
"uptime",
|
|
"whoami",
|
|
"id",
|
|
"groups",
|
|
"hostname",
|
|
"uname",
|
|
"env",
|
|
"printenv",
|
|
"set",
|
|
"export",
|
|
"alias",
|
|
"history",
|
|
"jobs",
|
|
"fg",
|
|
"bg",
|
|
"test",
|
|
"true",
|
|
"false",
|
|
"read",
|
|
"return",
|
|
"exit",
|
|
"break",
|
|
"continue",
|
|
"shift",
|
|
"wait",
|
|
"trap",
|
|
"basename",
|
|
"dirname",
|
|
"realpath",
|
|
"readlink",
|
|
"md5sum",
|
|
"sha256sum",
|
|
"base64",
|
|
"xxd",
|
|
"od",
|
|
"hexdump",
|
|
"strings",
|
|
"diff",
|
|
"cmp",
|
|
"comm",
|
|
"join",
|
|
"paste",
|
|
"column",
|
|
"fmt",
|
|
"fold",
|
|
"nl",
|
|
"pr",
|
|
"expand",
|
|
"unexpand",
|
|
"rev",
|
|
"tac",
|
|
"shuf",
|
|
"seq",
|
|
"yes",
|
|
"timeout",
|
|
"time",
|
|
"sleep",
|
|
"watch",
|
|
"logger",
|
|
"write",
|
|
"wall",
|
|
"mesg",
|
|
"notify-send"
|
|
]);
|
|
|
|
// src/core/analyze/find.ts
|
|
var REASON_FIND_DELETE = "find -delete permanently removes files. Use -print first to preview.";
|
|
function analyzeFind(tokens) {
|
|
if (findHasDelete(tokens.slice(1))) {
|
|
return REASON_FIND_DELETE;
|
|
}
|
|
for (let i = 0;i < tokens.length; i++) {
|
|
const token = tokens[i];
|
|
if (token === "-exec" || token === "-execdir") {
|
|
const execTokens = tokens.slice(i + 1);
|
|
const semicolonIdx = execTokens.indexOf(";");
|
|
const plusIdx = execTokens.indexOf("+");
|
|
const endIdx = semicolonIdx !== -1 && plusIdx !== -1 ? Math.min(semicolonIdx, plusIdx) : semicolonIdx !== -1 ? semicolonIdx : plusIdx !== -1 ? plusIdx : execTokens.length;
|
|
let execCommand = execTokens.slice(0, endIdx);
|
|
execCommand = stripWrappers(execCommand);
|
|
if (execCommand.length > 0) {
|
|
let head = getBasename(execCommand[0] ?? "");
|
|
if (head === "busybox" && execCommand.length > 1) {
|
|
execCommand = execCommand.slice(1);
|
|
head = getBasename(execCommand[0] ?? "");
|
|
}
|
|
if (head === "rm" && hasRecursiveForceFlags(execCommand)) {
|
|
return "find -exec rm -rf is dangerous. Use explicit file list instead.";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function findHasDelete(tokens) {
|
|
let i = 0;
|
|
let insideExec = false;
|
|
let execDepth = 0;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token === "-exec" || token === "-execdir") {
|
|
insideExec = true;
|
|
execDepth++;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (insideExec && (token === ";" || token === "+")) {
|
|
execDepth--;
|
|
if (execDepth === 0) {
|
|
insideExec = false;
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
if (insideExec) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token === "-name" || token === "-iname" || token === "-path" || token === "-ipath" || token === "-regex" || token === "-iregex" || token === "-type" || token === "-user" || token === "-group" || token === "-perm" || token === "-size" || token === "-mtime" || token === "-ctime" || token === "-atime" || token === "-newer" || token === "-printf" || token === "-fprint" || token === "-fprintf") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (token === "-delete") {
|
|
return true;
|
|
}
|
|
i++;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// src/core/analyze/interpreters.ts
|
|
function extractInterpreterCodeArg(tokens) {
|
|
for (let i = 1;i < tokens.length; i++) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
continue;
|
|
if ((token === "-c" || token === "-e") && tokens[i + 1]) {
|
|
return tokens[i + 1] ?? null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function containsDangerousCode(code) {
|
|
for (const pattern of DANGEROUS_PATTERNS) {
|
|
if (pattern.test(code)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// src/core/analyze/shell-wrappers.ts
|
|
function extractDashCArg(tokens) {
|
|
for (let i = 1;i < tokens.length; i++) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
continue;
|
|
if (token === "-c" && tokens[i + 1]) {
|
|
return tokens[i + 1] ?? null;
|
|
}
|
|
if (token.startsWith("-") && token.includes("c") && !token.startsWith("--")) {
|
|
const nextToken = tokens[i + 1];
|
|
if (nextToken && !nextToken.startsWith("-")) {
|
|
return nextToken;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// src/core/analyze/parallel.ts
|
|
var REASON_PARALLEL_RM = "parallel rm -rf with dynamic input is dangerous. Use explicit file list instead.";
|
|
var REASON_PARALLEL_SHELL = "parallel with shell -c can execute arbitrary commands from dynamic input.";
|
|
function analyzeParallel(tokens, context) {
|
|
const parseResult = parseParallelCommand(tokens);
|
|
if (!parseResult) {
|
|
return null;
|
|
}
|
|
const { template, args, hasPlaceholder } = parseResult;
|
|
if (template.length === 0) {
|
|
for (const arg of args) {
|
|
const reason = context.analyzeNested(arg);
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
let childTokens = stripWrappers([...template]);
|
|
let head = getBasename(childTokens[0] ?? "").toLowerCase();
|
|
if (head === "busybox" && childTokens.length > 1) {
|
|
childTokens = childTokens.slice(1);
|
|
head = getBasename(childTokens[0] ?? "").toLowerCase();
|
|
}
|
|
if (SHELL_WRAPPERS.has(head)) {
|
|
const dashCArg = extractDashCArg(childTokens);
|
|
if (dashCArg) {
|
|
if (dashCArg === "{}" || dashCArg === "{1}") {
|
|
return REASON_PARALLEL_SHELL;
|
|
}
|
|
if (dashCArg.includes("{}")) {
|
|
if (args.length > 0) {
|
|
for (const arg of args) {
|
|
const expandedScript = dashCArg.replace(/{}/g, arg);
|
|
const reason3 = context.analyzeNested(expandedScript);
|
|
if (reason3) {
|
|
return reason3;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
const reason2 = context.analyzeNested(dashCArg);
|
|
if (reason2) {
|
|
return reason2;
|
|
}
|
|
return null;
|
|
}
|
|
const reason = context.analyzeNested(dashCArg);
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
if (hasPlaceholder) {
|
|
return REASON_PARALLEL_SHELL;
|
|
}
|
|
return null;
|
|
}
|
|
if (args.length > 0) {
|
|
return REASON_PARALLEL_SHELL;
|
|
}
|
|
if (hasPlaceholder) {
|
|
return REASON_PARALLEL_SHELL;
|
|
}
|
|
return null;
|
|
}
|
|
if (head === "rm" && hasRecursiveForceFlags(childTokens)) {
|
|
if (hasPlaceholder && args.length > 0) {
|
|
for (const arg of args) {
|
|
const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg));
|
|
const rmResult = analyzeRm(expandedTokens, {
|
|
cwd: context.cwd,
|
|
originalCwd: context.originalCwd,
|
|
paranoid: context.paranoidRm,
|
|
allowTmpdirVar: context.allowTmpdirVar
|
|
});
|
|
if (rmResult) {
|
|
return rmResult;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
if (args.length > 0) {
|
|
const expandedTokens = [...childTokens, args[0] ?? ""];
|
|
const rmResult = analyzeRm(expandedTokens, {
|
|
cwd: context.cwd,
|
|
originalCwd: context.originalCwd,
|
|
paranoid: context.paranoidRm,
|
|
allowTmpdirVar: context.allowTmpdirVar
|
|
});
|
|
if (rmResult) {
|
|
return rmResult;
|
|
}
|
|
return null;
|
|
}
|
|
return REASON_PARALLEL_RM;
|
|
}
|
|
if (head === "find") {
|
|
const findResult = analyzeFind(childTokens);
|
|
if (findResult) {
|
|
return findResult;
|
|
}
|
|
}
|
|
if (head === "git") {
|
|
const gitResult = analyzeGit(childTokens);
|
|
if (gitResult) {
|
|
return gitResult;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function parseParallelCommand(tokens) {
|
|
const parallelOptsWithValue = new Set([
|
|
"-S",
|
|
"--sshlogin",
|
|
"--slf",
|
|
"--sshloginfile",
|
|
"-a",
|
|
"--arg-file",
|
|
"--colsep",
|
|
"-I",
|
|
"--replace",
|
|
"--results",
|
|
"--result",
|
|
"--res"
|
|
]);
|
|
let i = 1;
|
|
const templateTokens = [];
|
|
let markerIndex = -1;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === ":::") {
|
|
markerIndex = i;
|
|
break;
|
|
}
|
|
if (token === "--") {
|
|
i++;
|
|
while (i < tokens.length) {
|
|
const token2 = tokens[i];
|
|
if (token2 === undefined || token2 === ":::")
|
|
break;
|
|
templateTokens.push(token2);
|
|
i++;
|
|
}
|
|
if (i < tokens.length && tokens[i] === ":::") {
|
|
markerIndex = i;
|
|
}
|
|
break;
|
|
}
|
|
if (token.startsWith("-")) {
|
|
if (token.startsWith("-j") && token.length > 2 && /^\d+$/.test(token.slice(2))) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token.startsWith("--") && token.includes("=")) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (parallelOptsWithValue.has(token)) {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (token === "-j" || token === "--jobs") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
i++;
|
|
} else {
|
|
while (i < tokens.length) {
|
|
const token2 = tokens[i];
|
|
if (token2 === undefined || token2 === ":::")
|
|
break;
|
|
templateTokens.push(token2);
|
|
i++;
|
|
}
|
|
if (i < tokens.length && tokens[i] === ":::") {
|
|
markerIndex = i;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
const args = [];
|
|
if (markerIndex !== -1) {
|
|
for (let j = markerIndex + 1;j < tokens.length; j++) {
|
|
const token = tokens[j];
|
|
if (token && token !== ":::") {
|
|
args.push(token);
|
|
}
|
|
}
|
|
}
|
|
const hasPlaceholder = templateTokens.some((t) => t.includes("{}") || t.includes("{1}") || t.includes("{.}"));
|
|
if (templateTokens.length === 0 && markerIndex === -1) {
|
|
return null;
|
|
}
|
|
return { template: templateTokens, args, hasPlaceholder };
|
|
}
|
|
|
|
// src/core/analyze/tmpdir.ts
|
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
function isTmpdirOverriddenToNonTemp(envAssignments) {
|
|
if (!envAssignments.has("TMPDIR")) {
|
|
return false;
|
|
}
|
|
const tmpdirValue = envAssignments.get("TMPDIR") ?? "";
|
|
if (tmpdirValue === "") {
|
|
return true;
|
|
}
|
|
const sysTmpdir = tmpdir2();
|
|
if (isPathOrSubpath(tmpdirValue, "/tmp") || isPathOrSubpath(tmpdirValue, "/var/tmp") || isPathOrSubpath(tmpdirValue, sysTmpdir)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
function isPathOrSubpath(path, basePath) {
|
|
if (path === basePath) {
|
|
return true;
|
|
}
|
|
const baseWithSlash = basePath.endsWith("/") ? basePath : `${basePath}/`;
|
|
return path.startsWith(baseWithSlash);
|
|
}
|
|
|
|
// src/core/analyze/xargs.ts
|
|
var REASON_XARGS_RM = "xargs rm -rf with dynamic input is dangerous. Use explicit file list instead.";
|
|
var REASON_XARGS_SHELL = "xargs with shell -c can execute arbitrary commands from dynamic input.";
|
|
function analyzeXargs(tokens, context) {
|
|
const { childTokens: rawChildTokens } = extractXargsChildCommandWithInfo(tokens);
|
|
let childTokens = stripWrappers(rawChildTokens);
|
|
if (childTokens.length === 0) {
|
|
return null;
|
|
}
|
|
let head = getBasename(childTokens[0] ?? "").toLowerCase();
|
|
if (head === "busybox" && childTokens.length > 1) {
|
|
childTokens = childTokens.slice(1);
|
|
head = getBasename(childTokens[0] ?? "").toLowerCase();
|
|
}
|
|
if (SHELL_WRAPPERS.has(head)) {
|
|
return REASON_XARGS_SHELL;
|
|
}
|
|
if (head === "rm" && hasRecursiveForceFlags(childTokens)) {
|
|
const rmResult = analyzeRm(childTokens, {
|
|
cwd: context.cwd,
|
|
originalCwd: context.originalCwd,
|
|
paranoid: context.paranoidRm,
|
|
allowTmpdirVar: context.allowTmpdirVar
|
|
});
|
|
if (rmResult) {
|
|
return rmResult;
|
|
}
|
|
return REASON_XARGS_RM;
|
|
}
|
|
if (head === "find") {
|
|
const findResult = analyzeFind(childTokens);
|
|
if (findResult) {
|
|
return findResult;
|
|
}
|
|
}
|
|
if (head === "git") {
|
|
const gitResult = analyzeGit(childTokens);
|
|
if (gitResult) {
|
|
return gitResult;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function extractXargsChildCommandWithInfo(tokens) {
|
|
const xargsOptsWithValue = new Set([
|
|
"-L",
|
|
"-n",
|
|
"-P",
|
|
"-s",
|
|
"-a",
|
|
"-E",
|
|
"-e",
|
|
"-d",
|
|
"-J",
|
|
"--max-args",
|
|
"--max-procs",
|
|
"--max-chars",
|
|
"--arg-file",
|
|
"--eof",
|
|
"--delimiter",
|
|
"--max-lines"
|
|
]);
|
|
let replacementToken = null;
|
|
let i = 1;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (!token)
|
|
break;
|
|
if (token === "--") {
|
|
return { childTokens: [...tokens.slice(i + 1)], replacementToken };
|
|
}
|
|
if (token.startsWith("-")) {
|
|
if (token === "-I") {
|
|
replacementToken = tokens[i + 1] ?? "{}";
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (token.startsWith("-I") && token.length > 2) {
|
|
replacementToken = token.slice(2);
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token === "--replace") {
|
|
replacementToken = "{}";
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token.startsWith("--replace=")) {
|
|
const value = token.slice("--replace=".length);
|
|
replacementToken = value === "" ? "{}" : value;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (token === "-J") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (xargsOptsWithValue.has(token)) {
|
|
i += 2;
|
|
} else if (token.startsWith("--") && token.includes("=")) {
|
|
i++;
|
|
} else if (token.startsWith("-L") || token.startsWith("-n") || token.startsWith("-P") || token.startsWith("-s")) {
|
|
i++;
|
|
} else {
|
|
i++;
|
|
}
|
|
} else {
|
|
return { childTokens: [...tokens.slice(i)], replacementToken };
|
|
}
|
|
}
|
|
return { childTokens: [], replacementToken };
|
|
}
|
|
|
|
// src/core/analyze/segment.ts
|
|
var REASON_INTERPRETER_DANGEROUS = "Detected potentially dangerous command in interpreter code.";
|
|
var REASON_INTERPRETER_BLOCKED = "Interpreter one-liners are blocked in paranoid mode.";
|
|
var REASON_RM_HOME_CWD = "rm -rf in home directory is dangerous. Change to a project directory first.";
|
|
function deriveCwdContext(options) {
|
|
const cwdUnknown = options.effectiveCwd === null;
|
|
const cwdForRm = cwdUnknown ? undefined : options.effectiveCwd ?? options.cwd;
|
|
const originalCwd = cwdUnknown ? undefined : options.cwd;
|
|
return { cwdUnknown, cwdForRm, originalCwd };
|
|
}
|
|
function analyzeSegment(tokens, depth, options) {
|
|
if (tokens.length === 0) {
|
|
return null;
|
|
}
|
|
const { tokens: strippedEnv, envAssignments: leadingEnvAssignments } = stripEnvAssignmentsWithInfo(tokens);
|
|
const { tokens: stripped, envAssignments: wrapperEnvAssignments } = stripWrappersWithInfo(strippedEnv);
|
|
const envAssignments = new Map(leadingEnvAssignments);
|
|
for (const [k, v] of wrapperEnvAssignments) {
|
|
envAssignments.set(k, v);
|
|
}
|
|
if (stripped.length === 0) {
|
|
return null;
|
|
}
|
|
const head = stripped[0];
|
|
if (!head) {
|
|
return null;
|
|
}
|
|
const normalizedHead = normalizeCommandToken(head);
|
|
const basename = getBasename(head);
|
|
const { cwdForRm, originalCwd } = deriveCwdContext(options);
|
|
const allowTmpdirVar = !isTmpdirOverriddenToNonTemp(envAssignments);
|
|
if (SHELL_WRAPPERS.has(normalizedHead)) {
|
|
const dashCArg = extractDashCArg(stripped);
|
|
if (dashCArg) {
|
|
return options.analyzeNested(dashCArg);
|
|
}
|
|
}
|
|
if (INTERPRETERS.has(normalizedHead)) {
|
|
const codeArg = extractInterpreterCodeArg(stripped);
|
|
if (codeArg) {
|
|
if (options.paranoidInterpreters) {
|
|
return REASON_INTERPRETER_BLOCKED + PARANOID_INTERPRETERS_SUFFIX;
|
|
}
|
|
const innerReason = options.analyzeNested(codeArg);
|
|
if (innerReason) {
|
|
return innerReason;
|
|
}
|
|
if (containsDangerousCode(codeArg)) {
|
|
return REASON_INTERPRETER_DANGEROUS;
|
|
}
|
|
}
|
|
}
|
|
if (normalizedHead === "busybox" && stripped.length > 1) {
|
|
return analyzeSegment(stripped.slice(1), depth, options);
|
|
}
|
|
const isGit = basename.toLowerCase() === "git";
|
|
const isRm = basename === "rm";
|
|
const isFind = basename === "find";
|
|
const isXargs = basename === "xargs";
|
|
const isParallel = basename === "parallel";
|
|
if (isGit) {
|
|
const gitResult = analyzeGit(stripped);
|
|
if (gitResult) {
|
|
return gitResult;
|
|
}
|
|
}
|
|
if (isRm) {
|
|
if (cwdForRm && isHomeDirectory(cwdForRm)) {
|
|
if (hasRecursiveForceFlags(stripped)) {
|
|
return REASON_RM_HOME_CWD;
|
|
}
|
|
}
|
|
const rmResult = analyzeRm(stripped, {
|
|
cwd: cwdForRm,
|
|
originalCwd,
|
|
paranoid: options.paranoidRm,
|
|
allowTmpdirVar
|
|
});
|
|
if (rmResult) {
|
|
return rmResult;
|
|
}
|
|
}
|
|
if (isFind) {
|
|
const findResult = analyzeFind(stripped);
|
|
if (findResult) {
|
|
return findResult;
|
|
}
|
|
}
|
|
if (isXargs) {
|
|
const xargsResult = analyzeXargs(stripped, {
|
|
cwd: cwdForRm,
|
|
originalCwd,
|
|
paranoidRm: options.paranoidRm,
|
|
allowTmpdirVar
|
|
});
|
|
if (xargsResult) {
|
|
return xargsResult;
|
|
}
|
|
}
|
|
if (isParallel) {
|
|
const parallelResult = analyzeParallel(stripped, {
|
|
cwd: cwdForRm,
|
|
originalCwd,
|
|
paranoidRm: options.paranoidRm,
|
|
allowTmpdirVar,
|
|
analyzeNested: options.analyzeNested
|
|
});
|
|
if (parallelResult) {
|
|
return parallelResult;
|
|
}
|
|
}
|
|
const matchedKnown = isGit || isRm || isFind || isXargs || isParallel;
|
|
if (!matchedKnown) {
|
|
if (!DISPLAY_COMMANDS.has(normalizedHead)) {
|
|
for (let i = 1;i < stripped.length; i++) {
|
|
const token = stripped[i];
|
|
if (!token)
|
|
continue;
|
|
const cmd = normalizeCommandToken(token);
|
|
if (cmd === "rm") {
|
|
const rmTokens = ["rm", ...stripped.slice(i + 1)];
|
|
const reason = analyzeRm(rmTokens, {
|
|
cwd: cwdForRm,
|
|
originalCwd,
|
|
paranoid: options.paranoidRm,
|
|
allowTmpdirVar
|
|
});
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
}
|
|
if (cmd === "git") {
|
|
const gitTokens = ["git", ...stripped.slice(i + 1)];
|
|
const reason = analyzeGit(gitTokens);
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
}
|
|
if (cmd === "find") {
|
|
const findTokens = ["find", ...stripped.slice(i + 1)];
|
|
const reason = analyzeFind(findTokens);
|
|
if (reason) {
|
|
return reason;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const customRulesTopLevelOnly = isGit || isRm || isFind || isXargs || isParallel;
|
|
if (depth === 0 || !customRulesTopLevelOnly) {
|
|
const customResult = checkCustomRules(stripped, options.config.rules);
|
|
if (customResult) {
|
|
return customResult;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
var CWD_CHANGE_REGEX = /^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
|
|
function segmentChangesCwd(segment) {
|
|
const stripped = stripLeadingGrouping(segment);
|
|
const unwrapped = stripWrappers([...stripped]);
|
|
if (unwrapped.length === 0) {
|
|
return false;
|
|
}
|
|
let head = unwrapped[0] ?? "";
|
|
if (head === "builtin" && unwrapped.length > 1) {
|
|
head = unwrapped[1] ?? "";
|
|
}
|
|
if (head === "cd" || head === "pushd" || head === "popd") {
|
|
return true;
|
|
}
|
|
const joined = segment.join(" ");
|
|
return CWD_CHANGE_REGEX.test(joined);
|
|
}
|
|
function stripLeadingGrouping(tokens) {
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const token = tokens[i];
|
|
if (token === "{" || token === "(" || token === "$(") {
|
|
i++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return tokens.slice(i);
|
|
}
|
|
|
|
// src/core/analyze/analyze-command.ts
|
|
var REASON_STRICT_UNPARSEABLE = "Command could not be safely analyzed (strict mode). Verify manually.";
|
|
var REASON_RECURSION_LIMIT = "Command exceeds maximum recursion depth and cannot be safely analyzed.";
|
|
function analyzeCommandInternal(command, depth, options) {
|
|
if (depth >= MAX_RECURSION_DEPTH) {
|
|
return { reason: REASON_RECURSION_LIMIT, segment: command };
|
|
}
|
|
const segments = splitShellCommands(command);
|
|
if (options.strict && segments.length === 1 && segments[0]?.length === 1 && segments[0][0] === command && command.includes(" ")) {
|
|
return { reason: REASON_STRICT_UNPARSEABLE, segment: command };
|
|
}
|
|
const originalCwd = options.cwd;
|
|
let effectiveCwd = options.cwd;
|
|
for (const segment of segments) {
|
|
const segmentStr = segment.join(" ");
|
|
if (segment.length === 1 && segment[0]?.includes(" ")) {
|
|
const textReason = dangerousInText(segment[0]);
|
|
if (textReason) {
|
|
return { reason: textReason, segment: segmentStr };
|
|
}
|
|
if (segmentChangesCwd(segment)) {
|
|
effectiveCwd = null;
|
|
}
|
|
continue;
|
|
}
|
|
const reason = analyzeSegment(segment, depth, {
|
|
...options,
|
|
cwd: originalCwd,
|
|
effectiveCwd,
|
|
analyzeNested: (nestedCommand) => {
|
|
return analyzeCommandInternal(nestedCommand, depth + 1, options)?.reason ?? null;
|
|
}
|
|
});
|
|
if (reason) {
|
|
return { reason, segment: segmentStr };
|
|
}
|
|
if (segmentChangesCwd(segment)) {
|
|
effectiveCwd = null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// src/core/config.ts
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { homedir as homedir2 } from "node:os";
|
|
import { join, resolve as resolve2 } from "node:path";
|
|
var DEFAULT_CONFIG = {
|
|
version: 1,
|
|
rules: []
|
|
};
|
|
function loadConfig(cwd, options) {
|
|
const safeCwd = typeof cwd === "string" ? cwd : process.cwd();
|
|
const userConfigDir = options?.userConfigDir ?? join(homedir2(), ".cc-safety-net");
|
|
const userConfigPath = join(userConfigDir, "config.json");
|
|
const projectConfigPath = join(safeCwd, ".safety-net.json");
|
|
const userConfig = loadSingleConfig(userConfigPath);
|
|
const projectConfig = loadSingleConfig(projectConfigPath);
|
|
return mergeConfigs(userConfig, projectConfig);
|
|
}
|
|
function loadSingleConfig(path) {
|
|
if (!existsSync(path)) {
|
|
return null;
|
|
}
|
|
try {
|
|
const content = readFileSync(path, "utf-8");
|
|
if (!content.trim()) {
|
|
return null;
|
|
}
|
|
const parsed = JSON.parse(content);
|
|
const result = validateConfig(parsed);
|
|
if (result.errors.length > 0) {
|
|
return null;
|
|
}
|
|
const cfg = parsed;
|
|
return {
|
|
version: cfg.version,
|
|
rules: cfg.rules ?? []
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function mergeConfigs(userConfig, projectConfig) {
|
|
if (!userConfig && !projectConfig) {
|
|
return DEFAULT_CONFIG;
|
|
}
|
|
if (!userConfig) {
|
|
return projectConfig ?? DEFAULT_CONFIG;
|
|
}
|
|
if (!projectConfig) {
|
|
return userConfig;
|
|
}
|
|
const projectRuleNames = new Set(projectConfig.rules.map((r) => r.name.toLowerCase()));
|
|
const mergedRules = [
|
|
...userConfig.rules.filter((r) => !projectRuleNames.has(r.name.toLowerCase())),
|
|
...projectConfig.rules
|
|
];
|
|
return {
|
|
version: 1,
|
|
rules: mergedRules
|
|
};
|
|
}
|
|
function validateConfig(config) {
|
|
const errors = [];
|
|
const ruleNames = new Set;
|
|
if (!config || typeof config !== "object") {
|
|
errors.push("Config must be an object");
|
|
return { errors, ruleNames };
|
|
}
|
|
const cfg = config;
|
|
if (cfg.version !== 1) {
|
|
errors.push("version must be 1");
|
|
}
|
|
if (cfg.rules !== undefined) {
|
|
if (!Array.isArray(cfg.rules)) {
|
|
errors.push("rules must be an array");
|
|
} else {
|
|
for (let i = 0;i < cfg.rules.length; i++) {
|
|
const rule = cfg.rules[i];
|
|
const ruleErrors = validateRule(rule, i, ruleNames);
|
|
errors.push(...ruleErrors);
|
|
}
|
|
}
|
|
}
|
|
return { errors, ruleNames };
|
|
}
|
|
function validateRule(rule, index, ruleNames) {
|
|
const errors = [];
|
|
const prefix = `rules[${index}]`;
|
|
if (!rule || typeof rule !== "object") {
|
|
errors.push(`${prefix}: must be an object`);
|
|
return errors;
|
|
}
|
|
const r = rule;
|
|
if (typeof r.name !== "string") {
|
|
errors.push(`${prefix}.name: required string`);
|
|
} else {
|
|
if (!NAME_PATTERN.test(r.name)) {
|
|
errors.push(`${prefix}.name: must match pattern (letters, numbers, hyphens, underscores; max 64 chars)`);
|
|
}
|
|
const lowerName = r.name.toLowerCase();
|
|
if (ruleNames.has(lowerName)) {
|
|
errors.push(`${prefix}.name: duplicate rule name "${r.name}"`);
|
|
} else {
|
|
ruleNames.add(lowerName);
|
|
}
|
|
}
|
|
if (typeof r.command !== "string") {
|
|
errors.push(`${prefix}.command: required string`);
|
|
} else if (!COMMAND_PATTERN.test(r.command)) {
|
|
errors.push(`${prefix}.command: must match pattern (letters, numbers, hyphens, underscores)`);
|
|
}
|
|
if (r.subcommand !== undefined) {
|
|
if (typeof r.subcommand !== "string") {
|
|
errors.push(`${prefix}.subcommand: must be a string if provided`);
|
|
} else if (!COMMAND_PATTERN.test(r.subcommand)) {
|
|
errors.push(`${prefix}.subcommand: must match pattern (letters, numbers, hyphens, underscores)`);
|
|
}
|
|
}
|
|
if (!Array.isArray(r.block_args)) {
|
|
errors.push(`${prefix}.block_args: required array`);
|
|
} else {
|
|
if (r.block_args.length === 0) {
|
|
errors.push(`${prefix}.block_args: must have at least one element`);
|
|
}
|
|
for (let i = 0;i < r.block_args.length; i++) {
|
|
const arg = r.block_args[i];
|
|
if (typeof arg !== "string") {
|
|
errors.push(`${prefix}.block_args[${i}]: must be a string`);
|
|
} else if (arg === "") {
|
|
errors.push(`${prefix}.block_args[${i}]: must not be empty`);
|
|
}
|
|
}
|
|
}
|
|
if (typeof r.reason !== "string") {
|
|
errors.push(`${prefix}.reason: required string`);
|
|
} else if (r.reason === "") {
|
|
errors.push(`${prefix}.reason: must not be empty`);
|
|
} else if (r.reason.length > MAX_REASON_LENGTH) {
|
|
errors.push(`${prefix}.reason: must be at most ${MAX_REASON_LENGTH} characters`);
|
|
}
|
|
return errors;
|
|
}
|
|
function validateConfigFile(path) {
|
|
const errors = [];
|
|
const ruleNames = new Set;
|
|
if (!existsSync(path)) {
|
|
errors.push(`File not found: ${path}`);
|
|
return { errors, ruleNames };
|
|
}
|
|
try {
|
|
const content = readFileSync(path, "utf-8");
|
|
if (!content.trim()) {
|
|
errors.push("Config file is empty");
|
|
return { errors, ruleNames };
|
|
}
|
|
const parsed = JSON.parse(content);
|
|
return validateConfig(parsed);
|
|
} catch (e) {
|
|
errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
return { errors, ruleNames };
|
|
}
|
|
}
|
|
function getUserConfigPath() {
|
|
return join(homedir2(), ".cc-safety-net", "config.json");
|
|
}
|
|
function getProjectConfigPath(cwd) {
|
|
return resolve2(cwd ?? process.cwd(), ".safety-net.json");
|
|
}
|
|
|
|
// src/core/analyze.ts
|
|
function analyzeCommand(command, options = {}) {
|
|
const config = options.config ?? loadConfig(options.cwd);
|
|
return analyzeCommandInternal(command, 0, { ...options, config });
|
|
}
|
|
|
|
// src/core/env.ts
|
|
function envTruthy(name) {
|
|
const value = process.env[name];
|
|
return value === "1" || value?.toLowerCase() === "true";
|
|
}
|
|
|
|
// src/core/format.ts
|
|
function formatBlockedMessage(input) {
|
|
const { reason, command, segment } = input;
|
|
const maxLen = input.maxLen ?? 200;
|
|
const redact = input.redact ?? ((t) => t);
|
|
let message = `BLOCKED by Safety Net
|
|
|
|
Reason: ${reason}`;
|
|
if (command) {
|
|
const safeCommand = redact(command);
|
|
message += `
|
|
|
|
Command: ${excerpt(safeCommand, maxLen)}`;
|
|
}
|
|
if (segment && segment !== command) {
|
|
const safeSegment = redact(segment);
|
|
message += `
|
|
|
|
Segment: ${excerpt(safeSegment, maxLen)}`;
|
|
}
|
|
message += `
|
|
|
|
If this operation is truly needed, ask the user for explicit permission and have them run the command manually.`;
|
|
return message;
|
|
}
|
|
function excerpt(text, maxLen) {
|
|
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
|
|
}
|
|
|
|
// src/features/builtin-commands/templates/set-custom-rules.ts
|
|
var SET_CUSTOM_RULES_TEMPLATE = `You are helping the user configure custom blocking rules for claude-code-safety-net.
|
|
|
|
## Context
|
|
|
|
### Schema Documentation
|
|
|
|
!\`npx -y cc-safety-net --custom-rules-doc\`
|
|
|
|
## Your Task
|
|
|
|
Follow this flow exactly:
|
|
|
|
### Step 1: Ask for Scope
|
|
|
|
Ask: **Which scope would you like to configure?**
|
|
- **User** (\`~/.cc-safety-net/config.json\`) - applies to all your projects
|
|
- **Project** (\`.safety-net.json\`) - applies only to this project
|
|
|
|
### Step 2: Show Examples and Ask for Rules
|
|
|
|
Show examples in natural language:
|
|
- "Block \`git add -A\` and \`git add .\` to prevent blanket staging"
|
|
- "Block \`npm install -g\` to prevent global package installs"
|
|
- "Block \`docker system prune\` to prevent accidental cleanup"
|
|
|
|
Ask the user to describe rules in natural language. They can list multiple.
|
|
|
|
### Step 3: Generate JSON Config
|
|
|
|
Parse user input and generate valid schema JSON using the schema documentation above.
|
|
|
|
### Step 4: Show Config and Confirm
|
|
|
|
Display the generated JSON and ask:
|
|
- "Does this look correct?"
|
|
- "Would you like to modify anything?"
|
|
|
|
### Step 5: Check and Handle Existing Config
|
|
|
|
1. Check existing User Config with \`cat ~/.cc-safety-net/config.json 2>/dev/null || echo "No user config found"\`
|
|
2. Check existing Project Config with \`cat .safety-net.json 2>/dev/null || echo "No project config found"\`
|
|
|
|
If the chosen scope already has a config:
|
|
Show the existing config to the user.
|
|
Ask: **Merge** (add new rules, duplicates use new version) or **Replace**?
|
|
|
|
### Step 6: Write and Validate
|
|
|
|
Write the config to the chosen scope, then validate with \`npx -y cc-safety-net --verify-config\`.
|
|
|
|
If validation errors:
|
|
- Show specific errors
|
|
- Offer to fix with your best suggestion
|
|
- Confirm before proceeding
|
|
|
|
### Step 7: Confirm Success
|
|
|
|
Tell the user:
|
|
1. Config saved to [path]
|
|
2. **Changes take effect immediately** - no restart needed
|
|
3. Summary of rules added
|
|
|
|
## Important Notes
|
|
|
|
- Custom rules can only ADD restrictions, not bypass built-in protections
|
|
- Rule names must be unique (case-insensitive)
|
|
- Invalid config → entire config ignored, only built-in rules apply`;
|
|
|
|
// src/features/builtin-commands/templates/verify-custom-rules.ts
|
|
var VERIFY_CUSTOM_RULES_TEMPLATE = `You are helping the user verify the custom rules config file.
|
|
|
|
## Your Task
|
|
|
|
Run \`npx -y cc-safety-net --verify-config\` to check current validation status
|
|
|
|
If the config has validation errors:
|
|
1. Show the specific validation errors
|
|
2. Run \`npx -y cc-safety-net --custom-rules-doc\` to read the schema documentation
|
|
3. Offer to fix them with your best suggestion
|
|
4. Ask for confirmation before proceeding
|
|
5. After fixing, run \`npx -y cc-safety-net --verify-config\` to verify again`;
|
|
|
|
// src/features/builtin-commands/commands.ts
|
|
var BUILTIN_COMMAND_DEFINITIONS = {
|
|
"set-custom-rules": {
|
|
description: "Set custom rules for Safety Net",
|
|
template: SET_CUSTOM_RULES_TEMPLATE
|
|
},
|
|
"verify-custom-rules": {
|
|
description: "Verify custom rules for Safety Net",
|
|
template: VERIFY_CUSTOM_RULES_TEMPLATE
|
|
}
|
|
};
|
|
function loadBuiltinCommands(disabledCommands) {
|
|
const disabled = new Set(disabledCommands ?? []);
|
|
const commands = {};
|
|
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
|
|
if (!disabled.has(name)) {
|
|
commands[name] = definition;
|
|
}
|
|
}
|
|
return commands;
|
|
}
|
|
// src/index.ts
|
|
var SafetyNetPlugin = async ({ directory }) => {
|
|
const safetyNetConfig = loadConfig(directory);
|
|
const strict = envTruthy("SAFETY_NET_STRICT");
|
|
const paranoidAll = envTruthy("SAFETY_NET_PARANOID");
|
|
const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM");
|
|
const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS");
|
|
return {
|
|
config: async (opencodeConfig) => {
|
|
const builtinCommands = loadBuiltinCommands();
|
|
const existingCommands = opencodeConfig.command ?? {};
|
|
opencodeConfig.command = {
|
|
...builtinCommands,
|
|
...existingCommands
|
|
};
|
|
},
|
|
"tool.execute.before": async (input, output) => {
|
|
if (input.tool === "bash") {
|
|
const command = output.args.command;
|
|
const result = analyzeCommand(command, {
|
|
cwd: directory,
|
|
config: safetyNetConfig,
|
|
strict,
|
|
paranoidRm,
|
|
paranoidInterpreters
|
|
});
|
|
if (result) {
|
|
const message = formatBlockedMessage({
|
|
reason: result.reason,
|
|
command,
|
|
segment: result.segment
|
|
});
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
};
|
|
export {
|
|
SafetyNetPlugin
|
|
};
|