Initial policy gate package
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,174 @@
|
||||
# @enne2/pi-policy-gate
|
||||
|
||||
A standalone **pi** extension package that applies a practical policy layer to risky tool calls.
|
||||
|
||||
It classifies operations into four outcomes:
|
||||
|
||||
- **allow** — execute immediately
|
||||
- **confirm** — require human approval
|
||||
- **deny** — block outright
|
||||
- **refine** — block and tell the model how to reformulate the command safely
|
||||
|
||||
The default policy is opinionated but usable:
|
||||
|
||||
- `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**
|
||||
|
||||
## What it protects against
|
||||
|
||||
Examples of behavior it catches by default:
|
||||
|
||||
- `rm -rf *`
|
||||
- `rm -rf .`
|
||||
- `rm -rf ..`
|
||||
- `rm -rf /`
|
||||
- `find . -delete`
|
||||
- `sudo ...` (confirmation)
|
||||
- `ssh ...` / `scp` / `rsync` / `sftp` (confirmation)
|
||||
- `git reset --hard`, `git clean -fdx`, `git push` (confirmation)
|
||||
- writes outside the workspace (confirmation)
|
||||
- reads/writes to sensitive paths such as `~/.ssh` or `.env` (confirmation)
|
||||
|
||||
## Install
|
||||
|
||||
### Directly in pi from git
|
||||
|
||||
Private Gitea/GitHub style install:
|
||||
|
||||
```bash
|
||||
pi install git:git@git.enne2.net:enne2/pi-policy-gate.git
|
||||
```
|
||||
|
||||
Or with HTTPS:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Then load it from local `node_modules` or publish it to your npm registry later.
|
||||
|
||||
## Config files
|
||||
|
||||
The extension looks for config files in this order:
|
||||
|
||||
1. built-in defaults
|
||||
2. `~/.pi/agent/policy-gate.json`
|
||||
3. extra file passed with `--policy-gate-config /path/to/file.json`
|
||||
4. `.pi/policy-gate.json` in the current project
|
||||
|
||||
Project config overrides global config.
|
||||
|
||||
## Example config
|
||||
|
||||
Copy and adjust:
|
||||
|
||||
```bash
|
||||
cp policy-gate.example.json ~/.pi/agent/policy-gate.json
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceRoots": ["."],
|
||||
"requireAbsolutePathForRecursiveDelete": true,
|
||||
"overrides": [
|
||||
{
|
||||
"tool": "bash",
|
||||
"commandRegex": "^sudo systemctl restart my-safe-service$",
|
||||
"action": "allow"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Config reference
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceRoots": ["."],
|
||||
"requireAbsolutePathForRecursiveDelete": true,
|
||||
"requireAbsolutePathForFindDelete": true,
|
||||
"confirmSensitiveReads": true,
|
||||
"confirmSensitiveWrites": true,
|
||||
"confirmWritesOutsideWorkspace": true,
|
||||
"sensitivePathGlobs": ["~/.ssh/**", "**/.env"],
|
||||
"overrides": [
|
||||
{
|
||||
"id": "optional label",
|
||||
"tool": "bash",
|
||||
"commandRegex": "^ssh deploy@staging\\b",
|
||||
"pathGlob": "~/.ssh/**",
|
||||
"action": "allow | confirm | deny | refine",
|
||||
"reason": "Shown to the user / model",
|
||||
"suggest": "Only used with refine/deny to explain the safer alternative"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `workspaceRoots` can be absolute paths, `~` paths, or paths relative to the current project cwd.
|
||||
- `commandRegex` is evaluated against the raw bash command string.
|
||||
- `pathGlob` is matched against both the absolute path and the path relative to the current cwd.
|
||||
- last matching override wins.
|
||||
|
||||
## Behavior model
|
||||
|
||||
### Allow
|
||||
Low-risk reads and normal project-local writes are allowed.
|
||||
|
||||
### Confirm
|
||||
Potentially dangerous but legitimate actions prompt the human.
|
||||
|
||||
Examples:
|
||||
|
||||
- `sudo systemctl restart nginx`
|
||||
- `ssh deploy@staging 'systemctl status api'`
|
||||
- `rm -rf /full/path/to/some/build-cache`
|
||||
|
||||
### Deny / Refine
|
||||
Only clearly unsafe or ambiguous forms are blocked.
|
||||
|
||||
Examples:
|
||||
|
||||
- `rm -rf *`
|
||||
- `rm -rf .`
|
||||
- `find . -delete`
|
||||
|
||||
The extension tells the model to switch to a safer form such as using an explicit absolute path.
|
||||
|
||||
## Useful commands
|
||||
|
||||
Inside pi:
|
||||
|
||||
- `/policy-gate` — show current policy summary
|
||||
|
||||
## Development
|
||||
|
||||
Install deps and verify the package tarball:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run pack:check
|
||||
```
|
||||
|
||||
Try it without installing globally:
|
||||
|
||||
```bash
|
||||
pi -e /absolute/path/to/pi-policy-gate --list-models
|
||||
```
|
||||
|
||||
## Packaging notes
|
||||
|
||||
This package is intentionally TypeScript-only and relies on pi's built-in runtime loader.
|
||||
No build step is required.
|
||||
@@ -0,0 +1,714 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { minimatch } from "minimatch";
|
||||
|
||||
type PolicyAction = "allow" | "confirm" | "deny" | "refine";
|
||||
|
||||
type ToolName = "bash" | "read" | "write" | "edit";
|
||||
|
||||
interface RuleOverride {
|
||||
id?: string;
|
||||
tool?: ToolName;
|
||||
commandRegex?: string;
|
||||
pathGlob?: string;
|
||||
action: PolicyAction;
|
||||
reason?: string;
|
||||
suggest?: string;
|
||||
}
|
||||
|
||||
interface PolicyGateConfig {
|
||||
workspaceRoots?: string[];
|
||||
requireAbsolutePathForRecursiveDelete?: boolean;
|
||||
requireAbsolutePathForFindDelete?: boolean;
|
||||
confirmSensitiveReads?: boolean;
|
||||
confirmSensitiveWrites?: boolean;
|
||||
confirmWritesOutsideWorkspace?: boolean;
|
||||
sensitivePathGlobs?: string[];
|
||||
overrides?: RuleOverride[];
|
||||
}
|
||||
|
||||
interface ResolvedConfig {
|
||||
workspaceRoots: string[];
|
||||
requireAbsolutePathForRecursiveDelete: boolean;
|
||||
requireAbsolutePathForFindDelete: boolean;
|
||||
confirmSensitiveReads: boolean;
|
||||
confirmSensitiveWrites: boolean;
|
||||
confirmWritesOutsideWorkspace: boolean;
|
||||
sensitivePathGlobs: string[];
|
||||
overrides: RuleOverride[];
|
||||
configSources: string[];
|
||||
}
|
||||
|
||||
interface Decision {
|
||||
action: PolicyAction;
|
||||
title: string;
|
||||
reason: string;
|
||||
suggest?: string;
|
||||
details?: string[];
|
||||
overrideId?: string;
|
||||
}
|
||||
|
||||
const HOME = os.homedir();
|
||||
const GLOBAL_CONFIG = resolve(getAgentDir(), "policy-gate.json");
|
||||
const PROJECT_CONFIG_NAME = ".pi/policy-gate.json";
|
||||
|
||||
const DEFAULT_SENSITIVE_PATH_GLOBS = [
|
||||
"~/.ssh/**",
|
||||
"~/.aws/**",
|
||||
"~/.config/gcloud/**",
|
||||
"~/.azure/**",
|
||||
"~/.gnupg/**",
|
||||
"~/.pi/**",
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
"**/*.pem",
|
||||
"**/*.key",
|
||||
"**/.netrc",
|
||||
"**/.git-credentials",
|
||||
"~/.bashrc",
|
||||
"~/.zshrc",
|
||||
"~/.profile",
|
||||
"~/.bash_profile",
|
||||
];
|
||||
|
||||
const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
workspaceRoots: [],
|
||||
requireAbsolutePathForRecursiveDelete: true,
|
||||
requireAbsolutePathForFindDelete: true,
|
||||
confirmSensitiveReads: true,
|
||||
confirmSensitiveWrites: true,
|
||||
confirmWritesOutsideWorkspace: true,
|
||||
sensitivePathGlobs: DEFAULT_SENSITIVE_PATH_GLOBS,
|
||||
overrides: [],
|
||||
configSources: [],
|
||||
};
|
||||
|
||||
function stripOuterQuotes(value: string): string {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenizeShell(command: string): string[] {
|
||||
const matches = command.match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\S+/g) ?? [];
|
||||
return matches.map((token) => stripOuterQuotes(token));
|
||||
}
|
||||
|
||||
function expandHome(pathLike: string): string {
|
||||
return pathLike.startsWith("~/") ? resolve(HOME, pathLike.slice(2)) : pathLike;
|
||||
}
|
||||
|
||||
function canonicalizePath(absPath: string): string {
|
||||
const resolved = resolve(expandHome(absPath));
|
||||
try {
|
||||
return realpathSync.native(resolved);
|
||||
} catch {
|
||||
let cursor = resolved;
|
||||
const suffix: string[] = [];
|
||||
while (true) {
|
||||
if (existsSync(cursor)) {
|
||||
try {
|
||||
const real = realpathSync.native(cursor);
|
||||
return resolve(real, ...suffix.reverse());
|
||||
} catch {
|
||||
return resolve(cursor, ...suffix.reverse());
|
||||
}
|
||||
}
|
||||
const parent = dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
return resolved;
|
||||
}
|
||||
suffix.push(basename(cursor));
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(pathLike: string, cwd: string): string {
|
||||
const expanded = expandHome(pathLike);
|
||||
return canonicalizePath(isAbsolute(expanded) ? expanded : resolve(cwd, expanded));
|
||||
}
|
||||
|
||||
function normalizeForMatch(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function matchesPathGlob(absPath: string, cwd: string, pattern: string): boolean {
|
||||
const normalizedAbs = normalizeForMatch(absPath);
|
||||
const relativeToCwd = normalizeForMatch(relative(cwd, absPath));
|
||||
const expanded = expandHome(pattern);
|
||||
const normalizedPattern = normalizeForMatch(
|
||||
isAbsolute(expanded) ? canonicalizePath(expanded) : expanded,
|
||||
);
|
||||
|
||||
return (
|
||||
minimatch(normalizedAbs, normalizedPattern, { dot: true, nocase: false }) ||
|
||||
minimatch(relativeToCwd, normalizedPattern, { dot: true, nocase: false }) ||
|
||||
minimatch(basename(absPath), normalizedPattern, { dot: true, nocase: false })
|
||||
);
|
||||
}
|
||||
|
||||
function isWithinRoot(absPath: string, root: string): boolean {
|
||||
const rel = relative(root, absPath);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
function firstNonOption(tokens: string[], start = 0): number {
|
||||
for (let i = start; i < tokens.length; i += 1) {
|
||||
if (!tokens[i].startsWith("-")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function extractPrimaryCommand(tokens: string[]): {
|
||||
command: string | null;
|
||||
args: string[];
|
||||
usesSudo: boolean;
|
||||
} {
|
||||
let index = 0;
|
||||
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index])) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
let usesSudo = false;
|
||||
if (tokens[index] === "sudo") {
|
||||
usesSudo = true;
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index];
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("-")) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index >= tokens.length) {
|
||||
return { command: null, args: [], usesSudo };
|
||||
}
|
||||
|
||||
return {
|
||||
command: tokens[index],
|
||||
args: tokens.slice(index + 1),
|
||||
usesSudo,
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonFile<T>(path: string): T | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConfig(base: ResolvedConfig, patch: PolicyGateConfig | null, cwd: string, source?: string): ResolvedConfig {
|
||||
if (!patch) return base;
|
||||
|
||||
return {
|
||||
workspaceRoots:
|
||||
patch.workspaceRoots !== undefined
|
||||
? patch.workspaceRoots.map((item) => resolveConfigPath(item, cwd))
|
||||
: base.workspaceRoots,
|
||||
requireAbsolutePathForRecursiveDelete:
|
||||
patch.requireAbsolutePathForRecursiveDelete ?? base.requireAbsolutePathForRecursiveDelete,
|
||||
requireAbsolutePathForFindDelete: patch.requireAbsolutePathForFindDelete ?? base.requireAbsolutePathForFindDelete,
|
||||
confirmSensitiveReads: patch.confirmSensitiveReads ?? base.confirmSensitiveReads,
|
||||
confirmSensitiveWrites: patch.confirmSensitiveWrites ?? base.confirmSensitiveWrites,
|
||||
confirmWritesOutsideWorkspace:
|
||||
patch.confirmWritesOutsideWorkspace ?? base.confirmWritesOutsideWorkspace,
|
||||
sensitivePathGlobs: patch.sensitivePathGlobs ?? base.sensitivePathGlobs,
|
||||
overrides: patch.overrides ? [...base.overrides, ...patch.overrides] : base.overrides,
|
||||
configSources: source ? [...base.configSources, source] : base.configSources,
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig(cwd: string, extraConfigPath?: string): ResolvedConfig {
|
||||
let config: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
workspaceRoots: [canonicalizePath(cwd)],
|
||||
sensitivePathGlobs: [...DEFAULT_CONFIG.sensitivePathGlobs],
|
||||
overrides: [],
|
||||
configSources: [],
|
||||
};
|
||||
|
||||
if (existsSync(GLOBAL_CONFIG)) {
|
||||
config = mergeConfig(config, readJsonFile<PolicyGateConfig>(GLOBAL_CONFIG), cwd, GLOBAL_CONFIG);
|
||||
}
|
||||
|
||||
if (extraConfigPath && existsSync(extraConfigPath)) {
|
||||
config = mergeConfig(config, readJsonFile<PolicyGateConfig>(extraConfigPath), cwd, extraConfigPath);
|
||||
}
|
||||
|
||||
const projectConfig = resolve(cwd, PROJECT_CONFIG_NAME);
|
||||
if (existsSync(projectConfig)) {
|
||||
config = mergeConfig(config, readJsonFile<PolicyGateConfig>(projectConfig), cwd, projectConfig);
|
||||
}
|
||||
|
||||
if (config.workspaceRoots.length === 0) {
|
||||
config.workspaceRoots = [canonicalizePath(cwd)];
|
||||
}
|
||||
|
||||
config.workspaceRoots = Array.from(new Set(config.workspaceRoots.map((item) => canonicalizePath(item))));
|
||||
return config;
|
||||
}
|
||||
|
||||
function isSensitivePath(absPath: string, cwd: string, config: ResolvedConfig): boolean {
|
||||
return config.sensitivePathGlobs.some((pattern) => matchesPathGlob(absPath, cwd, pattern));
|
||||
}
|
||||
|
||||
function summarizePath(absPath: string, cwd: string): string {
|
||||
const rel = relative(cwd, absPath);
|
||||
if (rel === "") return `${absPath} (cwd)`;
|
||||
if (!rel.startsWith("..") && !isAbsolute(rel)) return `${absPath} (./${rel})`;
|
||||
return absPath;
|
||||
}
|
||||
|
||||
function summarizeRoots(config: ResolvedConfig, cwd: string): string[] {
|
||||
return config.workspaceRoots.map((root) => summarizePath(root, cwd));
|
||||
}
|
||||
|
||||
function decisionFromOverride(
|
||||
tool: ToolName,
|
||||
command: string | undefined,
|
||||
path: string | undefined,
|
||||
cwd: string,
|
||||
config: ResolvedConfig,
|
||||
): Decision | null {
|
||||
let decision: Decision | null = null;
|
||||
for (const rule of config.overrides) {
|
||||
if (rule.tool && rule.tool !== tool) continue;
|
||||
if (rule.commandRegex) {
|
||||
if (!command) continue;
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(rule.commandRegex);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!regex.test(command)) continue;
|
||||
}
|
||||
if (rule.pathGlob) {
|
||||
if (!path) continue;
|
||||
if (!matchesPathGlob(path, cwd, rule.pathGlob)) continue;
|
||||
}
|
||||
|
||||
decision = {
|
||||
action: rule.action,
|
||||
title: `Override${rule.id ? `: ${rule.id}` : ""}`,
|
||||
reason: rule.reason ?? "Matched explicit policy override.",
|
||||
suggest: rule.suggest,
|
||||
overrideId: rule.id,
|
||||
};
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
function makeDecision(action: PolicyAction, title: string, reason: string, details?: string[], suggest?: string): Decision {
|
||||
return { action, title, reason, details, suggest };
|
||||
}
|
||||
|
||||
function hasShellMetacharacters(target: string): boolean {
|
||||
return /[*?[\]{}$`]/.test(target);
|
||||
}
|
||||
|
||||
function isAmbiguousDeleteTarget(target: string): boolean {
|
||||
const normalized = target.trim();
|
||||
return (
|
||||
normalized === "" ||
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized === "*" ||
|
||||
normalized === ".*" ||
|
||||
normalized === "./*" ||
|
||||
normalized === "./.*" ||
|
||||
normalized === "../*" ||
|
||||
normalized === "../.*" ||
|
||||
normalized === "~" ||
|
||||
normalized === "~/" ||
|
||||
normalized === "~/*" ||
|
||||
normalized === "/" ||
|
||||
normalized === "/*" ||
|
||||
hasShellMetacharacters(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function formatDecision(decision: Decision): string {
|
||||
const lines = [decision.reason];
|
||||
if (decision.details?.length) lines.push(...decision.details);
|
||||
if (decision.suggest) lines.push(`Safer alternative: ${decision.suggest}`);
|
||||
if (decision.overrideId) lines.push(`Matched override: ${decision.overrideId}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function classifyRm(args: string[], cwd: string, config: ResolvedConfig): Decision {
|
||||
const options = args.filter((token) => token.startsWith("-"));
|
||||
const targets = args.filter((token) => !token.startsWith("-"));
|
||||
const recursive = options.some((token) => token.includes("r") || token.includes("R") || token === "--recursive");
|
||||
const force = options.some((token) => token.includes("f") || token === "--force");
|
||||
const destructive = recursive || force;
|
||||
|
||||
if (targets.length === 0) {
|
||||
return makeDecision(
|
||||
"refine",
|
||||
"Refine rm command",
|
||||
"Deletion command has no explicit target.",
|
||||
undefined,
|
||||
"Specify the exact path you want to remove.",
|
||||
);
|
||||
}
|
||||
|
||||
if (targets.some(isAmbiguousDeleteTarget)) {
|
||||
return makeDecision(
|
||||
"deny",
|
||||
"Blocked ambiguous delete",
|
||||
"Wildcard or broad delete target detected.",
|
||||
["Recursive or forceful deletes must not use *, ., .., ~, /, or shell expansions as targets."],
|
||||
"Replace it with an explicit absolute path and preview the target first if needed.",
|
||||
);
|
||||
}
|
||||
|
||||
if (destructive && config.requireAbsolutePathForRecursiveDelete && targets.some((target) => !isAbsolute(expandHome(target)))) {
|
||||
return makeDecision(
|
||||
"refine",
|
||||
"Refine recursive delete",
|
||||
"Recursive or forceful delete requires an explicit absolute path.",
|
||||
targets.map((target) => `Provided target: ${target}`),
|
||||
"Use an absolute path such as /full/path/to/target.",
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedTargets = targets.map((target) => canonicalizePath(isAbsolute(expandHome(target)) ? expandHome(target) : resolve(cwd, target)));
|
||||
if (resolvedTargets.some((target) => target === "/" || target === HOME)) {
|
||||
return makeDecision(
|
||||
"deny",
|
||||
"Blocked dangerous delete",
|
||||
"Deletion target resolves to the filesystem root or your home directory.",
|
||||
resolvedTargets.map((target) => `Resolved target: ${target}`),
|
||||
"Choose a narrower explicit path.",
|
||||
);
|
||||
}
|
||||
|
||||
if (destructive) {
|
||||
return makeDecision(
|
||||
"confirm",
|
||||
"Confirm recursive delete",
|
||||
"Recursive or forceful delete requested.",
|
||||
resolvedTargets.map((target) => `Resolved target: ${summarizePath(target, cwd)}`),
|
||||
);
|
||||
}
|
||||
|
||||
return makeDecision(
|
||||
"allow",
|
||||
"Allow rm",
|
||||
"Non-recursive delete with explicit target inside normal shell execution.",
|
||||
);
|
||||
}
|
||||
|
||||
function classifyFind(args: string[], cwd: string, config: ResolvedConfig): Decision {
|
||||
if (!args.includes("-delete")) {
|
||||
return makeDecision("allow", "Allow find", "Read-only find usage.");
|
||||
}
|
||||
|
||||
const pathIndex = firstNonOption(args, 0);
|
||||
const base = pathIndex >= 0 ? args[pathIndex] : null;
|
||||
if (!base || base === "." || base === "..") {
|
||||
return makeDecision(
|
||||
"refine",
|
||||
"Refine find -delete",
|
||||
"find -delete requires an explicit absolute base path.",
|
||||
undefined,
|
||||
"Use find /absolute/path -name 'pattern' -delete after previewing with -print.",
|
||||
);
|
||||
}
|
||||
|
||||
if (config.requireAbsolutePathForFindDelete && !isAbsolute(expandHome(base))) {
|
||||
return makeDecision(
|
||||
"refine",
|
||||
"Refine find -delete",
|
||||
"find -delete requires an explicit absolute base path.",
|
||||
[`Provided base path: ${base}`],
|
||||
"Use find /absolute/path ... -delete.",
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = canonicalizePath(expandHome(base));
|
||||
if (resolved === "/" || resolved === HOME) {
|
||||
return makeDecision(
|
||||
"deny",
|
||||
"Blocked dangerous find -delete",
|
||||
"find -delete targets the filesystem root or home directory.",
|
||||
[`Resolved base path: ${resolved}`],
|
||||
"Restrict it to a narrower absolute path.",
|
||||
);
|
||||
}
|
||||
|
||||
return makeDecision(
|
||||
"confirm",
|
||||
"Confirm find -delete",
|
||||
"find -delete can remove many files and should be reviewed.",
|
||||
[`Resolved base path: ${summarizePath(resolved, cwd)}`],
|
||||
"Prefer running the same find command with -print first.",
|
||||
);
|
||||
}
|
||||
|
||||
function classifyGit(args: string[]): Decision {
|
||||
const raw = args.join(" ");
|
||||
const sub = args[0] ?? "";
|
||||
if (sub === "push") {
|
||||
const forced = /(^|\s)(--force|-f|--force-with-lease)(\s|$)/.test(raw);
|
||||
return makeDecision(
|
||||
"confirm",
|
||||
forced ? "Confirm force push" : "Confirm git push",
|
||||
forced ? "git push with force semantics requested." : "git push requested.",
|
||||
);
|
||||
}
|
||||
if (sub === "reset" && args.includes("--hard")) {
|
||||
return makeDecision("confirm", "Confirm git reset --hard", "Destructive git reset requested.");
|
||||
}
|
||||
if (sub === "clean" && args.some((token) => /^-[^-]*f/.test(token) || token === "--force")) {
|
||||
return makeDecision("confirm", "Confirm git clean", "Potentially destructive git clean requested.");
|
||||
}
|
||||
if (sub === "restore" || sub === "checkout") {
|
||||
return makeDecision("confirm", `Confirm git ${sub}`, `Potentially destructive git ${sub} requested.`);
|
||||
}
|
||||
if (sub === "stash" && ["drop", "clear", "pop"].includes(args[1] ?? "")) {
|
||||
return makeDecision("confirm", `Confirm git stash ${args[1]}`, `Potentially destructive git stash ${args[1]} requested.`);
|
||||
}
|
||||
return makeDecision("allow", "Allow git", "Non-destructive git command.");
|
||||
}
|
||||
|
||||
function classifySystemCtl(args: string[]): Decision {
|
||||
const sub = args[0] ?? "";
|
||||
if (["start", "stop", "restart", "reload", "enable", "disable", "mask", "unmask"].includes(sub)) {
|
||||
return makeDecision("confirm", `Confirm systemctl ${sub}`, `systemctl ${sub} can affect system services.`);
|
||||
}
|
||||
return makeDecision("allow", "Allow systemctl", "Read-only or low-risk systemctl usage.");
|
||||
}
|
||||
|
||||
function classifyPackageManager(command: string, args: string[]): Decision {
|
||||
const sub = args[0] ?? "";
|
||||
if (["install", "remove", "uninstall", "upgrade", "update", "downgrade", "autoremove"].includes(sub)) {
|
||||
return makeDecision("confirm", `Confirm ${command} ${sub}`, `${command} ${sub} can change system or environment state.`);
|
||||
}
|
||||
return makeDecision("allow", `Allow ${command}`, `Low-risk ${command} usage.`);
|
||||
}
|
||||
|
||||
function classifyChmodLike(command: string, args: string[]): Decision {
|
||||
if (args.some((token) => token.startsWith("-R") || token === "--recursive")) {
|
||||
return makeDecision("confirm", `Confirm ${command} -R`, `Recursive ${command} can affect many files.`);
|
||||
}
|
||||
return makeDecision("confirm", `Confirm ${command}`, `${command} changes file ownership or permissions.`);
|
||||
}
|
||||
|
||||
function classifyBash(command: string, cwd: string, config: ResolvedConfig): Decision {
|
||||
if (/SAFEEXEC_DISABLED\s*=\s*1/.test(command) || /\bsafeexec\s+-off\b/.test(command) || /\.safeexec\.real\b/.test(command)) {
|
||||
return makeDecision(
|
||||
"deny",
|
||||
"Blocked policy bypass",
|
||||
"Command appears to disable or bypass a host safety wrapper.",
|
||||
undefined,
|
||||
"Ask the human before changing safety controls.",
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = tokenizeShell(command);
|
||||
const primary = extractPrimaryCommand(tokens);
|
||||
const cmd = primary.command;
|
||||
if (!cmd) {
|
||||
return makeDecision("allow", "Allow bash", "Empty or non-standard shell command.");
|
||||
}
|
||||
|
||||
if (primary.usesSudo) {
|
||||
const nestedDecision = classifyBash(primary.args.join(" "), cwd, config);
|
||||
if (nestedDecision.action === "deny" || nestedDecision.action === "refine") {
|
||||
return nestedDecision;
|
||||
}
|
||||
return makeDecision(
|
||||
"confirm",
|
||||
"Confirm sudo command",
|
||||
"sudo requires explicit human review.",
|
||||
[`Nested command: ${primary.command} ${primary.args.join(" ")}`.trim()],
|
||||
);
|
||||
}
|
||||
|
||||
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)) {
|
||||
return classifyPackageManager(cmd, primary.args);
|
||||
}
|
||||
if (["chmod", "chown"].includes(cmd)) {
|
||||
return classifyChmodLike(cmd, primary.args);
|
||||
}
|
||||
if (["ssh", "scp", "sftp", "rsync", "mosh"].includes(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.`);
|
||||
}
|
||||
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(", ")}`,
|
||||
];
|
||||
|
||||
if (tool === "read") {
|
||||
if (sensitive && config.confirmSensitiveReads) {
|
||||
return makeDecision("confirm", "Confirm sensitive read", "Read targets a sensitive path.", details);
|
||||
}
|
||||
return makeDecision("allow", "Allow read", "Read path does not match a sensitive pattern.");
|
||||
}
|
||||
|
||||
if (sensitive && config.confirmSensitiveWrites) {
|
||||
return makeDecision("confirm", "Confirm sensitive write", `${tool} targets a sensitive path.`, details);
|
||||
}
|
||||
|
||||
if (!inWorkspace && config.confirmWritesOutsideWorkspace) {
|
||||
return makeDecision("confirm", "Confirm write outside workspace", `${tool} targets a path outside the configured workspace roots.`, details);
|
||||
}
|
||||
|
||||
return makeDecision("allow", `Allow ${tool}`, `${tool} stays within the configured workspace roots.`);
|
||||
}
|
||||
|
||||
function buildConfirmationMessage(tool: ToolName, payload: string, decision: Decision, cwd: string): string {
|
||||
const lines = [
|
||||
decision.reason,
|
||||
`Tool: ${tool}`,
|
||||
`CWD: ${cwd}`,
|
||||
`Payload: ${payload}`,
|
||||
...(decision.details ?? []),
|
||||
];
|
||||
if (decision.suggest) lines.push(`Safer alternative: ${decision.suggest}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export default function policyGate(pi: ExtensionAPI) {
|
||||
pi.registerFlag("policy-gate-config", {
|
||||
description: "Path to an extra JSON config file for @enne2/pi-policy-gate",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
let activeConfig: ResolvedConfig = loadConfig(process.cwd());
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const extraConfig = pi.getFlag("policy-gate-config") as string | undefined;
|
||||
activeConfig = loadConfig(ctx.cwd, extraConfig ? resolveConfigPath(extraConfig, ctx.cwd) : undefined);
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setStatus("policy-gate", ctx.ui.theme.fg("accent", "policy-gate active"));
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const roots = activeConfig.workspaceRoots.join(", ");
|
||||
return {
|
||||
systemPrompt:
|
||||
event.systemPrompt +
|
||||
"\n\n[policy-gate]\n" +
|
||||
`- 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",
|
||||
};
|
||||
});
|
||||
|
||||
pi.registerCommand("policy-gate", {
|
||||
description: "Show active @enne2/pi-policy-gate settings",
|
||||
handler: async (_args, ctx) => {
|
||||
const summary = [
|
||||
"@enne2/pi-policy-gate",
|
||||
`Workspace roots: ${activeConfig.workspaceRoots.join(", ")}`,
|
||||
`Config sources: ${activeConfig.configSources.join(", ") || "defaults only"}`,
|
||||
`Overrides: ${activeConfig.overrides.length}`,
|
||||
`Sensitive reads require confirm: ${activeConfig.confirmSensitiveReads}`,
|
||||
`Sensitive writes require confirm: ${activeConfig.confirmSensitiveWrites}`,
|
||||
`Writes outside workspace require confirm: ${activeConfig.confirmWritesOutsideWorkspace}`,
|
||||
`Recursive delete requires absolute path: ${activeConfig.requireAbsolutePathForRecursiveDelete}`,
|
||||
].join("\n");
|
||||
if (ctx.hasUI) ctx.ui.notify(summary, "info");
|
||||
else console.log(summary);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
const cwd = ctx.cwd;
|
||||
let decision: Decision | null = null;
|
||||
let payload = "";
|
||||
let tool: ToolName | null = null;
|
||||
let override: Decision | null = null;
|
||||
|
||||
if (isToolCallEventType("bash", event)) {
|
||||
tool = "bash";
|
||||
payload = String(event.input.command ?? "");
|
||||
decision = classifyBash(payload, cwd, activeConfig);
|
||||
override = decisionFromOverride(tool, payload, undefined, cwd, activeConfig);
|
||||
} else if (isToolCallEventType("read", event)) {
|
||||
tool = "read";
|
||||
const rawPath = String(event.input.path ?? "");
|
||||
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath));
|
||||
payload = rawPath;
|
||||
decision = classifyPathTool(tool, rawPath, cwd, activeConfig);
|
||||
override = decisionFromOverride(tool, undefined, absPath, cwd, activeConfig);
|
||||
} else if (isToolCallEventType("write", event)) {
|
||||
tool = "write";
|
||||
const rawPath = String(event.input.path ?? "");
|
||||
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath));
|
||||
payload = rawPath;
|
||||
decision = classifyPathTool(tool, rawPath, cwd, activeConfig);
|
||||
override = decisionFromOverride(tool, undefined, absPath, cwd, activeConfig);
|
||||
} else if (isToolCallEventType("edit", event)) {
|
||||
tool = "edit";
|
||||
const rawPath = String(event.input.path ?? "");
|
||||
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath));
|
||||
payload = rawPath;
|
||||
decision = classifyPathTool(tool, rawPath, cwd, activeConfig);
|
||||
override = decisionFromOverride(tool, undefined, absPath, cwd, activeConfig);
|
||||
}
|
||||
|
||||
if (!tool || !decision) return undefined;
|
||||
if (override) decision = override;
|
||||
|
||||
if (decision.action === "allow") return undefined;
|
||||
if (decision.action === "deny" || decision.action === "refine") {
|
||||
return { block: true, reason: formatDecision(decision) };
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Confirmation required but no interactive UI is available.\n${formatDecision(decision)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select(
|
||||
`${decision.title}\n\n${buildConfirmationMessage(tool, payload, decision, cwd)}`,
|
||||
["Allow once", "Block"],
|
||||
);
|
||||
|
||||
if (choice !== "Allow once") {
|
||||
return { block: true, reason: formatDecision(decision) };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
Generated
+1977
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@enne2/pi-policy-gate",
|
||||
"version": "0.1.0",
|
||||
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"security",
|
||||
"policy",
|
||||
"guardrails"
|
||||
],
|
||||
"files": [
|
||||
"index.ts",
|
||||
"README.md",
|
||||
"policy-gate.example.json"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"check": "npm pack --dry-run >/dev/null",
|
||||
"pack:check": "npm pack --dry-run"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"workspaceRoots": [
|
||||
"."
|
||||
],
|
||||
"requireAbsolutePathForRecursiveDelete": true,
|
||||
"requireAbsolutePathForFindDelete": true,
|
||||
"confirmSensitiveReads": true,
|
||||
"confirmSensitiveWrites": true,
|
||||
"confirmWritesOutsideWorkspace": true,
|
||||
"sensitivePathGlobs": [
|
||||
"~/.ssh/**",
|
||||
"~/.aws/**",
|
||||
"~/.config/gcloud/**",
|
||||
"~/.azure/**",
|
||||
"~/.gnupg/**",
|
||||
"~/.pi/**",
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
"**/*.pem",
|
||||
"**/*.key",
|
||||
"**/.netrc",
|
||||
"**/.git-credentials",
|
||||
"~/.bashrc",
|
||||
"~/.zshrc",
|
||||
"~/.profile",
|
||||
"~/.bash_profile"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"id": "allow-known-service-restart",
|
||||
"tool": "bash",
|
||||
"commandRegex": "^sudo systemctl restart my-safe-service$",
|
||||
"action": "allow",
|
||||
"reason": "Known maintenance command explicitly allowlisted by the operator."
|
||||
},
|
||||
{
|
||||
"id": "confirm-specific-ssh-host",
|
||||
"tool": "bash",
|
||||
"commandRegex": "^ssh deploy@staging\\b",
|
||||
"action": "confirm",
|
||||
"reason": "Remote access to staging should still be reviewed interactively."
|
||||
},
|
||||
{
|
||||
"id": "refine-ambiguous-rm",
|
||||
"tool": "bash",
|
||||
"commandRegex": "\\brm\\b.*\\*",
|
||||
"action": "refine",
|
||||
"reason": "Wildcard deletes must be replaced with an explicit full path.",
|
||||
"suggest": "Use an absolute path such as /full/path/to/target and, if needed, preview with find before deleting."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user