commit
f10266ca97
6 changed files with 2954 additions and 0 deletions
@ -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; |
||||||
|
}); |
||||||
|
} |
||||||
@ -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." |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
Loading…
Reference in new issue