v1.0.0 — AI Agent Safety Guard CLI
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.venv/
|
||||||
|
*.audit.log
|
||||||
|
.audit/
|
||||||
Generated
+51
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user