@kontourai/flow-agents 3.8.0 → 3.10.0

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.
Files changed (109) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +1 -1
  4. package/build/src/builder-flow-runtime.js +16 -10
  5. package/build/src/cli/assignment-provider.js +4 -18
  6. package/build/src/cli/continuation-adapter.d.ts +26 -0
  7. package/build/src/cli/continuation-adapter.js +243 -0
  8. package/build/src/cli/validate-workflow-artifacts.js +21 -1
  9. package/build/src/cli/workflow-sidecar.js +15 -20
  10. package/build/src/cli/workflow.js +99 -6
  11. package/build/src/continuation-driver.d.ts +116 -0
  12. package/build/src/continuation-driver.js +527 -0
  13. package/build/src/index.d.ts +2 -0
  14. package/build/src/index.js +1 -0
  15. package/build/src/tools/build-universal-bundles.js +52 -25
  16. package/build/src/tools/validate-source-tree.js +5 -2
  17. package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  18. package/context/scripts/hooks/stop-goal-fit.js +244 -73
  19. package/context/scripts/hooks/workflow-steering.js +24 -15
  20. package/context/scripts/telemetry/lib/pricing.sh +18 -6
  21. package/context/scripts/telemetry/lib/transport.sh +139 -37
  22. package/context/scripts/telemetry/lib/usage.sh +282 -32
  23. package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  24. package/context/scripts/telemetry/telemetry.sh +76 -1
  25. package/docs/coordination-guide.md +21 -5
  26. package/docs/fixture-ownership.md +2 -1
  27. package/docs/public-workflow-cli.md +98 -0
  28. package/docs/spec/runtime-hook-surface.md +8 -4
  29. package/docs/workflow-usage-guide.md +7 -0
  30. package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
  31. package/evals/acceptance/prove-capture-teeth.sh +13 -0
  32. package/evals/ci/run-baseline.sh +2 -0
  33. package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
  34. package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
  35. package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
  36. package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
  37. package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
  38. package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
  39. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
  40. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
  41. package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
  42. package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
  43. package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
  44. package/evals/integration/test_actor_identity.sh +63 -0
  45. package/evals/integration/test_assignment_provider_local_file.sh +34 -0
  46. package/evals/integration/test_builder_entry_enforcement.sh +5 -1
  47. package/evals/integration/test_bundle_install.sh +106 -17
  48. package/evals/integration/test_bundle_lifecycle.sh +17 -3
  49. package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
  50. package/evals/integration/test_ci_actor_identity.sh +2 -2
  51. package/evals/integration/test_command_log_concurrency.sh +11 -0
  52. package/evals/integration/test_command_log_fork_classification.sh +11 -0
  53. package/evals/integration/test_command_log_integrity.sh +11 -0
  54. package/evals/integration/test_current_json_per_actor.sh +416 -21
  55. package/evals/integration/test_enforcer_expects_driven.sh +11 -0
  56. package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
  57. package/evals/integration/test_evidence_capture_hook.sh +25 -6
  58. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  59. package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
  60. package/evals/integration/test_gate_bypass_chain.sh +11 -0
  61. package/evals/integration/test_gate_lockdown.sh +10 -0
  62. package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
  63. package/evals/integration/test_goal_fit_hook.sh +257 -9
  64. package/evals/integration/test_goal_fit_rederive.sh +11 -0
  65. package/evals/integration/test_install_merge.sh +141 -3
  66. package/evals/integration/test_liveness_conflict_injection.sh +39 -3
  67. package/evals/integration/test_liveness_heartbeat.sh +46 -9
  68. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
  69. package/evals/integration/test_public_workflow_cli.sh +6 -6
  70. package/evals/integration/test_session_resume_roundtrip.sh +30 -0
  71. package/evals/integration/test_stop_hook_release.sh +111 -11
  72. package/evals/integration/test_telemetry_tool_usage.sh +394 -0
  73. package/evals/integration/test_verify_hold.sh +64 -1
  74. package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
  75. package/evals/integration/test_workflow_steering_hook.sh +20 -4
  76. package/evals/run.sh +2 -0
  77. package/evals/static/test_universal_bundles.sh +123 -0
  78. package/package.json +4 -4
  79. package/packaging/README.md +21 -0
  80. package/packaging/codex-legacy-agents-fingerprints.json +33 -0
  81. package/scripts/README.md +2 -1
  82. package/scripts/audit-codex-legacy-agents.js +43 -0
  83. package/scripts/classify-codex-legacy-agents.js +71 -0
  84. package/scripts/hooks/evidence-capture.js +18 -6
  85. package/scripts/hooks/lib/actor-identity.js +62 -29
  86. package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  87. package/scripts/hooks/lib/current-pointer.js +114 -12
  88. package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
  89. package/scripts/hooks/stop-goal-fit.js +244 -73
  90. package/scripts/hooks/workflow-steering.js +24 -15
  91. package/scripts/install-codex-home.sh +8 -7
  92. package/scripts/telemetry/lib/pricing.sh +18 -6
  93. package/scripts/telemetry/lib/transport.sh +71 -0
  94. package/scripts/telemetry/lib/usage.sh +282 -32
  95. package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  96. package/scripts/telemetry/telemetry.sh +76 -1
  97. package/src/builder-flow-runtime.ts +9 -3
  98. package/src/cli/assignment-provider.ts +5 -26
  99. package/src/cli/builder-flow-runtime.test.mjs +215 -2
  100. package/src/cli/continuation-adapter.ts +265 -0
  101. package/src/cli/continuation-driver.test.mjs +1303 -0
  102. package/src/cli/public-api.test.mjs +1 -0
  103. package/src/cli/validate-workflow-artifacts.ts +19 -1
  104. package/src/cli/workflow-sidecar.ts +18 -23
  105. package/src/cli/workflow.ts +113 -6
  106. package/src/continuation-driver.ts +638 -0
  107. package/src/index.ts +24 -0
  108. package/src/tools/build-universal-bundles.ts +61 -25
  109. package/src/tools/validate-source-tree.ts +5 -2
@@ -36,6 +36,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
36
36
  HOOK="$ROOT/scripts/hooks/stop-goal-fit.js"
37
37
  BUILT_SIDECAR="$ROOT/build/src/cli/workflow-sidecar.js"
38
38
  BUILT_PROVIDER="$ROOT/build/src/cli/assignment-provider.js"
39
+ CURRENT_POINTER_HELPER="$ROOT/scripts/hooks/lib/current-pointer.js"
39
40
 
40
41
  for m in "$HOOK" "$BUILT_SIDECAR" "$BUILT_PROVIDER"; do
41
42
  if [[ ! -f "$m" ]]; then
@@ -129,6 +130,41 @@ write_session_state() {
129
130
  JSON
130
131
  }
131
132
 
133
+ # seed_current_pointer <repo> <slug> [actor_key] — #440: writes BOTH the legacy
134
+ # current.json AND (when actor_key is given) the per-actor current/<actor>.json pointer, with
135
+ # the SAME payload — mirroring workflow-sidecar.ts's real writeCurrent() dual-write exactly, via
136
+ # the shared current-pointer.js helper's own writePerActorCurrent (not a hand-rolled
137
+ # reimplementation of its sanitize/path rule), so #440's readOwnCurrentPointer finds this
138
+ # fixture's session exactly like a real ensure-session'd one would. Call AFTER seed_session (same
139
+ # slug). A resolved actor with no per-actor pointer is intentionally out of scope for the
140
+ # Stop-hook's blocking/release scoping per #440 — every scenario below that exercises "self, owns
141
+ # this session" behavior (release, handoff refresh, fail-open reachability) now needs this seed;
142
+ # scenarios that are actually about an unresolved/foreign actor do not call this.
143
+ seed_current_pointer() {
144
+ local repo="$1" slug="$2" actor_key="${3:-}"
145
+ CP_HELPER_ARG="$CURRENT_POINTER_HELPER" FLOW_AGENTS_DIR_ARG="$repo/.kontourai/flow-agents" \
146
+ SLUG_ARG="$slug" ACTOR_KEY_ARG="$actor_key" node - <<'NODE'
147
+ const fs = require('fs');
148
+ const path = require('path');
149
+ const { writePerActorCurrent } = require(process.env.CP_HELPER_ARG);
150
+ const flowAgentsDir = process.env.FLOW_AGENTS_DIR_ARG;
151
+ const slug = process.env.SLUG_ARG;
152
+ const actorKey = process.env.ACTOR_KEY_ARG;
153
+ const payload = {
154
+ schema_version: '1.0',
155
+ active_slug: slug,
156
+ artifact_dir: slug,
157
+ updated_at: '2026-06-25T00:00:00Z',
158
+ owner: 'eval',
159
+ source: 'test-fixture',
160
+ active_agents: [],
161
+ };
162
+ fs.mkdirSync(flowAgentsDir, { recursive: true });
163
+ fs.writeFileSync(path.join(flowAgentsDir, 'current.json'), JSON.stringify(payload, null, 2) + '\n');
164
+ if (actorKey) writePerActorCurrent(flowAgentsDir, actorKey, payload);
165
+ NODE
166
+ }
167
+
132
168
  # assignment_provider_settings_local_file <repo> — writes a project settings file
133
169
  # naming the local-file provider (schema shape per schemas/assignment-provider-settings.schema.json).
134
170
  assignment_provider_settings_local_file() {
@@ -271,6 +307,10 @@ A_DIR="$(seed_session "$A_REPO" "task-a" "in_progress")"
271
307
  # (never a hardcoded placeholder, which the real process could never match).
272
308
  A_SESSION_ID="eval-a-session-$$"
273
309
  A_ACTOR_TRIPLE="claude-code:${A_SESSION_ID}:${REAL_HOSTNAME}"
310
+ # #440: A owns task-a — seed its own per-actor current pointer so the Stop hook's
311
+ # ownership-scoped analyze() finds it (FIXTURE-GAP: this scenario asserts real "self, owns this
312
+ # session" release behavior, not stale ownership semantics).
313
+ seed_current_pointer "$A_REPO" "task-a" "$A_ACTOR_TRIPLE"
274
314
  seed_liveness_claim "$A_REPO" "task-a" "$A_ACTOR_TRIPLE" "2026-06-25T09:00:00.000Z"
275
315
  seed_assignment_claim "$A_REPO" "task-a" "claude-code" "$A_SESSION_ID" "$REAL_HOSTNAME"
276
316
 
@@ -307,8 +347,16 @@ B_REPO="$(new_repo repo-b)"
307
347
  assignment_provider_settings_github "$B_REPO"
308
348
  B_DIR="$(seed_session "$B_REPO" "task-b" "in_progress")"
309
349
  seed_liveness_claim "$B_REPO" "task-b" "claude-code:sess-b:host-b" "2026-06-25T09:00:00.000Z"
310
-
311
- B_OUT="$(call_stop_hook "$B_REPO" '{}' 2>"$TMPDIR_EVAL/b.err")"
350
+ # #440: B owns task-b — a stable "self, derived form" actor (matching A's convention) plus its
351
+ # own per-actor current pointer, so the Stop hook's ownership-scoped analyze() finds it
352
+ # (FIXTURE-GAP: this scenario asserts real handoff/provider-release behavior for B's own session,
353
+ # not stale ownership semantics). Previously this scenario ran under whatever ambient/ancestry
354
+ # actor happened to resolve — never asserted on, but now load-bearing for scoping.
355
+ B_SESSION_ID="eval-b-session-$$"
356
+ B_ACTOR_TRIPLE="claude-code:${B_SESSION_ID}:${REAL_HOSTNAME}"
357
+ seed_current_pointer "$B_REPO" "task-b" "$B_ACTOR_TRIPLE"
358
+
359
+ B_OUT="$(call_stop_hook "$B_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$B_SESSION_ID\"}" 2>"$TMPDIR_EVAL/b.err")"
312
360
  B_STATUS=$?
313
361
 
314
362
  if ! grep -Eq '(^|[^a-zA-Z])gh (issue|api|pr) ' "$TMPDIR_EVAL/b.err" && ! grep -Eq '(^|[^a-zA-Z])gh (issue|api|pr) ' <<<"$B_OUT"; then
@@ -349,7 +397,13 @@ cat > "$C_DIR/handoff.json" <<'JSON'
349
397
  }
350
398
  JSON
351
399
 
352
- call_stop_hook "$C_REPO" '{}' > /dev/null 2>"$TMPDIR_EVAL/c.err"
400
+ # #440: C owns task-c — stable "self, derived form" actor + own per-actor current pointer
401
+ # (FIXTURE-GAP: this scenario asserts real handoff-refresh behavior for C's own session).
402
+ C_SESSION_ID="eval-c-session-$$"
403
+ C_ACTOR_TRIPLE="claude-code:${C_SESSION_ID}:${REAL_HOSTNAME}"
404
+ seed_current_pointer "$C_REPO" "task-c" "$C_ACTOR_TRIPLE"
405
+
406
+ call_stop_hook "$C_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$C_SESSION_ID\"}" > /dev/null 2>"$TMPDIR_EVAL/c.err"
353
407
 
354
408
  C_HANDOFF="$C_DIR/handoff.json"
355
409
  C_SUMMARY="$(json_query "$C_HANDOFF" "summary")"
@@ -365,11 +419,18 @@ echo "--- D. Terminal (delivered) status: no release, no mutation ---"
365
419
  D_REPO="$(new_repo repo-d)"
366
420
  assignment_provider_settings_local_file "$D_REPO"
367
421
  D_DIR="$(seed_session "$D_REPO" "task-d" "delivered")"
368
- seed_liveness_claim "$D_REPO" "task-d" "claude-code:sess-d:host-d" "2026-06-25T09:00:00.000Z"
369
- seed_assignment_claim "$D_REPO" "task-d" "claude-code" "sess-d" "host-d"
422
+ # #440 FIX 2 (de-vacuate, independent review): D owns task-d — a stable "self, derived form"
423
+ # actor + own per-actor current pointer, so releaseOnNonTerminalStop is REACHED and its OWN
424
+ # terminal-status (LIVENESS_TERMINAL) early-return is what's actually exercised here, not the
425
+ # outer "no artifactDir at all" early-return (which would pass vacuously for the same reason).
426
+ D_SESSION_ID="eval-d-session-$$"
427
+ D_ACTOR_TRIPLE="claude-code:${D_SESSION_ID}:${REAL_HOSTNAME}"
428
+ seed_current_pointer "$D_REPO" "task-d" "$D_ACTOR_TRIPLE"
429
+ seed_liveness_claim "$D_REPO" "task-d" "$D_ACTOR_TRIPLE" "2026-06-25T09:00:00.000Z"
430
+ seed_assignment_claim "$D_REPO" "task-d" "claude-code" "$D_SESSION_ID" "$REAL_HOSTNAME"
370
431
 
371
432
  D_LINES_BEFORE="$(liveness_line_count "$D_REPO")"
372
- call_stop_hook "$D_REPO" '{}' > /dev/null 2>"$TMPDIR_EVAL/d.err"
433
+ call_stop_hook "$D_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$D_SESSION_ID\"}" > /dev/null 2>"$TMPDIR_EVAL/d.err"
373
434
  D_LINES_AFTER="$(liveness_line_count "$D_REPO")"
374
435
 
375
436
  if [[ "$D_LINES_AFTER" -eq "$D_LINES_BEFORE" ]]; then
@@ -391,9 +452,18 @@ echo "--- E. Foreign-actor holder is never released (derived form) ---"
391
452
  E_REPO="$(new_repo repo-e)"
392
453
  assignment_provider_settings_local_file "$E_REPO"
393
454
  E_DIR="$(seed_session "$E_REPO" "task-e" "in_progress")"
455
+ # #440 FIX 2 (de-vacuate, independent review): the ACTING actor (the one running the Stop hook
456
+ # below) owns task-e — a stable "self, derived form" actor + own per-actor pointer, so
457
+ # releaseOnNonTerminalStop is REACHED and its actual holder-comparison logic is what rejects the
458
+ # release, not the outer "no artifactDir at all" early-return. The ASSIGNMENT claim itself stays
459
+ # held by a genuinely DIFFERENT actor ("actor-other-session"/"actor-other-host") — the real
460
+ # foreign-holder differential this scenario is about.
461
+ E_SESSION_ID="eval-e-session-$$"
462
+ E_ACTOR_TRIPLE="claude-code:${E_SESSION_ID}:${REAL_HOSTNAME}"
463
+ seed_current_pointer "$E_REPO" "task-e" "$E_ACTOR_TRIPLE"
394
464
  seed_assignment_claim "$E_REPO" "task-e" "claude-code" "actor-other-session" "actor-other-host"
395
465
 
396
- E_OUT="$(call_stop_hook "$E_REPO" '{}' 2>"$TMPDIR_EVAL/e.err")"
466
+ E_OUT="$(call_stop_hook "$E_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$E_SESSION_ID\"}" 2>"$TMPDIR_EVAL/e.err")"
397
467
  E_STATUS=$?
398
468
 
399
469
  E_RECORD="$(assignment_record_file "$E_REPO" "task-e")"
@@ -421,8 +491,16 @@ F1_REPO="$(new_repo repo-f1)"
421
491
  assignment_provider_settings_local_file "$F1_REPO"
422
492
  seed_session "$F1_REPO" "task-f1" "in_progress" > /dev/null
423
493
  seed_liveness_claim "$F1_REPO" "task-f1" "claude-code:sess-f1:host-f1" "2026-06-25T09:00:00.000Z"
424
-
425
- F1_OUT="$(STOP_HOOK_OVERRIDE="$F1_PKG_ROOT/scripts/hooks/stop-goal-fit.js" call_stop_hook "$F1_REPO" '{}' 2>"$TMPDIR_EVAL/f1.err")"
494
+ # #440: F1 owns task-f1 — stable "self, derived form" actor + own per-actor current pointer, so
495
+ # analyze() actually resolves an artifactDir and releaseOnNonTerminalStop is REACHED far enough to
496
+ # emit its "missing build/" diagnostic (FIXTURE-GAP: this scenario asserts real fail-open
497
+ # reachability for F1's own session, not stale ownership semantics — without this seed the Stop
498
+ # hook never gets far enough to exercise the missing-build/ code path at all).
499
+ F1_SESSION_ID="eval-f1-session-$$"
500
+ F1_ACTOR_TRIPLE="claude-code:${F1_SESSION_ID}:${REAL_HOSTNAME}"
501
+ seed_current_pointer "$F1_REPO" "task-f1" "$F1_ACTOR_TRIPLE"
502
+
503
+ F1_OUT="$(STOP_HOOK_OVERRIDE="$F1_PKG_ROOT/scripts/hooks/stop-goal-fit.js" call_stop_hook "$F1_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$F1_SESSION_ID\"}" 2>"$TMPDIR_EVAL/f1.err")"
426
504
  F1_STATUS=$?
427
505
  if [[ "$F1_STATUS" -eq 0 || "$F1_STATUS" -eq 2 ]] && ! grep -q 'Node\.js v' "$TMPDIR_EVAL/f1.err" && ! grep -q ' at ' "$TMPDIR_EVAL/f1.err"; then
428
506
  pass "F(i): missing build/ fails open — hook does not crash, exit code unaffected by release logic (AC7)"
@@ -437,11 +515,18 @@ grep -q 'build/src/cli/workflow-sidecar.js not available' "$TMPDIR_EVAL/f1.err"
437
515
  F2_REPO="$(new_repo repo-f2)"
438
516
  assignment_provider_settings_local_file "$F2_REPO"
439
517
  seed_session "$F2_REPO" "task-f2" "in_progress" > /dev/null
440
- seed_liveness_claim "$F2_REPO" "task-f2" "claude-code:sess-f2:host-f2" "2026-06-25T09:00:00.000Z"
518
+ # #440 FIX 2 (de-vacuate, independent review): F2 owns task-f2 — stable "self, derived form"
519
+ # actor + own per-actor pointer, so releaseOnNonTerminalStop is REACHED far enough to attempt
520
+ # reading the corrupt assignment record below (not short-circuited by the outer "no artifactDir
521
+ # at all" early-return, which would fail open for an unrelated reason).
522
+ F2_SESSION_ID="eval-f2-session-$$"
523
+ F2_ACTOR_TRIPLE="claude-code:${F2_SESSION_ID}:${REAL_HOSTNAME}"
524
+ seed_current_pointer "$F2_REPO" "task-f2" "$F2_ACTOR_TRIPLE"
525
+ seed_liveness_claim "$F2_REPO" "task-f2" "$F2_ACTOR_TRIPLE" "2026-06-25T09:00:00.000Z"
441
526
  mkdir -p "$F2_REPO/.kontourai/flow-agents/assignment"
442
527
  printf '{not valid json at all' > "$(assignment_record_file "$F2_REPO" "task-f2")"
443
528
 
444
- F2_OUT="$(call_stop_hook "$F2_REPO" '{}' 2>"$TMPDIR_EVAL/f2.err")"
529
+ F2_OUT="$(call_stop_hook "$F2_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$F2_SESSION_ID\"}" 2>"$TMPDIR_EVAL/f2.err")"
445
530
  F2_STATUS=$?
446
531
  if [[ "$F2_STATUS" -eq 0 || "$F2_STATUS" -eq 2 ]] && ! grep -q 'Node\.js v' "$TMPDIR_EVAL/f2.err" && ! grep -q ' at ' "$TMPDIR_EVAL/f2.err"; then
447
532
  pass "F(ii): corrupt assignment record fails open — no uncaught throw, exit unaffected (AC7)"
@@ -478,6 +563,10 @@ assignment_provider_settings_local_file "$G_REPO"
478
563
  seed_session "$G_REPO" "task-g" "in_progress" > /dev/null
479
564
  G_SESSION_ID="eval-g-session-$$"
480
565
  G_ENV_JSON="{\"CLAUDE_CODE_SESSION_ID\":\"$G_SESSION_ID\"}"
566
+ # #440: G owns task-g — own per-actor current pointer, keyed to the SAME stable actor triple G
567
+ # already uses for its liveness/assignment claims (FIXTURE-GAP: this scenario asserts real
568
+ # idempotent-release behavior for G's own session).
569
+ seed_current_pointer "$G_REPO" "task-g" "claude-code:${G_SESSION_ID}:${REAL_HOSTNAME}"
481
570
  seed_liveness_claim "$G_REPO" "task-g" "claude-code:${G_SESSION_ID}:${REAL_HOSTNAME}" "2026-06-25T09:00:00.000Z"
482
571
  seed_assignment_claim "$G_REPO" "task-g" "claude-code" "$G_SESSION_ID" "$REAL_HOSTNAME"
483
572
 
@@ -510,6 +599,11 @@ echo "--- H. Override-actor canonical-key release (Task A proof) ---"
510
599
  H1_REPO="$(new_repo repo-h1)"
511
600
  assignment_provider_settings_local_file "$H1_REPO"
512
601
  seed_session "$H1_REPO" "task-h1" "in_progress" > /dev/null
602
+ # #440: H1 owns task-h1 under the SAME override actor ("canonical-x") the Stop hook will run as
603
+ # below — own per-actor current pointer keyed to that exact override value (FIXTURE-GAP: this
604
+ # scenario asserts real actor_key-keyed release for the override-actor's own session, the exact
605
+ # Task A regression lock; not stale ownership semantics).
606
+ seed_current_pointer "$H1_REPO" "task-h1" "canonical-x"
513
607
  seed_liveness_claim "$H1_REPO" "task-h1" "canonical-x" "2026-06-25T09:00:00.000Z"
514
608
  # runtime/host mirror what the Stop hook itself would derive (detectRuntime + os.hostname())
515
609
  # under a plain env with no runtime markers set — "unknown" runtime, real hostname — but the
@@ -532,6 +626,12 @@ fi
532
626
  H2_REPO="$(new_repo repo-h2)"
533
627
  assignment_provider_settings_local_file "$H2_REPO"
534
628
  seed_session "$H2_REPO" "task-h2" "in_progress" > /dev/null
629
+ # #440 FIX 2 (de-vacuate, independent review): the ACTING override actor ("different-y") owns
630
+ # task-h2 — its own per-actor pointer, keyed to that exact override value, so
631
+ # releaseOnNonTerminalStop is REACHED and its actual actor_key comparison against the assignment
632
+ # record's "canonical-x" holder is what rejects the release, not the outer "no artifactDir at
633
+ # all" early-return.
634
+ seed_current_pointer "$H2_REPO" "task-h2" "different-y"
535
635
  seed_assignment_claim "$H2_REPO" "task-h2" "unknown" "some-other-session-id" "some-other-host" "canonical-x"
536
636
 
537
637
  H2_OUT="$(call_stop_hook "$H2_REPO" '{"FLOW_AGENTS_ACTOR":"different-y"}' 2>"$TMPDIR_EVAL/h2.err")"
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env bash
2
+ # test_telemetry_tool_usage.sh — Layer 2: per-tool-call usage enrichment
3
+ # (kontourai/flow-agents#568 slice 1)
4
+ #
5
+ # Proves that tool.invoke (preToolUse) and tool.result (postToolUse) telemetry
6
+ # events carry a .usage object sourced from the transcript's LAST assistant
7
+ # turn — not the session-cumulative aggregate — with the same fallback tiers
8
+ # usage_parse_transcript uses at `stop`: transcript join -> full usage;
9
+ # model-only when hook.model is present but the transcript join fails; fully
10
+ # null when neither is available. Also proves the bounded tail-read still
11
+ # finds the last usage entry behind an oversized filler prefix, and that
12
+ # permissionRequest is explicitly excluded from .usage enrichment.
13
+ #
14
+ # Uses the same TELEMETRY_DIR resolution convention as test_telemetry.sh /
15
+ # test_telemetry_usage_pipeline.sh (prefers context/scripts/telemetry when
16
+ # present, so this exercises the same copy CI actually runs), the same
17
+ # hermetic env variables, and the same FLOW_AGENTS_TELEMETRY_FOREGROUND=true
18
+ # convention so assertions don't race a backgrounded subshell.
19
+ set -uo pipefail
20
+
21
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
22
+ if [[ -d "$ROOT_DIR/context/scripts/telemetry" ]]; then
23
+ TELEMETRY_DIR="$ROOT_DIR/context/scripts/telemetry"
24
+ else
25
+ TELEMETRY_DIR="$HOME/.flow-agents/context/scripts/telemetry"
26
+ fi
27
+ TELEMETRY_SH="${TELEMETRY_DIR}/telemetry.sh"
28
+ PRICING_FILE="$ROOT_DIR/scripts/telemetry/pricing.json"
29
+ FIXTURE_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-sample.jsonl"
30
+ OVERSIZED_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl"
31
+ ADVERSARIAL_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl"
32
+ MALFORMED_SANDWICH_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl"
33
+ TORN_TRAILING_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl"
34
+ SECRET_MODEL_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl"
35
+ SECRET_MODEL_JWT_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl"
36
+ PREFIXED_SECRET_UPPER_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl"
37
+ PREFIXED_SECRET_LOWER_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl"
38
+ NEGATIVE_TOKENS_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl"
39
+ OVERSIZED_TOKENS_TRANSCRIPT="$ROOT_DIR/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl"
40
+
41
+ TMPDIR_EVAL=$(mktemp -d /tmp/eval-telemetry-tool-usage.XXXXXX)
42
+ TMPLOG="${TMPDIR_EVAL}/test-output.jsonl"
43
+
44
+ pass=0; fail=0
45
+ _pass() { echo " ✓ $1"; pass=$((pass + 1)); }
46
+ _fail() { echo " ✗ $1"; fail=$((fail + 1)); }
47
+
48
+ echo "=== Layer 2: Telemetry Tool-Event Usage Enrichment (#568 slice 1) ==="
49
+ echo ""
50
+
51
+ if [[ ! -f "$TELEMETRY_SH" ]]; then
52
+ _fail "telemetry.sh not found at $TELEMETRY_SH"
53
+ echo "Cannot continue without telemetry script"
54
+ rm -rf "$TMPDIR_EVAL"
55
+ exit 1
56
+ fi
57
+ if [[ ! -f "$FIXTURE_TRANSCRIPT" ]]; then
58
+ _fail "fixture transcript not found at $FIXTURE_TRANSCRIPT"
59
+ rm -rf "$TMPDIR_EVAL"
60
+ exit 1
61
+ fi
62
+ if [[ ! -f "$OVERSIZED_TRANSCRIPT" ]]; then
63
+ _fail "oversized-prefix fixture not found at $OVERSIZED_TRANSCRIPT"
64
+ rm -rf "$TMPDIR_EVAL"
65
+ exit 1
66
+ fi
67
+ if [[ ! -f "$ADVERSARIAL_TRANSCRIPT" ]]; then
68
+ _fail "adversarial fixture not found at $ADVERSARIAL_TRANSCRIPT"
69
+ rm -rf "$TMPDIR_EVAL"
70
+ exit 1
71
+ fi
72
+ if [[ ! -f "$MALFORMED_SANDWICH_TRANSCRIPT" ]]; then
73
+ _fail "malformed-sandwich fixture not found at $MALFORMED_SANDWICH_TRANSCRIPT"
74
+ rm -rf "$TMPDIR_EVAL"
75
+ exit 1
76
+ fi
77
+ if [[ ! -f "$TORN_TRAILING_TRANSCRIPT" ]]; then
78
+ _fail "torn-trailing fixture not found at $TORN_TRAILING_TRANSCRIPT"
79
+ rm -rf "$TMPDIR_EVAL"
80
+ exit 1
81
+ fi
82
+ if [[ ! -f "$SECRET_MODEL_TRANSCRIPT" ]]; then
83
+ _fail "secret-model fixture not found at $SECRET_MODEL_TRANSCRIPT"
84
+ rm -rf "$TMPDIR_EVAL"
85
+ exit 1
86
+ fi
87
+ if [[ ! -f "$SECRET_MODEL_JWT_TRANSCRIPT" ]]; then
88
+ _fail "secret-model-jwt fixture not found at $SECRET_MODEL_JWT_TRANSCRIPT"
89
+ rm -rf "$TMPDIR_EVAL"
90
+ exit 1
91
+ fi
92
+ if [[ ! -f "$NEGATIVE_TOKENS_TRANSCRIPT" ]]; then
93
+ _fail "negative-tokens fixture not found at $NEGATIVE_TOKENS_TRANSCRIPT"
94
+ rm -rf "$TMPDIR_EVAL"
95
+ exit 1
96
+ fi
97
+ if [[ ! -f "$OVERSIZED_TOKENS_TRANSCRIPT" ]]; then
98
+ _fail "oversized-tokens fixture not found at $OVERSIZED_TOKENS_TRANSCRIPT"
99
+ rm -rf "$TMPDIR_EVAL"
100
+ exit 1
101
+ fi
102
+
103
+ # Run a single hook event (preToolUse/postToolUse/permissionRequest) against
104
+ # telemetry.sh in foreground mode and return the resulting event (jq-compact,
105
+ # one line) from the full-channel log.
106
+ _run_tool_event() {
107
+ local hook_type="$1" input="$2"; shift 2
108
+ local extra_env=("$@")
109
+ local common_env=(
110
+ HOME="${TMPDIR_EVAL}/home"
111
+ TELEMETRY_ENABLED=true
112
+ TELEMETRY_CHANNELS=full
113
+ TELEMETRY_CHANNEL_FULL_LOG_FILE="$TMPLOG"
114
+ FLOW_AGENTS_TELEMETRY_FOREGROUND=true
115
+ TELEMETRY_CONFIG_FILE="$TMPDIR_EVAL/telemetry.conf"
116
+ TELEMETRY_DATA_DIR="$TMPDIR_EVAL"
117
+ TELEMETRY_SESSION_DIR="$TMPDIR_EVAL/sessions"
118
+ TELEMETRY_USAGE_TRACKING=true
119
+ )
120
+ mkdir -p "${TMPDIR_EVAL}/home" "${TMPDIR_EVAL}/sessions"
121
+
122
+ local before_lines
123
+ touch "$TMPLOG"
124
+ before_lines=$(wc -l < "$TMPLOG" | tr -d ' ')
125
+
126
+ echo "$input" | env "${common_env[@]}" "${extra_env[@]}" bash "$TELEMETRY_SH" "$hook_type" dev 2>/dev/null
127
+
128
+ tail -n +"$((before_lines + 1))" "$TMPLOG" 2>/dev/null | tail -1
129
+ }
130
+
131
+ # --- 1. Per-turn, not aggregate: last-line model/tokens differ from the
132
+ # session-aggregate dominant model (claude-opus-4-8) ------------------
133
+ echo "--- preToolUse/postToolUse with fixture transcript: per-turn, not aggregate (AC1) ---"
134
+ input1=$(jq -nc --arg tp "$FIXTURE_TRANSCRIPT" '{session_id:"tool-usage-1",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
135
+ out1=$(_run_tool_event "preToolUse" "$input1" TELEMETRY_PRICING_FILE="$PRICING_FILE")
136
+
137
+ if [[ -n "$out1" ]]; then
138
+ event_type1=$(echo "$out1" | jq -r '.event_type // empty')
139
+ model1=$(echo "$out1" | jq -r '.usage.model // empty')
140
+ it1=$(echo "$out1" | jq -r '.usage.input_tokens // empty')
141
+ ot1=$(echo "$out1" | jq -r '.usage.output_tokens // empty')
142
+ cost1=$(echo "$out1" | jq -r '.usage.estimated_cost_usd // empty')
143
+ pv1=$(echo "$out1" | jq -r '.usage.pricing_version // empty')
144
+
145
+ [[ "$event_type1" == "tool.invoke" ]] && _pass "preToolUse maps to tool.invoke" || _fail "expected tool.invoke, got $event_type1"
146
+ [[ "$model1" == "claude-fable-5" ]] && _pass "usage.model is the LAST turn's model (claude-fable-5), not the aggregate-dominant model" || _fail "expected usage.model=claude-fable-5 (last turn), got $model1"
147
+ [[ "$it1" == "50" ]] && _pass "usage.input_tokens is the last turn's value (50)" || _fail "expected usage.input_tokens=50, got $it1"
148
+ [[ "$ot1" == "200" ]] && _pass "usage.output_tokens is the last turn's value (200)" || _fail "expected usage.output_tokens=200, got $ot1"
149
+ [[ -n "$cost1" && "$cost1" != "null" ]] && _pass "usage.estimated_cost_usd is non-null (got: $cost1)" || _fail "expected non-null estimated_cost_usd, got $cost1"
150
+ [[ "$pv1" != "null" && -n "$pv1" ]] && _pass "usage.pricing_version is populated" || _fail "expected non-null pricing_version, got $pv1"
151
+ else
152
+ _fail "no tool.invoke event emitted for fixture transcript"
153
+ fi
154
+
155
+ echo ""
156
+ echo "--- postToolUse with fixture transcript: same per-turn usage as preToolUse ---"
157
+ input1b=$(jq -nc --arg tp "$FIXTURE_TRANSCRIPT" '{session_id:"tool-usage-1b",transcript_path:$tp,hook_event_name:"PostToolUse",tool_name:"Bash",tool_response:{output:"hi"}}')
158
+ out1b=$(_run_tool_event "postToolUse" "$input1b" TELEMETRY_PRICING_FILE="$PRICING_FILE")
159
+
160
+ if [[ -n "$out1b" ]]; then
161
+ event_type1b=$(echo "$out1b" | jq -r '.event_type // empty')
162
+ model1b=$(echo "$out1b" | jq -r '.usage.model // empty')
163
+ [[ "$event_type1b" == "tool.result" ]] && _pass "postToolUse maps to tool.result" || _fail "expected tool.result, got $event_type1b"
164
+ [[ "$model1b" == "claude-fable-5" ]] && _pass "postToolUse usage.model is also the last turn's model (claude-fable-5)" || _fail "expected usage.model=claude-fable-5, got $model1b"
165
+ else
166
+ _fail "no tool.result event emitted for fixture transcript"
167
+ fi
168
+
169
+ # --- 2. Bounded tail-read: oversized filler prefix, still finds the last entry (AC2)
170
+ echo ""
171
+ echo "--- preToolUse with oversized-prefix transcript: bounded tail-read still finds it (AC2) ---"
172
+ input2=$(jq -nc --arg tp "$OVERSIZED_TRANSCRIPT" '{session_id:"tool-usage-2",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
173
+ out2=$(_run_tool_event "preToolUse" "$input2" TELEMETRY_PRICING_FILE="$PRICING_FILE")
174
+
175
+ if [[ -n "$out2" ]]; then
176
+ model2=$(echo "$out2" | jq -r '.usage.model // empty')
177
+ it2=$(echo "$out2" | jq -r '.usage.input_tokens // empty')
178
+ ot2=$(echo "$out2" | jq -r '.usage.output_tokens // empty')
179
+
180
+ [[ "$model2" == "claude-oversized-prefix-model" ]] && _pass "bounded tail-read finds the last usage entry behind an oversized filler prefix" || _fail "expected model=claude-oversized-prefix-model, got $model2"
181
+ [[ "$it2" == "777" ]] && _pass "bounded tail-read recovers correct input_tokens (777)" || _fail "expected input_tokens=777, got $it2"
182
+ [[ "$ot2" == "333" ]] && _pass "bounded tail-read recovers correct output_tokens (333)" || _fail "expected output_tokens=333, got $ot2"
183
+ else
184
+ _fail "no tool.invoke event emitted for oversized-prefix transcript"
185
+ fi
186
+
187
+ # --- 3. Model-only fallback: no transcript_path, but hook.model present (AC3)
188
+ echo ""
189
+ echo "--- preToolUse with no transcript_path but hook.model present: model-only fallback (AC3) ---"
190
+ input3=$(jq -nc '{session_id:"tool-usage-3",hook_event_name:"PreToolUse","model":"test-model",tool_name:"Bash",tool_input:{command:"echo hi"}}')
191
+ out3=$(_run_tool_event "preToolUse" "$input3")
192
+
193
+ if [[ -n "$out3" ]]; then
194
+ model3=$(echo "$out3" | jq -r '.usage.model // empty')
195
+ it3=$(echo "$out3" | jq -r '.usage.input_tokens')
196
+ ot3=$(echo "$out3" | jq -r '.usage.output_tokens')
197
+ cct3=$(echo "$out3" | jq -r '.usage.cache_creation_input_tokens')
198
+ crt3=$(echo "$out3" | jq -r '.usage.cache_read_input_tokens')
199
+ cost3=$(echo "$out3" | jq -r '.usage.estimated_cost_usd')
200
+ pv3=$(echo "$out3" | jq -r '.usage.pricing_version')
201
+
202
+ [[ "$model3" == "test-model" ]] && _pass "usage.model falls back to hook.model (test-model)" || _fail "expected usage.model=test-model, got $model3"
203
+ [[ "$it3" == "null" && "$ot3" == "null" && "$cct3" == "null" && "$crt3" == "null" && "$cost3" == "null" && "$pv3" == "null" ]] && _pass "all token/cost fields are explicitly null (never guessed) in model-only fallback" || _fail "expected all token/cost fields null, got input_tokens=$it3 output_tokens=$ot3 cache_creation=$cct3 cache_read=$crt3 cost=$cost3 pricing_version=$pv3"
204
+ else
205
+ _fail "no tool.invoke event emitted for model-only fallback case"
206
+ fi
207
+
208
+ # --- 4. Full-null degradation: neither transcript nor hook.model (AC4) -------
209
+ echo ""
210
+ echo "--- preToolUse with neither transcript_path nor hook.model: full-null degradation (AC4) ---"
211
+ input4='{"session_id":"tool-usage-4","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}'
212
+ out4=$(_run_tool_event "preToolUse" "$input4")
213
+
214
+ if [[ -n "$out4" ]]; then
215
+ model4=$(echo "$out4" | jq -r '.usage.model')
216
+ it4=$(echo "$out4" | jq -r '.usage.input_tokens')
217
+ [[ "$model4" == "null" ]] && _pass "usage.model is null when neither transcript nor hook.model is available" || _fail "expected usage.model=null, got $model4"
218
+ [[ "$it4" == "null" ]] && _pass "usage.input_tokens is null when neither source is available" || _fail "expected usage.input_tokens=null, got $it4"
219
+ else
220
+ _fail "no tool.invoke event emitted for full-null case (should still emit, just with null usage)"
221
+ fi
222
+
223
+ # --- 6. Adversarial line: crafted model/token strings never leak verbatim ---
224
+ echo ""
225
+ echo "--- preToolUse with adversarial transcript: sanitized, never leaks raw strings (security review) ---"
226
+ input6=$(jq -nc --arg tp "$ADVERSARIAL_TRANSCRIPT" '{session_id:"tool-usage-6",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
227
+ out6=$(_run_tool_event "preToolUse" "$input6" TELEMETRY_PRICING_FILE="$PRICING_FILE")
228
+
229
+ if [[ -n "$out6" ]]; then
230
+ model6=$(echo "$out6" | jq -r '.usage.model // empty')
231
+ it6=$(echo "$out6" | jq -r '.usage.input_tokens')
232
+ ot6=$(echo "$out6" | jq -r '.usage.output_tokens')
233
+ cct6=$(echo "$out6" | jq -r '.usage.cache_creation_input_tokens')
234
+ crt6=$(echo "$out6" | jq -r '.usage.cache_read_input_tokens')
235
+ cost6=$(echo "$out6" | jq -r '.usage.estimated_cost_usd')
236
+
237
+ [[ "$model6" == "unknown" ]] && _pass "adversarial model string degrades to 'unknown' (never the leak string)" || _fail "expected usage.model=unknown, got '$model6' (POSSIBLE LEAK)"
238
+ [[ "$it6" == "null" ]] && _pass "adversarial (string-typed) input_tokens degrades to null, not fabricated" || _fail "expected null input_tokens, got $it6"
239
+ [[ "$ot6" == "null" ]] && _pass "adversarial (string-typed) output_tokens degrades to null" || _fail "expected null output_tokens, got $ot6"
240
+ [[ "$cct6" == "null" ]] && _pass "adversarial (object-typed) cache_creation_input_tokens degrades to null" || _fail "expected null cache_creation_input_tokens, got $cct6"
241
+ [[ "$crt6" == "null" ]] && _pass "adversarial (array-typed) cache_read_input_tokens degrades to null" || _fail "expected null cache_read_input_tokens, got $crt6"
242
+ [[ "$cost6" == "null" ]] && _pass "estimated_cost_usd degrades to null when any token field is invalid (no partial/fabricated cost)" || _fail "expected null estimated_cost_usd, got $cost6"
243
+ else
244
+ _fail "no tool.invoke event emitted for adversarial transcript"
245
+ fi
246
+
247
+ # --- 7. Malformed lines BEFORE and AFTER the real usage line are skipped, not fatal (HIGH fix) ---
248
+ echo ""
249
+ echo "--- preToolUse with malformed-sandwich transcript: real line still found despite noise on both sides ---"
250
+ input7=$(jq -nc --arg tp "$MALFORMED_SANDWICH_TRANSCRIPT" '{session_id:"tool-usage-7",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
251
+ out7=$(_run_tool_event "preToolUse" "$input7" TELEMETRY_PRICING_FILE="$PRICING_FILE")
252
+
253
+ if [[ -n "$out7" ]]; then
254
+ model7=$(echo "$out7" | jq -r '.usage.model // empty')
255
+ it7=$(echo "$out7" | jq -r '.usage.input_tokens // empty')
256
+ ot7=$(echo "$out7" | jq -r '.usage.output_tokens // empty')
257
+
258
+ [[ "$model7" == "claude-malformed-sandwich-safe" ]] && _pass "malformed lines before/after the real line do not abort the parse; real model recovered" || _fail "expected model=claude-malformed-sandwich-safe, got $model7"
259
+ [[ "$it7" == "10" ]] && _pass "real input_tokens recovered despite surrounding malformed noise (10)" || _fail "expected input_tokens=10, got $it7"
260
+ [[ "$ot7" == "20" ]] && _pass "real output_tokens recovered despite surrounding malformed noise (20)" || _fail "expected output_tokens=20, got $ot7"
261
+ else
262
+ _fail "no tool.invoke event emitted for malformed-sandwich transcript (a single bad line should never abort the whole parse)"
263
+ fi
264
+
265
+ # --- 8. Torn trailing line (concurrent-append style) after a valid line: prior valid line found ---
266
+ echo ""
267
+ echo "--- preToolUse with torn-trailing transcript: prior valid line found, torn line skipped ---"
268
+ input8=$(jq -nc --arg tp "$TORN_TRAILING_TRANSCRIPT" '{session_id:"tool-usage-8",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
269
+ out8=$(_run_tool_event "preToolUse" "$input8" TELEMETRY_PRICING_FILE="$PRICING_FILE")
270
+
271
+ if [[ -n "$out8" ]]; then
272
+ model8=$(echo "$out8" | jq -r '.usage.model // empty')
273
+ it8=$(echo "$out8" | jq -r '.usage.input_tokens // empty')
274
+
275
+ [[ "$model8" == "claude-torn-trailing-safe" ]] && _pass "torn trailing line (no closing brace) is skipped; prior valid line's model found" || _fail "expected model=claude-torn-trailing-safe, got $model8"
276
+ [[ "$it8" == "15" ]] && _pass "torn trailing line does not leak its own tokens (999) -- prior valid line's tokens found (15)" || _fail "expected input_tokens=15, got $it8"
277
+ else
278
+ _fail "no tool.invoke event emitted for torn-trailing transcript"
279
+ fi
280
+
281
+ # --- 9. Secret-shaped model (sk-ant-...) never leaks; valid tokens still resolve (HIGH fix) ---
282
+ echo ""
283
+ echo "--- preToolUse with sk-ant-api03-... as model: sanitized to 'unknown', tokens still resolve ---"
284
+ input9=$(jq -nc --arg tp "$SECRET_MODEL_TRANSCRIPT" '{session_id:"tool-usage-9",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
285
+ out9=$(_run_tool_event "preToolUse" "$input9" TELEMETRY_PRICING_FILE="$PRICING_FILE")
286
+
287
+ if [[ -n "$out9" ]]; then
288
+ model9=$(echo "$out9" | jq -r '.usage.model // empty')
289
+ it9=$(echo "$out9" | jq -r '.usage.input_tokens // empty')
290
+ ot9=$(echo "$out9" | jq -r '.usage.output_tokens // empty')
291
+
292
+ [[ "$model9" == "unknown" ]] && _pass "sk-ant-api03-... model string is NEVER relayed; degrades to 'unknown'" || _fail "SECRET LEAK: expected usage.model=unknown, got '$model9'"
293
+ [[ "$it9" == "42" ]] && _pass "valid numeric input_tokens still resolves despite invalid model (42)" || _fail "expected input_tokens=42, got $it9"
294
+ [[ "$ot9" == "17" ]] && _pass "valid numeric output_tokens still resolves despite invalid model (17)" || _fail "expected output_tokens=17, got $ot9"
295
+ else
296
+ _fail "no tool.invoke event emitted for secret-model transcript"
297
+ fi
298
+
299
+ # --- 10. Secret-shaped model (JWT eyJ...) never leaks --------------------------
300
+ echo ""
301
+ echo "--- preToolUse with a JWT (eyJ...) as model: sanitized to 'unknown' ---"
302
+ input10=$(jq -nc --arg tp "$SECRET_MODEL_JWT_TRANSCRIPT" '{session_id:"tool-usage-10",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
303
+ out10=$(_run_tool_event "preToolUse" "$input10" TELEMETRY_PRICING_FILE="$PRICING_FILE")
304
+
305
+ if [[ -n "$out10" ]]; then
306
+ model10=$(echo "$out10" | jq -r '.usage.model // empty')
307
+ it10=$(echo "$out10" | jq -r '.usage.input_tokens // empty')
308
+
309
+ [[ "$model10" == "unknown" ]] && _pass "JWT (eyJ...) model string, with an embedded email, is NEVER relayed; degrades to 'unknown'" || _fail "SECRET/PII LEAK: expected usage.model=unknown, got '$model10'"
310
+ [[ "$it10" == "30" ]] && _pass "valid numeric input_tokens still resolves despite invalid model (30)" || _fail "expected input_tokens=30, got $it10"
311
+ else
312
+ _fail "no tool.invoke event emitted for JWT secret-model transcript"
313
+ fi
314
+
315
+ # --- 10c. Vendor-PREFIXED secret (uppercase suffix) never leaks (r4: suffix gap) ---
316
+ echo ""
317
+ echo "--- preToolUse with 'claude-AKIA...' (vendor-prefixed secret, uppercase): sanitized to 'unknown' ---"
318
+ input10c=$(jq -nc --arg tp "$PREFIXED_SECRET_UPPER_TRANSCRIPT" '{session_id:"tool-usage-10c",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
319
+ out10c=$(_run_tool_event "preToolUse" "$input10c" TELEMETRY_PRICING_FILE="$PRICING_FILE")
320
+ if [[ -n "$out10c" ]]; then
321
+ model10c=$(echo "$out10c" | jq -r '.usage.model // empty')
322
+ it10c=$(echo "$out10c" | jq -r '.usage.input_tokens // empty')
323
+ [[ "$model10c" == "unknown" ]] && _pass "vendor-prefixed uppercase secret (claude-AKIA...) is NEVER relayed; degrades to 'unknown'" || _fail "SECRET LEAK: expected usage.model=unknown, got '$model10c'"
324
+ [[ "$it10c" == "11" ]] && _pass "valid input_tokens still resolves despite prefixed-secret model (11)" || _fail "expected input_tokens=11, got $it10c"
325
+ else
326
+ _fail "no tool.invoke event emitted for prefixed-secret-upper transcript"
327
+ fi
328
+
329
+ # --- 10d. Vendor-PREFIXED secret (long lowercase suffix > 16 chars) never leaks ---
330
+ echo ""
331
+ echo "--- preToolUse with 'claude-<27-char lowercase>' (prefixed, long token): sanitized to 'unknown' ---"
332
+ input10d=$(jq -nc --arg tp "$PREFIXED_SECRET_LOWER_TRANSCRIPT" '{session_id:"tool-usage-10d",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
333
+ out10d=$(_run_tool_event "preToolUse" "$input10d" TELEMETRY_PRICING_FILE="$PRICING_FILE")
334
+ if [[ -n "$out10d" ]]; then
335
+ model10d=$(echo "$out10d" | jq -r '.usage.model // empty')
336
+ it10d=$(echo "$out10d" | jq -r '.usage.input_tokens // empty')
337
+ [[ "$model10d" == "unknown" ]] && _pass "vendor-prefixed long-lowercase secret is NEVER relayed; degrades to 'unknown' (>16-char token cap)" || _fail "SECRET LEAK: expected usage.model=unknown, got '$model10d'"
338
+ [[ "$it10d" == "13" ]] && _pass "valid input_tokens still resolves despite prefixed-secret model (13)" || _fail "expected input_tokens=13, got $it10d"
339
+ else
340
+ _fail "no tool.invoke event emitted for prefixed-secret-lower transcript"
341
+ fi
342
+
343
+ # --- 11. Negative token value degrades to null (LOW fix: sign/magnitude bound) ---
344
+ echo ""
345
+ echo "--- preToolUse with a negative input_tokens value: degrades to null ---"
346
+ input11=$(jq -nc --arg tp "$NEGATIVE_TOKENS_TRANSCRIPT" '{session_id:"tool-usage-11",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
347
+ out11=$(_run_tool_event "preToolUse" "$input11" TELEMETRY_PRICING_FILE="$PRICING_FILE")
348
+
349
+ if [[ -n "$out11" ]]; then
350
+ it11=$(echo "$out11" | jq -r '.usage.input_tokens')
351
+ cost11=$(echo "$out11" | jq -r '.usage.estimated_cost_usd')
352
+
353
+ [[ "$it11" == "null" ]] && _pass "negative input_tokens (-500) degrades to null, never a negative number" || _fail "expected null input_tokens, got $it11"
354
+ [[ "$cost11" == "null" ]] && _pass "estimated_cost_usd degrades to null when a token field is out of bounds" || _fail "expected null estimated_cost_usd, got $cost11"
355
+ else
356
+ _fail "no tool.invoke event emitted for negative-tokens transcript"
357
+ fi
358
+
359
+ # --- 12. Oversized (1e308) token value degrades to null (LOW fix) -----------
360
+ echo ""
361
+ echo "--- preToolUse with an absurd-magnitude (1e308) input_tokens value: degrades to null ---"
362
+ input12=$(jq -nc --arg tp "$OVERSIZED_TOKENS_TRANSCRIPT" '{session_id:"tool-usage-12",transcript_path:$tp,hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"echo hi"}}')
363
+ out12=$(_run_tool_event "preToolUse" "$input12" TELEMETRY_PRICING_FILE="$PRICING_FILE")
364
+
365
+ if [[ -n "$out12" ]]; then
366
+ it12=$(echo "$out12" | jq -r '.usage.input_tokens')
367
+ cost12=$(echo "$out12" | jq -r '.usage.estimated_cost_usd')
368
+
369
+ [[ "$it12" == "null" ]] && _pass "absurd-magnitude input_tokens (1e308) degrades to null, never a fabricated huge number" || _fail "expected null input_tokens, got $it12"
370
+ [[ "$cost12" == "null" ]] && _pass "estimated_cost_usd degrades to null when a token field is out of bounds (oversized case)" || _fail "expected null estimated_cost_usd, got $cost12"
371
+ else
372
+ _fail "no tool.invoke event emitted for oversized-tokens transcript"
373
+ fi
374
+
375
+ # --- 5. permissionRequest exclusion: no .usage key at all (AC7) --------------
376
+ echo ""
377
+ echo "--- permissionRequest with transcript_path present: no .usage enrichment (AC7) ---"
378
+ input5=$(jq -nc --arg tp "$FIXTURE_TRANSCRIPT" '{session_id:"tool-usage-5",transcript_path:$tp,hook_event_name:"PermissionRequest",tool_name:"Bash",tool_input:{command:"echo hi", description:"run a command"}}')
379
+ out5=$(_run_tool_event "permissionRequest" "$input5" TELEMETRY_PRICING_FILE="$PRICING_FILE")
380
+
381
+ if [[ -n "$out5" ]]; then
382
+ event_type5=$(echo "$out5" | jq -r '.event_type // empty')
383
+ has_usage5=$(echo "$out5" | jq -r 'has("usage")')
384
+ [[ "$event_type5" == "tool.permission_request" ]] && _pass "permissionRequest maps to tool.permission_request" || _fail "expected tool.permission_request, got $event_type5"
385
+ [[ "$has_usage5" == "false" ]] && _pass "permissionRequest event carries no .usage key (explicit scope boundary preserved)" || _fail "expected permissionRequest to have no .usage key, but has(\"usage\")=$has_usage5"
386
+ else
387
+ _fail "no tool.permission_request event emitted"
388
+ fi
389
+
390
+ rm -rf "$TMPDIR_EVAL"
391
+
392
+ echo ""
393
+ echo "Telemetry tool-event usage: $pass passed, $fail failed"
394
+ [ "$fail" -eq 0 ]