From 50a2f9058ca42223f0f9315176a36d01ec570b27 Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Tue, 16 Jun 2026 00:30:52 +0000 Subject: [PATCH] Initial MVP: Claude Permission Observability CLI tool --- .gitignore | 6 + README.md | 103 +++++++++++ examples/permission-manifest.json | 83 +++++++++ package-lock.json | 286 ++++++++++++++++++++++++++++++ package.json | 35 ++++ src/auditor.ts | 117 ++++++++++++ src/config.ts | 137 ++++++++++++++ src/index.ts | 188 ++++++++++++++++++++ src/reporter.ts | 132 ++++++++++++++ src/rules.ts | 68 +++++++ src/tests/core.test.ts | 267 ++++++++++++++++++++++++++++ src/types.ts | 54 ++++++ src/watcher.ts | 204 +++++++++++++++++++++ tsconfig.json | 21 +++ 14 files changed, 1701 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 examples/permission-manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/auditor.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/reporter.ts create mode 100644 src/rules.ts create mode 100644 src/tests/core.test.ts create mode 100644 src/types.ts create mode 100644 src/watcher.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3b25fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.verdict +cpo-audit/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2de2b1 --- /dev/null +++ b/README.md @@ -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 diff --git a/examples/permission-manifest.json b/examples/permission-manifest.json new file mode 100644 index 0000000..d0d892e --- /dev/null +++ b/examples/permission-manifest.json @@ -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" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..56f0dd2 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..efb3e4d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/auditor.ts b/src/auditor.ts new file mode 100644 index 0000000..e18acb0 --- /dev/null +++ b/src/auditor.ts @@ -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, + }, + }; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d829b53 --- /dev/null +++ b/src/config.ts @@ -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; + + 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; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..39c96f3 --- /dev/null +++ b/src/index.ts @@ -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 to permission manifest') + .requiredOption('-d, --dir ', 'Directory to watch') + .option('-a, --agent-id ', 'Agent identifier', 'claude-agent') + .option('-o, --output ', 'Output directory for audit trails', './cpo-audit') + .option('--claude-dir ', 'Path to .claude state directory for enhanced monitoring') + .option('--ignore ', 'Additional ignore patterns') + .action((options: Record) => { + 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 to audit trail file') + .option('-o, --output ', 'Path to write report') + .option('--json', 'Output as JSON') + .option('-d, --dir ', 'Directory to scan for latest audit trail', './cpo-audit') + .action((options: Record) => { + 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 to permission manifest') + .requiredOption('-t, --type ', 'Action type: file, command, network, env') + .requiredOption('--action ', 'Action description') + .requiredOption('--target ', 'Target of the action') + .option('-a, --agent-id ', 'Agent identifier', 'manual-agent') + .option('-o, --output ', 'Output directory', './cpo-audit') + .action(async (options: Record) => { + 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 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 ', 'Audit directory', './cpo-audit') + .action(async (options: Record) => { + 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(); diff --git a/src/reporter.ts b/src/reporter.ts new file mode 100644 index 0000000..11870ec --- /dev/null +++ b/src/reporter.ts @@ -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(); + const agentCounts = new Map(); + + 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; +} diff --git a/src/rules.ts b/src/rules.ts new file mode 100644 index 0000000..a66d6a6 --- /dev/null +++ b/src/rules.ts @@ -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'); +} diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts new file mode 100644 index 0000000..2eb5d7f --- /dev/null +++ b/src/tests/core.test.ts @@ -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, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5bf821f --- /dev/null +++ b/src/types.ts @@ -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; +} + +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 +} diff --git a/src/watcher.ts b/src/watcher.ts new file mode 100644 index 0000000..c0b82bf --- /dev/null +++ b/src/watcher.ts @@ -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'})`); + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8ad8c4e --- /dev/null +++ b/tsconfig.json @@ -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"] +}