v1.0.0 — AI Agent Safety Guard CLI

This commit is contained in:
Bun Bun
2026-06-14 13:18:49 +00:00
commit 2331e5ef46
15 changed files with 869 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.venv/
*.audit.log
.audit/
+1
View File
@@ -0,0 +1 @@
built
+51
View File
@@ -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"
}
}
}
+22
View File
@@ -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"
}
}
+66
View File
@@ -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);
});
});
+40
View File
@@ -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<string, string>; 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,
};
}
+136
View File
@@ -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] -- <command> [args...]
Options:
-c, --config <path> 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<void> {
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);
});
+46
View File
@@ -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 });
});
});
+71
View File
@@ -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<SafetyConfig>;
return mergeConfig(parsed);
}
export function mergeConfig(override: Partial<SafetyConfig>): 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 });
}
}
+72
View File
@@ -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);
});
});
+124
View File
@@ -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<ExecutionResult> {
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();
});
}
+69
View File
@@ -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);
});
});
+94
View File
@@ -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<string, string>, 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;
}
+53
View File
@@ -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<string, string>;
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<string, string>;
cwd?: string;
}
+18
View File
@@ -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"]
}