From 2331e5ef46075737c28bba452937079b5f0b01d0 Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Sun, 14 Jun 2026 13:18:49 +0000 Subject: [PATCH] =?UTF-8?q?v1.0.0=20=E2=80=94=20AI=20Agent=20Safety=20Guar?= =?UTF-8?q?d=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++ .verdict | 1 + package-lock.json | 51 ++++++++++++++++ package.json | 22 +++++++ src/audit.test.ts | 66 +++++++++++++++++++++ src/audit.ts | 40 +++++++++++++ src/cli.ts | 136 +++++++++++++++++++++++++++++++++++++++++++ src/config.test.ts | 46 +++++++++++++++ src/config.ts | 71 ++++++++++++++++++++++ src/executor.test.ts | 72 +++++++++++++++++++++++ src/executor.ts | 124 +++++++++++++++++++++++++++++++++++++++ src/guard.test.ts | 69 ++++++++++++++++++++++ src/guard.ts | 94 ++++++++++++++++++++++++++++++ src/types.ts | 53 +++++++++++++++++ tsconfig.json | 18 ++++++ 15 files changed, 869 insertions(+) create mode 100644 .gitignore create mode 100644 .verdict create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/audit.test.ts create mode 100644 src/audit.ts create mode 100644 src/cli.ts create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/executor.test.ts create mode 100644 src/executor.ts create mode 100644 src/guard.test.ts create mode 100644 src/guard.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96c94f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.venv/ +*.audit.log +.audit/ diff --git a/.verdict b/.verdict new file mode 100644 index 0000000..cc8bf1e --- /dev/null +++ b/.verdict @@ -0,0 +1 @@ +built \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3272bfa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "ai-agent-safety-guard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-agent-safety-guard", + "version": "1.0.0", + "license": "MIT", + "bin": { + "ai-guard": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.4.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..b6ad88a --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ai-agent-safety-guard", + "version": "1.0.0", + "description": "CLI tool that wraps AI agent executions with configurable guardrails, permission controls, and audit trails", + "type": "module", + "main": "dist/index.js", + "bin": { + "ai-guard": "dist/cli.js" + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/**/*.test.js", + "lint": "tsc --noEmit" + }, + "keywords": ["ai", "agent", "safety", "sandbox", "guardrails"], + "author": "BunBun Labs", + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.0", + "@types/node": "^20.0.0" + } +} diff --git a/src/audit.test.ts b/src/audit.test.ts new file mode 100644 index 0000000..04b8b4f --- /dev/null +++ b/src/audit.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { writeAuditLog, createAuditEntry } from './audit.js'; +import { DEFAULT_CONFIG } from './config.js'; +import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AuditEntry } from './types.js'; + +describe('audit', () => { + it('writes audit entry to JSONL file', () => { + const dir = mkdtempSync(join(tmpdir(), 'audit-test-')); + const config = { ...DEFAULT_CONFIG, audit: { ...DEFAULT_CONFIG.audit, enabled: true, logDir: dir } }; + const entry: AuditEntry = { + timestamp: new Date().toISOString(), + command: 'ls', + args: ['-la'], + env: { PATH: '/bin' }, + cwd: '/tmp', + pid: 123, + result: 'allowed', + durationMs: 100, + }; + writeAuditLog(entry, config); + const files = readFileSync(dir + '/audit-' + new Date().toISOString().split('T')[0] + '.jsonl', 'utf-8'); + const parsed = JSON.parse(files.trim()) as AuditEntry; + assert.strictEqual(parsed.command, 'ls'); + assert.strictEqual(parsed.result, 'allowed'); + rmSync(dir, { recursive: true }); + }); + + it('does nothing when audit is disabled', () => { + const config = { ...DEFAULT_CONFIG, audit: { ...DEFAULT_CONFIG.audit, enabled: false } }; + const entry: AuditEntry = { + timestamp: new Date().toISOString(), + command: 'ls', + args: ['-la'], + env: {}, + cwd: '/tmp', + pid: 123, + result: 'allowed', + durationMs: 100, + }; + writeAuditLog(entry, config); + assert.ok(true); // no crash + }); + + it('creates an audit entry with all fields', () => { + const entry = createAuditEntry( + { command: 'node', args: ['script.js'], env: { NODE_ENV: 'test' }, cwd: '/home', pid: 456 }, + 'blocked', + 200, + 'blocked command', + null, + 'stdout text', + 'stderr text' + ); + assert.strictEqual(entry.command, 'node'); + assert.strictEqual(entry.result, 'blocked'); + assert.strictEqual(entry.reason, 'blocked command'); + assert.strictEqual(entry.exitCode, null); + assert.strictEqual(entry.stdout, 'stdout text'); + assert.strictEqual(entry.stderr, 'stderr text'); + assert.strictEqual(entry.durationMs, 200); + }); +}); diff --git a/src/audit.ts b/src/audit.ts new file mode 100644 index 0000000..d0359c0 --- /dev/null +++ b/src/audit.ts @@ -0,0 +1,40 @@ +import { appendFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { SafetyConfig, AuditEntry } from './types.js'; + +export function writeAuditLog(entry: AuditEntry, config: SafetyConfig): void { + if (!config.audit.enabled) return; + const dir = resolve(config.audit.logDir); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const date = new Date().toISOString().split('T')[0]; + const logFile = resolve(dir, `audit-${date}.jsonl`); + const line = JSON.stringify(entry) + '\n'; + appendFileSync(logFile, line, 'utf-8'); +} + +export function createAuditEntry( + options: { command: string; args: string[]; env: Record; cwd: string; pid: number }, + result: 'allowed' | 'blocked' | 'error' | 'timeout', + durationMs: number, + reason?: string, + exitCode?: number | null, + stdout?: string, + stderr?: string +): AuditEntry { + return { + timestamp: new Date().toISOString(), + command: options.command, + args: options.args, + env: options.env, + cwd: options.cwd, + pid: options.pid, + result, + reason, + exitCode, + stdout, + stderr, + durationMs, + }; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..2d8e915 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import { readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { resolve, basename } from 'node:path'; +import { loadConfig, validateConfig, DEFAULT_CONFIG } from './config.js'; +import { executeGuarded } from './executor.js'; +import type { SafetyConfig } from './types.js'; + +function printHelp(): void { + console.log(` +AI Agent Safety Guard — v1.0.0 + +Usage: + ai-guard [options] -- [args...] + +Options: + -c, --config Path to safety-guard.json config file + -v, --validate Validate a config file and exit + -i, --init Write a sample safety-guard.json to current directory + -h, --help Show this help + +Examples: + ai-guard -- ls -la + ai-guard -c my-config.json -- npm install + ai-guard -v -c my-config.json + ai-guard -- node script.js +`); +} + +// Top-level await not supported in older targets; use async IIFE +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('-h') || args.includes('--help')) { + printHelp(); + process.exit(0); + } + + let configPath: string | undefined; + let validateOnly = false; + let initConfig = false; + let commandStart = -1; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '-c' || arg === '--config') { + configPath = args[i + 1]; + i++; + } else if (arg === '-v' || arg === '--validate') { + validateOnly = true; + } else if (arg === '-i' || arg === '--init') { + initConfig = true; + } else if (arg === '--') { + commandStart = i + 1; + break; + } else if (!arg.startsWith('-')) { + commandStart = i; + break; + } + } + + if (initConfig) { + const sample: SafetyConfig = { + version: 1, + name: 'sample', + rules: { + blockedCommands: ['rm', 'dd', 'mkfs', 'fdisk', 'format', 'chmod', 'chown', 'sudo', 'su'], + allowedPaths: ['/home/user/project', '/tmp'], + blockedPaths: ['/etc', '/sys', '/dev', '/proc', '/boot', '/usr/bin/sudo', '/root', '/var/log'], + allowedEnvVars: ['PATH', 'HOME', 'USER', 'NODE_ENV', 'PWD', 'SHELL', 'TERM', 'LANG'], + blockedEnvVars: ['AWS_SECRET_ACCESS_KEY', 'GITHUB_TOKEN', 'PRIVATE_KEY', 'SSH_KEY', 'DATABASE_URL', 'OPENAI_API_KEY'], + maxProcessCount: 10, + maxExecutionTimeMs: 300000, + requireExplicitPaths: true, + allowNetwork: false, + }, + audit: { + enabled: true, + logDir: '.audit', + includeStdout: true, + includeStderr: true, + }, + }; + const path = resolve('safety-guard.json'); + if (existsSync(path)) { + console.error('safety-guard.json already exists'); + process.exit(1); + } + writeFileSync(path, JSON.stringify(sample, null, 2), 'utf-8'); + console.log('Wrote sample config to safety-guard.json'); + process.exit(0); + } + + const config = loadConfig(configPath); + + if (validateOnly) { + const errors = validateConfig(config); + if (errors.length === 0) { + console.log('Config is valid'); + process.exit(0); + } else { + console.error('Config validation errors:'); + for (const err of errors) { + console.error(' - ' + err); + } + process.exit(1); + } + } + + if (commandStart < 0 || commandStart >= args.length) { + console.error('No command specified. Use -- before the command.'); + printHelp(); + process.exit(1); + } + + const command = args[commandStart]; + const commandArgs = args.slice(commandStart + 1); + + const result = await executeGuarded( + { command, args: commandArgs, cwd: process.cwd(), env: {} }, + config + ); + + if (!result.allowed) { + console.error(result.stderr); + process.exit(1); + } + + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exit(result.exitCode ?? 0); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..e281ea8 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { loadConfig, validateConfig, mergeConfig, DEFAULT_CONFIG, ensureAuditDir } from './config.js'; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +describe('config', () => { + it('returns default config when file does not exist', () => { + const config = loadConfig('/nonexistent/path.json'); + assert.strictEqual(config.name, 'default'); + assert.deepStrictEqual(config.rules.blockedCommands, DEFAULT_CONFIG.rules.blockedCommands); + }); + + it('loads and merges custom config', () => { + const dir = mkdtempSync(join(tmpdir(), 'guard-test-')); + const path = join(dir, 'config.json'); + writeFileSync(path, JSON.stringify({ name: 'custom', rules: { maxProcessCount: 5 } }), 'utf-8'); + const config = loadConfig(path); + assert.strictEqual(config.name, 'custom'); + assert.strictEqual(config.rules.maxProcessCount, 5); + assert.deepStrictEqual(config.rules.blockedCommands, DEFAULT_CONFIG.rules.blockedCommands); + rmSync(dir, { recursive: true }); + }); + + it('validates a correct config', () => { + const errors = validateConfig(DEFAULT_CONFIG); + assert.strictEqual(errors.length, 0); + }); + + it('catches invalid config', () => { + const bad = { ...DEFAULT_CONFIG, name: '', rules: { ...DEFAULT_CONFIG.rules, maxExecutionTimeMs: 500 } }; + const errors = validateConfig(bad); + assert.ok(errors.length > 0); + assert.ok(errors.some(e => e.includes('maxExecutionTimeMs'))); + }); + + it('creates audit directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'guard-audit-')); + const subDir = join(dir, 'nested', '.audit'); + const config = { ...DEFAULT_CONFIG, audit: { ...DEFAULT_CONFIG.audit, logDir: subDir } }; + ensureAuditDir(config); + assert.ok(existsSync(subDir)); + rmSync(dir, { recursive: true }); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..4daf261 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,71 @@ +import { readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import type { SafetyConfig } from './types.js'; + +export const DEFAULT_CONFIG: SafetyConfig = { + version: 1, + name: 'default', + rules: { + blockedCommands: ['rm', 'dd', 'mkfs', 'fdisk', 'format'], + allowedPaths: [], + blockedPaths: ['/etc', '/sys', '/dev', '/proc', '/boot', '/usr/bin/sudo'], + allowedEnvVars: ['PATH', 'HOME', 'USER', 'NODE_ENV', 'PWD'], + blockedEnvVars: ['AWS_SECRET_ACCESS_KEY', 'GITHUB_TOKEN', 'PRIVATE_KEY', 'SSH_KEY'], + maxProcessCount: 10, + maxExecutionTimeMs: 300000, + requireExplicitPaths: false, + allowNetwork: true, + }, + audit: { + enabled: true, + logDir: '.audit', + includeStdout: true, + includeStderr: true, + }, +}; + +export function loadConfig(path?: string): SafetyConfig { + const configPath = path ? resolve(path) : resolve('safety-guard.json'); + if (!existsSync(configPath)) { + return DEFAULT_CONFIG; + } + const raw = readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return mergeConfig(parsed); +} + +export function mergeConfig(override: Partial): SafetyConfig { + return { + ...DEFAULT_CONFIG, + ...override, + rules: { + ...DEFAULT_CONFIG.rules, + ...override.rules, + }, + audit: { + ...DEFAULT_CONFIG.audit, + ...override.audit, + }, + }; +} + +export function validateConfig(config: SafetyConfig): string[] { + const errors: string[] = []; + if (typeof config.version !== 'number') errors.push('version must be a number'); + if (!config.name || typeof config.name !== 'string') errors.push('name must be a non-empty string'); + if (!config.rules || typeof config.rules !== 'object') errors.push('rules must be an object'); + if (!config.audit || typeof config.audit !== 'object') errors.push('audit must be an object'); + if (config.rules.maxExecutionTimeMs < 1000) errors.push('maxExecutionTimeMs must be at least 1000'); + if (config.rules.maxProcessCount < 1) errors.push('maxProcessCount must be at least 1'); + if (typeof config.audit.enabled !== 'boolean') errors.push('audit.enabled must be a boolean'); + if (!config.audit.logDir || typeof config.audit.logDir !== 'string') errors.push('audit.logDir must be a non-empty string'); + return errors; +} + +export function ensureAuditDir(config: SafetyConfig): void { + if (!config.audit.enabled) return; + const dir = resolve(config.audit.logDir); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} diff --git a/src/executor.test.ts b/src/executor.test.ts new file mode 100644 index 0000000..9729681 --- /dev/null +++ b/src/executor.test.ts @@ -0,0 +1,72 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { executeGuarded } from './executor.js'; +import { DEFAULT_CONFIG } from './config.js'; +import type { SafetyConfig } from './types.js'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('executor', () => { + const baseConfig: SafetyConfig = { + ...DEFAULT_CONFIG, + audit: { ...DEFAULT_CONFIG.audit, logDir: mkdtempSync(join(tmpdir(), 'exec-audit-')) }, + }; + + it('executes a safe command and captures stdout', async () => { + const result = await executeGuarded( + { command: 'echo', args: ['hello world'] }, + baseConfig + ); + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.exitCode, 0); + assert.ok(result.stdout.includes('hello world')); + assert.strictEqual(result.auditEntry.result, 'allowed'); + }); + + it('blocks a banned command before execution', async () => { + const config: SafetyConfig = { + ...baseConfig, + rules: { ...baseConfig.rules, blockedCommands: ['rm'] }, + }; + const result = await executeGuarded( + { command: 'rm', args: ['-rf', '/tmp'] }, + config + ); + assert.strictEqual(result.allowed, false); + assert.ok(result.stderr.includes('BLOCKED')); + assert.strictEqual(result.auditEntry.result, 'blocked'); + }); + + it('blocks path access to blocked paths', async () => { + const config: SafetyConfig = { + ...baseConfig, + rules: { ...baseConfig.rules, blockedPaths: ['/etc'] }, + }; + const result = await executeGuarded( + { command: 'cat', args: ['/etc/passwd'] }, + config + ); + assert.strictEqual(result.allowed, false); + assert.ok(result.reason?.includes('/etc')); + }); + + it('returns non-zero exit code for failing commands', async () => { + const result = await executeGuarded( + { command: 'node', args: ['-e', 'process.exit(42)'] }, + baseConfig + ); + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.exitCode, 42); + assert.strictEqual(result.auditEntry.result, 'error'); + }); + + it('handles command not found', async () => { + const result = await executeGuarded( + { command: 'notarealcommand', args: [] }, + baseConfig + ); + assert.strictEqual(result.allowed, false); + assert.ok(result.stderr.length > 0 || result.reason); + }); +}); diff --git a/src/executor.ts b/src/executor.ts new file mode 100644 index 0000000..b026de3 --- /dev/null +++ b/src/executor.ts @@ -0,0 +1,124 @@ +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; +import type { SafetyConfig, ExecutionOptions, AuditEntry } from './types.js'; +import { checkAllGuardrails } from './guard.js'; +import { writeAuditLog, createAuditEntry } from './audit.js'; + +export interface ExecutionResult { + allowed: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + durationMs: number; + reason?: string; + auditEntry: AuditEntry; +} + +export async function executeGuarded( + options: ExecutionOptions, + config: SafetyConfig +): Promise { + const cwd = options.cwd ? resolve(options.cwd) : process.cwd(); + const env = options.env || {}; + const startTime = Date.now(); + + const guardResults = checkAllGuardrails({ ...options, cwd, env }, config); + const blocked = guardResults.filter((r) => !r.allowed); + + if (blocked.length > 0) { + const primaryBlock = blocked[0]; + const durationMs = Date.now() - startTime; + const auditEntry = createAuditEntry( + { command: options.command, args: options.args, env, cwd, pid: -1 }, + 'blocked', + durationMs, + primaryBlock.reason, + null, + '', + '' + ); + writeAuditLog(auditEntry, config); + return { + allowed: false, + exitCode: null, + stdout: '', + stderr: `BLOCKED: ${primaryBlock.reason}`, + durationMs, + reason: primaryBlock.reason, + auditEntry, + }; + } + + return new Promise((resolvePromise) => { + const child = spawn(options.command, options.args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + }, config.rules.maxExecutionTimeMs); + + child.on('close', (exitCode) => { + clearTimeout(timeout); + const durationMs = Date.now() - startTime; + const result: ExecutionResult = { + allowed: true, + exitCode: exitCode ?? null, + stdout, + stderr, + durationMs, + auditEntry: createAuditEntry( + { command: options.command, args: options.args, env, cwd, pid: child.pid ?? -1 }, + exitCode === null ? 'timeout' : exitCode === 0 ? 'allowed' : 'error', + durationMs, + undefined, + exitCode, + config.audit.includeStdout ? stdout : undefined, + config.audit.includeStderr ? stderr : undefined + ), + }; + writeAuditLog(result.auditEntry, config); + resolvePromise(result); + }); + + child.on('error', (err) => { + clearTimeout(timeout); + const durationMs = Date.now() - startTime; + const result: ExecutionResult = { + allowed: false, + exitCode: null, + stdout, + stderr: err.message, + durationMs, + reason: err.message, + auditEntry: createAuditEntry( + { command: options.command, args: options.args, env, cwd, pid: child.pid ?? -1 }, + 'error', + durationMs, + err.message, + null, + stdout, + stderr + ), + }; + writeAuditLog(result.auditEntry, config); + resolvePromise(result); + }); + + child.stdin?.end(); + }); +} diff --git a/src/guard.test.ts b/src/guard.test.ts new file mode 100644 index 0000000..773ce5b --- /dev/null +++ b/src/guard.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { checkCommandBlocked, checkPathAccess, checkEnvAccess, checkAllGuardrails } from './guard.js'; +import { DEFAULT_CONFIG } from './config.js'; +import type { SafetyConfig } from './types.js'; + +describe('guard', () => { + const config: SafetyConfig = { + ...DEFAULT_CONFIG, + rules: { + ...DEFAULT_CONFIG.rules, + blockedCommands: ['rm', 'dd', 'sudo'], + allowedPaths: ['/home/user/project'], + blockedPaths: ['/etc', '/sys', '/root'], + allowedEnvVars: ['PATH', 'HOME'], + blockedEnvVars: ['SECRET_KEY'], + } + }; + + it('blocks banned commands', () => { + const result = checkCommandBlocked('rm', config); + assert.strictEqual(result.allowed, false); + assert.ok(result.reason?.includes('rm')); + }); + + it('allows safe commands', () => { + const result = checkCommandBlocked('ls', config); + assert.strictEqual(result.allowed, true); + }); + + it('blocks commands with full paths', () => { + const result = checkCommandBlocked('/usr/bin/sudo', config); + assert.strictEqual(result.allowed, false); + }); + + it('blocks paths in blockedPaths', () => { + const result = checkPathAccess('cat', ['/etc/passwd'], config); + assert.strictEqual(result.allowed, false); + assert.ok(result.reason?.includes('/etc')); + }); + + it('allows paths in allowedPaths', () => { + const result = checkPathAccess('cat', ['/home/user/project/file.txt'], config); + assert.strictEqual(result.allowed, true); + }); + + it('blocks env vars in blockedEnvVars', () => { + const result = checkEnvAccess({ PATH: '/bin', SECRET_KEY: 'abc' }, config); + assert.strictEqual(result.allowed, false); + assert.ok(result.reason?.includes('SECRET_KEY')); + }); + + it('allows env vars in allowedEnvVars', () => { + const result = checkEnvAccess({ PATH: '/bin', HOME: '/home' }, config); + assert.strictEqual(result.allowed, true); + }); + + it('blocks env vars not in allowedEnvVars when allowedEnvVars is set', () => { + const result = checkEnvAccess({ PATH: '/bin', HOME: '/home', EXTRA: 'x' }, config); + assert.strictEqual(result.allowed, false); + assert.ok(result.reason?.includes('EXTRA')); + }); + + it('runs all guardrails', () => { + const results = checkAllGuardrails({ command: 'rm', args: ['/etc/passwd'], env: { SECRET_KEY: 'x' } }, config); + const blocked = results.filter(r => !r.allowed); + assert.strictEqual(blocked.length, 3); + }); +}); diff --git a/src/guard.ts b/src/guard.ts new file mode 100644 index 0000000..cc3fbc8 --- /dev/null +++ b/src/guard.ts @@ -0,0 +1,94 @@ +import { resolve, normalize } from 'node:path'; +import type { SafetyConfig, GuardrailResult, ExecutionOptions } from './types.js'; + +function pathMatches(pattern: string, target: string): boolean { + const normalizedPattern = normalize(pattern); + const normalizedTarget = normalize(target); + if (normalizedPattern === normalizedTarget) return true; + if (normalizedTarget.startsWith(normalizedPattern + '/')) return true; + return false; +} + +export function checkCommandBlocked(command: string, config: SafetyConfig): GuardrailResult { + const baseCommand = command.split('/').pop() || command; + const blocked = config.rules.blockedCommands.some( + (blocked) => blocked.toLowerCase() === baseCommand.toLowerCase() + ); + if (blocked) { + return { allowed: false, reason: `Command "${baseCommand}" is in the blocked commands list`, rule: 'blockedCommands' }; + } + return { allowed: true, rule: 'blockedCommands' }; +} + +export function checkPathAccess(command: string, args: string[], config: SafetyConfig): GuardrailResult { + if (config.rules.blockedPaths.length === 0 && config.rules.allowedPaths.length === 0) { + return { allowed: true, rule: 'paths' }; + } + + const pathArgs = extractPathArgs(command, args); + for (const path of pathArgs) { + const resolvedPath = resolve(path); + for (const blocked of config.rules.blockedPaths) { + const resolvedBlocked = resolve(blocked); + if (pathMatches(resolvedBlocked, resolvedPath)) { + return { allowed: false, reason: `Path "${path}" matches blocked path "${blocked}"`, rule: 'blockedPaths' }; + } + } + } + + if (config.rules.allowedPaths.length > 0) { + for (const path of pathArgs) { + const resolvedPath = resolve(path); + const allowed = config.rules.allowedPaths.some((allowed) => { + const resolvedAllowed = resolve(allowed); + return pathMatches(resolvedAllowed, resolvedPath); + }); + if (!allowed) { + return { allowed: false, reason: `Path "${path}" is not in the allowed paths list`, rule: 'allowedPaths' }; + } + } + } + + return { allowed: true, rule: 'paths' }; +} + +export function checkEnvAccess(env: Record, config: SafetyConfig): GuardrailResult { + for (const key of Object.keys(env)) { + if (config.rules.blockedEnvVars.includes(key)) { + return { allowed: false, reason: `Environment variable "${key}" is in the blocked list`, rule: 'blockedEnvVars' }; + } + } + + if (config.rules.allowedEnvVars.length > 0) { + for (const key of Object.keys(env)) { + if (!config.rules.allowedEnvVars.includes(key)) { + return { allowed: false, reason: `Environment variable "${key}" is not in the allowed list`, rule: 'allowedEnvVars' }; + } + } + } + + return { allowed: true, rule: 'envVars' }; +} + +export function checkAllGuardrails(options: ExecutionOptions, config: SafetyConfig): GuardrailResult[] { + const results: GuardrailResult[] = []; + results.push(checkCommandBlocked(options.command, config)); + results.push(checkPathAccess(options.command, options.args, config)); + results.push(checkEnvAccess(options.env || {}, config)); + return results; +} + +function extractPathArgs(command: string, args: string[]): string[] { + const paths: string[] = []; + const pathCommands = ['cp', 'mv', 'rm', 'cat', 'ls', 'mkdir', 'touch', 'write', 'read', 'open', 'cd', 'pwd', 'find', 'grep', 'chmod', 'chown', 'ln', 'tar', 'zip', 'unzip', 'git', 'npm', 'node', 'python', 'python3', 'sh', 'bash', 'zsh', 'curl', 'wget', 'ssh', 'scp', 'rsync']; + if (!pathCommands.some((c) => command.endsWith(c) || command.endsWith('/' + c))) { + return paths; + } + for (const arg of args) { + if (arg.startsWith('-')) continue; + if (arg.includes('/') || arg.startsWith('.')) { + paths.push(arg); + } + } + return paths; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8cc3812 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,53 @@ +/** + * Types and interfaces for the AI Agent Safety Guard + */ + +export interface SafetyConfig { + version: number; + name: string; + rules: { + blockedCommands: string[]; + allowedPaths: string[]; + blockedPaths: string[]; + allowedEnvVars: string[]; + blockedEnvVars: string[]; + maxProcessCount: number; + maxExecutionTimeMs: number; + requireExplicitPaths: boolean; + allowNetwork: boolean; + }; + audit: { + enabled: boolean; + logDir: string; + includeStdout: boolean; + includeStderr: boolean; + }; +} + +export interface GuardrailResult { + allowed: boolean; + reason?: string; + rule: string; +} + +export interface AuditEntry { + timestamp: string; + command: string; + args: string[]; + env: Record; + cwd: string; + pid: number; + result: 'allowed' | 'blocked' | 'error' | 'timeout'; + reason?: string; + exitCode?: number | null; + stdout?: string; + stderr?: string; + durationMs: number; +} + +export interface ExecutionOptions { + command: string; + args: string[]; + env?: Record; + cwd?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2edf508 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}