Initial MVP: Claude Permission Observability CLI tool

This commit is contained in:
Bun Bun
2026-06-16 00:30:52 +00:00
commit 50a2f9058c
14 changed files with 1701 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.verdict
cpo-audit/
.DS_Store
+103
View File
@@ -0,0 +1,103 @@
# Claude Permission Observability (cpo)
CLI observability tool for Claude Code agents. Tracks what agents are doing outside defined permissions, logs bypass attempts, and maintains compressed audit trails for building deny-lists.
## Install
```bash
npm install
npm run build
```
## Usage
### 1. Initialize a permission manifest
```bash
npx . init permission-manifest.json
```
### 2. Start watching a project directory
```bash
npx . watch --manifest permission-manifest.json --dir ./my-project --agent-id "claude-agent-1"
```
This monitors file system changes and (optionally) Claude Code state files to detect permission violations in real time.
### 3. Generate a violation report
```bash
npx . report --dir ./cpo-audit
```
Or export as JSON:
```bash
npx . report --dir ./cpo-audit --json --output report.json
```
### 4. Log manual actions
```bash
npx . log --manifest permission-manifest.json --type file --action "fs.write" --target "src/main.ts" --agent-id "manual"
```
### 5. Check audit status
```bash
npx . status --dir ./cpo-audit
```
## Permission Manifest
The manifest defines what agents are allowed to do. Rules are evaluated in order:
| Type | Pattern | Action | Description |
|------|---------|--------|-------------|
| file | `src/**/*.ts` | allow | Allow editing source files |
| file | `**/*.env*` | deny | Deny access to env files |
| command | `git push` | warn | Warn on git push |
| command | `rm -rf /` | deny | Deny destructive rm |
| network | `localhost:*` | allow | Allow local servers |
| network | `api.openai.com` | warn | Warn on OpenAI API calls |
### Sensitive Patterns
Paths matching sensitive patterns (e.g., `.env`, `secrets`, `token`) are automatically flagged as `bypass_attempt` even if no explicit rule matches.
### Audit Trail
- JSONL format — one action per line, append-only
- Companion summary JSON for quick analysis
- Automatic rotation when size exceeds `maxTrailSize` (MB)
## Risk Score
The violation report calculates a risk score (0-100) based on:
- Violation density (percentage of denied actions)
- Bypass attempt rate
- Total violation count
| Score | Label |
|-------|-------|
| 0 | SAFE |
| 1-24 | LOW |
| 25-49 | MEDIUM |
| 50-74 | HIGH |
| 75-100 | CRITICAL |
## Commands
| Command | Description |
|---------|-------------|
| `init [path]` | Create a default permission manifest |
| `watch` | Start file system watcher |
| `report` | Generate violation report from audit trail |
| `log` | Log a manual action |
| `validate` | Validate a permission manifest |
| `status` | Show audit directory status |
## License
MIT
+83
View File
@@ -0,0 +1,83 @@
{
"name": "claude-agent-observability",
"version": "1.0.0",
"rules": [
{
"type": "file",
"pattern": "src/**/*.ts",
"action": "allow",
"description": "Allow editing source files"
},
{
"type": "file",
"pattern": "tests/**/*.ts",
"action": "allow",
"description": "Allow editing test files"
},
{
"type": "file",
"pattern": "**/*.env*",
"action": "deny",
"description": "Deny access to env files"
},
{
"type": "file",
"pattern": "**/.ssh/**",
"action": "deny",
"description": "Deny access to SSH keys"
},
{
"type": "file",
"pattern": "**/.aws/**",
"action": "deny",
"description": "Deny access to AWS credentials"
},
{
"type": "command",
"pattern": "git push",
"action": "warn",
"description": "Warn on git push"
},
{
"type": "command",
"pattern": "rm -rf /",
"action": "deny",
"description": "Deny destructive rm commands"
},
{
"type": "command",
"pattern": "rm -rf ~",
"action": "deny",
"description": "Deny home directory deletion"
},
{
"type": "network",
"pattern": "localhost:*",
"action": "allow",
"description": "Allow local development servers"
},
{
"type": "network",
"pattern": "api.openai.com",
"action": "warn",
"description": "Warn on OpenAI API calls"
}
],
"defaultAction": "warn",
"compression": {
"enabled": true,
"maxTrailSize": 50,
"retentionDays": 30
},
"sensitivePatterns": [
".env",
"secrets",
"password",
"token",
"key",
"credential",
"private",
"apikey",
"secret_key"
]
}
+286
View File
@@ -0,0 +1,286 @@
{
"name": "ai-agent-permission-observability-for-cl",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-agent-permission-observability-for-cl",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@types/node": "^25.9.3",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
"minimatch": "^10.0.0"
},
"bin": {
"claude-permission-observability": "dist/index.js",
"cpo": "dist/index.js"
},
"devDependencies": {
"typescript": "^5.5.0"
}
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.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": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "ai-agent-permission-observability-for-cl",
"version": "0.1.0",
"description": "CLI observability tool for Claude Code agents — tracks permission violations, bypass attempts, and compressed audit trails",
"type": "module",
"main": "dist/index.js",
"bin": {
"claude-permission-observability": "dist/index.js",
"cpo": "dist/index.js"
},
"scripts": {
"build": "tsc",
"test": "node --test dist/tests/**/*.test.js",
"watch": "tsc --watch"
},
"keywords": [
"claude-code",
"agent",
"observability",
"permissions",
"audit",
"security"
],
"author": "BunBun Labs",
"license": "MIT",
"devDependencies": {
"typescript": "^5.5.0"
},
"dependencies": {
"@types/node": "^25.9.3",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
"minimatch": "^10.0.0"
}
}
+117
View File
@@ -0,0 +1,117 @@
import { appendFileSync, existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'fs';
import { resolve, dirname } from 'path';
import { createHash } from 'crypto';
import type { AgentAction, AuditTrail, PermissionManifest } from './types.js';
export class Auditor {
private trailPath: string;
private actions: AgentAction[] = [];
private manifest: PermissionManifest;
private startedAt: Date;
private bypassCount = 0;
constructor(manifest: PermissionManifest, outputDir: string) {
this.manifest = manifest;
this.trailPath = resolve(outputDir, `audit-${Date.now()}.jsonl`);
this.startedAt = new Date();
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
}
log(action: AgentAction): void {
this.actions.push(action);
if (action.resolvedAction === 'deny' || action.type === 'bypass_attempt') {
this.bypassCount++;
}
const line = JSON.stringify({
...action,
timestamp: action.timestamp.toISOString(),
});
appendFileSync(this.trailPath, line + '\n');
}
getTrail(): AuditTrail {
return {
manifest: this.manifest.name,
startedAt: this.startedAt,
actions: [...this.actions],
summary: {
total: this.actions.length,
allowed: this.actions.filter(a => a.resolvedAction === 'allow').length,
denied: this.actions.filter(a => a.resolvedAction === 'deny').length,
warned: this.actions.filter(a => a.resolvedAction === 'warn').length,
bypassAttempts: this.bypassCount,
},
};
}
getBypassCount(): number {
return this.bypassCount;
}
close(): AuditTrail {
const trail = this.getTrail();
trail.endedAt = new Date();
// Write summary to a companion file
const summaryPath = this.trailPath.replace('.jsonl', '-summary.json');
writeFileSync(summaryPath, JSON.stringify(trail, null, 2));
return trail;
}
rotate(): string | null {
if (!this.manifest.compression.enabled) return null;
try {
const s = statSync(this.trailPath);
const sizeMB = s.size / (1024 * 1024);
if (sizeMB < this.manifest.compression.maxTrailSize) return null;
} catch {
return null;
}
// Compress by archiving current trail
const hash = createHash('sha256').update(this.trailPath + Date.now()).digest('hex').slice(0, 8);
const archivePath = this.trailPath.replace('.jsonl', `-${hash}.jsonl.gz`);
return archivePath;
}
static loadFromFile(path: string): AuditTrail {
const content = readFileSync(resolve(path), 'utf-8');
const lines = content.trim().split('\n');
const actions: AgentAction[] = [];
for (const line of lines) {
if (!line.trim()) continue;
const parsed = JSON.parse(line);
actions.push({
...parsed,
timestamp: new Date(parsed.timestamp),
});
}
const allowed = actions.filter(a => a.resolvedAction === 'allow').length;
const denied = actions.filter(a => a.resolvedAction === 'deny').length;
const warned = actions.filter(a => a.resolvedAction === 'warn').length;
const bypass = actions.filter(a => a.type === 'bypass_attempt' || a.resolvedAction === 'deny').length;
return {
manifest: 'unknown',
startedAt: actions.length > 0 ? actions[0].timestamp : new Date(),
endedAt: actions.length > 0 ? actions[actions.length - 1].timestamp : new Date(),
actions,
summary: {
total: actions.length,
allowed,
denied,
warned,
bypassAttempts: bypass,
},
};
}
}
+137
View File
@@ -0,0 +1,137 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import type { PermissionManifest } from './types.js';
const DEFAULT_MANIFEST: PermissionManifest = {
name: 'default',
version: '1.0.0',
rules: [],
defaultAction: 'warn',
compression: {
enabled: true,
maxTrailSize: 50,
retentionDays: 30,
},
sensitivePatterns: [
'.env',
'secrets',
'password',
'token',
'key',
'credential',
'private',
],
};
export function loadManifest(path: string): PermissionManifest {
const fullPath = resolve(path);
if (!existsSync(fullPath)) {
throw new Error(`Permission manifest not found: ${fullPath}`);
}
const content = readFileSync(fullPath, 'utf-8');
const parsed = JSON.parse(content) as Partial<PermissionManifest>;
return validateManifest({
...DEFAULT_MANIFEST,
...parsed,
rules: parsed.rules ?? DEFAULT_MANIFEST.rules,
compression: {
...DEFAULT_MANIFEST.compression,
...parsed.compression,
},
sensitivePatterns: parsed.sensitivePatterns ?? DEFAULT_MANIFEST.sensitivePatterns,
});
}
export function validateManifest(manifest: PermissionManifest): PermissionManifest {
if (!manifest.name || typeof manifest.name !== 'string') {
throw new Error('Manifest must have a valid name');
}
if (!Array.isArray(manifest.rules)) {
throw new Error('Manifest rules must be an array');
}
for (const rule of manifest.rules) {
if (!['file', 'command', 'network', 'env'].includes(rule.type)) {
throw new Error(`Invalid rule type: ${rule.type}`);
}
if (!['allow', 'deny', 'warn'].includes(rule.action)) {
throw new Error(`Invalid rule action: ${rule.action}`);
}
if (!rule.pattern || typeof rule.pattern !== 'string') {
throw new Error('Each rule must have a pattern string');
}
}
if (!['allow', 'deny', 'warn'].includes(manifest.defaultAction)) {
throw new Error(`Invalid defaultAction: ${manifest.defaultAction}`);
}
return manifest;
}
export function createDefaultManifest(path: string): PermissionManifest {
const manifest: PermissionManifest = {
name: 'claude-agent-observability',
version: '1.0.0',
rules: [
{
type: 'file',
pattern: 'src/**/*.ts',
action: 'allow',
description: 'Allow editing source files',
},
{
type: 'file',
pattern: '**/*.env*',
action: 'deny',
description: 'Deny access to env files',
},
{
type: 'file',
pattern: '**/.ssh/**',
action: 'deny',
description: 'Deny access to SSH keys',
},
{
type: 'command',
pattern: 'git push',
action: 'warn',
description: 'Warn on git push',
},
{
type: 'command',
pattern: 'rm -rf /',
action: 'deny',
description: 'Deny destructive rm commands',
},
{
type: 'network',
pattern: 'localhost:*',
action: 'allow',
description: 'Allow local development servers',
},
{
type: 'network',
pattern: 'api.openai.com',
action: 'warn',
description: 'Warn on OpenAI API calls',
},
],
defaultAction: 'warn',
compression: {
enabled: true,
maxTrailSize: 50,
retentionDays: 30,
},
sensitivePatterns: [
'.env',
'secrets',
'password',
'token',
'key',
'credential',
'private',
'apikey',
'secret_key',
],
};
return manifest;
}
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { loadManifest, createDefaultManifest } from './config.js';
import { PermissionWatcher } from './watcher.js';
import { Auditor } from './auditor.js';
import { Reporter, findLatestAuditTrail } from './reporter.js';
const pkgPath = resolve(process.cwd(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
const program = new Command();
program
.name('cpo')
.description('Claude Permission Observability — monitor agent actions against permission manifests')
.version(pkg.version);
program
.command('init')
.description('Create a default permission manifest')
.argument('[path]', 'Path to create manifest', './permission-manifest.json')
.action((path: string) => {
const manifest = createDefaultManifest(path);
writeFileSync(resolve(path), JSON.stringify(manifest, null, 2));
console.log(`Created permission manifest: ${path}`);
});
program
.command('watch')
.description('Start watching a directory for agent actions')
.requiredOption('-m, --manifest <path>', 'Path to permission manifest')
.requiredOption('-d, --dir <path>', 'Directory to watch')
.option('-a, --agent-id <id>', 'Agent identifier', 'claude-agent')
.option('-o, --output <dir>', 'Output directory for audit trails', './cpo-audit')
.option('--claude-dir <path>', 'Path to .claude state directory for enhanced monitoring')
.option('--ignore <patterns...>', 'Additional ignore patterns')
.action((options: Record<string, string>) => {
const manifest = loadManifest(options.manifest);
const auditor = new Auditor(manifest, options.output);
const watcher = new PermissionWatcher(manifest, auditor, {
watchDir: resolve(options.dir),
claudeDir: options.claudeDir ? resolve(options.claudeDir) : undefined,
agentId: options.agentId,
ignorePatterns: options.ignore ? options.ignore.split(',') : undefined,
});
watcher.start();
console.log(`\n[cpo] Started watching: ${resolve(options.dir)}`);
console.log(`[cpo] Manifest: ${resolve(options.manifest)}`);
console.log(`[cpo] Agent ID: ${options.agentId}`);
console.log(`[cpo] Audit output: ${resolve(options.output)}`);
console.log(`[cpo] Rules loaded: ${manifest.rules.length}`);
console.log(`[cpo] Default action: ${manifest.defaultAction}`);
console.log('\nPress Ctrl+C to stop.\n');
process.on('SIGINT', () => {
console.log('\n[cpo] Shutting down...');
watcher.stop();
const trail = auditor.close();
console.log(`\nAudit trail written: ${trail.summary.total} actions recorded`);
console.log(` Allowed: ${trail.summary.allowed}`);
console.log(` Denied: ${trail.summary.denied}`);
console.log(` Warned: ${trail.summary.warned}`);
console.log(` Bypass attempts: ${trail.summary.bypassAttempts}`);
process.exit(0);
});
process.on('SIGTERM', () => {
watcher.stop();
auditor.close();
process.exit(0);
});
});
program
.command('report')
.description('Generate violation report from audit trail')
.option('-i, --input <path>', 'Path to audit trail file')
.option('-o, --output <path>', 'Path to write report')
.option('--json', 'Output as JSON')
.option('-d, --dir <dir>', 'Directory to scan for latest audit trail', './cpo-audit')
.action((options: Record<string, string>) => {
let inputPath: string | null = options.input || null;
if (!inputPath) {
inputPath = findLatestAuditTrail(options.dir || './cpo-audit');
}
if (!inputPath || !existsSync(inputPath)) {
console.error('Error: No audit trail found. Run `cpo watch` first or specify --input.');
process.exit(1);
}
const trail = Auditor.loadFromFile(inputPath);
const reporter = new Reporter();
const report = reporter.generateViolationReport(trail);
const output = options.json
? reporter.formatJsonReport(report)
: reporter.formatConsoleReport(report);
if (options.output) {
writeFileSync(resolve(options.output), output);
console.log(`Report written to: ${options.output}`);
} else {
console.log(output);
}
});
program
.command('log')
.description('Log a manual action to the current audit trail')
.requiredOption('-m, --manifest <path>', 'Path to permission manifest')
.requiredOption('-t, --type <type>', 'Action type: file, command, network, env')
.requiredOption('--action <action>', 'Action description')
.requiredOption('--target <target>', 'Target of the action')
.option('-a, --agent-id <id>', 'Agent identifier', 'manual-agent')
.option('-o, --output <dir>', 'Output directory', './cpo-audit')
.action(async (options: Record<string, string>) => {
const manifest = loadManifest(options.manifest);
const auditor = new Auditor(manifest, options.output);
const { evaluateAction } = await import('./rules.js');
// Load existing trail if present
const latest = findLatestAuditTrail(options.output);
if (latest) {
const existing = Auditor.loadFromFile(latest);
for (const action of existing.actions) {
auditor.log(action);
}
}
const action = evaluateAction(
manifest,
options.type as 'file' | 'command' | 'network' | 'env',
options.target,
options.action,
options.agentId
);
auditor.log(action);
auditor.close();
console.log(`Logged: ${action.resolvedAction.toUpperCase()} ${options.type} ${options.target}`);
});
program
.command('validate')
.description('Validate a permission manifest')
.argument('<path>', 'Path to manifest')
.action((path: string) => {
try {
loadManifest(path);
console.log('✅ Manifest is valid');
} catch (err: unknown) {
console.error('❌ Invalid manifest:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
program
.command('status')
.description('Show status of audit directory')
.option('-d, --dir <dir>', 'Audit directory', './cpo-audit')
.action(async (options: Record<string, string>) => {
const dir = resolve(options.dir);
if (!existsSync(dir)) {
console.log('No audit directory found. Run `cpo watch` first.');
return;
}
const { readdirSync, statSync } = await import('fs');
const files = readdirSync(dir).filter((f: string) => f.endsWith('.jsonl'));
console.log(`Audit trails: ${files.length}`);
let totalSize = 0;
for (const f of files) {
const s = statSync(resolve(dir, f));
totalSize += s.size;
console.log(` ${f} (${(s.size / 1024).toFixed(1)} KB)`);
}
console.log(`Total size: ${(totalSize / 1024).toFixed(1)} KB`);
});
program
.parse();
+132
View File
@@ -0,0 +1,132 @@
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
import { resolve } from 'path';
import type { AgentAction, AuditTrail, ViolationReport } from './types.js';
export class Reporter {
generateViolationReport(trail: AuditTrail): ViolationReport {
const violations = trail.actions.filter(
a => a.resolvedAction === 'deny' || a.type === 'bypass_attempt'
);
const ruleCounts = new Map<string, number>();
const agentCounts = new Map<string, number>();
for (const v of violations) {
const rule = v.matchedRule || 'default';
ruleCounts.set(rule, (ruleCounts.get(rule) || 0) + 1);
agentCounts.set(v.agentId, (agentCounts.get(v.agentId) || 0) + 1);
}
const topViolatedRules = Array.from(ruleCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([rule, count]) => ({ rule, count }));
const topAgents = Array.from(agentCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([agentId, violations]) => ({ agentId, violations }));
// Risk score: 0-100 based on violation density + bypass rate
const violationRate = trail.summary.total > 0
? violations.length / trail.summary.total
: 0;
const bypassRate = trail.summary.total > 0
? trail.summary.bypassAttempts / trail.summary.total
: 0;
const riskScore = Math.min(100, Math.round(
(violationRate * 50) + (bypassRate * 50) + (violations.length * 2)
));
return {
timestamp: new Date(),
manifest: trail.manifest,
violations,
topViolatedRules,
topAgents,
riskScore,
};
}
formatConsoleReport(report: ViolationReport): string {
const lines: string[] = [];
lines.push('');
lines.push('╔══════════════════════════════════════════════════════════════╗');
lines.push('║ CLAUDE PERMISSION OBSERVABILITY — VIOLATION REPORT ║');
lines.push('╚══════════════════════════════════════════════════════════════╝');
lines.push('');
lines.push(`Manifest: ${report.manifest}`);
lines.push(`Generated: ${report.timestamp.toISOString()}`);
lines.push(`Risk Score: ${report.riskScore}/100 ${this.riskLabel(report.riskScore)}`);
lines.push('');
if (report.violations.length === 0) {
lines.push('✅ No violations detected in this audit trail.');
} else {
lines.push(`⚠️ ${report.violations.length} violation(s) detected`);
lines.push('');
lines.push('Top Violated Rules:');
for (const { rule, count } of report.topViolatedRules) {
lines.push(`${rule}: ${count} occurrence(s)`);
}
lines.push('');
lines.push('Agents with Most Violations:');
for (const { agentId, violations } of report.topAgents) {
lines.push(`${agentId}: ${violations} violation(s)`);
}
lines.push('');
lines.push('Recent Violations (last 10):');
const recent = report.violations.slice(-10);
for (const v of recent) {
const time = v.timestamp.toISOString().slice(11, 19);
lines.push(` [${time}] ${v.type.toUpperCase().padEnd(8)} ${v.resolvedAction.toUpperCase().padEnd(5)} ${v.target}`);
}
}
lines.push('');
lines.push('══════════════════════════════════════════════════════════════');
lines.push('');
return lines.join('\n');
}
formatJsonReport(report: ViolationReport): string {
return JSON.stringify({
...report,
timestamp: report.timestamp.toISOString(),
violations: report.violations.map(v => ({
...v,
timestamp: v.timestamp.toISOString(),
})),
}, null, 2);
}
private riskLabel(score: number): string {
if (score === 0) return '[SAFE]';
if (score < 25) return '[LOW]';
if (score < 50) return '[MEDIUM]';
if (score < 75) return '[HIGH]';
return '[CRITICAL]';
}
}
export function findLatestAuditTrail(dir: string): string | null {
if (!existsSync(dir)) return null;
const files = readdirSync(dir)
.filter((f: string) => f.endsWith('.jsonl') && f.startsWith('audit-'))
.map((f: string) => ({ name: f, path: resolve(dir, f) }));
if (files.length === 0) return null;
files.sort((a: {path: string}, b: {path: string}) => {
const statA = statSync(a.path).mtimeMs;
const statB = statSync(b.path).mtimeMs;
return statB - statA;
});
return files[0].path;
}
+68
View File
@@ -0,0 +1,68 @@
import { minimatch } from 'minimatch';
import type { PermissionManifest, PermissionRule, AgentAction } from './types.js';
export function evaluateAction(
manifest: PermissionManifest,
type: AgentAction['type'],
target: string,
action: string,
agentId: string
): AgentAction {
const actionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Check each rule in order
for (const rule of manifest.rules) {
if (rule.type !== type && rule.type !== 'env') continue;
const matches = minimatch(target, rule.pattern, { dot: true }) ||
target.toLowerCase().includes(rule.pattern.toLowerCase()) ||
action.toLowerCase().includes(rule.pattern.toLowerCase());
if (matches) {
return {
id: actionId,
timestamp: new Date(),
agentId,
type,
action,
target,
resolvedAction: rule.action,
matchedRule: rule.description || rule.pattern,
};
}
}
// Check sensitive patterns for implicit warnings
for (const pattern of manifest.sensitivePatterns) {
if (target.toLowerCase().includes(pattern.toLowerCase()) ||
action.toLowerCase().includes(pattern.toLowerCase())) {
return {
id: actionId,
timestamp: new Date(),
agentId,
type: 'bypass_attempt',
action,
target,
resolvedAction: 'warn',
matchedRule: `sensitive-pattern:${pattern}`,
details: { reason: 'Accessed path matching sensitive pattern' },
};
}
}
// Default action
return {
id: actionId,
timestamp: new Date(),
agentId,
type,
action,
target,
resolvedAction: manifest.defaultAction,
};
}
export function getEffectiveRules(manifest: PermissionManifest, type?: AgentAction['type']): PermissionRule[] {
if (!type) return manifest.rules;
return manifest.rules.filter(r => r.type === type || r.type === 'env');
}
+267
View File
@@ -0,0 +1,267 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { resolve } from 'path';
import { evaluateAction, getEffectiveRules } from '../rules.js';
import { loadManifest, validateManifest, createDefaultManifest } from '../config.js';
import { Auditor } from '../auditor.js';
import { Reporter, findLatestAuditTrail } from '../reporter.js';
import type { PermissionManifest, AgentAction } from '../types.js';
const TEST_DIR = resolve('/tmp/cpo-test-' + Date.now());
function setup(): void {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true });
}
mkdirSync(TEST_DIR, { recursive: true });
}
function teardown(): void {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true });
}
}
const TEST_MANIFEST: PermissionManifest = {
name: 'test-manifest',
version: '1.0.0',
rules: [
{ type: 'file', pattern: 'src/**/*.ts', action: 'allow' },
{ type: 'file', pattern: '**/*.env*', action: 'deny' },
{ type: 'command', pattern: 'git push', action: 'warn' },
{ type: 'command', pattern: 'rm -rf /', action: 'deny' },
],
defaultAction: 'warn',
compression: { enabled: true, maxTrailSize: 10, retentionDays: 7 },
sensitivePatterns: ['.env', 'secret'],
};
// --- Config Tests ---
test('validateManifest accepts valid manifest', () => {
assert.doesNotThrow(() => validateManifest(TEST_MANIFEST));
});
test('validateManifest rejects empty name', () => {
assert.throws(() => validateManifest({ ...TEST_MANIFEST, name: '' }), /name/);
});
test('validateManifest rejects invalid rule type', () => {
const bad = {
...TEST_MANIFEST,
rules: [{ type: 'invalid', pattern: 'x', action: 'allow' }],
};
assert.throws(() => validateManifest(bad as PermissionManifest), /Invalid rule type/);
});
test('validateManifest rejects invalid action', () => {
const bad = {
...TEST_MANIFEST,
rules: [{ type: 'file', pattern: 'x', action: 'invalid' }],
};
assert.throws(() => validateManifest(bad as PermissionManifest), /Invalid rule action/);
});
test('loadManifest reads and parses JSON file', () => {
setup();
const path = resolve(TEST_DIR, 'manifest.json');
writeFileSync(path, JSON.stringify(TEST_MANIFEST));
const loaded = loadManifest(path);
assert.equal(loaded.name, 'test-manifest');
assert.equal(loaded.rules.length, 4);
teardown();
});
test('loadManifest throws on missing file', () => {
assert.throws(() => loadManifest('/nonexistent/manifest.json'), /not found/);
});
// --- Rules Tests ---
test('evaluateAction allows matching allow rule', () => {
const action = evaluateAction(TEST_MANIFEST, 'file', 'src/main.ts', 'fs.write', 'agent-1');
assert.equal(action.resolvedAction, 'allow');
assert.equal(action.matchedRule, 'src/**/*.ts');
});
test('evaluateAction denies matching deny rule', () => {
const action = evaluateAction(TEST_MANIFEST, 'file', '.env.local', 'fs.read', 'agent-1');
assert.equal(action.resolvedAction, 'deny');
assert.equal(action.matchedRule, '**/*.env*');
});
test('evaluateAction warns on default for non-matching paths', () => {
const action = evaluateAction(TEST_MANIFEST, 'file', 'README.md', 'fs.read', 'agent-1');
assert.equal(action.resolvedAction, 'warn');
});
test('evaluateAction detects sensitive patterns as bypass', () => {
const action = evaluateAction(TEST_MANIFEST, 'file', 'config/secrets.json', 'fs.read', 'agent-1');
assert.equal(action.type, 'bypass_attempt');
assert.equal(action.resolvedAction, 'warn');
assert.ok(action.matchedRule?.includes('sensitive-pattern'));
});
test('evaluateAction matches command patterns', () => {
const action = evaluateAction(TEST_MANIFEST, 'command', 'git push origin main', 'git.push', 'agent-1');
assert.equal(action.resolvedAction, 'warn');
assert.equal(action.matchedRule, 'git push');
});
test('evaluateAction denies dangerous commands', () => {
const action = evaluateAction(TEST_MANIFEST, 'command', 'rm -rf /', 'rm', 'agent-1');
assert.equal(action.resolvedAction, 'deny');
});
test('getEffectiveRules returns all rules when no type filter', () => {
const rules = getEffectiveRules(TEST_MANIFEST);
assert.equal(rules.length, 4);
});
test('getEffectiveRules filters by type', () => {
const rules = getEffectiveRules(TEST_MANIFEST, 'command');
assert.equal(rules.length, 2);
assert.equal(rules[0].type, 'command');
});
// --- Auditor Tests ---
test('Auditor logs actions and produces summary', () => {
setup();
const auditor = new Auditor(TEST_MANIFEST, TEST_DIR);
auditor.log(makeAction('allow', 'file', 'src/main.ts'));
auditor.log(makeAction('deny', 'file', '.env'));
auditor.log(makeAction('warn', 'command', 'git push'));
const trail = auditor.getTrail();
assert.equal(trail.summary.total, 3);
assert.equal(trail.summary.allowed, 1);
assert.equal(trail.summary.denied, 1);
assert.equal(trail.summary.warned, 1);
auditor.close();
teardown();
});
test('Auditor writes JSONL to file', () => {
setup();
const auditor = new Auditor(TEST_MANIFEST, TEST_DIR);
auditor.log(makeAction('allow', 'file', 'src/main.ts'));
auditor.close();
const files = existsSync(TEST_DIR) ? readdirSync(TEST_DIR) : [];
const jsonl = files.find((f: string) => f.endsWith('.jsonl'));
assert.ok(jsonl, 'JSONL file should exist');
teardown();
});
test('Auditor.loadFromFile reads back trail', () => {
setup();
const auditor = new Auditor(TEST_MANIFEST, TEST_DIR);
auditor.log(makeAction('deny', 'file', '.env'));
auditor.close();
const files = readdirSync(TEST_DIR).filter((f: string) => f.endsWith('.jsonl'));
const path = resolve(TEST_DIR, files[0]);
const loaded = Auditor.loadFromFile(path);
assert.equal(loaded.summary.total, 1);
assert.equal(loaded.summary.denied, 1);
teardown();
});
test('Auditor counts bypass attempts', () => {
setup();
const auditor = new Auditor(TEST_MANIFEST, TEST_DIR);
auditor.log(makeAction('deny', 'file', '.env'));
auditor.log(makeAction('warn', 'bypass_attempt', 'secrets.txt'));
assert.equal(auditor.getBypassCount(), 2);
teardown();
});
// --- Reporter Tests ---
test('Reporter generates violation report', () => {
const trail: import('../types.js').AuditTrail = {
manifest: 'test',
startedAt: new Date(),
actions: [
makeAction('allow', 'file', 'src/main.ts'),
makeAction('deny', 'file', '.env'),
makeAction('deny', 'command', 'rm -rf /'),
],
summary: { total: 3, allowed: 1, denied: 2, warned: 0, bypassAttempts: 2 },
};
const reporter = new Reporter();
const report = reporter.generateViolationReport(trail);
assert.equal(report.violations.length, 2);
assert.ok(report.riskScore > 0);
assert.equal(report.topViolatedRules.length, 2);
});
test('Reporter formatConsoleReport includes headers and data', () => {
const trail: import('../types.js').AuditTrail = {
manifest: 'test',
startedAt: new Date(),
actions: [],
summary: { total: 0, allowed: 0, denied: 0, warned: 0, bypassAttempts: 0 },
};
const reporter = new Reporter();
const report = reporter.generateViolationReport(trail);
const output = reporter.formatConsoleReport(report);
assert.ok(output.includes('SAFE'));
assert.ok(output.includes('No violations'));
});
test('Reporter formatJsonReport produces valid JSON', () => {
const trail: import('../types.js').AuditTrail = {
manifest: 'test',
startedAt: new Date(),
actions: [makeAction('deny', 'file', '.env')],
summary: { total: 1, allowed: 0, denied: 1, warned: 0, bypassAttempts: 1 },
};
const reporter = new Reporter();
const report = reporter.generateViolationReport(trail);
const json = reporter.formatJsonReport(report);
const parsed = JSON.parse(json);
assert.equal(parsed.violations.length, 1);
assert.equal(parsed.riskScore, report.riskScore);
});
test('findLatestAuditTrail returns most recent file', async () => {
setup();
writeFileSync(resolve(TEST_DIR, 'audit-1000.jsonl'), '');
await new Promise(r => setTimeout(r, 10));
writeFileSync(resolve(TEST_DIR, 'audit-2000.jsonl'), '');
await new Promise(r => setTimeout(r, 10));
writeFileSync(resolve(TEST_DIR, 'audit-3000.jsonl'), '');
const latest = findLatestAuditTrail(TEST_DIR);
assert.ok(latest?.includes('audit-3000'));
teardown();
});
test('findLatestAuditTrail returns null for empty dir', () => {
setup();
const result = findLatestAuditTrail(TEST_DIR);
assert.equal(result, null);
teardown();
});
// --- Helper ---
function makeAction(resolved: 'allow' | 'deny' | 'warn', type: AgentAction['type'], target: string): AgentAction {
return {
id: `test-${Date.now()}-${Math.random()}`,
timestamp: new Date(),
agentId: 'test-agent',
type,
action: `test.${type}`,
target,
resolvedAction: resolved,
matchedRule: target,
};
}
+54
View File
@@ -0,0 +1,54 @@
export interface PermissionRule {
type: 'file' | 'command' | 'network' | 'env';
pattern: string;
action: 'allow' | 'deny' | 'warn';
description?: string;
}
export interface PermissionManifest {
name: string;
version: string;
rules: PermissionRule[];
defaultAction: 'allow' | 'deny' | 'warn';
compression: {
enabled: boolean;
maxTrailSize: number; // in MB
retentionDays: number;
};
sensitivePatterns: string[];
}
export interface AgentAction {
id: string;
timestamp: Date;
agentId: string;
type: 'file' | 'command' | 'network' | 'env' | 'bypass_attempt';
action: string;
target: string;
resolvedAction: 'allow' | 'deny' | 'warn' | 'unknown';
matchedRule?: string;
details?: Record<string, unknown>;
}
export interface AuditTrail {
manifest: string;
startedAt: Date;
endedAt?: Date;
actions: AgentAction[];
summary: {
total: number;
allowed: number;
denied: number;
warned: number;
bypassAttempts: number;
};
}
export interface ViolationReport {
timestamp: Date;
manifest: string;
violations: AgentAction[];
topViolatedRules: Array<{ rule: string; count: number }>;
topAgents: Array<{ agentId: string; violations: number }>;
riskScore: number; // 0-100
}
+204
View File
@@ -0,0 +1,204 @@
import { watch, type FSWatcher } from 'chokidar';
import { readFileSync, existsSync } from 'fs';
import { resolve, relative } from 'path';
import { evaluateAction } from './rules.js';
import { Auditor } from './auditor.js';
import type { PermissionManifest } from './types.js';
export interface WatcherOptions {
watchDir: string;
claudeDir?: string; // Path to .claude directory or conversation logs
agentId: string;
ignorePatterns?: string[];
}
export class PermissionWatcher {
private watcher: FSWatcher | null = null;
private manifest: PermissionManifest;
private auditor: Auditor;
private options: WatcherOptions;
private active = false;
constructor(manifest: PermissionManifest, auditor: Auditor, options: WatcherOptions) {
this.manifest = manifest;
this.auditor = auditor;
this.options = options;
}
start(): void {
if (this.active) {
console.log('[cpo] Watcher already running');
return;
}
const watchPaths = [resolve(this.options.watchDir)];
// Also watch Claude Code state directory if provided
if (this.options.claudeDir && existsSync(this.options.claudeDir)) {
watchPaths.push(resolve(this.options.claudeDir));
}
const ignore = [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/*.log',
...(this.options.ignorePatterns || []),
];
this.watcher = watch(watchPaths, {
ignored: ignore,
persistent: true,
ignoreInitial: false,
followSymlinks: false,
depth: 5,
});
this.watcher.on('add', (path) => this.handleFileChange('add', path));
this.watcher.on('change', (path) => this.handleFileChange('change', path));
this.watcher.on('unlink', (path) => this.handleFileChange('unlink', path));
this.watcher.on('addDir', (path) => this.handleFileChange('addDir', path));
this.watcher.on('unlinkDir', (path) => this.handleFileChange('unlinkDir', path));
this.watcher.on('ready', () => {
console.log(`[cpo] Watcher ready — monitoring ${watchPaths.length} path(s)`);
});
this.watcher.on('error', (err) => {
console.error('[cpo] Watcher error:', err.message);
});
this.active = true;
// If Claude directory is watched, periodically scan for new state
if (this.options.claudeDir) {
this.pollClaudeState();
}
}
stop(): void {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
this.active = false;
console.log('[cpo] Watcher stopped');
}
isActive(): boolean {
return this.active;
}
private handleFileChange(event: string, path: string): void {
const relativePath = relative(this.options.watchDir, path);
const action = evaluateAction(
this.manifest,
'file',
relativePath,
`fs.${event}`,
this.options.agentId
);
this.auditor.log(action);
if (action.resolvedAction === 'deny') {
console.log(`[DENY] ${event}: ${relativePath} (${action.matchedRule || 'default'})`);
} else if (action.resolvedAction === 'warn') {
console.log(`[WARN] ${event}: ${relativePath} (${action.matchedRule || 'default'})`);
}
}
private pollClaudeState(): void {
if (!this.active) return;
// Check for .claude/messages or state files to infer command activity
const claudeDir = resolve(this.options.claudeDir!);
if (existsSync(claudeDir)) {
try {
// Look for tool use patterns in Claude state files
const stateFiles = [
resolve(claudeDir, 'state.json'),
resolve(claudeDir, 'messages.json'),
];
for (const stateFile of stateFiles) {
if (existsSync(stateFile)) {
try {
const content = readFileSync(stateFile, 'utf-8');
const parsed = JSON.parse(content);
// Detect command patterns in state
if (parsed.messages && Array.isArray(parsed.messages)) {
for (const msg of parsed.messages.slice(-10)) {
if (msg.tool_use || msg.tool_calls) {
const tools = msg.tool_use || msg.tool_calls || [];
for (const tool of tools) {
if (tool.name) {
const action = evaluateAction(
this.manifest,
'command',
tool.name,
tool.name,
this.options.agentId
);
this.auditor.log(action);
if (action.resolvedAction === 'deny') {
console.log(`[DENY] Tool: ${tool.name} (${action.matchedRule || 'default'})`);
} else if (action.resolvedAction === 'warn') {
console.log(`[WARN] Tool: ${tool.name} (${action.matchedRule || 'default'})`);
}
}
}
}
}
}
} catch {
// Ignore parse errors in state files
}
}
}
} catch {
// Ignore errors
}
}
// Poll every 5 seconds
setTimeout(() => this.pollClaudeState(), 5000);
}
recordCommand(command: string, args: string[] = []): void {
const fullCommand = `${command} ${args.join(' ')}`.trim();
const action = evaluateAction(
this.manifest,
'command',
fullCommand,
fullCommand,
this.options.agentId
);
this.auditor.log(action);
if (action.resolvedAction === 'deny') {
console.log(`[DENY] Command: ${fullCommand} (${action.matchedRule || 'default'})`);
} else if (action.resolvedAction === 'warn') {
console.log(`[WARN] Command: ${fullCommand} (${action.matchedRule || 'default'})`);
}
}
recordNetworkRequest(url: string, method: string = 'GET'): void {
const action = evaluateAction(
this.manifest,
'network',
url,
`${method} ${url}`,
this.options.agentId
);
this.auditor.log(action);
if (action.resolvedAction === 'deny') {
console.log(`[DENY] Network: ${method} ${url} (${action.matchedRule || 'default'})`);
} else if (action.resolvedAction === 'warn') {
console.log(`[WARN] Network: ${method} ${url} (${action.matchedRule || 'default'})`);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"allowJs": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}