Initial MVP: Claude Permission Observability CLI tool
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.verdict
|
||||
cpo-audit/
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+286
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user