agentbox-flight-recorder 0.2.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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/action.yml +52 -0
- package/bin/agentbox.js +3 -0
- package/docs/index.html +308 -0
- package/examples/fake-agent.js +56 -0
- package/examples/fake-mcp-server.js +92 -0
- package/package.json +53 -0
- package/scripts/preflight.sh +324 -0
- package/src/adapters/claude.js +308 -0
- package/src/adapters/mcp.js +346 -0
- package/src/chain.js +286 -0
- package/src/cli.js +262 -0
- package/src/clip.js +196 -0
- package/src/parse.js +265 -0
- package/src/receipt.js +176 -0
- package/src/redact.js +231 -0
- package/src/replay.js +412 -0
- package/src/wrap.js +310 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# agentbox — preflight.sh
|
|
3
|
+
# Run before every public post (Reddit, Show HN, Twitter, npm publish).
|
|
4
|
+
# Exit 0 = green light. Exit 1 = fix the failures first.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ./scripts/preflight.sh
|
|
8
|
+
# ./scripts/preflight.sh --quick # skip slow e2e (demo/wrap)
|
|
9
|
+
# npm run preflight
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
13
|
+
cd "$ROOT"
|
|
14
|
+
|
|
15
|
+
QUICK=0
|
|
16
|
+
for arg in "$@"; do
|
|
17
|
+
case "$arg" in
|
|
18
|
+
--quick|-q) QUICK=1 ;;
|
|
19
|
+
--help|-h)
|
|
20
|
+
echo "Usage: $0 [--quick]"
|
|
21
|
+
exit 0
|
|
22
|
+
;;
|
|
23
|
+
esac
|
|
24
|
+
done
|
|
25
|
+
|
|
26
|
+
PASS=0
|
|
27
|
+
FAIL=0
|
|
28
|
+
WARN=0
|
|
29
|
+
failures=()
|
|
30
|
+
warnings=()
|
|
31
|
+
|
|
32
|
+
GREEN='\033[32m'
|
|
33
|
+
RED='\033[31m'
|
|
34
|
+
YELLOW='\033[33m'
|
|
35
|
+
DIM='\033[2m'
|
|
36
|
+
BOLD='\033[1m'
|
|
37
|
+
CYAN='\033[36m'
|
|
38
|
+
RESET='\033[0m'
|
|
39
|
+
|
|
40
|
+
ok() { PASS=$((PASS + 1)); printf " ${GREEN}✓${RESET} %s\n" "$1"; }
|
|
41
|
+
bad() { FAIL=$((FAIL + 1)); failures+=("$1"); printf " ${RED}✗${RESET} %s\n" "$1"; }
|
|
42
|
+
warn() { WARN=$((WARN + 1)); warnings+=("$1"); printf " ${YELLOW}!${RESET} %s\n" "$1"; }
|
|
43
|
+
section() { printf "\n${BOLD}${CYAN}▸ %s${RESET}\n" "$1"; }
|
|
44
|
+
|
|
45
|
+
# ─── 1. Repo shape ──────────────────────────────────────────────────────────
|
|
46
|
+
section "Repo shape"
|
|
47
|
+
|
|
48
|
+
[[ -f package.json ]] && ok "package.json present" || bad "package.json missing"
|
|
49
|
+
[[ -f LICENSE ]] && ok "LICENSE present" || bad "LICENSE missing"
|
|
50
|
+
[[ -f README.md ]] && ok "README.md present" || bad "README.md missing"
|
|
51
|
+
[[ -f bin/agentbox.js ]] && ok "bin/agentbox.js present" || bad "bin/agentbox.js missing"
|
|
52
|
+
[[ -f src/redact.js ]] && ok "src/redact.js present (redaction layer)" || bad "src/redact.js missing"
|
|
53
|
+
[[ -f src/chain.js ]] && ok "src/chain.js present" || bad "src/chain.js missing"
|
|
54
|
+
[[ -x bin/agentbox.js || -f bin/agentbox.js ]] && ok "CLI entry exists" || bad "CLI entry missing"
|
|
55
|
+
|
|
56
|
+
if [[ -f .gitignore ]]; then
|
|
57
|
+
if grep -qE '^\.agentbox/?$|^\.agentbox/' .gitignore || grep -q '\.agentbox' .gitignore; then
|
|
58
|
+
ok ".gitignore mentions .agentbox"
|
|
59
|
+
else
|
|
60
|
+
warn ".gitignore does not ignore .agentbox/ — session tapes could be committed"
|
|
61
|
+
fi
|
|
62
|
+
else
|
|
63
|
+
bad ".gitignore missing"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# ─── 2. Identity / placeholders ─────────────────────────────────────────────
|
|
67
|
+
section "Identity (no placeholders for public post)"
|
|
68
|
+
|
|
69
|
+
PKG_NAME=$(node -p "require('./package.json').name" 2>/dev/null || echo "")
|
|
70
|
+
PKG_VER=$(node -p "require('./package.json').version" 2>/dev/null || echo "")
|
|
71
|
+
PKG_AUTHOR=$(node -p "require('./package.json').author" 2>/dev/null || echo "")
|
|
72
|
+
PKG_REPO=$(node -p "require('./package.json').repository && require('./package.json').repository.url" 2>/dev/null || echo "")
|
|
73
|
+
|
|
74
|
+
[[ -n "$PKG_NAME" ]] && ok "package name: $PKG_NAME" || bad "package name empty"
|
|
75
|
+
[[ -n "$PKG_VER" ]] && ok "package version: $PKG_VER" || bad "package version empty"
|
|
76
|
+
|
|
77
|
+
CHAIN_VER=$(node -p "require('./src/chain.js').VERSION" 2>/dev/null || echo "")
|
|
78
|
+
if [[ -n "$CHAIN_VER" && -n "$PKG_VER" ]]; then
|
|
79
|
+
if [[ "$CHAIN_VER" == "$PKG_VER" ]]; then
|
|
80
|
+
ok "VERSION matches package.json ($PKG_VER)"
|
|
81
|
+
else
|
|
82
|
+
bad "VERSION mismatch: chain.js=$CHAIN_VER package.json=$PKG_VER"
|
|
83
|
+
fi
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
if echo "$PKG_AUTHOR" | grep -qiE 'you@example\.com|arunsoman|TODO|placeholder'; then
|
|
87
|
+
warn "package.json author still looks like a placeholder: $PKG_AUTHOR"
|
|
88
|
+
else
|
|
89
|
+
ok "package.json author set"
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
if echo "$PKG_REPO" | grep -qiE 'example\.com|TODO'; then
|
|
93
|
+
warn "package.json repository URL still a placeholder: $PKG_REPO"
|
|
94
|
+
else
|
|
95
|
+
ok "package.json repository URL set"
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
PLACEHOLDER_HITS=$(grep -RInE 'example\.com|you@example\.com|TODO' \
|
|
99
|
+
--include='*.md' --include='*.json' --include='*.yml' --include='*.js' \
|
|
100
|
+
README.md package.json action.yml 2>/dev/null | grep -v preflight | head -20 || true)
|
|
101
|
+
if [[ -n "$PLACEHOLDER_HITS" ]]; then
|
|
102
|
+
warn "placeholder strings still in published files:"
|
|
103
|
+
while IFS= read -r line; do printf " ${DIM}%s${RESET}\n" "$line"; done <<< "$PLACEHOLDER_HITS"
|
|
104
|
+
else
|
|
105
|
+
ok "no arunsoman/you@example placeholders in core published files"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# ─── 3. Secret scanning (static) ────────────────────────────────────────────
|
|
109
|
+
section "Secret-looking literals (GitHub push protection)"
|
|
110
|
+
|
|
111
|
+
# Continuous token-ish literals that secret scanners flag.
|
|
112
|
+
# We allow fragmented construction in tests; we ban continuous forms in tracked source.
|
|
113
|
+
SECRET_PATTERNS=(
|
|
114
|
+
'xox[baprs]-[0-9A-Za-z-]{10,}'
|
|
115
|
+
'sk-[A-Za-z0-9]{20,}'
|
|
116
|
+
'sk-ant-[A-Za-z0-9_-]{20,}'
|
|
117
|
+
'ghp_[A-Za-z0-9]{20,}'
|
|
118
|
+
'sk_live_[A-Za-z0-9]{16,}'
|
|
119
|
+
'sk_test_[A-Za-z0-9]{16,}'
|
|
120
|
+
'AKIA[0-9A-Z]{16}'
|
|
121
|
+
'AIza[0-9A-Za-z_-]{20,}'
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
secret_hits=0
|
|
125
|
+
for pat in "${SECRET_PATTERNS[@]}"; do
|
|
126
|
+
# scan source + tests + docs; skip binary, skip node_modules, skip .agentbox sessions
|
|
127
|
+
hits=$(grep -RInE "$pat" \
|
|
128
|
+
--include='*.js' --include='*.md' --include='*.json' --include='*.yml' --include='*.html' \
|
|
129
|
+
src test examples bin README.md CHANGELOG.md package.json action.yml docs 2>/dev/null \
|
|
130
|
+
| grep -v 'node_modules\|\.agentbox\|preflight\.sh' || true)
|
|
131
|
+
if [[ -n "$hits" ]]; then
|
|
132
|
+
secret_hits=$((secret_hits + 1))
|
|
133
|
+
bad "pattern /$pat/ found in source:"
|
|
134
|
+
while IFS= read -r line; do printf " ${DIM}%s${RESET}\n" "$line"; done <<< "$(echo "$hits" | head -5)"
|
|
135
|
+
fi
|
|
136
|
+
done
|
|
137
|
+
if [[ $secret_hits -eq 0 ]]; then
|
|
138
|
+
ok "no continuous secret-like literals in source tree"
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# ─── 4. Security docs ───────────────────────────────────────────────────────
|
|
142
|
+
section "Security documentation"
|
|
143
|
+
|
|
144
|
+
if grep -qE 'Security & privacy|redact|AGENTBOX_REDACT' README.md; then
|
|
145
|
+
ok "README mentions redaction / security"
|
|
146
|
+
else
|
|
147
|
+
bad "README missing Security & privacy / redaction docs"
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
if grep -q 'AGENTBOX_REDACT' README.md; then
|
|
151
|
+
ok "README documents AGENTBOX_REDACT kill-switch"
|
|
152
|
+
else
|
|
153
|
+
warn "README does not document AGENTBOX_REDACT=0"
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# ─── 5. Unit / integration tests ────────────────────────────────────────────
|
|
157
|
+
section "Test suite"
|
|
158
|
+
|
|
159
|
+
if npm test >/tmp/agentbox-preflight-tests.log 2>&1; then
|
|
160
|
+
tcount=$(grep -cE '✔|✓' /tmp/agentbox-preflight-tests.log 2>/dev/null || echo 0)
|
|
161
|
+
ok "npm test passed ($tcount checks logged)"
|
|
162
|
+
else
|
|
163
|
+
bad "npm test FAILED — see /tmp/agentbox-preflight-tests.log"
|
|
164
|
+
tail -20 /tmp/agentbox-preflight-tests.log | sed 's/^/ /'
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
# ─── 6. Live smoke (skip with --quick) ──────────────────────────────────────
|
|
168
|
+
# Helper: run a command with a hard timeout and stdin from /dev/null so a
|
|
169
|
+
# leftover TTY stdin listener can never hang the preflight process.
|
|
170
|
+
run_timed() {
|
|
171
|
+
local secs="$1"; shift
|
|
172
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
173
|
+
timeout "$secs" "$@" </dev/null
|
|
174
|
+
else
|
|
175
|
+
"$@" </dev/null
|
|
176
|
+
fi
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if [[ $QUICK -eq 0 ]]; then
|
|
180
|
+
section "Live smoke (demo / wrap / receipt / verify)"
|
|
181
|
+
printf " ${DIM}(demo takes ~5–6s — agent is intentionally slow)${RESET}\n"
|
|
182
|
+
|
|
183
|
+
TMP=$(mktemp -d)
|
|
184
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
185
|
+
|
|
186
|
+
# demo
|
|
187
|
+
printf " ${DIM}→ agentbox demo…${RESET}\n"
|
|
188
|
+
if run_timed 30 node bin/agentbox.js demo >/tmp/agentbox-preflight-demo.log 2>&1; then
|
|
189
|
+
ok "agentbox demo completed"
|
|
190
|
+
else
|
|
191
|
+
ec=$?
|
|
192
|
+
if [[ $ec -eq 124 ]]; then
|
|
193
|
+
bad "agentbox demo TIMED OUT (30s) — likely stdin not detached after wrap"
|
|
194
|
+
else
|
|
195
|
+
bad "agentbox demo failed (exit $ec)"
|
|
196
|
+
fi
|
|
197
|
+
tail -15 /tmp/agentbox-preflight-demo.log | sed 's/^/ /'
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
# wrap a trivial command in an isolated cwd so we don't pollute the repo
|
|
201
|
+
(
|
|
202
|
+
cd "$TMP"
|
|
203
|
+
printf " ${DIM}→ agentbox wrap…${RESET}\n"
|
|
204
|
+
if run_timed 15 node "$ROOT/bin/agentbox.js" wrap --name preflight -- \
|
|
205
|
+
node -e "console.log('preflight-ok')" \
|
|
206
|
+
>/tmp/agentbox-preflight-wrap.log 2>&1; then
|
|
207
|
+
ok "agentbox wrap records a child process"
|
|
208
|
+
else
|
|
209
|
+
ec=$?
|
|
210
|
+
if [[ $ec -eq 124 ]]; then
|
|
211
|
+
bad "agentbox wrap TIMED OUT (15s)"
|
|
212
|
+
else
|
|
213
|
+
bad "agentbox wrap failed (exit $ec)"
|
|
214
|
+
fi
|
|
215
|
+
tail -10 /tmp/agentbox-preflight-wrap.log | sed 's/^/ /'
|
|
216
|
+
exit 0
|
|
217
|
+
fi
|
|
218
|
+
|
|
219
|
+
SESSION=$(ls -t .agentbox/sessions/*.jsonl 2>/dev/null | head -1 || true)
|
|
220
|
+
if [[ -n "$SESSION" ]]; then
|
|
221
|
+
ok "session file created: $(basename "$SESSION")"
|
|
222
|
+
if run_timed 10 node "$ROOT/bin/agentbox.js" verify "$SESSION" >/tmp/agentbox-preflight-verify.log 2>&1; then
|
|
223
|
+
ok "agentbox verify: chain intact"
|
|
224
|
+
else
|
|
225
|
+
bad "agentbox verify failed"
|
|
226
|
+
fi
|
|
227
|
+
if run_timed 10 node "$ROOT/bin/agentbox.js" receipt "$SESSION" >/tmp/agentbox-preflight-receipt.log 2>&1; then
|
|
228
|
+
ok "agentbox receipt renders"
|
|
229
|
+
else
|
|
230
|
+
bad "agentbox receipt failed"
|
|
231
|
+
fi
|
|
232
|
+
if run_timed 15 node "$ROOT/bin/agentbox.js" clip "$SESSION" --out "$TMP/clip.html" >/tmp/agentbox-preflight-clip.log 2>&1; then
|
|
233
|
+
if [[ -f "$TMP/clip.html" ]]; then
|
|
234
|
+
ok "agentbox clip wrote HTML"
|
|
235
|
+
else
|
|
236
|
+
bad "agentbox clip produced no file"
|
|
237
|
+
fi
|
|
238
|
+
else
|
|
239
|
+
bad "agentbox clip failed"
|
|
240
|
+
fi
|
|
241
|
+
else
|
|
242
|
+
bad "no session file after wrap"
|
|
243
|
+
fi
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# redaction smoke: secret must not land on disk
|
|
247
|
+
(
|
|
248
|
+
cd "$TMP"
|
|
249
|
+
printf " ${DIM}→ redaction smoke…${RESET}\n"
|
|
250
|
+
# assemble at runtime so this script itself isn't flagged
|
|
251
|
+
FAKE_SECRET="sk-""abcdefghijklmnopqrstuvwxyz""012345"
|
|
252
|
+
run_timed 15 node "$ROOT/bin/agentbox.js" wrap --name redact-smoke -- \
|
|
253
|
+
node -e "console.log('KEY=${FAKE_SECRET}')" \
|
|
254
|
+
>/tmp/agentbox-preflight-redact.log 2>&1 || true
|
|
255
|
+
SFILE=$(ls -t .agentbox/sessions/*redact-smoke*.jsonl 2>/dev/null | head -1 || true)
|
|
256
|
+
if [[ -n "$SFILE" ]]; then
|
|
257
|
+
if grep -qF "$FAKE_SECRET" "$SFILE"; then
|
|
258
|
+
bad "redaction failed — secret found in session file"
|
|
259
|
+
else
|
|
260
|
+
ok "redaction: secret not present on disk"
|
|
261
|
+
fi
|
|
262
|
+
if grep -q '\[REDACTED\]' "$SFILE"; then
|
|
263
|
+
ok "redaction: [REDACTED] placeholder present"
|
|
264
|
+
else
|
|
265
|
+
warn "redaction: no [REDACTED] placeholder (pattern may not have matched)"
|
|
266
|
+
fi
|
|
267
|
+
else
|
|
268
|
+
warn "redaction smoke: no session file to inspect"
|
|
269
|
+
fi
|
|
270
|
+
)
|
|
271
|
+
else
|
|
272
|
+
section "Live smoke"
|
|
273
|
+
warn "skipped (--quick)"
|
|
274
|
+
fi
|
|
275
|
+
|
|
276
|
+
# ─── 7. Git hygiene (if in a git repo) ──────────────────────────────────────
|
|
277
|
+
section "Git hygiene"
|
|
278
|
+
|
|
279
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
280
|
+
if git diff --quiet && git diff --cached --quiet; then
|
|
281
|
+
ok "working tree clean"
|
|
282
|
+
else
|
|
283
|
+
warn "uncommitted changes present — commit or stash before posting"
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
# scan last 5 commits for secret-like blobs (cheap)
|
|
287
|
+
if git log -5 --pretty=format: --name-only 2>/dev/null | grep -q .; then
|
|
288
|
+
hist=$(git grep -nE 'xox[baprs]-[0-9A-Za-z-]{10,}|ghp_[A-Za-z0-9]{20,}' $(git rev-list -5 HEAD) 2>/dev/null | head -5 || true)
|
|
289
|
+
if [[ -n "$hist" ]]; then
|
|
290
|
+
bad "secret-like strings in recent git history (push protection will block):"
|
|
291
|
+
while IFS= read -r line; do printf " ${DIM}%s${RESET}\n" "$line"; done <<< "$hist"
|
|
292
|
+
else
|
|
293
|
+
ok "no obvious secrets in last 5 commits (git grep)"
|
|
294
|
+
fi
|
|
295
|
+
fi
|
|
296
|
+
else
|
|
297
|
+
warn "not a git repo — skip history checks"
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
# ─── Summary ────────────────────────────────────────────────────────────────
|
|
301
|
+
printf "\n${BOLD}────────────────────────────────────────${RESET}\n"
|
|
302
|
+
printf "${BOLD} preflight result${RESET}\n"
|
|
303
|
+
printf " ${GREEN}passed${RESET}: %d\n" "$PASS"
|
|
304
|
+
printf " ${YELLOW}warnings${RESET}: %d\n" "$WARN"
|
|
305
|
+
printf " ${RED}failed${RESET}: %d\n" "$FAIL"
|
|
306
|
+
|
|
307
|
+
if [[ $FAIL -gt 0 ]]; then
|
|
308
|
+
printf "\n${RED}${BOLD}✗ NOT READY TO POST${RESET}\n"
|
|
309
|
+
printf " Fix these first:\n"
|
|
310
|
+
for f in "${failures[@]}"; do printf " ${RED}•${RESET} %s\n" "$f"; done
|
|
311
|
+
[[ $WARN -gt 0 ]] && printf " Also consider:\n" && for w in "${warnings[@]}"; do printf " ${YELLOW}•${RESET} %s\n" "$w"; done
|
|
312
|
+
exit 1
|
|
313
|
+
fi
|
|
314
|
+
|
|
315
|
+
if [[ $WARN -gt 0 ]]; then
|
|
316
|
+
printf "\n${YELLOW}${BOLD}✓ tests green, but warnings remain${RESET}\n"
|
|
317
|
+
for w in "${warnings[@]}"; do printf " ${YELLOW}•${RESET} %s\n" "$w"; done
|
|
318
|
+
printf " ${DIM}Post only if those are intentional (placeholders, etc.).${RESET}\n"
|
|
319
|
+
exit 0
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
printf "\n${GREEN}${BOLD}✓ CLEAR TO POST${RESET}\n"
|
|
323
|
+
printf " ${DIM}Demo GIF + honest title + Security section link in first comment.${RESET}\n"
|
|
324
|
+
exit 0
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* agentbox — adapters/claude.js
|
|
4
|
+
* The Claude Code hook adapter: passive capture, no wrapper needed.
|
|
5
|
+
*
|
|
6
|
+
* agentbox init claude # one command: wires hooks into .claude/settings.json
|
|
7
|
+
* agentbox hook claude # what every hook invocation runs (reads the
|
|
8
|
+
* # hook payload JSON from stdin, appends one
|
|
9
|
+
* # hash-chained event, exits 0 — ALWAYS)
|
|
10
|
+
*
|
|
11
|
+
* Design rules (a recorder must never break a flight):
|
|
12
|
+
* 1. exit 0 no matter what — even on internal errors
|
|
13
|
+
* 2. never print to stdout on the hot path (hooks parse stdout as control JSON)
|
|
14
|
+
* 3. drop events under contention rather than fork the hash chain
|
|
15
|
+
* 4. per-Claude-session file, deterministic name → zero state files
|
|
16
|
+
*
|
|
17
|
+
* Session mapping: claude session_id → .agentbox/sessions/<stamp>-claude-<sid>.jsonl
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const {
|
|
23
|
+
VERSION, loadEvents, verifyChain, appendToChain, withFileLock, sessionsDir, assertNotSymlink,
|
|
24
|
+
} = require('../chain');
|
|
25
|
+
const { summarize, verdict } = require('../parse');
|
|
26
|
+
const { markdownReceipt } = require('../receipt');
|
|
27
|
+
|
|
28
|
+
// The hook events agentbox manages in settings.json. Entries are recognized
|
|
29
|
+
// for idempotent re-init / --remove by this marker inside the command string.
|
|
30
|
+
const HOOK_MARKER = 'hook claude';
|
|
31
|
+
const MANAGED_EVENTS = [
|
|
32
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
33
|
+
'Notification', 'Stop', 'SessionEnd',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function trunc(s, n) {
|
|
37
|
+
const str = s == null ? '' : String(s);
|
|
38
|
+
return str.length <= n ? str : str.slice(0, n);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Shrink a tool payload: keep objects under cap chars, else stringify+truncate. */
|
|
42
|
+
function slim(v, cap) {
|
|
43
|
+
if (v == null) return null;
|
|
44
|
+
if (typeof v === 'string') return trunc(v, cap);
|
|
45
|
+
try {
|
|
46
|
+
const j = JSON.stringify(v);
|
|
47
|
+
if (j.length <= cap) return v;
|
|
48
|
+
const keys = ['command', 'file_path', 'notebook_path', 'path', 'url', 'query', 'pattern'];
|
|
49
|
+
const summary = { _truncated: true };
|
|
50
|
+
for (const key of keys) if (v[key] != null) summary[key] = trunc(v[key], cap / 2);
|
|
51
|
+
return Object.keys(summary).length > 1 ? summary : trunc(j, cap);
|
|
52
|
+
} catch { return trunc(String(v), cap); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStdin() {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
if (process.stdin.isTTY) return resolve('');
|
|
58
|
+
let buf = '';
|
|
59
|
+
const timer = setTimeout(() => { try { process.stdin.destroy(); } catch { /* gone */ } resolve(buf); }, 10000);
|
|
60
|
+
process.stdin.setEncoding('utf8');
|
|
61
|
+
process.stdin.on('data', (c) => { buf += c; });
|
|
62
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(buf); });
|
|
63
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(buf); });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Deterministic session file for a Claude session_id (no state file needed). */
|
|
68
|
+
function claudeSessionFile(cwd, sid) {
|
|
69
|
+
const dir = sessionsDir(cwd);
|
|
70
|
+
const safe = String(sid || 'unknown').replace(/[^\w-]/g, '').slice(0, 64) || 'unknown';
|
|
71
|
+
const suffix = `-claude-${safe}.jsonl`;
|
|
72
|
+
let files = [];
|
|
73
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith(suffix)).sort(); } catch { /* no dir yet */ }
|
|
74
|
+
if (files.length) return path.join(dir, files[files.length - 1]);
|
|
75
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
76
|
+
return path.join(dir, `${stamp}${suffix}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toolResponseIsError(resp) {
|
|
80
|
+
if (!resp || typeof resp !== 'object') return false;
|
|
81
|
+
if (resp.is_error === true || resp.isError === true) return true;
|
|
82
|
+
return resp.error != null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write the auto receipt on SessionEnd (`.agentbox/receipts/<session>.md`). */
|
|
86
|
+
function autoReceipt(cwd, file) {
|
|
87
|
+
if (process.env.AGENTBOX_HOOKS_RECEIPT === '0') return;
|
|
88
|
+
const res = verifyChain(file);
|
|
89
|
+
if (!res.ok || !res.complete) return;
|
|
90
|
+
const stats = summarize(res.events);
|
|
91
|
+
const dir = path.join(cwd, '.agentbox', 'receipts');
|
|
92
|
+
const out = path.join(dir, path.basename(file).replace(/\.jsonl$/, '.md'));
|
|
93
|
+
assertNotSymlink(out);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
const md = [
|
|
96
|
+
'# ⬢ agentbox — flight receipt (auto-generated on session end)',
|
|
97
|
+
'',
|
|
98
|
+
markdownReceipt(stats, true),
|
|
99
|
+
'',
|
|
100
|
+
`> ${verdict(stats)}`,
|
|
101
|
+
'',
|
|
102
|
+
].join('\n');
|
|
103
|
+
fs.writeFileSync(out, md);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Route one hook payload into the chain. Sync on purpose — the process
|
|
108
|
+
* exits right after. Called under the session file lock.
|
|
109
|
+
*/
|
|
110
|
+
function recordHookEvent(ev) {
|
|
111
|
+
const sid = ev.session_id || 'unknown';
|
|
112
|
+
const sidSafe = String(sid).replace(/[^\w-]/g, '').slice(0, 64) || 'unknown';
|
|
113
|
+
// Hook payloads are untrusted input; never let them redirect filesystem writes.
|
|
114
|
+
const cwd = process.cwd();
|
|
115
|
+
const file = claudeSessionFile(cwd, sid);
|
|
116
|
+
const isNew = !fs.existsSync(file);
|
|
117
|
+
|
|
118
|
+
withFileLock(file, (locked) => {
|
|
119
|
+
if (!locked) return; // contention timeout — drop the event, exit 0 (rule 3)
|
|
120
|
+
if (isNew) {
|
|
121
|
+
appendToChain(file, 'meta', {
|
|
122
|
+
name: `claude-${sidSafe.slice(0, 8)}`,
|
|
123
|
+
cmd: 'claude (passive — via hooks)',
|
|
124
|
+
argv: ['claude'],
|
|
125
|
+
adapter: 'claude-code',
|
|
126
|
+
session_id: sid,
|
|
127
|
+
cwd,
|
|
128
|
+
user: os.userInfo().username,
|
|
129
|
+
host: os.hostname(),
|
|
130
|
+
platform: `${process.platform} ${process.arch}`,
|
|
131
|
+
agentbox: VERSION,
|
|
132
|
+
pid: process.pid,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
switch (ev.hook_event_name) {
|
|
137
|
+
case 'SessionStart':
|
|
138
|
+
appendToChain(file, 'note', { message: `session start (${ev.source || 'startup'})` });
|
|
139
|
+
break;
|
|
140
|
+
case 'UserPromptSubmit':
|
|
141
|
+
appendToChain(file, 'prompt', { text: trunc(ev.prompt, 1000), source: 'claude-code' });
|
|
142
|
+
break;
|
|
143
|
+
case 'PreToolUse':
|
|
144
|
+
appendToChain(file, 'tool_call', {
|
|
145
|
+
phase: 'start',
|
|
146
|
+
name: String(ev.tool_name || 'unknown'),
|
|
147
|
+
input: slim(ev.tool_input, 2000),
|
|
148
|
+
tool_use_id: ev.tool_use_id == null ? null : String(ev.tool_use_id),
|
|
149
|
+
source: 'claude-code',
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
case 'PostToolUse':
|
|
153
|
+
appendToChain(file, 'tool_call', {
|
|
154
|
+
phase: 'end',
|
|
155
|
+
name: String(ev.tool_name || 'unknown'),
|
|
156
|
+
tool_use_id: ev.tool_use_id == null ? null : String(ev.tool_use_id),
|
|
157
|
+
status: toolResponseIsError(ev.tool_response) ? 'error' : 'ok',
|
|
158
|
+
preview: slim(ev.tool_response, 500),
|
|
159
|
+
source: 'claude-code',
|
|
160
|
+
});
|
|
161
|
+
break;
|
|
162
|
+
case 'Notification':
|
|
163
|
+
appendToChain(file, 'notification', { message: trunc(ev.message, 300) });
|
|
164
|
+
break;
|
|
165
|
+
case 'Stop':
|
|
166
|
+
appendToChain(file, 'turn_end', {});
|
|
167
|
+
break;
|
|
168
|
+
case 'SubagentStop':
|
|
169
|
+
appendToChain(file, 'note', { message: 'subagent stopped' });
|
|
170
|
+
break;
|
|
171
|
+
case 'PreCompact':
|
|
172
|
+
appendToChain(file, 'note', { message: 'context compacted' });
|
|
173
|
+
break;
|
|
174
|
+
case 'SessionEnd': {
|
|
175
|
+
let started = null;
|
|
176
|
+
try { started = loadEvents(file).events.find((e) => e.type === 'meta'); } catch { /* keep null */ }
|
|
177
|
+
appendToChain(file, 'exit', {
|
|
178
|
+
code: 0,
|
|
179
|
+
durationMs: started ? Date.now() - started.t : null,
|
|
180
|
+
reason: ev.reason || 'session-end',
|
|
181
|
+
source: 'claude-code',
|
|
182
|
+
});
|
|
183
|
+
try { autoReceipt(cwd, file); } catch { /* receipt is a luxury, never a failure */ }
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
default:
|
|
187
|
+
// Unknown/forward-compatible hook events still land on the tape.
|
|
188
|
+
appendToChain(file, 'note', { message: `hook: ${ev.hook_event_name || 'unknown-event'}` });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `agentbox hook claude` — read payload, record, exit 0. Always. */
|
|
194
|
+
async function runHook() {
|
|
195
|
+
let ev = {};
|
|
196
|
+
try { ev = JSON.parse((await readStdin()) || '{}'); } catch { ev = { hook_event_name: 'unknown-event' }; }
|
|
197
|
+
try {
|
|
198
|
+
recordHookEvent(ev);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (process.env.AGENTBOX_DEBUG) {
|
|
201
|
+
process.stderr.write(`agentbox hook: swallowed error: ${e && e.message}\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
process.exit(0); // rule 1: the flight always continues
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Absolute, space-safe command string that lands inside settings.json hooks. */
|
|
208
|
+
function hookCommand() {
|
|
209
|
+
const bin = path.join(__dirname, '..', '..', 'bin', 'agentbox.js');
|
|
210
|
+
const quoted = `'${bin.replace(/'/g, `'"'"'`)}'`;
|
|
211
|
+
return `node ${quoted} ${HOOK_MARKER}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function settingsPath(opts) {
|
|
215
|
+
return path.join(process.cwd(), '.claude', opts && opts.local ? 'settings.local.json' : 'settings.json');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function entryIsOurs(entry) {
|
|
219
|
+
const expected = hookCommand();
|
|
220
|
+
return entry && Array.isArray(entry.hooks) && entry.hooks.some((h) => h && h.type === 'command' && h.command === expected);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `agentbox init claude [--local] [--remove]`
|
|
225
|
+
* Merge agentbox hooks into .claude/settings.json (idempotent), or strip them.
|
|
226
|
+
* Returns { file, changed, events }.
|
|
227
|
+
*/
|
|
228
|
+
function initClaude(opts = {}) {
|
|
229
|
+
const file = settingsPath(opts);
|
|
230
|
+
assertNotSymlink(file);
|
|
231
|
+
const existed = fs.existsSync(file);
|
|
232
|
+
try {
|
|
233
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
234
|
+
process.stderr.write(`⬢ agentbox: refusing symlinked settings file ${file}\n`);
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
return { file, changed: false };
|
|
237
|
+
}
|
|
238
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
239
|
+
let settings = {};
|
|
240
|
+
if (existed) {
|
|
241
|
+
try { settings = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {
|
|
242
|
+
process.stderr.write(`⬢ agentbox: cannot parse ${file} — ${e.message}\nfix it first, or use --local for a separate file\n`);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return { file, changed: false };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
248
|
+
|
|
249
|
+
let changed = 0;
|
|
250
|
+
|
|
251
|
+
if (opts.remove) {
|
|
252
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
253
|
+
const groups = settings.hooks[event];
|
|
254
|
+
if (!Array.isArray(groups)) continue;
|
|
255
|
+
const kept = groups.filter((g) => !entryIsOurs(g));
|
|
256
|
+
if (kept.length !== groups.length) changed += groups.length - kept.length;
|
|
257
|
+
if (kept.length) settings.hooks[event] = kept;
|
|
258
|
+
else delete settings.hooks[event];
|
|
259
|
+
}
|
|
260
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
261
|
+
} else {
|
|
262
|
+
// first-time safety net: never clobber a hand-written settings file silently
|
|
263
|
+
if (existed && !fs.existsSync(`${file}.agentbox-backup`)) {
|
|
264
|
+
try { fs.copyFileSync(file, `${file}.agentbox-backup`); } catch { /* best effort */ }
|
|
265
|
+
}
|
|
266
|
+
const cmd = hookCommand();
|
|
267
|
+
for (const event of MANAGED_EVENTS) {
|
|
268
|
+
const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
269
|
+
if (groups.some(entryIsOurs)) continue; // idempotent
|
|
270
|
+
groups.push({ hooks: [{ type: 'command', command: cmd }] });
|
|
271
|
+
settings.hooks[event] = groups;
|
|
272
|
+
changed += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (changed > 0 || !existed) {
|
|
277
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
278
|
+
fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const CYAN = '\x1b[36m'; const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; const GREEN = '\x1b[32m'; const RESET = '\x1b[0m';
|
|
282
|
+
if (opts.remove) {
|
|
283
|
+
process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: removed ${changed} hook entr${changed === 1 ? 'y' : 'ies'} from ${DIM}${file}${RESET}\n`);
|
|
284
|
+
} else if (changed === 0) {
|
|
285
|
+
process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: hooks already installed in ${DIM}${file}${RESET} ${GREEN}(nothing to do)${RESET}\n`);
|
|
286
|
+
} else {
|
|
287
|
+
process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: passive mode ON — ${changed} hooks → ${DIM}${file}${RESET}\n`);
|
|
288
|
+
if (existed) process.stdout.write(`${DIM} original backed up to ${file}.agentbox-backup${RESET}\n`);
|
|
289
|
+
process.stdout.write(`
|
|
290
|
+
${BOLD}what gets recorded${RESET} (per claude session → .agentbox/sessions/):
|
|
291
|
+
SessionStart flight opened
|
|
292
|
+
UserPromptSubmit every prompt you type
|
|
293
|
+
PreToolUse every tool call before it runs (name + arguments)
|
|
294
|
+
PostToolUse every result (ok / error)
|
|
295
|
+
Notification agent pings
|
|
296
|
+
Stop turn boundaries
|
|
297
|
+
SessionEnd flight closed + auto receipt → .agentbox/receipts/
|
|
298
|
+
|
|
299
|
+
${BOLD}next${RESET}: start a ${DIM}claude${RESET} session in this project, then:
|
|
300
|
+
agentbox list see the flight
|
|
301
|
+
agentbox receipt read the tape
|
|
302
|
+
`);
|
|
303
|
+
process.stdout.write(`${DIM} hooks are read at session start — restart claude to pick them up${RESET}\n`);
|
|
304
|
+
}
|
|
305
|
+
return { file, changed };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = { runHook, initClaude, claudeSessionFile, HOOK_MARKER, MANAGED_EVENTS };
|