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