|
|
|
|
@ -61,17 +61,63 @@ const DEFAULT_SENSITIVE_PATH_GLOBS = [
|
|
|
|
|
"~/.config/gcloud/**", |
|
|
|
|
"~/.azure/**", |
|
|
|
|
"~/.gnupg/**", |
|
|
|
|
"~/.docker/**", |
|
|
|
|
"~/.kube/**", |
|
|
|
|
"~/.config/gh/**", |
|
|
|
|
"~/.pi/**", |
|
|
|
|
"**/.pi/**", |
|
|
|
|
"**/.env", |
|
|
|
|
"**/.env.*", |
|
|
|
|
"**/*.pem", |
|
|
|
|
"**/*.key", |
|
|
|
|
"**/.netrc", |
|
|
|
|
"**/.git-credentials", |
|
|
|
|
"~/.gitconfig", |
|
|
|
|
"~/.wgetrc", |
|
|
|
|
"~/.curlrc", |
|
|
|
|
"~/.npmrc", |
|
|
|
|
"~/.pypirc", |
|
|
|
|
"~/.bashrc", |
|
|
|
|
"~/.zshrc", |
|
|
|
|
"~/.profile", |
|
|
|
|
"~/.bash_profile", |
|
|
|
|
"~/.local/bin/**", |
|
|
|
|
"~/.config/systemd/**", |
|
|
|
|
"**/.git/config", |
|
|
|
|
"**/.git/hooks/**", |
|
|
|
|
"**/AGENTS.md", |
|
|
|
|
"**/CLAUDE.md", |
|
|
|
|
"**/.cursorrules", |
|
|
|
|
"**/copilot-instructions.md", |
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "fish"]); |
|
|
|
|
const REMOTE_COMMANDS = new Set(["ssh", "scp", "sftp", "rsync", "mosh"]); |
|
|
|
|
const NETWORK_COMMANDS = new Set(["curl", "wget", "nc", "ncat", "netcat", "socat", "telnet"]); |
|
|
|
|
const PACKAGE_MANAGER_COMMANDS = new Set(["dnf", "yum", "apt", "apt-get", "zypper", "pacman"]); |
|
|
|
|
const PLATFORM_CONTROL_COMMANDS = new Set(["docker", "podman", "kubectl", "helm", "mount", "umount", "dd", "crontab", "at"]); |
|
|
|
|
const PATH_AWARE_WRITE_COMMANDS = new Set(["cp", "mv", "install", "ln", "touch", "mkdir", "tee", "truncate"]); |
|
|
|
|
const PATH_AWARE_READ_COMMANDS = new Set(["cat", "less", "more", "head", "tail", "file", "stat"]); |
|
|
|
|
const SENSITIVE_BASENAMES = new Set([ |
|
|
|
|
"AGENTS.md", |
|
|
|
|
"CLAUDE.md", |
|
|
|
|
".cursorrules", |
|
|
|
|
"copilot-instructions.md", |
|
|
|
|
".env", |
|
|
|
|
".netrc", |
|
|
|
|
".git-credentials", |
|
|
|
|
".bashrc", |
|
|
|
|
".zshrc", |
|
|
|
|
".profile", |
|
|
|
|
".bash_profile", |
|
|
|
|
"config", |
|
|
|
|
]); |
|
|
|
|
const DEFAULT_POLICY_FEATURES = [ |
|
|
|
|
"confirm sudo, ssh/scp/rsync/sftp, service changes, package-manager mutations, network clients, and destructive git", |
|
|
|
|
"deny only objectively unsafe forms such as wildcard recursive deletes or safety-wrapper bypass attempts", |
|
|
|
|
"refine recursive deletes and find -delete into explicit absolute paths", |
|
|
|
|
"confirm reads/writes against sensitive files and writes outside the workspace", |
|
|
|
|
"confirm bash-based file mutations targeting sensitive paths such as AGENTS.md, .env, shell dotfiles, SSH keys, and local policy files", |
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
const DEFAULT_CONFIG: ResolvedConfig = { |
|
|
|
|
@ -168,6 +214,111 @@ function firstNonOption(tokens: string[], start = 0): number {
|
|
|
|
|
return -1; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function uniqueStrings(values: string[]): string[] { |
|
|
|
|
return Array.from(new Set(values.filter(Boolean))); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractShellCodeArg(args: string[]): string | null { |
|
|
|
|
const flagIndex = args.findIndex((token) => token === "-c"); |
|
|
|
|
if (flagIndex >= 0 && args[flagIndex + 1]) { |
|
|
|
|
return args[flagIndex + 1]; |
|
|
|
|
} |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function isLikelyUrl(token: string): boolean { |
|
|
|
|
return /^[a-z][a-z0-9+.-]*:\/\//i.test(token); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function looksLikePathToken(token: string): boolean { |
|
|
|
|
if (!token || token === "-" || isLikelyUrl(token)) return false; |
|
|
|
|
return ( |
|
|
|
|
token.startsWith("/") || |
|
|
|
|
token.startsWith("./") || |
|
|
|
|
token.startsWith("../") || |
|
|
|
|
token.startsWith("~/") || |
|
|
|
|
token === "." || |
|
|
|
|
token === ".." || |
|
|
|
|
token.includes("/") || |
|
|
|
|
SENSITIVE_BASENAMES.has(token) |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractRedirectionTargets(command: string): string[] { |
|
|
|
|
const targets: string[] = []; |
|
|
|
|
const regex = /(?:^|\s)(?:\d*>>?|<<?)\s*([^\s|&;]+)/g; |
|
|
|
|
for (const match of command.matchAll(regex)) { |
|
|
|
|
const target = stripOuterQuotes(match[1] ?? ""); |
|
|
|
|
if (looksLikePathToken(target)) targets.push(target); |
|
|
|
|
} |
|
|
|
|
return uniqueStrings(targets); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractCandidatePathTokens(commandName: string, args: string[], rawCommand: string): string[] { |
|
|
|
|
const candidates = args.filter((token) => !token.startsWith("-") && looksLikePathToken(token)); |
|
|
|
|
if (commandName === "tee") { |
|
|
|
|
return uniqueStrings([...candidates, ...extractRedirectionTargets(rawCommand)]); |
|
|
|
|
} |
|
|
|
|
return uniqueStrings([...candidates, ...extractRedirectionTargets(rawCommand)]); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractPathAssessment(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig) { |
|
|
|
|
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath)); |
|
|
|
|
const inWorkspace = config.workspaceRoots.some((root) => isWithinRoot(absPath, root)); |
|
|
|
|
const sensitive = isSensitivePath(absPath, cwd, config); |
|
|
|
|
const details = [ |
|
|
|
|
`Resolved path: ${summarizePath(absPath, cwd)}`, |
|
|
|
|
`Workspace roots: ${summarizeRoots(config, cwd).join(", ")}`, |
|
|
|
|
]; |
|
|
|
|
return { absPath, inWorkspace, sensitive, details }; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function classifyPathAccessFromBash( |
|
|
|
|
mode: "read" | "write", |
|
|
|
|
commandName: string, |
|
|
|
|
pathTokens: string[], |
|
|
|
|
cwd: string, |
|
|
|
|
config: ResolvedConfig, |
|
|
|
|
): Decision | null { |
|
|
|
|
if (pathTokens.length === 0) return null; |
|
|
|
|
const assessments = pathTokens.map((token) => ({ token, ...extractPathAssessment(mode, token, cwd, config) })); |
|
|
|
|
|
|
|
|
|
if (mode === "read") { |
|
|
|
|
const sensitive = assessments.filter((item) => item.sensitive); |
|
|
|
|
if (sensitive.length > 0 && config.confirmSensitiveReads) { |
|
|
|
|
return makeDecision( |
|
|
|
|
"confirm", |
|
|
|
|
`Confirm ${commandName} sensitive read`, |
|
|
|
|
`${commandName} reads a sensitive path.`, |
|
|
|
|
sensitive.flatMap((item) => [`Argument: ${item.token}`, ...item.details]), |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const sensitiveWrites = assessments.filter((item) => item.sensitive); |
|
|
|
|
if (sensitiveWrites.length > 0 && config.confirmSensitiveWrites) { |
|
|
|
|
return makeDecision( |
|
|
|
|
"confirm", |
|
|
|
|
`Confirm ${commandName} sensitive write`, |
|
|
|
|
`${commandName} may modify a sensitive path.`, |
|
|
|
|
sensitiveWrites.flatMap((item) => [`Argument: ${item.token}`, ...item.details]), |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const outsideWorkspace = assessments.filter((item) => !item.inWorkspace); |
|
|
|
|
if (outsideWorkspace.length > 0 && config.confirmWritesOutsideWorkspace) { |
|
|
|
|
return makeDecision( |
|
|
|
|
"confirm", |
|
|
|
|
`Confirm ${commandName} write outside workspace`, |
|
|
|
|
`${commandName} may modify a path outside the configured workspace roots.`, |
|
|
|
|
outsideWorkspace.flatMap((item) => [`Argument: ${item.token}`, ...item.details]), |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
return null; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function extractPrimaryCommand(tokens: string[]): { |
|
|
|
|
command: string | null; |
|
|
|
|
args: string[]; |
|
|
|
|
@ -542,36 +693,62 @@ function classifyBash(command: string, cwd: string, config: ResolvedConfig): Dec
|
|
|
|
|
"Confirm sudo command", |
|
|
|
|
"sudo requires explicit human review.", |
|
|
|
|
[`Nested command: ${primary.command} ${primary.args.join(" ")}`.trim()], |
|
|
|
|
nestedDecision.action === "confirm" ? nestedDecision.suggest : undefined, |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (SHELL_WRAPPERS.has(cmd)) { |
|
|
|
|
const nestedCode = extractShellCodeArg(primary.args); |
|
|
|
|
if (nestedCode) { |
|
|
|
|
return classifyBash(nestedCode, cwd, config); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (cmd === "rm") return classifyRm(primary.args, cwd, config); |
|
|
|
|
if (cmd === "find") return classifyFind(primary.args, cwd, config); |
|
|
|
|
if (cmd === "git") return classifyGit(primary.args); |
|
|
|
|
if (cmd === "systemctl") return classifySystemCtl(primary.args); |
|
|
|
|
if (["dnf", "yum", "apt", "apt-get", "zypper", "pacman"].includes(cmd)) { |
|
|
|
|
if (PACKAGE_MANAGER_COMMANDS.has(cmd)) { |
|
|
|
|
return classifyPackageManager(cmd, primary.args); |
|
|
|
|
} |
|
|
|
|
if (["chmod", "chown"].includes(cmd)) { |
|
|
|
|
return classifyChmodLike(cmd, primary.args); |
|
|
|
|
} |
|
|
|
|
if (["ssh", "scp", "sftp", "rsync", "mosh"].includes(cmd)) { |
|
|
|
|
if (REMOTE_COMMANDS.has(cmd)) { |
|
|
|
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect remote systems or transfer data.`); |
|
|
|
|
} |
|
|
|
|
if (["docker", "podman", "kubectl", "helm", "mount", "umount", "dd"].includes(cmd)) { |
|
|
|
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect containers, clusters, devices, or mounts.`); |
|
|
|
|
if (NETWORK_COMMANDS.has(cmd)) { |
|
|
|
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can reach external systems or open raw network channels.`); |
|
|
|
|
} |
|
|
|
|
if (["env", "printenv"].includes(cmd)) { |
|
|
|
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can reveal credentials or sensitive environment data.`); |
|
|
|
|
} |
|
|
|
|
if (PLATFORM_CONTROL_COMMANDS.has(cmd)) { |
|
|
|
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect containers, clusters, scheduled tasks, devices, or mounts.`); |
|
|
|
|
} |
|
|
|
|
if (cmd === "sed" && primary.args.some((token) => token === "-i" || token.startsWith("-i"))) { |
|
|
|
|
const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); |
|
|
|
|
if (pathDecision) return pathDecision; |
|
|
|
|
return makeDecision("confirm", "Confirm sed -i", "In-place sed edit requested."); |
|
|
|
|
} |
|
|
|
|
if (cmd === "perl" && primary.args.some((token) => token === "-pi" || token.startsWith("-pi"))) { |
|
|
|
|
const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); |
|
|
|
|
if (pathDecision) return pathDecision; |
|
|
|
|
return makeDecision("confirm", "Confirm perl -pi", "In-place perl edit requested."); |
|
|
|
|
} |
|
|
|
|
if (PATH_AWARE_WRITE_COMMANDS.has(cmd)) { |
|
|
|
|
const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); |
|
|
|
|
if (pathDecision) return pathDecision; |
|
|
|
|
} |
|
|
|
|
if (PATH_AWARE_READ_COMMANDS.has(cmd)) { |
|
|
|
|
const pathDecision = classifyPathAccessFromBash("read", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); |
|
|
|
|
if (pathDecision) return pathDecision; |
|
|
|
|
} |
|
|
|
|
return makeDecision("allow", "Allow bash", "No risky pattern matched by the default policy."); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function classifyPathTool(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig): Decision { |
|
|
|
|
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath)); |
|
|
|
|
const inWorkspace = config.workspaceRoots.some((root) => isWithinRoot(absPath, root)); |
|
|
|
|
const sensitive = isSensitivePath(absPath, cwd, config); |
|
|
|
|
const details = [ |
|
|
|
|
`Resolved path: ${summarizePath(absPath, cwd)}`, |
|
|
|
|
`Workspace roots: ${summarizeRoots(config, cwd).join(", ")}`, |
|
|
|
|
]; |
|
|
|
|
const { inWorkspace, sensitive, details } = extractPathAssessment(tool, rawPath, cwd, config); |
|
|
|
|
|
|
|
|
|
if (tool === "read") { |
|
|
|
|
if (sensitive && config.confirmSensitiveReads) { |
|
|
|
|
@ -628,7 +805,8 @@ export default function policyGate(pi: ExtensionAPI) {
|
|
|
|
|
`- Workspace roots currently protected: ${roots}.\n` + |
|
|
|
|
"- For recursive or forceful deletes, use an explicit absolute path, never wildcards or broad targets.\n" + |
|
|
|
|
"- If a command is blocked or requires refinement, propose a safer reformulation instead of trying to bypass the gate.\n" + |
|
|
|
|
"- Treat sudo, ssh, rsync, scp, service changes, and destructive git operations as review-worthy actions.\n", |
|
|
|
|
"- Treat sudo, ssh, rsync, scp, service changes, destructive git operations, raw network clients, and environment dumps as review-worthy actions.\n" + |
|
|
|
|
"- Be especially careful with AGENTS.md, CLAUDE.md, .env files, shell dotfiles, SSH keys, and local policy files.\n", |
|
|
|
|
}; |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
@ -644,6 +822,8 @@ export default function policyGate(pi: ExtensionAPI) {
|
|
|
|
|
`Sensitive writes require confirm: ${activeConfig.confirmSensitiveWrites}`, |
|
|
|
|
`Writes outside workspace require confirm: ${activeConfig.confirmWritesOutsideWorkspace}`, |
|
|
|
|
`Recursive delete requires absolute path: ${activeConfig.requireAbsolutePathForRecursiveDelete}`, |
|
|
|
|
"Built-in default policy:", |
|
|
|
|
...DEFAULT_POLICY_FEATURES.map((feature) => `- ${feature}`), |
|
|
|
|
].join("\n"); |
|
|
|
|
if (ctx.hasUI) ctx.ui.notify(summary, "info"); |
|
|
|
|
else console.log(summary); |
|
|
|
|
|