Browse Source

Strengthen built-in default policy

main
Matteo Benedetto 4 weeks ago
parent
commit
4af71a207f
  1. 16
      README.md
  2. 204
      index.ts
  3. 4
      package-lock.json
  4. 2
      package.json

16
README.md

@ -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.

204
index.ts

@ -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);

4
package-lock.json generated

@ -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"

2
package.json

@ -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": [

Loading…
Cancel
Save