Initial MVP: AI Agent Permission Guard — sandbox and audit AI agent actions with configurable policies

This commit is contained in:
Bun Bun
2026-06-18 06:30:32 +00:00
commit 6d9b37ede7
16 changed files with 1386 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.verdict
.venv/
coverage/
.env
*.local
+127
View File
@@ -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
+52
View File
@@ -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"
}
}
}
+22
View File
@@ -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"
}
}
+65
View File
@@ -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"
}
+22
View File
@@ -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"
}
+50
View File
@@ -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);
});
});
+65
View File
@@ -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<AuditEvent, "timestamp">): 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<string, unknown>
): void {
logAudit({ type, allowed: true, target, details });
}
/** Convenience: log a denied action. */
export function logDenied(
type: AuditEventType,
target: string,
reason: string,
details?: Record<string, unknown>
): void {
logAudit({
type: type === "policy:allowed" ? "policy:violation" : type,
allowed: false,
target,
details: { ...details, reason },
});
}
+88
View File
@@ -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 <path> -- <command> [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();
+93
View File
@@ -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);
});
});
+314
View File
@@ -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";
}
}
+17
View File
@@ -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";
+138
View File
@@ -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);
});
});
+201
View File
@@ -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}`,
};
}
+104
View File
@@ -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<string, unknown>;
/** 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;
}
+20
View File
@@ -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"]
}