Strengthen built-in default policy
This commit is contained in:
@@ -9,12 +9,17 @@ It classifies operations into four outcomes:
|
||||
- **deny** — block outright
|
||||
- **refine** — block and tell the model how to reformulate the command safely
|
||||
|
||||
The default policy is opinionated but usable:
|
||||
The default policy is opinionated but usable, and it is now **baked into the code** even if you provide no config file.
|
||||
|
||||
Built-in defaults:
|
||||
|
||||
- `sudo` and `ssh` are **not denied by default**
|
||||
- risky commands are generally **confirmed**
|
||||
- only objectively dangerous or ambiguous forms are **denied/refined**
|
||||
- destructive deletes should use an **explicit absolute path**
|
||||
- `curl` / `wget` / `nc` / `socat` / `telnet` are **confirmed**
|
||||
- `env` / `printenv` are **confirmed**
|
||||
- bash-based writes to sensitive files such as `AGENTS.md`, `.env`, shell dotfiles, SSH config, and local policy files are **confirmed**
|
||||
|
||||
## What it protects against
|
||||
|
||||
@@ -27,9 +32,12 @@ Examples of behavior it catches by default:
|
||||
- `find . -delete`
|
||||
- `sudo ...` (confirmation)
|
||||
- `ssh ...` / `scp` / `rsync` / `sftp` (confirmation)
|
||||
- `curl ...`, `wget ...`, `nc ...`, `socat ...` (confirmation)
|
||||
- `env`, `printenv` (confirmation)
|
||||
- `git reset --hard`, `git clean -fdx`, `git push` (confirmation)
|
||||
- `sed -i ~/.bashrc ...` or `tee AGENTS.md` (confirmation)
|
||||
- writes outside the workspace (confirmation)
|
||||
- reads/writes to sensitive paths such as `~/.ssh` or `.env` (confirmation)
|
||||
- reads/writes to sensitive paths such as `~/.ssh`, `AGENTS.md`, or `.env` (confirmation)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -38,7 +46,7 @@ Examples of behavior it catches by default:
|
||||
Private Gitea/GitHub style install:
|
||||
|
||||
```bash
|
||||
pi install git:git@git.enne2.net:enne2/pi-policy-gate.git
|
||||
pi install 'ssh://git@git.enne2.net:222/enne2/pi-policy-gate.git'
|
||||
```
|
||||
|
||||
Or with HTTPS:
|
||||
@@ -50,7 +58,7 @@ pi install https://git.enne2.net/enne2/pi-policy-gate.git
|
||||
### With npm from the git repository
|
||||
|
||||
```bash
|
||||
npm install git+ssh://git@git.enne2.net/enne2/pi-policy-gate.git
|
||||
npm install git+ssh://git@git.enne2.net:222/enne2/pi-policy-gate.git
|
||||
```
|
||||
|
||||
Then load it from local `node_modules` or publish it to your npm registry later.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
|
||||
Reference in New Issue
Block a user