Initial MVP: AI Agent Permission Guard — sandbox and audit AI agent actions with configurable policies
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.verdict
|
||||
.venv/
|
||||
coverage/
|
||||
.env
|
||||
*.local
|
||||
@@ -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
|
||||
Generated
+52
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user