commit 6d9b37ede71199ed6287fe37185094260be40c6e Author: Bun Bun Date: Thu Jun 18 06:30:32 2026 +0000 Initial MVP: AI Agent Permission Guard — sandbox and audit AI agent actions with configurable policies diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..483473e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.verdict +.venv/ +coverage/ +.env +*.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..f379fdc --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# AI Agent Permission Guard + +A lightweight CLI sandbox and audit tool for AI agent actions. + +## What It Does + +AI agents (Claude Code, Cursor, GitHub Copilot, etc.) can read, write, execute, and +network with minimal constraints. **AI Agent Permission Guard (aapg)** wraps your +agent commands in a configurable permission policy that: + +- **Intercepts** file system reads/writes/deletes +- **Blocks** unauthorized network requests +- **Restricts** child process execution +- **Filters** environment variable access +- **Audits** every action to a structured log + +## Installation + +```bash +npm install -g ai-agent-permission-guard +``` + +Or run directly with `npx`: + +```bash +npx ai-agent-permission-guard --policy policies/readonly.json -- node my-agent.js +``` + +## Usage + +```bash +# Run a script with a read-only policy +aapg --policy policies/readonly.json -- node agent.js + +# Run npm test with the default policy +aapg --policy policies/default.json -- npm test + +# Check the audit log after a run +cat audit.log | jq . +``` + +## Policy Format + +Policies are JSON files that define what an AI agent is allowed to do. + +```json +{ + "name": "readonly", + "version": "1.0.0", + "defaultPermission": "deny", + "filesystem": [ + { + "path": "./**", + "operations": ["read"], + "permission": "allow" + } + ], + "network": [ + { + "host": "*.example.com", + "protocols": ["https"], + "permission": "allow" + } + ], + "exec": [ + { + "command": "node", + "permission": "allow" + } + ], + "envAllowlist": ["NODE_ENV", "PATH"], + "auditLogPath": "./audit.log" +} +``` + +### Permission Levels + +- `allow` — Permit the action +- `deny` — Block the action (throws `PermissionDeniedError`) +- `prompt` — Request user confirmation (future feature, currently allows) + +### Policy Fields + +| Field | Description | +|-------|-------------| +| `name` | Policy name | +| `version` | Policy version | +| `defaultPermission` | Fallback when no rule matches (`allow` / `deny` / `prompt`) | +| `filesystem` | Array of path patterns + allowed operations | +| `network` | Array of host patterns + allowed protocols | +| `exec` | Array of command patterns | +| `envAllowlist` | Environment variables the agent may read | +| `auditLogPath` | Path to append-only NDJSON audit log | + +## Example: Read-Only Policy + +Prevent an AI agent from modifying your codebase: + +```json +{ + "name": "readonly", + "version": "1.0.0", + "defaultPermission": "deny", + "filesystem": [ + { "path": "./**", "operations": ["read"], "permission": "allow" }, + { "path": "/tmp/**", "operations": ["read", "write"], "permission": "allow" } + ], + "network": [], + "exec": [], + "envAllowlist": ["NODE_ENV", "PATH", "HOME"], + "auditLogPath": "./audit.log" +} +``` + +## Audit Log Format + +Each line is a JSON object: + +```json +{"timestamp":"2024-01-15T10:30:00.000Z","type":"fs:write","allowed":false,"target":"/etc/passwd","details":{"reason":"No matching rule — default permission: deny"}} +``` + +Event types: `fs:read`, `fs:write`, `fs:append`, `fs:delete`, `net:request`, `exec:spawn`, `exec:exec`, `env:read`, `policy:violation`. + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7ca1c21 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,52 @@ +{ + "name": "ai-agent-permission-guard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-agent-permission-guard", + "version": "0.1.0", + "license": "MIT", + "bin": { + "aapg": "dist/cli.js", + "ai-agent-permission-guard": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e95bde6 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ai-agent-permission-guard", + "version": "0.1.0", + "description": "Sandbox and audit AI agent actions with configurable permission policies", + "main": "dist/index.js", + "bin": { + "aapg": "dist/cli.js", + "ai-agent-permission-guard": "dist/cli.js" + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/**/*.test.js", + "lint": "echo 'No linter configured'" + }, + "keywords": ["ai", "agent", "sandbox", "permissions", "audit", "security"], + "author": "BunBun Labs", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } +} diff --git a/policies/default.json b/policies/default.json new file mode 100644 index 0000000..986d47f --- /dev/null +++ b/policies/default.json @@ -0,0 +1,65 @@ +{ + "name": "default", + "version": "1.0.0", + "description": "Default policy — allows common development operations with restrictions.", + "defaultPermission": "deny", + "filesystem": [ + { + "path": "./**", + "operations": ["read", "write", "append"], + "permission": "allow" + }, + { + "path": "/tmp/**", + "operations": ["read", "write", "append", "delete"], + "permission": "allow" + }, + { + "path": "/etc/**", + "operations": ["read"], + "permission": "allow" + } + ], + "network": [ + { + "host": "*.npmjs.org", + "protocols": ["https"], + "permission": "allow" + }, + { + "host": "registry.npmjs.org", + "protocols": ["https"], + "permission": "allow" + }, + { + "host": "github.com", + "protocols": ["https"], + "permission": "allow" + }, + { + "host": "api.github.com", + "protocols": ["https"], + "permission": "allow" + } + ], + "exec": [ + { + "command": "node", + "permission": "allow" + }, + { + "command": "npm", + "permission": "allow" + }, + { + "command": "git", + "permission": "allow" + }, + { + "command": "tsc", + "permission": "allow" + } + ], + "envAllowlist": ["NODE_ENV", "PATH", "HOME", "USER", "SHELL", "TMPDIR", "PWD"], + "auditLogPath": "./audit.log" +} diff --git a/policies/readonly.json b/policies/readonly.json new file mode 100644 index 0000000..1f66778 --- /dev/null +++ b/policies/readonly.json @@ -0,0 +1,22 @@ +{ + "name": "readonly", + "version": "1.0.0", + "description": "Read-only policy — allows reading from project directory, blocks all writes, network, and exec.", + "defaultPermission": "deny", + "filesystem": [ + { + "path": "./**", + "operations": ["read"], + "permission": "allow" + }, + { + "path": "/tmp/**", + "operations": ["read", "write"], + "permission": "allow" + } + ], + "network": [], + "exec": [], + "envAllowlist": ["NODE_ENV", "PATH", "HOME"], + "auditLogPath": "./audit.log" +} diff --git a/src/audit.test.ts b/src/audit.test.ts new file mode 100644 index 0000000..68c5151 --- /dev/null +++ b/src/audit.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for audit logger. + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initAuditLog, logAudit } from "./audit.js"; + +describe("audit logger", () => { + let tmpDir: string; + let logPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "aapg-test-")); + logPath = join(tmpDir, "audit.log"); + initAuditLog(logPath); + }); + + afterEach(() => { + try { + unlinkSync(logPath); + rmdirSync(tmpDir); + } catch { + // ignore cleanup errors + } + }); + + it("writes JSON line to log file", () => { + logAudit({ type: "fs:read", allowed: true, target: "/test" }); + const content = readFileSync(logPath, "utf-8"); + const lines = content.trim().split("\n"); + assert.strictEqual(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.strictEqual(entry.type, "fs:read"); + assert.strictEqual(entry.allowed, true); + assert.strictEqual(entry.target, "/test"); + assert.ok(entry.timestamp); + }); + + it("appends multiple entries", () => { + logAudit({ type: "fs:read", allowed: true, target: "/a" }); + logAudit({ type: "fs:write", allowed: false, target: "/b" }); + const content = readFileSync(logPath, "utf-8"); + const lines = content.trim().split("\n"); + assert.strictEqual(lines.length, 2); + }); +}); diff --git a/src/audit.ts b/src/audit.ts new file mode 100644 index 0000000..707d258 --- /dev/null +++ b/src/audit.ts @@ -0,0 +1,65 @@ +/** + * Audit logging — append-only structured log file. + */ + +import { mkdirSync, existsSync, openSync, writeSync, closeSync } from "node:fs"; +import { dirname } from "node:path"; +import type { AuditEvent, AuditEventType } from "./types.js"; + +let globalAuditPath: string | null = null; + +/** Initialize the audit log file path. */ +export function initAuditLog(path: string): void { + globalAuditPath = path; + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** Write a single audit event to the log. */ +export function logAudit(event: Omit): void { + const fullEvent: AuditEvent = { + timestamp: new Date().toISOString(), + ...event, + }; + + const line = JSON.stringify(fullEvent) + "\n"; + + if (globalAuditPath) { + // Use low-level fd operations to bypass any fs patches + const fd = openSync(globalAuditPath, "a"); + try { + writeSync(fd, line); + } finally { + closeSync(fd); + } + } + + // Also write to stderr for real-time monitoring + process.stderr.write(`[AAPG] ${line}`); +} + +/** Convenience: log an allowed action. */ +export function logAllowed( + type: AuditEventType, + target: string, + details?: Record +): void { + logAudit({ type, allowed: true, target, details }); +} + +/** Convenience: log a denied action. */ +export function logDenied( + type: AuditEventType, + target: string, + reason: string, + details?: Record +): void { + logAudit({ + type: type === "policy:allowed" ? "policy:violation" : type, + allowed: false, + target, + details: { ...details, reason }, + }); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..d27792d --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * CLI entry point for AI Agent Permission Guard. + * + * Usage: + * aapg --policy policy.json -- node script.js + * aapg --policy policy.json -- npm test + */ + +import { parseArgs } from "node:util"; +import { loadPolicy } from "./policy.js"; +import { initAuditLog } from "./audit.js"; +import { activateGuard } from "./guard.js"; +import { spawn } from "node:child_process"; + +function printUsage(): void { + console.log(` +AI Agent Permission Guard (aapg) — Sandbox and audit AI agent actions. + +Usage: + aapg --policy -- [args...] + +Options: + --policy Path to JSON policy file (required) + --help Show this help + +Examples: + aapg --policy policies/readonly.json -- node my-agent.js + aapg --policy policies/default.json -- npm run agent:task +`); +} + +function main(): void { + const { values, positionals } = parseArgs({ + options: { + policy: { type: "string" }, + help: { type: "boolean" }, + }, + allowPositionals: true, + }); + + if (values.help) { + printUsage(); + process.exit(0); + } + + if (!values.policy) { + console.error("Error: --policy is required"); + printUsage(); + process.exit(1); + } + + if (positionals.length === 0) { + console.error("Error: No command provided after --"); + printUsage(); + process.exit(1); + } + + // Load and activate policy + const policy = loadPolicy(values.policy); + console.error(`[AAPG] Loaded policy: ${policy.name} v${policy.version}`); + + if (policy.auditLogPath) { + initAuditLog(policy.auditLogPath); + console.error(`[AAPG] Audit log: ${policy.auditLogPath}`); + } + + activateGuard(policy); + console.error(`[AAPG] Guard active. Running: ${positionals.join(" ")}`); + + const [cmd, ...args] = positionals; + + const child = spawn(cmd, args, { + stdio: "inherit", + shell: false, + }); + + child.on("exit", (code) => { + process.exit(code ?? 0); + }); + + child.on("error", (err) => { + console.error(`[AAPG] Failed to spawn: ${err.message}`); + process.exit(1); + }); +} + +main(); diff --git a/src/guard.test.ts b/src/guard.test.ts new file mode 100644 index 0000000..97948dc --- /dev/null +++ b/src/guard.test.ts @@ -0,0 +1,93 @@ +/** + * Integration tests for the runtime guard. + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { activateGuard, deactivateGuard, PermissionDeniedError } from "./guard.js"; +import { initAuditLog } from "./audit.js"; +import type { Policy } from "./types.js"; + +describe("guard integration", () => { + let tmpDir: string; + let logPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "aapg-guard-test-")); + logPath = join(tmpDir, "audit.log"); + initAuditLog(logPath); + }); + + afterEach(() => { + deactivateGuard(); + try { + unlinkSync(logPath); + } catch {} + try { + // cleanup any test files + } catch {} + }); + + it("blocks fs write when policy denies", () => { + const policy: Policy = { + name: "deny-all", + version: "1.0.0", + defaultPermission: "deny", + }; + activateGuard(policy); + + const testFile = join(tmpDir, "blocked.txt"); + assert.throws(() => { + writeFileSync(testFile, "should not write"); + }, (err: any) => err instanceof PermissionDeniedError || err.code === "EPERMISSION"); + }); + + it("allows fs write when policy allows", () => { + const policy: Policy = { + name: "allow-tmp", + version: "1.0.0", + defaultPermission: "deny", + filesystem: [ + { path: tmpDir + "/**", operations: ["read", "write", "append", "delete"], permission: "allow" }, + ], + }; + activateGuard(policy); + + const testFile = join(tmpDir, "allowed.txt"); + writeFileSync(testFile, "hello"); + assert.strictEqual(readFileSync(testFile, "utf-8"), "hello"); + unlinkSync(testFile); + }); + + it("blocks fs read when policy denies", () => { + const testFile = join(tmpDir, "secret.txt"); + writeFileSync(testFile, "secret"); + + const policy: Policy = { + name: "deny-all", + version: "1.0.0", + defaultPermission: "deny", + }; + activateGuard(policy); + + assert.throws(() => { + readFileSync(testFile, "utf-8"); + }, (err: any) => err instanceof PermissionDeniedError || err.code === "EPERMISSION"); + }); + + it("allows env read for allowlisted vars", () => { + const policy: Policy = { + name: "env-test", + version: "1.0.0", + defaultPermission: "deny", + envAllowlist: ["PATH"], + }; + activateGuard(policy); + + const path = process.env.PATH; + assert.ok(path); + }); +}); diff --git a/src/guard.ts b/src/guard.ts new file mode 100644 index 0000000..6712e81 --- /dev/null +++ b/src/guard.ts @@ -0,0 +1,314 @@ +/** + * Runtime guard — patches Node.js built-in modules to enforce policy. + */ + +import type { Policy } from "./types.js"; +import { + checkFsPermission, + checkNetworkPermission, + checkExecPermission, + checkEnvPermission, +} from "./policy.js"; +import { logAllowed, logDenied } from "./audit.js"; + +let activePolicy: Policy | null = null; + +/** Activate the guard with a policy. */ +export function activateGuard(policy: Policy): void { + activePolicy = policy; + patchFs(); + patchChildProcess(); + patchProcessEnv(); + patchNet(); +} + +/** Get the currently active policy. */ +export function getActivePolicy(): Policy | null { + return activePolicy; +} + +/** Deactivate the guard (mainly for testing). */ +export function deactivateGuard(): void { + activePolicy = null; +} + +/** Check if guard is currently active. */ +export function isGuardActive(): boolean { + return activePolicy !== null; +} + +// ------------------------------------------------------------------ +// File System Patches +// ------------------------------------------------------------------ + +function patchFs(): void { + const fs = require("node:fs") as typeof import("node:fs"); + + const origReadFileSync = fs.readFileSync; + (fs as any).readFileSync = function ( + path: unknown, + options?: unknown + ): string | Buffer { + if (!isGuardActive()) return origReadFileSync(path as any, options as any); + const pathStr = pathToString(path); + const result = checkFsPermission(activePolicy!, pathStr, "read"); + if (!result.allowed) { + logDenied("fs:read", pathStr, result.reason); + throw new PermissionDeniedError(`fs:read denied for ${pathStr}: ${result.reason}`); + } + logAllowed("fs:read", pathStr); + return origReadFileSync(path as any, options as any); + }; + + const origWriteFileSync = fs.writeFileSync; + (fs as any).writeFileSync = function ( + path: unknown, + data: unknown, + options?: unknown + ): void { + if (!isGuardActive()) return origWriteFileSync(path as any, data as any, options as any); + const pathStr = pathToString(path); + const op = isAppend(options) ? "append" : "write"; + const result = checkFsPermission(activePolicy!, pathStr, op); + if (!result.allowed) { + logDenied(`fs:${op}` as any, pathStr, result.reason); + throw new PermissionDeniedError(`fs:${op} denied for ${pathStr}: ${result.reason}`); + } + logAllowed(`fs:${op}` as any, pathStr); + return origWriteFileSync(path as any, data as any, options as any); + }; + + const origAppendFileSync = fs.appendFileSync; + (fs as any).appendFileSync = function ( + path: unknown, + data: unknown, + options?: unknown + ): void { + if (!isGuardActive()) return origAppendFileSync(path as any, data as any, options as any); + const pathStr = pathToString(path); + const result = checkFsPermission(activePolicy!, pathStr, "append"); + if (!result.allowed) { + logDenied("fs:append", pathStr, result.reason); + throw new PermissionDeniedError(`fs:append denied for ${pathStr}: ${result.reason}`); + } + logAllowed("fs:append", pathStr); + return origAppendFileSync(path as any, data as any, options as any); + }; + + const origUnlinkSync = fs.unlinkSync; + (fs as any).unlinkSync = function (path: unknown): void { + if (!isGuardActive()) return origUnlinkSync(path as any); + const pathStr = pathToString(path); + const result = checkFsPermission(activePolicy!, pathStr, "delete"); + if (!result.allowed) { + logDenied("fs:delete", pathStr, result.reason); + throw new PermissionDeniedError(`fs:delete denied for ${pathStr}: ${result.reason}`); + } + logAllowed("fs:delete", pathStr); + return origUnlinkSync(path as any); + }; + + const origExistsSync = fs.existsSync; + (fs as any).existsSync = function (path: unknown): boolean { + if (!isGuardActive()) return origExistsSync(path as any); + const pathStr = pathToString(path); + const result = checkFsPermission(activePolicy!, pathStr, "read"); + if (!result.allowed) { + logDenied("fs:read", pathStr, result.reason); + return false; + } + return origExistsSync(path as any); + }; +} + +function pathToString(path: unknown): string { + if (typeof path === "string") return path; + if (Buffer.isBuffer(path)) return path.toString("utf-8"); + if (path instanceof URL) return path.pathname; + return String(path); +} + +function isAppend(options: unknown): boolean { + if (typeof options === "object" && options !== null) { + const flag = (options as any).flag; + return flag === "a" || flag === "as" || flag === "ax" || flag === "as+" || flag === "ax+"; + } + return false; +} + +// ------------------------------------------------------------------ +// Child Process Patches +// ------------------------------------------------------------------ + +function patchChildProcess(): void { + const cp = require("node:child_process") as typeof import("node:child_process"); + + const origSpawn = cp.spawn; + (cp as any).spawn = function ( + command: string, + args?: unknown, + options?: unknown + ): any { + if (!isGuardActive()) { + if (args && !Array.isArray(args)) return origSpawn(command, args as any); + return origSpawn(command, args as any, options as any); + } + const result = checkExecPermission(activePolicy!, command); + if (!result.allowed) { + logDenied("exec:spawn", command, result.reason, { args }); + throw new PermissionDeniedError(`exec:spawn denied for ${command}: ${result.reason}`); + } + logAllowed("exec:spawn", command, { args: args || [] }); + if (args && !Array.isArray(args)) { + return origSpawn(command, args as any); + } + return origSpawn(command, args as any, options as any); + }; + + const origExec = cp.exec; + (cp as any).exec = function ( + command: string, + options?: unknown, + callback?: unknown + ): any { + if (!isGuardActive()) return origExec(command, options as any, callback as any); + const cmd = command.split(" ")[0]; + const result = checkExecPermission(activePolicy!, cmd); + if (!result.allowed) { + logDenied("exec:exec", command, result.reason); + throw new PermissionDeniedError(`exec:exec denied for ${command}: ${result.reason}`); + } + logAllowed("exec:exec", command); + return origExec(command, options as any, callback as any); + }; + + const origExecSync = cp.execSync; + (cp as any).execSync = function (command: string, options?: unknown): any { + if (!isGuardActive()) return origExecSync(command, options as any); + const cmd = command.split(" ")[0]; + const result = checkExecPermission(activePolicy!, cmd); + if (!result.allowed) { + logDenied("exec:exec", command, result.reason); + throw new PermissionDeniedError(`exec:exec denied for ${command}: ${result.reason}`); + } + logAllowed("exec:exec", command); + return origExecSync(command, options as any); + }; + + const origSpawnSync = cp.spawnSync; + (cp as any).spawnSync = function ( + command: string, + args?: unknown, + options?: unknown + ): any { + if (!isGuardActive()) { + if (args && !Array.isArray(args)) return origSpawnSync(command, args as any); + return origSpawnSync(command, args as any, options as any); + } + const result = checkExecPermission(activePolicy!, command); + if (!result.allowed) { + logDenied("exec:spawn", command, result.reason, { args }); + throw new PermissionDeniedError(`exec:spawn denied for ${command}: ${result.reason}`); + } + logAllowed("exec:spawn", command, { args: args || [] }); + if (args && !Array.isArray(args)) { + return origSpawnSync(command, args as any); + } + return origSpawnSync(command, args as any, options as any); + }; +} + +// ------------------------------------------------------------------ +// Process Env Patch +// ------------------------------------------------------------------ + +function patchProcessEnv(): void { + const origEnv = process.env; + const proxy = new Proxy(origEnv, { + get(target, prop: string) { + if (!isGuardActive()) return target[prop]; + const result = checkEnvPermission(activePolicy!, prop); + if (!result.allowed) { + logDenied("env:read", prop, result.reason); + return undefined; + } + logAllowed("env:read", prop); + return target[prop]; + }, + set() { + // Block all env writes when guard is active + if (!isGuardActive()) return false; + return false; + }, + has(target, prop: string) { + if (!isGuardActive()) return prop in target; + const result = checkEnvPermission(activePolicy!, prop); + return result.allowed && prop in target; + }, + }); + // @ts-ignore — replacing process.env + process.env = proxy; +} + +// ------------------------------------------------------------------ +// Network Patch (basic — intercept http/https module) +// ------------------------------------------------------------------ + +function patchNet(): void { + try { + const http = require("node:http") as typeof import("node:http"); + const https = require("node:https") as typeof import("node:https"); + + const patchModule = (mod: typeof http | typeof https, proto: "http" | "https") => { + const origRequest = mod.request; + (mod as any).request = function ( + options: unknown, + callback?: unknown + ): any { + if (!isGuardActive()) return origRequest(options as any, callback as any); + let host: string; + let port: number; + + if (typeof options === "string") { + const url = new URL(options); + host = url.hostname; + port = parseInt(url.port || (proto === "https" ? "443" : "80"), 10); + } else if (options instanceof URL) { + host = options.hostname; + port = parseInt(options.port || (proto === "https" ? "443" : "80"), 10); + } else { + const opts = options as any; + host = opts.hostname || opts.host || "localhost"; + port = opts.port || (proto === "https" ? 443 : 80); + } + + const result = checkNetworkPermission(activePolicy!, host, port, proto); + if (!result.allowed) { + logDenied("net:request", `${host}:${port}`, result.reason, { protocol: proto }); + throw new PermissionDeniedError( + `net:request denied for ${host}:${port}: ${result.reason}` + ); + } + logAllowed("net:request", `${host}:${port}`, { protocol: proto }); + return origRequest(options as any, callback as any); + }; + }; + + patchModule(http, "http"); + patchModule(https, "https"); + } catch { + // http/https module may not be available in all contexts + } +} + +// ------------------------------------------------------------------ +// Custom Error +// ------------------------------------------------------------------ + +export class PermissionDeniedError extends Error { + readonly code = "EPERMISSION"; + constructor(message: string) { + super(message); + this.name = "PermissionDeniedError"; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a292342 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,17 @@ +/** + * Library exports for AI Agent Permission Guard. + */ + +export { loadPolicy, validatePolicy, checkFsPermission, checkNetworkPermission, checkExecPermission, checkEnvPermission } from "./policy.js"; +export { initAuditLog, logAudit, logAllowed, logDenied } from "./audit.js"; +export { activateGuard, deactivateGuard, getActivePolicy, PermissionDeniedError } from "./guard.js"; +export type { + Policy, + FsPermission, + NetworkPermission, + ExecPermission, + PermissionLevel, + AuditEvent, + AuditEventType, + PermissionResult, +} from "./types.js"; diff --git a/src/policy.test.ts b/src/policy.test.ts new file mode 100644 index 0000000..cf0fc64 --- /dev/null +++ b/src/policy.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for policy engine. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + validatePolicy, + checkFsPermission, + checkNetworkPermission, + checkExecPermission, + checkEnvPermission, +} from "./policy.js"; +import type { Policy } from "./types.js"; + +const basePolicy: Policy = { + name: "test", + version: "1.0.0", + defaultPermission: "deny", +}; + +describe("validatePolicy", () => { + it("accepts valid policy", () => { + assert.doesNotThrow(() => validatePolicy(basePolicy)); + }); + + it("rejects missing name", () => { + assert.throws(() => validatePolicy({ ...basePolicy, name: undefined as any })); + }); + + it("rejects missing version", () => { + assert.throws(() => validatePolicy({ ...basePolicy, version: undefined as any })); + }); + + it("rejects invalid defaultPermission", () => { + assert.throws(() => validatePolicy({ ...basePolicy, defaultPermission: "maybe" as any })); + }); +}); + +describe("checkFsPermission", () => { + it("allows read when rule matches", () => { + const policy: Policy = { + ...basePolicy, + filesystem: [{ path: "./src/**", operations: ["read"], permission: "allow" }], + }; + const result = checkFsPermission(policy, "./src/index.ts", "read"); + assert.strictEqual(result.allowed, true); + }); + + it("denies write when only read is allowed", () => { + const policy: Policy = { + ...basePolicy, + filesystem: [{ path: "./src/**", operations: ["read"], permission: "allow" }], + }; + const result = checkFsPermission(policy, "./src/index.ts", "write"); + assert.strictEqual(result.allowed, false); + }); + + it("denies when no rule matches and default is deny", () => { + const result = checkFsPermission(basePolicy, "/etc/passwd", "read"); + assert.strictEqual(result.allowed, false); + }); + + it("allows when default is allow", () => { + const policy: Policy = { ...basePolicy, defaultPermission: "allow" }; + const result = checkFsPermission(policy, "/any/path", "read"); + assert.strictEqual(result.allowed, true); + }); +}); + +describe("checkNetworkPermission", () => { + it("allows matching host", () => { + const policy: Policy = { + ...basePolicy, + network: [{ host: "example.com", protocols: ["https"], permission: "allow" }], + }; + const result = checkNetworkPermission(policy, "example.com", 443, "https"); + assert.strictEqual(result.allowed, true); + }); + + it("denies non-matching host", () => { + const policy: Policy = { + ...basePolicy, + network: [{ host: "example.com", protocols: ["https"], permission: "allow" }], + }; + const result = checkNetworkPermission(policy, "evil.com", 443, "https"); + assert.strictEqual(result.allowed, false); + }); + + it("allows wildcard subdomain", () => { + const policy: Policy = { + ...basePolicy, + network: [{ host: "*.example.com", protocols: ["https"], permission: "allow" }], + }; + const result = checkNetworkPermission(policy, "api.example.com", 443, "https"); + assert.strictEqual(result.allowed, true); + }); +}); + +describe("checkExecPermission", () => { + it("allows matching command", () => { + const policy: Policy = { + ...basePolicy, + exec: [{ command: "node", permission: "allow" }], + }; + const result = checkExecPermission(policy, "node"); + assert.strictEqual(result.allowed, true); + }); + + it("denies non-matching command", () => { + const policy: Policy = { + ...basePolicy, + exec: [{ command: "node", permission: "allow" }], + }; + const result = checkExecPermission(policy, "rm"); + assert.strictEqual(result.allowed, false); + }); +}); + +describe("checkEnvPermission", () => { + it("allows env in allowlist", () => { + const policy: Policy = { + ...basePolicy, + envAllowlist: ["NODE_ENV"], + }; + const result = checkEnvPermission(policy, "NODE_ENV"); + assert.strictEqual(result.allowed, true); + }); + + it("denies env not in allowlist", () => { + const policy: Policy = { + ...basePolicy, + envAllowlist: ["NODE_ENV"], + }; + const result = checkEnvPermission(policy, "SECRET_KEY"); + assert.strictEqual(result.allowed, false); + }); +}); diff --git a/src/policy.ts b/src/policy.ts new file mode 100644 index 0000000..01b4529 --- /dev/null +++ b/src/policy.ts @@ -0,0 +1,201 @@ +/** + * Policy loading, validation, and permission checking. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { resolve, normalize } from "node:path"; +import type { + Policy, + FsPermission, + NetworkPermission, + ExecPermission, + PermissionResult, + PermissionLevel, +} from "./types.js"; + +/** Load a policy from a JSON file. */ +export function loadPolicy(path: string): Policy { + if (!existsSync(path)) { + throw new Error(`Policy file not found: ${path}`); + } + const raw = readFileSync(path, "utf-8"); + const policy = JSON.parse(raw) as Policy; + validatePolicy(policy); + return policy; +} + +/** Validate a policy structure. */ +export function validatePolicy(policy: Policy): void { + if (!policy.name || typeof policy.name !== "string") { + throw new Error("Policy missing required field: name"); + } + if (!policy.version || typeof policy.version !== "string") { + throw new Error("Policy missing required field: version"); + } + if (!policy.defaultPermission || !isValidPermission(policy.defaultPermission)) { + throw new Error("Policy missing or invalid defaultPermission"); + } +} + +function isValidPermission(p: string): p is PermissionLevel { + return p === "allow" || p === "deny" || p === "prompt"; +} + +/** Check if a path matches a glob pattern. Simple glob support: * and ** */ +function matchPattern(value: string, pattern: string): boolean { + const normalizedValue = normalize(value); + const normalizedPattern = normalize(pattern); + + if (normalizedPattern === "*") return true; + if (normalizedPattern === normalizedValue) return true; + + // Convert glob to regex + let regexStr = "^"; + for (let i = 0; i < normalizedPattern.length; i++) { + const c = normalizedPattern[i]; + if (c === "*" && normalizedPattern[i + 1] === "*") { + regexStr += ".*"; + i++; // skip second * + } else if (c === "*") { + regexStr += "[^/]*"; + } else if (c === "?") { + regexStr += "."; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + regexStr += "\\" + c; + } else { + regexStr += c; + } + } + regexStr += "$"; + + const regex = new RegExp(regexStr); + return regex.test(normalizedValue); +} + +/** Check if a host matches a host pattern. */ +function matchHost(host: string, pattern: string): boolean { + if (pattern === "*") return true; + if (pattern === host) return true; + if (pattern.startsWith("*.")) { + const suffix = pattern.slice(2); + return host === suffix || host.endsWith("." + suffix); + } + return false; +} + +/** Resolve a path relative to cwd. */ +function resolvePath(p: string): string { + return resolve(p); +} + +/** Check file system permission. */ +export function checkFsPermission( + policy: Policy, + path: string, + operation: "read" | "write" | "append" | "delete" | "execute" +): PermissionResult { + const resolved = resolvePath(path); + + if (policy.filesystem) { + for (const rule of policy.filesystem) { + const rulePath = resolvePath(rule.path); + if (matchPattern(resolved, rulePath)) { + const opAllowed = rule.operations.includes(operation); + if (opAllowed) { + return { + allowed: rule.permission !== "deny", + prompt: rule.permission === "prompt", + rule, + reason: `Matched rule for ${rule.path}: ${rule.permission}`, + }; + } + } + } + } + + // No matching rule — apply default + return { + allowed: policy.defaultPermission !== "deny", + prompt: policy.defaultPermission === "prompt", + reason: `No matching rule — default permission: ${policy.defaultPermission}`, + }; +} + +/** Check network permission. */ +export function checkNetworkPermission( + policy: Policy, + host: string, + port: number, + protocol: "http" | "https" | "ws" | "wss" +): PermissionResult { + if (policy.network) { + for (const rule of policy.network) { + if (matchHost(host, rule.host)) { + const portAllowed = !rule.ports || rule.ports.includes(port); + const protoAllowed = !rule.protocols || rule.protocols.includes(protocol); + if (portAllowed && protoAllowed) { + return { + allowed: rule.permission !== "deny", + prompt: rule.permission === "prompt", + rule, + reason: `Matched network rule for ${rule.host}: ${rule.permission}`, + }; + } + } + } + } + + return { + allowed: policy.defaultPermission !== "deny", + prompt: policy.defaultPermission === "prompt", + reason: `No matching network rule — default: ${policy.defaultPermission}`, + }; +} + +/** Check execution permission. */ +export function checkExecPermission( + policy: Policy, + command: string +): PermissionResult { + if (policy.exec) { + for (const rule of policy.exec) { + if (matchPattern(command, rule.command)) { + return { + allowed: rule.permission !== "deny", + prompt: rule.permission === "prompt", + rule, + reason: `Matched exec rule for ${rule.command}: ${rule.permission}`, + }; + } + } + } + + return { + allowed: policy.defaultPermission !== "deny", + prompt: policy.defaultPermission === "prompt", + reason: `No matching exec rule — default: ${policy.defaultPermission}`, + }; +} + +/** Check environment variable read permission. */ +export function checkEnvPermission( + policy: Policy, + key: string +): PermissionResult { + if (policy.envAllowlist && policy.envAllowlist.length > 0) { + const allowed = policy.envAllowlist.includes(key); + return { + allowed, + prompt: false, + reason: allowed + ? `Env var ${key} in allowlist` + : `Env var ${key} not in allowlist`, + }; + } + + return { + allowed: policy.defaultPermission !== "deny", + prompt: policy.defaultPermission === "prompt", + reason: `No env allowlist — default: ${policy.defaultPermission}`, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f74003a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,104 @@ +/** + * Core type definitions for AI Agent Permission Guard. + */ + +/** Permission levels for operations */ +export type PermissionLevel = "allow" | "deny" | "prompt"; + +/** A glob pattern or exact path */ +export type PathPattern = string; + +/** Permission rule for file system operations */ +export interface FsPermission { + /** Path pattern (glob or exact path) */ + path: PathPattern; + /** Allowed operations */ + operations: Array<"read" | "write" | "append" | "delete" | "execute">; + /** Permission level */ + permission: PermissionLevel; +} + +/** Permission rule for network operations */ +export interface NetworkPermission { + /** Host pattern (glob or exact host, e.g., "*.example.com") */ + host: string; + /** Allowed ports (empty = all) */ + ports?: number[]; + /** Allowed protocols */ + protocols?: Array<"http" | "https" | "ws" | "wss">; + /** Permission level */ + permission: PermissionLevel; +} + +/** Permission rule for child process execution */ +export interface ExecPermission { + /** Command pattern (exact or glob) */ + command: string; + /** Allowed arguments patterns */ + args?: string[]; + /** Permission level */ + permission: PermissionLevel; +} + +/** The complete permission policy */ +export interface Policy { + /** Policy name */ + name: string; + /** Policy version */ + version: string; + /** File system permissions */ + filesystem?: FsPermission[]; + /** Network permissions */ + network?: NetworkPermission[]; + /** Execution permissions */ + exec?: ExecPermission[]; + /** Environment variable allowlist (empty = all blocked) */ + envAllowlist?: string[]; + /** Default permission when no rule matches */ + defaultPermission: PermissionLevel; + /** Audit log output path */ + auditLogPath?: string; +} + +/** Types of audit events */ +export type AuditEventType = + | "fs:read" + | "fs:write" + | "fs:append" + | "fs:delete" + | "fs:execute" + | "net:connect" + | "net:request" + | "exec:spawn" + | "exec:exec" + | "env:read" + | "policy:violation" + | "policy:allowed"; + +/** A single audit log entry */ +export interface AuditEvent { + /** ISO timestamp */ + timestamp: string; + /** Event type */ + type: AuditEventType; + /** Whether the action was allowed */ + allowed: boolean; + /** The target of the action (path, URL, command) */ + target: string; + /** Additional details */ + details?: Record; + /** Stack trace at point of interception */ + stack?: string; +} + +/** Result of a permission check */ +export interface PermissionResult { + /** Whether the action is allowed */ + allowed: boolean; + /** Whether user should be prompted */ + prompt: boolean; + /** The matching rule, if any */ + rule?: FsPermission | NetworkPermission | ExecPermission; + /** Human-readable reason */ + reason: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..156b6d5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}