@matt82198/aesop 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env python3
2
+ # secretscan: allow-pattern-docs
3
+ """
4
+ secret_scan.py — Pre-push secret/credential detection gate.
5
+
6
+ Modes:
7
+ secret_scan.py --staged [--repo PATH] Scan git staged files (default repo=cwd)
8
+ secret_scan.py --history [--repo PATH] Scan all blobs in git history
9
+ secret_scan.py PATH [PATH...] Scan files/dirs directly (recurse dirs)
10
+
11
+ Exit codes: 0=clean, 1=findings, 2=usage error
12
+ Output: one line per finding or summary (never prints full secrets)
13
+
14
+ Pragma escape hatch (defensive only):
15
+ If the literal string 'secretscan: allow-pattern-docs' appears in a file's first 10 lines
16
+ (any comment syntax: #, //, <!-- -->), pattern-based findings in that file are reported
17
+ as ALLOWED-DOC and do NOT cause exit 1. Filename-based findings (credential files like
18
+ .credentials.json, *.pem, id_rsa*, *.p12) stay fatal regardless. Use only for deliberate
19
+ pattern documentation; the pragma appears in git diffs and is a reviewable act.
20
+
21
+ NOTE: Public repo version has NO vault allowlist. All secrets block unless permitted by pragma.
22
+ """
23
+
24
+ import argparse
25
+ import os
26
+ import re
27
+ import sys
28
+ import subprocess
29
+ from pathlib import Path
30
+
31
+
32
+ # Regex patterns for secret detection (case-insensitive where sensible)
33
+ PATTERNS = {
34
+ "pem_private_key": (r"-----BEGIN .* PRIVATE KEY-----", re.IGNORECASE),
35
+ "aws_access_key": (r"AKIA[0-9A-Z]{16}", 0),
36
+ "aws_secret_pattern": (
37
+ r"aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*[^\s\$\<\{]",
38
+ re.IGNORECASE,
39
+ ),
40
+ "github_token": (
41
+ r"(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}",
42
+ 0,
43
+ ),
44
+ "slack_token": (r"xox[baprs]-[A-Za-z0-9-]{10,}", 0),
45
+ "openai_anthropic_key": (r"sk-[A-Za-z0-9_\-]{20,}", 0),
46
+ "generic_secret_assignment": (
47
+ r"(password|passwd|secret|api[_-]?key|token|authorization)\s*[:=]\s*[\"'](?!.*(?:xxx|changeme|your-|<|$\{|example)\b).{8,}[\"']",
48
+ re.IGNORECASE,
49
+ ),
50
+ "connection_string": (r"://[^:]+:[^@/\s]+@(?!localhost(?:[:/]|$)|127\.0\.0\.1(?:[:/]|$)|example\.com(?:[:/]|$))[^\s]+", 0),
51
+ "env_access": (
52
+ r"(?i:(?:os\.getenv|os\.environ|System\.getenv|process\.env)\s*[\[\(][\"']?[A-Z_]*(?:password|secret|api[_-]?key|token|auth|key)[A-Z_0-9]*[\"']?[\)\]])",
53
+ 0,
54
+ ),
55
+ "env_assignment": (
56
+ r"(?i:[A-Z_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE|CREDENTIAL|AUTH)[A-Z_0-9]*\s*=\s*)(?!.*(?:xxx|changeme|your-|example))[^\s].{8,}",
57
+ 0,
58
+ ),
59
+ }
60
+
61
+ # File patterns that look like credentials (case-insensitive)
62
+ CREDENTIAL_FILENAMES = [
63
+ r"\.credentials.*",
64
+ r".*token.*",
65
+ r".*\.pem$",
66
+ r".*\.p12$",
67
+ r"id_rsa.*",
68
+ ]
69
+
70
+ # Placeholders that don't count as secrets
71
+ PLACEHOLDERS = {"xxx", "changeme", "your-key-here", "example", "test", "demo"}
72
+
73
+
74
+ def has_pragma(filepath):
75
+ """Check if file has 'secretscan: allow-pattern-docs' pragma in first 10 lines."""
76
+ try:
77
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
78
+ for i, line in enumerate(f):
79
+ if i >= 10: # Only check first 10 lines
80
+ break
81
+ if "secretscan: allow-pattern-docs" in line:
82
+ return True
83
+ except Exception:
84
+ pass
85
+ return False
86
+
87
+
88
+ def is_binary_file(filepath):
89
+ """Check if file is binary (contains null bytes)."""
90
+ try:
91
+ with open(filepath, "rb") as f:
92
+ return b"\x00" in f.read(8192)
93
+ except Exception:
94
+ return True
95
+
96
+
97
+ def should_skip_file(filepath):
98
+ """Check if file should be skipped (binary, too large, in .git/)."""
99
+ if ".git" in filepath.parts or ".git" in str(filepath):
100
+ return True
101
+
102
+ try:
103
+ stat = filepath.stat()
104
+ if stat.st_size > 1024 * 1024: # >1MB
105
+ return True
106
+ if is_binary_file(filepath):
107
+ return True
108
+ except Exception:
109
+ return True
110
+
111
+ return False
112
+
113
+
114
+ def is_placeholder(value):
115
+ """Check if a string is a placeholder value (word-boundary aware)."""
116
+ lower_val = value.lower()
117
+ for p in PLACEHOLDERS:
118
+ if re.search(r'\b' + re.escape(p) + r'\b(?!\.)', lower_val):
119
+ return True
120
+ # Also check for template syntax
121
+ return bool(re.search(r"<[^>]*>|\$\{[^}]*\}", value))
122
+
123
+
124
+ def mask_secret(secret_str):
125
+ """Mask secret: show first 4 chars + *** (or *** if <4 chars)."""
126
+ if len(secret_str) <= 4:
127
+ return "***"
128
+ return f"{secret_str[:4]}***"
129
+
130
+
131
+ def is_env_file(filepath):
132
+ r"""Check if file is .env-like (basename matches ^\.env(\..*)?$ or *.env or *.properties)."""
133
+ name = filepath.name.lower()
134
+ return bool(
135
+ re.match(r"^\.env(\..*)?$", name) or
136
+ name.endswith(".env") or
137
+ name.endswith(".properties")
138
+ )
139
+
140
+
141
+ def scan_file(filepath):
142
+ """
143
+ Scan a single file for secrets.
144
+ Returns list of (line_num, rule, match_str, is_fatal).
145
+ is_fatal=True for credential filenames; is_fatal=False if pragma present and rule-based.
146
+ """
147
+ findings = []
148
+
149
+ if should_skip_file(filepath):
150
+ return findings
151
+
152
+ # Check for pragma (applies only to rule-based findings, not filename findings)
153
+ has_file_pragma = has_pragma(filepath)
154
+
155
+ # Check if filename matches credential patterns (always fatal, pragma does NOT apply)
156
+ filename = filepath.name.lower()
157
+ for pattern in CREDENTIAL_FILENAMES:
158
+ if re.match(pattern, filename, re.IGNORECASE):
159
+ findings.append(
160
+ (0, "credential_filename", f"File name matches credential pattern: {filepath.name}", True)
161
+ )
162
+ break
163
+
164
+ try:
165
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
166
+ for line_num, line in enumerate(f, start=1):
167
+ for rule_name, (pattern, flags) in PATTERNS.items():
168
+ # Skip env_assignment rule if not an .env-like file
169
+ if rule_name == "env_assignment" and not is_env_file(filepath):
170
+ continue
171
+
172
+ matches = re.finditer(pattern, line, flags)
173
+ for match in matches:
174
+ match_str = match.group(0)
175
+
176
+ # Skip if it's a placeholder
177
+ if is_placeholder(match_str):
178
+ continue
179
+
180
+ # Mark as non-fatal if pragma present (rule-based only)
181
+ is_fatal = not has_file_pragma
182
+ findings.append((line_num, rule_name, match_str, is_fatal))
183
+
184
+ except Exception:
185
+ pass
186
+
187
+ return findings
188
+
189
+
190
+ def get_staged_files(repo_path):
191
+ """Get list of staged files from git repo."""
192
+ try:
193
+ result = subprocess.run(
194
+ ["git", "diff", "--cached", "--name-only"],
195
+ cwd=repo_path,
196
+ capture_output=True,
197
+ text=True,
198
+ timeout=10,
199
+ )
200
+ if result.returncode != 0:
201
+ return []
202
+ return [
203
+ Path(repo_path) / f
204
+ for f in result.stdout.strip().split("\n")
205
+ if f.strip()
206
+ ]
207
+ except Exception:
208
+ return []
209
+
210
+
211
+ def get_history_files(repo_path):
212
+ """Get all file contents from git history via git log -p."""
213
+ files_content = []
214
+ try:
215
+ # Use git log -p to get full diff history
216
+ result = subprocess.run(
217
+ ["git", "log", "--all", "-p", "--reverse"],
218
+ cwd=repo_path,
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=60,
222
+ )
223
+ if result.returncode == 0:
224
+ # Parse the git log output: each file appears as +++ b/path/to/file followed by its content
225
+ current_file = None
226
+ current_content = []
227
+ for line in result.stdout.split("\n"):
228
+ if line.startswith("+++ b/"):
229
+ if current_file and current_content:
230
+ files_content.append((current_file, "\n".join(current_content)))
231
+ current_file = line[6:] # Remove "+++ b/"
232
+ current_content = []
233
+ elif current_file and line.startswith("+") and not line.startswith("+++"):
234
+ # Content line (added), strip the leading +
235
+ current_content.append(line[1:])
236
+ elif current_file and line.startswith(" "):
237
+ # Context line, keep it as-is (strip leading space)
238
+ current_content.append(line[1:])
239
+ if current_file and current_content:
240
+ files_content.append((current_file, "\n".join(current_content)))
241
+ except Exception:
242
+ pass
243
+ return files_content
244
+
245
+
246
+ def scan_paths(paths):
247
+ """Recursively scan paths (files and directories)."""
248
+ files_to_scan = []
249
+ for path_str in paths:
250
+ path = Path(path_str).resolve()
251
+ if path.is_file():
252
+ files_to_scan.append(path)
253
+ elif path.is_dir():
254
+ files_to_scan.extend(path.rglob("*"))
255
+ return [p for p in files_to_scan if p.is_file()]
256
+
257
+
258
+ def scan_content(content):
259
+ """Scan raw content for secrets (used by history scanning)."""
260
+ findings = []
261
+ for line_num, line in enumerate(content.split("\n"), start=1):
262
+ for rule_name, (pattern, flags) in PATTERNS.items():
263
+ matches = re.finditer(pattern, line, flags)
264
+ for match in matches:
265
+ match_str = match.group(0)
266
+ if is_placeholder(match_str):
267
+ continue
268
+ findings.append((line_num, rule_name, match_str, True))
269
+ return findings
270
+
271
+
272
+ def main():
273
+ parser = argparse.ArgumentParser(
274
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
275
+ )
276
+ parser.add_argument(
277
+ "--staged",
278
+ action="store_true",
279
+ help="Scan git staged files (requires --repo or uses cwd)",
280
+ )
281
+ parser.add_argument(
282
+ "--history",
283
+ action="store_true",
284
+ help="Scan all blobs in git history (requires --repo or uses cwd)",
285
+ )
286
+ parser.add_argument(
287
+ "--repo",
288
+ default=os.getcwd(),
289
+ help="Git repo path (default: current directory)",
290
+ )
291
+ parser.add_argument(
292
+ "paths", nargs="*", help="Paths to scan directly (files or directories)"
293
+ )
294
+
295
+ args = parser.parse_args()
296
+
297
+ # Validate usage
298
+ if sum([args.staged, args.history, bool(args.paths)]) != 1:
299
+ print("ERROR: Use exactly one of --staged, --history, or path arguments", file=sys.stderr)
300
+ sys.exit(2)
301
+
302
+ # Collect files to scan
303
+ if args.staged:
304
+ files = get_staged_files(args.repo)
305
+ elif args.history:
306
+ # History mode: scan all files from git log
307
+ history_files = get_history_files(args.repo)
308
+ files = [] # We'll handle history differently
309
+ else:
310
+ files = scan_paths(args.paths)
311
+
312
+ # Scan files
313
+ all_findings = []
314
+ fatal_findings = []
315
+ allowed_doc_count = 0
316
+
317
+ if args.history:
318
+ # History scanning mode
319
+ for filepath, content in history_files:
320
+ findings = scan_content(content)
321
+ for line_num, rule, match_str, is_fatal in findings:
322
+ all_findings.append((filepath, line_num, rule, match_str, is_fatal))
323
+ if is_fatal:
324
+ fatal_findings.append((filepath, line_num, rule, match_str))
325
+ else:
326
+ # Regular file scanning mode
327
+ for filepath in files:
328
+ findings = scan_file(filepath)
329
+ for line_num, rule, match_str, is_fatal in findings:
330
+ all_findings.append((filepath, line_num, rule, match_str, is_fatal))
331
+ if is_fatal:
332
+ fatal_findings.append((filepath, line_num, rule, match_str))
333
+ else:
334
+ allowed_doc_count += 1
335
+
336
+ # Output findings
337
+ for filepath, line_num, rule, match_str, is_fatal in all_findings:
338
+ masked = mask_secret(match_str)
339
+ if is_fatal:
340
+ print(f"HIGH {filepath}:{line_num} {rule} ({masked})")
341
+ else:
342
+ print(f"ALLOWED-DOC {filepath}:{line_num} {rule} ({masked})")
343
+
344
+ # Summary and exit
345
+ file_count = len(files) if not args.history else len(set(f for f, _, _, _, _ in all_findings))
346
+
347
+ if len(fatal_findings) == 0:
348
+ if allowed_doc_count == 0:
349
+ if args.history:
350
+ print(f"CLEAN: git history scanned")
351
+ else:
352
+ print(f"CLEAN: {file_count} files scanned")
353
+ else:
354
+ pragma_file_count = len(set(f for f, _, _, _, is_fatal in all_findings if not is_fatal))
355
+ print(f"CLEAN: scanned ({allowed_doc_count} allowed-doc findings in {pragma_file_count} pragma files)")
356
+ sys.exit(0)
357
+ else:
358
+ print(f"FOUND: {len(fatal_findings)} secret(s)")
359
+ sys.exit(1)
360
+
361
+
362
+ if __name__ == "__main__":
363
+ main()
package/ui/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # Aesop Web Dashboard
2
+
3
+ A lightweight, self-contained web dashboard for real-time fleet monitoring. Replaces the bash TUI with a modern, responsive HTML interface that displays heartbeats, active agents, security alerts, and inbox status.
4
+
5
+ ## Launch
6
+
7
+ ```bash
8
+ export AESOP_ROOT=$HOME/aesop
9
+ python $AESOP_ROOT/ui/serve.py
10
+ ```
11
+
12
+ Prints `Dashboard: http://localhost:8770` and serves until Ctrl-C.
13
+
14
+ ### Configuration
15
+
16
+ The dashboard reads configuration from `aesop.config.json` if present. Key paths:
17
+
18
+ - **`AESOP_ROOT`** (env var): Path to aesop installation (default: `$HOME/aesop`)
19
+ - **`PORT`** (env var): Dashboard port (default: `8770`)
20
+ - **`AESOP_TRANSCRIPTS_ROOT`** (env var): Path to Claude session transcripts (default: `~/.claude/projects`)
21
+
22
+ Example with custom paths:
23
+
24
+ ```bash
25
+ export AESOP_ROOT=/opt/aesop
26
+ export AESOP_TRANSCRIPTS_ROOT=/home/user/.claude/projects
27
+ export PORT=9000
28
+ python $AESOP_ROOT/ui/serve.py
29
+ ```
30
+
31
+ ## Features
32
+
33
+ ### Header Status
34
+ - **Watchdog Status**: Daemon heartbeat age + ALIVE/STALE (300s threshold)
35
+ - **Monitor Status**: Orchestration monitor liveness + age (3600s threshold)
36
+ - **Security Alerts**: Count of unreviewed alerts
37
+ - **Running Agents**: Number of active subagents (<2 min old)
38
+
39
+ ### Panels
40
+
41
+ 1. **Inbox** (top): Text input → Send button appends to `state/ui-inbox.md`. Orchestrator reads each turn.
42
+ 2. **Fleet Agents**: Currently-running subagents with status and hint text (refreshed every 3s)
43
+ 3. **Repos Status**: Repository sync status from `state/.watchdog-repos.json`
44
+ 4. **Recent Events**: Last 8 lines from `state/FLEET-BACKUP.log`
45
+ 5. **Security Alerts**: Unreviewed lines from `scan/SECURITY-ALERTS.log` (skips NOTE:/RESOLVED-FP)
46
+ 6. **Main-Thread Prompts**: Last ~12 messages from newest session JSONL in transcripts root
47
+
48
+ ### Agent Details
49
+
50
+ Click any agent row in the **Fleet Agents** panel to expand and view full dispatch details:
51
+
52
+ - **Dispatcher**: Who launched this agent (main thread or parent agent)
53
+ - **Model**: LLM model used for the agent
54
+ - **Messages**: Total NDJSON message count in transcript
55
+ - **Prompt**: Full dispatch prompt (expandable container with scroll)
56
+
57
+ #### API: `/agent?id=<agent_id>`
58
+
59
+ GET endpoint returns JSON with agent metadata:
60
+
61
+ ```json
62
+ {
63
+ "id": "a77b995bcdb9",
64
+ "dispatch_prompt": "full prompt text here...",
65
+ "dispatcher": "main thread",
66
+ "model": "haiku",
67
+ "message_count": 42,
68
+ "first_seen": 1720700000,
69
+ "last_activity": 1720700300
70
+ }
71
+ ```
72
+
73
+ **Note**: Agent IDs are prefix-matched via glob (truncated IDs in the dashboard resolve to full transcripts on disk).
74
+
75
+ ### Inbox Contract
76
+
77
+ POST `/submit` appends timestamped line to `state/ui-inbox.md`:
78
+
79
+ ```markdown
80
+ # UI Inbox — orchestrator reads each turn / on /power
81
+
82
+ - [2026-07-11T14:23:45.123456] user's task here
83
+ - [2026-07-11T14:24:12.654321] another task
84
+ ```
85
+
86
+ ## Requirements
87
+
88
+ - **Python 3.10+**: stdlib-only (no external dependencies)
89
+ - **Node.js v18+** (optional): required for agent detection via `dash/dash-extra.mjs`
90
+
91
+ ## Robustness
92
+
93
+ - Every data source is wrapped with exception handling
94
+ - Missing or locked files → placeholder text (never 500 error)
95
+ - `/data` endpoint always returns valid JSON
96
+ - Auto-refresh every 3s via client-side fetch
97
+
98
+ ## Path Configuration
99
+
100
+ If you have a non-standard setup, set paths in `aesop.config.json`:
101
+
102
+ ```json
103
+ {
104
+ "state_root": "/path/to/state",
105
+ "scan_root": "/path/to/scan",
106
+ "transcripts_root": "/path/to/transcripts"
107
+ }
108
+ ```
109
+
110
+ Or use environment variables:
111
+
112
+ ```bash
113
+ export AESOP_TRANSCRIPTS_ROOT=/my/custom/transcripts/path
114
+ python ui/serve.py
115
+ ```
116
+
117
+ ## Troubleshooting
118
+
119
+ ### "Agents panel unavailable"
120
+ - Ensure `node` is on PATH and `dash/dash-extra.mjs` exists
121
+ - Install Node.js v18+
122
+
123
+ ### Dashboard shows no data
124
+ - Verify `AESOP_ROOT` is set correctly: `echo $AESOP_ROOT`
125
+ - Check that `state/` directory exists and is readable
126
+ - Verify `aesop.config.json` is valid JSON (if present)
127
+
128
+ ### Can't connect to http://localhost:8770
129
+ - Check port is not in use: `lsof -i :8770` (macOS/Linux) or `netstat -ano | findstr :8770` (Windows)
130
+ - Try a different port: `export PORT=9000`
131
+
132
+ ## Integration with Claude Code
133
+
134
+ To display main-thread transcripts, ensure `AESOP_TRANSCRIPTS_ROOT` points to your Claude session directory. Example:
135
+
136
+ ```bash
137
+ export AESOP_TRANSCRIPTS_ROOT=$HOME/.claude/projects/my-project
138
+ python $AESOP_ROOT/ui/serve.py
139
+ ```
140
+
141
+ See the main Aesop README for full orchestration setup.