@kontourai/flow-agents 3.9.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 (106) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +1 -1
  4. package/build/src/cli/assignment-provider.js +4 -18
  5. package/build/src/cli/continuation-adapter.d.ts +10 -8
  6. package/build/src/cli/continuation-adapter.js +22 -4
  7. package/build/src/cli/validate-workflow-artifacts.js +21 -1
  8. package/build/src/cli/workflow-sidecar.js +15 -20
  9. package/build/src/cli/workflow.js +58 -11
  10. package/build/src/continuation-driver.d.ts +27 -3
  11. package/build/src/continuation-driver.js +97 -14
  12. package/build/src/index.d.ts +2 -2
  13. package/build/src/index.js +1 -1
  14. package/build/src/tools/build-universal-bundles.js +52 -25
  15. package/build/src/tools/validate-source-tree.js +5 -2
  16. package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +244 -73
  18. package/context/scripts/hooks/workflow-steering.js +24 -15
  19. package/context/scripts/telemetry/lib/pricing.sh +18 -6
  20. package/context/scripts/telemetry/lib/transport.sh +139 -37
  21. package/context/scripts/telemetry/lib/usage.sh +282 -32
  22. package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  23. package/context/scripts/telemetry/telemetry.sh +76 -1
  24. package/docs/coordination-guide.md +21 -5
  25. package/docs/fixture-ownership.md +2 -1
  26. package/docs/public-workflow-cli.md +44 -0
  27. package/docs/spec/runtime-hook-surface.md +8 -4
  28. package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
  29. package/evals/acceptance/prove-capture-teeth.sh +13 -0
  30. package/evals/ci/run-baseline.sh +2 -0
  31. package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
  32. package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
  33. package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
  34. package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
  35. package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
  36. package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
  37. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
  38. package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
  39. package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
  40. package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
  41. package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
  42. package/evals/integration/test_actor_identity.sh +63 -0
  43. package/evals/integration/test_assignment_provider_local_file.sh +34 -0
  44. package/evals/integration/test_builder_entry_enforcement.sh +5 -1
  45. package/evals/integration/test_bundle_install.sh +106 -17
  46. package/evals/integration/test_bundle_lifecycle.sh +17 -3
  47. package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
  48. package/evals/integration/test_ci_actor_identity.sh +2 -2
  49. package/evals/integration/test_command_log_concurrency.sh +11 -0
  50. package/evals/integration/test_command_log_fork_classification.sh +11 -0
  51. package/evals/integration/test_command_log_integrity.sh +11 -0
  52. package/evals/integration/test_current_json_per_actor.sh +416 -21
  53. package/evals/integration/test_enforcer_expects_driven.sh +11 -0
  54. package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
  55. package/evals/integration/test_evidence_capture_hook.sh +25 -6
  56. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  57. package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
  58. package/evals/integration/test_gate_bypass_chain.sh +11 -0
  59. package/evals/integration/test_gate_lockdown.sh +10 -0
  60. package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
  61. package/evals/integration/test_goal_fit_hook.sh +257 -9
  62. package/evals/integration/test_goal_fit_rederive.sh +11 -0
  63. package/evals/integration/test_install_merge.sh +141 -3
  64. package/evals/integration/test_liveness_conflict_injection.sh +39 -3
  65. package/evals/integration/test_liveness_heartbeat.sh +46 -9
  66. package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
  67. package/evals/integration/test_public_workflow_cli.sh +6 -6
  68. package/evals/integration/test_session_resume_roundtrip.sh +30 -0
  69. package/evals/integration/test_stop_hook_release.sh +111 -11
  70. package/evals/integration/test_telemetry_tool_usage.sh +394 -0
  71. package/evals/integration/test_verify_hold.sh +64 -1
  72. package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
  73. package/evals/integration/test_workflow_steering_hook.sh +20 -4
  74. package/evals/run.sh +2 -0
  75. package/evals/static/test_universal_bundles.sh +123 -0
  76. package/package.json +1 -1
  77. package/packaging/README.md +21 -0
  78. package/packaging/codex-legacy-agents-fingerprints.json +33 -0
  79. package/scripts/README.md +2 -1
  80. package/scripts/audit-codex-legacy-agents.js +43 -0
  81. package/scripts/classify-codex-legacy-agents.js +71 -0
  82. package/scripts/hooks/evidence-capture.js +18 -6
  83. package/scripts/hooks/lib/actor-identity.js +62 -29
  84. package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
  85. package/scripts/hooks/lib/current-pointer.js +114 -12
  86. package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
  87. package/scripts/hooks/stop-goal-fit.js +244 -73
  88. package/scripts/hooks/workflow-steering.js +24 -15
  89. package/scripts/install-codex-home.sh +8 -7
  90. package/scripts/telemetry/lib/pricing.sh +18 -6
  91. package/scripts/telemetry/lib/transport.sh +71 -0
  92. package/scripts/telemetry/lib/usage.sh +282 -32
  93. package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
  94. package/scripts/telemetry/telemetry.sh +76 -1
  95. package/src/cli/assignment-provider.ts +5 -26
  96. package/src/cli/builder-flow-runtime.test.mjs +93 -6
  97. package/src/cli/continuation-adapter.ts +33 -7
  98. package/src/cli/continuation-driver.test.mjs +741 -2
  99. package/src/cli/public-api.test.mjs +1 -0
  100. package/src/cli/validate-workflow-artifacts.ts +19 -1
  101. package/src/cli/workflow-sidecar.ts +18 -23
  102. package/src/cli/workflow.ts +71 -11
  103. package/src/continuation-driver.ts +125 -19
  104. package/src/index.ts +4 -0
  105. package/src/tools/build-universal-bundles.ts +61 -25
  106. package/src/tools/validate-source-tree.ts +5 -2
@@ -2,6 +2,9 @@
2
2
  # transport.sh — Dual-channel telemetry transport
3
3
 
4
4
  source "${TELEMETRY_DIR}/lib/redact.sh"
5
+ # Shared usage.model / numeric-bound validation constants (security review
6
+ # HIGH + LOW findings) -- single source, also used by usage.sh's extractor.
7
+ source "${TELEMETRY_DIR}/lib/usage_model_guard.sh"
5
8
 
6
9
  console_telemetry_endpoint_url() {
7
10
  if [[ -n "${CONSOLE_TELEMETRY_ENDPOINT_URL:-}" ]]; then
@@ -178,12 +181,80 @@ console_project_label() {
178
181
  printf '%s' "$label"
179
182
  }
180
183
 
184
+ # Defense-in-depth sanitize backstop (#568 slice 1 security review, MEDIUM,
185
+ # hardened per HIGH + LOW follow-up findings): nulls any
186
+ # usage.{input_tokens,output_tokens,cache_creation_input_tokens,
187
+ # cache_read_input_tokens,estimated_cost_usd} that isn't a JSON number within
188
+ # [USAGE_NUMERIC_MIN, USAGE_NUMERIC_MAX] (negative/absurd-magnitude values are
189
+ # as untrusted as a wrong-typed one), and bounds/validates usage.model against
190
+ # the SAME shared vendor-prefix allowlist (USAGE_MODEL_REGEX/
191
+ # USAGE_MODEL_MAX_LEN, from usage_model_guard.sh) usage_last_turn_usage
192
+ # enforces at extraction time -- a charset-only check here would be, and
193
+ # previously was, a strict superset of secret/credential shapes (API keys,
194
+ # JWTs) that a regression in the primary extractor could otherwise relay
195
+ # verbatim. FAILS CLOSED: if `.usage` is present but is not a JSON object (a
196
+ # raw string/number/array -- e.g. a future caller populating it wrong), the
197
+ # whole `.usage` is nulled rather than left unsanitized (a naive
198
+ # `.usage? != null` + `?`-chained field check on a non-object silently
199
+ # produces NO jq output at all, which the caller would then treat as "sanitize
200
+ # failed" and fall through to the ORIGINAL unsanitized event -- exactly the
201
+ # fail-OPEN bug this branch exists to close). This exists so a FUTURE
202
+ # extractor regression (a new caller populating .usage from a less-trusted
203
+ # source, a refactor that drops a type/shape guard, etc.) can never again
204
+ # relay an untyped/oversized/malformed/secret-shaped value to the console; it
205
+ # is not the primary defense (usage_last_turn_usage already type-guards and
206
+ # allowlists every field) but a last-resort net right before the POST. One jq
207
+ # pass, no extra process spawned beyond the jq call already needed here.
208
+ console_telemetry_sanitize_usage() {
209
+ local event="$1"
210
+ # FAIL CLOSED if the shared guard constants didn't resolve (e.g. a partial
211
+ # dist/ sync dropped usage_model_guard.sh): null .usage entirely rather than
212
+ # running a broken/empty jq that would return nothing and let the caller relay
213
+ # the ORIGINAL unsanitized event. Never fail open on a packaging defect.
214
+ if [[ -z "$USAGE_MODEL_REGEX" || -z "$USAGE_MODEL_MAX_LEN" || -z "$USAGE_NUMERIC_MIN" || -z "$USAGE_NUMERIC_MAX" ]]; then
215
+ printf '%s' "$event" | jq -c 'if has("usage") then .usage = null else . end'
216
+ return
217
+ fi
218
+ printf '%s' "$event" | jq -c \
219
+ --arg model_regex "$USAGE_MODEL_REGEX" \
220
+ --argjson model_max_len "$USAGE_MODEL_MAX_LEN" \
221
+ --argjson numeric_min "$USAGE_NUMERIC_MIN" \
222
+ --argjson numeric_max "$USAGE_NUMERIC_MAX" '
223
+ def bounded_number($v):
224
+ if ($v | type) == "number" and $v >= $numeric_min and $v <= $numeric_max then $v else null end;
225
+ if (.usage? == null) then .
226
+ elif ((.usage | type) != "object") then (.usage = null)
227
+ else
228
+ .usage.input_tokens = bounded_number(.usage.input_tokens?)
229
+ | .usage.output_tokens = bounded_number(.usage.output_tokens?)
230
+ | .usage.cache_creation_input_tokens = bounded_number(.usage.cache_creation_input_tokens?)
231
+ | .usage.cache_read_input_tokens = bounded_number(.usage.cache_read_input_tokens?)
232
+ | .usage.estimated_cost_usd = bounded_number(.usage.estimated_cost_usd?)
233
+ | .usage.model = (
234
+ if ((.usage.model? | type) == "string")
235
+ and ((.usage.model | length) <= $model_max_len)
236
+ and (.usage.model | test($model_regex))
237
+ then .usage.model
238
+ else null
239
+ end
240
+ )
241
+ end
242
+ ' 2>/dev/null
243
+ }
244
+
181
245
  console_telemetry_emit() {
182
246
  local event="$1"
183
247
  local endpoint_url
184
248
  endpoint_url=$(console_telemetry_endpoint_url)
185
249
  [[ -z "$endpoint_url" ]] && return
186
250
 
251
+ # Sanitize backstop runs before anything else so labeling/redaction/POST all
252
+ # see the already-bounded event; a failed/empty sanitize pass (bad JSON)
253
+ # falls back to the original event unchanged (never blocks relay).
254
+ local sanitized_event
255
+ sanitized_event=$(console_telemetry_sanitize_usage "$event") || sanitized_event=""
256
+ [[ -n "$sanitized_event" ]] && event="$sanitized_event"
257
+
187
258
  # Attribution: stamp a coarse, path-free project label (see console_project_label) before redaction
188
259
  # so the console buckets by project consistently across developers. The full context.cwd is still
189
260
  # redacted below — only the label leaves the machine. Every substitution is `|| var=""`-guarded so
@@ -6,6 +6,9 @@ USAGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
 
7
7
  # Single-source pricing registry loader (local / remote / bundled).
8
8
  source "${USAGE_LIB_DIR}/pricing.sh"
9
+ # Shared usage.model / numeric-bound validation constants (security review
10
+ # HIGH + LOW findings) -- single source, also used by transport.sh's backstop.
11
+ source "${USAGE_LIB_DIR}/usage_model_guard.sh"
9
12
 
10
13
  # Resolve model from agent-spec.json
11
14
  usage_get_model() {
@@ -89,6 +92,50 @@ usage_count_delegations() {
89
92
  # if the on-disk schema changes so drift is logged rather than silently zeroed.
90
93
  USAGE_TRANSCRIPT_SCHEMA="message.usage.input_tokens"
91
94
 
95
+ # Bounded tail-read size for usage_last_turn_usage (per-tool-call join), in
96
+ # bytes. Overridable via env for tuning; default 256 KiB is large enough to
97
+ # reliably contain the most recent assistant usage block even with sizeable
98
+ # tool-output content nearby, small enough to bound per-call latency (this
99
+ # function runs once per tool call, unlike usage_parse_transcript which runs
100
+ # once per session at stop).
101
+ TELEMETRY_USAGE_TAIL_BYTES="${TELEMETRY_USAGE_TAIL_BYTES:-262144}"
102
+
103
+ # Shared pricing/cache-multiplier formula — the ONE place the per-model cost
104
+ # arithmetic is defined. Both usage_parse_transcript (whole-session aggregate,
105
+ # at `stop`) and usage_last_turn_usage (single-turn, per tool call) prepend
106
+ # this jq `def` to their program text and call price_one(...), rather than
107
+ # each hand-copying the formula (see #356 shared-not-forked discipline in
108
+ # transport.sh — a duplicated formula is exactly the kind of drift that
109
+ # invites). Given a model id ($m), a token breakdown ($u — object with
110
+ # input/output/cache_creation/cache_read keys), the resolved pricing registry
111
+ # ($registry), whether it's usable ($has_registry), and a requested pricing
112
+ # version ($version, empty string = registry's current_version), returns
113
+ # {estimated_cost_usd, pricing_version} — both null when no registry/version
114
+ # resolves (never invents a cost from unpriced data).
115
+ USAGE_PRICE_ONE_JQ_DEF='
116
+ def price_one($m; $u; $registry; $has_registry; $version):
117
+ ($has_registry and ($registry != null)) as $has_reg
118
+ | (if $has_reg then (if $version == "" then $registry.current_version else $version end) else null end) as $ver
119
+ | (if $has_reg and ($ver != null) then ($registry.versions[$ver]) else null end) as $p
120
+ | ($p != null) as $priced
121
+ | (if $priced then $p.cache_multipliers else null end) as $cm
122
+ | (if $priced then (($p.models[$m]) // $p.default) else null end) as $rate
123
+ | (if $priced then (if ([$m] | inside($p.zero_cost_models)) then 0 else 1 end) else null end) as $billable
124
+ | {
125
+ estimated_cost_usd: (
126
+ if $priced then
127
+ $billable * (
128
+ ($u.input // 0) * $rate.input
129
+ + ($u.output // 0) * $rate.output
130
+ + ($u.cache_creation // 0) * $rate.input * $cm.write_5m
131
+ + ($u.cache_read // 0) * $rate.input * $cm.read
132
+ ) / 1000000
133
+ else null end
134
+ ),
135
+ pricing_version: (if $priced then $ver else null end)
136
+ };
137
+ '
138
+
92
139
  # Append a one-line schema-drift warning (transcript carried usage data we could
93
140
  # not parse). Goes to TELEMETRY_DRIFT_LOG if set, else stderr. Never fatal.
94
141
  usage_log_drift() {
@@ -118,6 +165,54 @@ usage_log_debug() {
118
165
  fi
119
166
  }
120
167
 
168
+ # Shared registry resolution (DRY fix, review finding MEDIUM/code): both
169
+ # usage_parse_transcript and usage_last_turn_usage need {registry, has_registry}
170
+ # before invoking price_one() -- resolve it in exactly one place instead of
171
+ # hand-copying the resolve+validate block in each caller.
172
+ # $1 = name of the caller's variable to receive the registry JSON (or the
173
+ # literal string "null" when unavailable/unparseable)
174
+ # $2 = name of the caller's variable to receive has_registry ("true"/"false")
175
+ # $3 = mode: "" (default -- pricing_registry() may hit network/local/bundled
176
+ # sources, used by usage_parse_transcript's once-per-session stop path)
177
+ # | "cache_only" (never perform a network fetch -- see
178
+ # usage_last_turn_usage's per-tool-call latency/race note below; a
179
+ # cold/stale cache degrades straight to the bundled snapshot)
180
+ # $4 = optional log context (e.g. transcript path) for usage_log_debug parity
181
+ # with the pre-refactor per-caller messages
182
+ usage_resolve_registry() {
183
+ local __usage_reg_var="$1" __usage_has_var="$2" __usage_mode="${3:-}" __usage_log_ctx="${4:-}"
184
+ # Double-underscore-prefixed locals throughout (matching this function's own
185
+ # parameter convention, code review LOW finding) to harden against a future
186
+ # third caller's variable-name collision -- these never leak into the
187
+ # caller's scope (output is returned only via printf -v into the caller's
188
+ # named variables above), but a plain `_registry`/`_has_registry` name is
189
+ # exactly the kind of generic local name a future edit could accidentally
190
+ # shadow.
191
+ local __registry __has_registry
192
+ if [[ "$__usage_mode" == "cache_only" ]]; then
193
+ __registry="$(TELEMETRY_PRICING_CACHE_ONLY=1 pricing_registry 2>/dev/null)"
194
+ else
195
+ __registry="$(pricing_registry 2>/dev/null)"
196
+ fi
197
+ if [[ $? -eq 0 && -n "$__registry" ]]; then
198
+ __has_registry=true
199
+ else
200
+ __has_registry=false
201
+ __registry='null'
202
+ [[ -n "$__usage_log_ctx" ]] && usage_log_debug "pricing registry unavailable (${__usage_log_ctx}) — extracting tokens without cost"
203
+ fi
204
+ # A non-empty registry can still be malformed (corrupt/truncated pricing.json,
205
+ # or a bad remote 200) — validate it parses before handing it to `jq -n
206
+ # --argjson`, which would otherwise abort the whole parse and discard tokens.
207
+ if [[ "$__has_registry" == true ]] && ! jq -e . >/dev/null 2>&1 <<<"$__registry"; then
208
+ __has_registry=false
209
+ __registry='null'
210
+ [[ -n "$__usage_log_ctx" ]] && usage_log_debug "pricing registry unparseable (${__usage_log_ctx}) — extracting tokens without cost"
211
+ fi
212
+ printf -v "$__usage_reg_var" '%s' "$__registry"
213
+ printf -v "$__usage_has_var" '%s' "$__has_registry"
214
+ }
215
+
121
216
  usage_parse_transcript() {
122
217
  local transcript="$1" version="${2:-}"
123
218
  if [[ -z "$transcript" ]]; then
@@ -136,33 +231,17 @@ usage_parse_transcript() {
136
231
  # Pricing registry is best-effort: when unavailable, tokens are still
137
232
  # extracted below and only the cost fields degrade to null (defect #2 fix —
138
233
  # previously this hard-returned 1 here, discarding token extraction
139
- # entirely whenever pricing_registry() failed).
234
+ # entirely whenever pricing_registry() failed). Resolution itself is shared
235
+ # with usage_last_turn_usage via usage_resolve_registry (DRY fix).
140
236
  local registry has_registry
141
- registry="$(pricing_registry 2>/dev/null)"
142
- if [[ $? -eq 0 && -n "$registry" ]]; then
143
- has_registry=true
144
- else
145
- has_registry=false
146
- registry='null'
147
- usage_log_debug "pricing registry unavailable (${transcript}) — extracting tokens without cost"
148
- fi
149
- # A non-empty registry can still be malformed (corrupt/truncated pricing.json,
150
- # or a bad remote 200) — validate it parses before handing it to `jq -n
151
- # --argjson`, which would otherwise abort the whole parse and discard tokens
152
- # (the exact intermittent-null-tokens defect this function exists to fix).
153
- if [[ "$has_registry" == true ]] && ! jq -e . >/dev/null 2>&1 <<<"$registry"; then
154
- has_registry=false
155
- registry='null'
156
- usage_log_debug "pricing registry unparseable (${transcript}) — extracting tokens without cost"
157
- fi
237
+ usage_resolve_registry registry has_registry "" "$transcript"
158
238
 
159
239
  local out
160
- out="$(jq -n --argjson registry "$registry" --argjson has_registry "$has_registry" --arg version "$version" '
240
+ out="$(jq -n --argjson registry "$registry" --argjson has_registry "$has_registry" --arg version "$version" "${USAGE_PRICE_ONE_JQ_DEF}"'
161
241
  ($has_registry and ($registry != null)) as $has_reg
162
242
  | (if $has_reg then (if $version == "" then $registry.current_version else $version end) else null end) as $ver
163
243
  | (if $has_reg and ($ver != null) then ($registry.versions[$ver]) else null end) as $p
164
244
  | ($p != null) as $priced
165
- | (if $priced then $p.cache_multipliers else null end) as $cm
166
245
  | (reduce inputs as $l ({};
167
246
  ($l.message.usage) as $u
168
247
  | if $u then
@@ -175,24 +254,14 @@ usage_parse_transcript() {
175
254
  | ($agg | to_entries
176
255
  | map(
177
256
  .key as $m | .value as $u
178
- | (if $priced then (($p.models[$m]) // $p.default) else null end) as $rate
179
- | (if $priced then (if ([$m] | inside($p.zero_cost_models)) then 0 else 1 end) else null end) as $billable
257
+ | price_one($m; $u; $registry; $has_registry; $version) as $price
180
258
  | {
181
259
  model: $m,
182
260
  input_tokens: ($u.input // 0),
183
261
  output_tokens: ($u.output // 0),
184
262
  cache_creation_input_tokens: ($u.cache_creation // 0),
185
263
  cache_read_input_tokens: ($u.cache_read // 0),
186
- estimated_cost_usd: (
187
- if $priced then
188
- $billable * (
189
- ($u.input // 0) * $rate.input
190
- + ($u.output // 0) * $rate.output
191
- + ($u.cache_creation // 0) * $rate.input * $cm.write_5m
192
- + ($u.cache_read // 0) * $rate.input * $cm.read
193
- ) / 1000000
194
- else null end
195
- )
264
+ estimated_cost_usd: $price.estimated_cost_usd
196
265
  })) as $by_model
197
266
  | {
198
267
  by_model: $by_model,
@@ -228,3 +297,184 @@ usage_parse_transcript() {
228
297
 
229
298
  printf '%s\n' "$out"
230
299
  }
300
+
301
+ # Bounded, single-turn usage extractor — the tool-event (preToolUse/
302
+ # postToolUse) counterpart to usage_parse_transcript's whole-session
303
+ # aggregate (used only at `stop`). This runs once per tool call, so unlike
304
+ # usage_parse_transcript it MUST NOT scan the whole transcript: a large
305
+ # session can fire hundreds of tool calls, and a full-file jq scan per call
306
+ # would add real, cumulative latency to every tool invocation.
307
+ #
308
+ # Reads only the last TELEMETRY_USAGE_TAIL_BYTES bytes of the transcript,
309
+ # drops the (possibly partial) first line of that tail window, and returns
310
+ # the LAST well-formed `type=="assistant"` line with a `message.usage` object.
311
+ # Because tool execution is synchronous — the CLI appends the requesting
312
+ # assistant message (including its tool_use blocks) before firing PreToolUse
313
+ # for each block, and no new assistant turn can be appended mid-tool-call —
314
+ # that last assistant/usage entry at hook-fire time is exactly the turn that
315
+ # produced this specific tool call, for both PreToolUse and PostToolUse of the
316
+ # same call.
317
+ #
318
+ # Hardened line selection (security review findings CRITICAL/HIGH): the tail
319
+ # window is parsed in `jq -R` RAW mode, one line at a time, with `fromjson?`
320
+ # per line — a torn/malformed line ANYWHERE in the window (e.g. a concurrent
321
+ # writer's partial trailing write, or noise before/after the real line) is
322
+ # SKIPPED, never fatal, and never masks a real usage line elsewhere in the
323
+ # window (previously a single malformed line anywhere in the tail aborted the
324
+ # whole `jq -n` JSON-mode parse, silently discarding a present, findable usage
325
+ # line). Every extracted field is independently type-guarded AND bound-checked
326
+ # before it is ever emitted: a token/cost field is emitted ONLY if it is a
327
+ # JSON number within [USAGE_NUMERIC_MIN, USAGE_NUMERIC_MAX] (never a string,
328
+ # object, array, negative, or absurd magnitude like 1e308 — never
329
+ # "0-as-fabrication" when the source type/range is wrong; see
330
+ # usage_model_guard.sh). `model` is emitted ONLY if it is a string, <=
331
+ # USAGE_MODEL_MAX_LEN chars, AND matches USAGE_MODEL_REGEX — a small,
332
+ # case-insensitive VENDOR-PREFIX ALLOWLIST (claude/gpt/o<digit>/gemini/glm/
333
+ # llama/mistral/deepseek/qwen/grok/command/codestral), NOT a bare charset
334
+ # check (security review HIGH finding: a charset-only check like
335
+ # `^[A-Za-z0-9._:-]+$` is a strict SUPERSET of common secret/credential
336
+ # shapes — an Anthropic API key `sk-ant-api03-...`, an AWS key `AKIA...`, or a
337
+ # JWT `eyJ...` all satisfy that charset and would pass through verbatim).
338
+ # Anything that doesn't match the allowlist resolves to "unknown". This is
339
+ # what stops a crafted transcript line (e.g. {"message":{"model":"<secret or
340
+ # arbitrary text>","usage":{"input_tokens":"<arbitrary text>"}}}) from ever
341
+ # landing a raw string in the emitted record (previously `// 0` /
342
+ # `// "unknown"` copied a wrong-typed value through verbatim, which then
343
+ # relayed to the console unredacted since usage.* isn't on the redact
344
+ # deny-list). estimated_cost_usd/pricing_version are computed ONLY when every
345
+ # token field for that turn is a valid, bounded number — a turn with any
346
+ # invalid/out-of-range token field degrades to null cost too, never a cost
347
+ # computed from partly-zeroed untrusted data; the computed cost itself is
348
+ # then also bound-checked before being emitted (defense-in-depth against an
349
+ # implausible pricing-registry rate).
350
+ #
351
+ # Prints a compact JSON object on success:
352
+ # {model, input_tokens, output_tokens, cache_creation_input_tokens,
353
+ # cache_read_input_tokens, estimated_cost_usd, pricing_version}
354
+ # and returns 0. Prints nothing and returns 1 when the transcript is
355
+ # missing/unreadable, jq is unavailable, or no assistant/usage line is found
356
+ # within the tail window (e.g. transcript not Anthropic-message-shaped, or a
357
+ # huge preceding blob pushed the last assistant entry outside the bound) —
358
+ # the caller degrades to its own model-only/null fallback; this function
359
+ # never invents a number. Reuses the exact pricing/cache-multiplier formula
360
+ # usage_parse_transcript uses, via the shared USAGE_PRICE_ONE_JQ_DEF/
361
+ # price_one() helper above (one source, two call sites), and the shared
362
+ # usage_resolve_registry() registry-resolution helper (also one source, two
363
+ # call sites — review finding MEDIUM/code DRY fix).
364
+ #
365
+ # Pricing resolution here is deliberately CACHE-ONLY (review finding
366
+ # MEDIUM/code latency+race): this function runs once per tool call, so a
367
+ # cold/stale local pricing cache must never trigger a blocking
368
+ # `curl --max-time 5` (and race pricing.sh's fixed `${cache}.tmp` path)
369
+ # per tool call. `usage_resolve_registry ... cache_only` skips the network
370
+ # branch entirely; session.usage (usage_parse_transcript, once per session at
371
+ # `stop`) remains the authoritative cost source and still resolves the
372
+ # registry normally (network-eligible). A cold cache here just means
373
+ # estimated_cost_usd/pricing_version are null for this tool event — tokens
374
+ # still survive.
375
+ usage_last_turn_usage() {
376
+ local transcript="$1" version="${2:-}"
377
+ if [[ -z "$transcript" ]]; then
378
+ usage_log_debug "usage_last_turn_usage: no usage — reason: empty transcript_path"
379
+ return 1
380
+ fi
381
+ if [[ ! -f "$transcript" || ! -r "$transcript" ]]; then
382
+ usage_log_debug "usage_last_turn_usage: no usage — reason: transcript file missing/unreadable (${transcript})"
383
+ return 1
384
+ fi
385
+ if ! command -v jq >/dev/null 2>&1; then
386
+ usage_log_debug "usage_last_turn_usage: no usage — reason: jq unavailable"
387
+ return 1
388
+ fi
389
+
390
+ local file_size
391
+ file_size="$(wc -c < "$transcript" 2>/dev/null | tr -d ' ')"
392
+ [[ "$file_size" =~ ^[0-9]+$ ]] || file_size=0
393
+
394
+ # Bounded tail-read: only the last N bytes when the file exceeds the bound,
395
+ # then drop the first (possibly partial/truncated-mid-JSON) line of that
396
+ # window. This is a cheap belt-and-suspenders trim, not a correctness
397
+ # requirement any more — the jq program below tolerates a malformed line
398
+ # anywhere in the window (see the hardened-parsing note above) — but there
399
+ # is no reason to hand jq a line we already know is certainly truncated.
400
+ local tail_text
401
+ if [[ "$file_size" -gt "$TELEMETRY_USAGE_TAIL_BYTES" ]]; then
402
+ tail_text="$(tail -c "$TELEMETRY_USAGE_TAIL_BYTES" "$transcript" 2>/dev/null | tail -n +2)"
403
+ else
404
+ tail_text="$(cat "$transcript" 2>/dev/null)"
405
+ fi
406
+ if [[ -z "$tail_text" ]]; then
407
+ usage_log_debug "usage_last_turn_usage: no usage — reason: empty tail window (${transcript})"
408
+ return 1
409
+ fi
410
+
411
+ # Pricing registry — cache-only (see function header): never a network
412
+ # fetch per tool call. Missing/unparseable registry degrades cost/
413
+ # pricing_version to null but never blocks token extraction.
414
+ local registry has_registry
415
+ usage_resolve_registry registry has_registry cache_only "$transcript"
416
+
417
+ local jq_program
418
+ jq_program="${USAGE_PRICE_ONE_JQ_DEF}"'
419
+ def bounded_number($v):
420
+ if ($v | type) == "number" and $v >= $numeric_min and $v <= $numeric_max then $v else null end;
421
+ (
422
+ [inputs]
423
+ | map(try fromjson catch null)
424
+ | map(select(. != null))
425
+ | map(select(
426
+ (.type? == "assistant")
427
+ and ((.message?.usage?) != null)
428
+ and ((.message.usage | type) == "object")
429
+ ))
430
+ | last
431
+ ) as $turn
432
+ | if $turn == null then empty else
433
+ ($turn.message.usage) as $raw
434
+ | (
435
+ (($turn.message.model?) | type) == "string"
436
+ and (($turn.message.model | length) <= $model_max_len)
437
+ and ($turn.message.model | test($model_regex))
438
+ ) as $model_valid
439
+ | (if $model_valid then $turn.message.model else "unknown" end) as $m
440
+ | bounded_number($raw.input_tokens?) as $input
441
+ | bounded_number($raw.output_tokens?) as $output
442
+ | bounded_number($raw.cache_creation_input_tokens?) as $cache_creation
443
+ | bounded_number($raw.cache_read_input_tokens?) as $cache_read
444
+ | ($input != null and $output != null and $cache_creation != null and $cache_read != null) as $tokens_all_valid
445
+ | (if $tokens_all_valid then
446
+ price_one($m; {input: $input, output: $output, cache_creation: $cache_creation, cache_read: $cache_read}; $registry; $has_registry; $version)
447
+ else
448
+ {estimated_cost_usd: null, pricing_version: null}
449
+ end) as $price
450
+ | bounded_number($price.estimated_cost_usd?) as $cost_bounded
451
+ | {
452
+ model: $m,
453
+ input_tokens: $input,
454
+ output_tokens: $output,
455
+ cache_creation_input_tokens: $cache_creation,
456
+ cache_read_input_tokens: $cache_read,
457
+ estimated_cost_usd: $cost_bounded,
458
+ pricing_version: $price.pricing_version
459
+ }
460
+ end
461
+ '
462
+
463
+ local out
464
+ out="$(jq -R -n -c \
465
+ --argjson registry "$registry" \
466
+ --argjson has_registry "$has_registry" \
467
+ --arg version "$version" \
468
+ --arg model_regex "$USAGE_MODEL_REGEX" \
469
+ --argjson model_max_len "$USAGE_MODEL_MAX_LEN" \
470
+ --argjson numeric_min "$USAGE_NUMERIC_MIN" \
471
+ --argjson numeric_max "$USAGE_NUMERIC_MAX" \
472
+ "$jq_program" <<<"$tail_text" 2>/dev/null)"
473
+
474
+ if [[ -z "$out" ]]; then
475
+ usage_log_debug "usage_last_turn_usage: no usage — reason: no assistant/usage entry in tail window (${transcript})"
476
+ return 1
477
+ fi
478
+
479
+ printf '%s\n' "$out"
480
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # usage_model_guard.sh — shared usage.model/usage.numeric validation constants
3
+ # (#568 slice 1 security review, HIGH + LOW findings).
4
+ #
5
+ # HIGH: a charset-only check (e.g. `^[A-Za-z0-9._:-]+$`, <=128 chars) is a
6
+ # STRICT SUPERSET of common secret/credential shapes -- an Anthropic API key
7
+ # (`sk-ant-api03-...`), an AWS access key (`AKIA...`), or a JWT (`eyJ...`,
8
+ # often with an embedded email) all satisfy that charset and would pass
9
+ # through verbatim into usage.model. A model id must instead match a small,
10
+ # known VENDOR-PREFIX ALLOWLIST (case-insensitive) and stay under a much
11
+ # tighter length bound. Sourced by BOTH usage.sh (the primary extractor,
12
+ # usage_last_turn_usage) and transport.sh (the defense-in-depth backstop,
13
+ # console_telemetry_sanitize_usage) so the pattern is defined in exactly ONE
14
+ # place -- never hand-copied into two jq programs that could drift apart.
15
+ #
16
+ # LOW: a `type=="number"` check alone accepts negative and absurd-magnitude
17
+ # values (e.g. -1, 1e308) as if they were real token counts / costs. Every
18
+ # numeric usage field must additionally satisfy a sane [min, max] bound.
19
+ # Same shared-constant discipline: defined once, used by both usage.sh and
20
+ # transport.sh.
21
+ USAGE_MODEL_MAX_LEN=40
22
+ # jq regex source. IMPORTANT: matched CASE-SENSITIVELY (plain `test($regex)`,
23
+ # NO "i" flag) at both call sites — the suffix is `[a-z0-9]` (lowercase+digit)
24
+ # ON PURPOSE, so an uppercase run (AWS `AKIA...`, mixed-case JWT `eyJ...`)
25
+ # cannot appear even when a secret is prefixed with a vendor token.
26
+ #
27
+ # The suffix is also STRUCTURED like a real version string, not free text: a
28
+ # known vendor prefix, then zero or more `[-._:]`-separated tokens each at most
29
+ # 16 chars. This closes the earlier "prefix-only allowlist" gap where a secret
30
+ # wearing a vendor prefix (`claude-UPPERCASE-REJECTED`, or a long lowercase
31
+ # token) still passed a bare `[a-z0-9._:-]*` suffix. Legitimate model ids this
32
+ # keeps matching: claude-opus-4-8, claude-3-5-sonnet-20241022, claude-fable-5,
33
+ # gpt-5, gpt-4o-2024-08-06, o1, gemini-2.0, glm-5.2. Anything else → "unknown".
34
+ #
35
+ # Residual (accepted, tracked): a caller that FULLY controls the transcript
36
+ # could still pack <=40 chars of lowercase dash-separated text into .model.
37
+ # That requires complete transcript forgery (already in-session code exec),
38
+ # .model is display-only, and the transport backstop is defense-in-depth.
39
+ USAGE_MODEL_REGEX='^(claude|gpt|o[0-9]|gemini|glm|llama|mistral|deepseek|qwen|grok|command|codestral)([._:-][a-z0-9]{1,16})*$'
40
+
41
+ USAGE_NUMERIC_MIN=0
42
+ USAGE_NUMERIC_MAX=1000000000000
@@ -294,6 +294,72 @@ add_tool_data_and_emit_delegation() {
294
294
  echo "$event"
295
295
  }
296
296
 
297
+ # add_tool_usage_data — populates .usage on tool.invoke/tool.result events
298
+ # (preToolUse/postToolUse only; see add_event_specific_data's explicit
299
+ # permissionRequest exclusion) with the model/token/cost usage of the turn
300
+ # that produced this specific tool call (#568 slice 1). Same field shape
301
+ # session.usage already emits (model, input_tokens, output_tokens,
302
+ # cache_creation_input_tokens, cache_read_input_tokens, estimated_cost_usd,
303
+ # pricing_version) so console consumers reuse the same parsing path.
304
+ #
305
+ # Fallback tiers (never invent a number):
306
+ # 1. transcript-tail join succeeds (usage_last_turn_usage) -> full usage,
307
+ # attributable to this exact turn (not the session aggregate).
308
+ # 2. transcript join fails but hook.model is present (and not "unknown")
309
+ # -> .usage.model only, every token/cost field explicitly null.
310
+ # 3. neither available -> all .usage.* fields null (today's pre-existing,
311
+ # unchanged no-data state).
312
+ add_tool_usage_data() {
313
+ local event="$1"
314
+ local transcript_path hook_model
315
+ transcript_path=$(echo "$event" | jq -r '.hook.transcript_path // ""')
316
+ hook_model=$(echo "$event" | jq -r '.hook.model // ""')
317
+
318
+ local turn_usage
319
+ turn_usage=""
320
+ if [[ -n "$transcript_path" ]]; then
321
+ turn_usage=$(usage_last_turn_usage "$transcript_path")
322
+ fi
323
+
324
+ if [[ -n "$turn_usage" ]]; then
325
+ echo "$event" | jq -c --argjson tu "$turn_usage" '. + {
326
+ usage: {
327
+ model: $tu.model,
328
+ input_tokens: $tu.input_tokens,
329
+ output_tokens: $tu.output_tokens,
330
+ cache_creation_input_tokens: $tu.cache_creation_input_tokens,
331
+ cache_read_input_tokens: $tu.cache_read_input_tokens,
332
+ estimated_cost_usd: $tu.estimated_cost_usd,
333
+ pricing_version: $tu.pricing_version
334
+ }
335
+ }'
336
+ elif [[ -n "$hook_model" && "$hook_model" != "unknown" ]]; then
337
+ echo "$event" | jq -c --arg m "$hook_model" '. + {
338
+ usage: {
339
+ model: $m,
340
+ input_tokens: null,
341
+ output_tokens: null,
342
+ cache_creation_input_tokens: null,
343
+ cache_read_input_tokens: null,
344
+ estimated_cost_usd: null,
345
+ pricing_version: null
346
+ }
347
+ }'
348
+ else
349
+ echo "$event" | jq -c '. + {
350
+ usage: {
351
+ model: null,
352
+ input_tokens: null,
353
+ output_tokens: null,
354
+ cache_creation_input_tokens: null,
355
+ cache_read_input_tokens: null,
356
+ estimated_cost_usd: null,
357
+ pricing_version: null
358
+ }
359
+ }'
360
+ fi
361
+ }
362
+
297
363
  add_stop_data_and_emit_usage() {
298
364
  local event="$1" agent_name="$2"
299
365
  local duration_s
@@ -399,7 +465,16 @@ add_event_specific_data() {
399
465
  userPromptSubmit|UserPromptSubmit)
400
466
  add_user_prompt_data "$event" "$stdin_json"
401
467
  ;;
402
- preToolUse|PreToolUse|permissionRequest|PermissionRequest|postToolUse|PostToolUse|PostToolUseFailure)
468
+ preToolUse|PreToolUse|postToolUse|PostToolUse|PostToolUseFailure)
469
+ event=$(add_tool_data_and_emit_delegation "$event" "$event_type" "$stdin_json")
470
+ if [[ "$TELEMETRY_USAGE_TRACKING" == "true" ]]; then
471
+ event=$(add_tool_usage_data "$event")
472
+ fi
473
+ echo "$event"
474
+ ;;
475
+ permissionRequest|PermissionRequest)
476
+ # Explicit scope boundary (#568 slice 1): tool.permission_request does
477
+ # NOT receive .usage enrichment — only preToolUse/postToolUse do.
403
478
  add_tool_data_and_emit_delegation "$event" "$event_type" "$stdin_json"
404
479
  ;;
405
480
  stop|Stop|SessionEnd)
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import { randomBytes } from "node:crypto";
5
4
  import { createRequire } from "node:module";
@@ -118,25 +117,19 @@ const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
118
117
  */
119
118
  function loadActorIdentityHelper(): {
120
119
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
120
+ resolveActorIdentity: (env: NodeJS.ProcessEnv) => { actor: string; source: string; actorStruct: ActorStruct | null };
121
121
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
122
122
  isUnresolvedActor: (actor: string) => boolean;
123
123
  sanitizeSegment: (value: unknown) => string;
124
- detectRuntime: (env: NodeJS.ProcessEnv) => string;
125
- runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
126
- ancestorActorSeed: () => string;
127
- detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
128
124
  } {
129
125
  const _req = createRequire(import.meta.url);
130
126
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
131
127
  return _req(helperPath) as {
132
128
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
129
+ resolveActorIdentity: (env: NodeJS.ProcessEnv) => { actor: string; source: string; actorStruct: ActorStruct | null };
133
130
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
134
131
  isUnresolvedActor: (actor: string) => boolean;
135
132
  sanitizeSegment: (value: unknown) => string;
136
- detectRuntime: (env: NodeJS.ProcessEnv) => string;
137
- runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
138
- ancestorActorSeed: () => string;
139
- detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
140
133
  };
141
134
  }
142
135
 
@@ -209,24 +202,10 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
209
202
 
210
203
  export function resolveCurrentAssignmentActor(): { actor: ActorStruct; actorKey: string } {
211
204
  const helper = loadActorIdentityHelper();
212
- const resolved = helper.resolveActor(process.env);
205
+ const resolved = helper.resolveActorIdentity(process.env);
213
206
  if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
214
- // #398: reconstruct the SAME struct resolveActor serialized for a CI actor, mirroring
215
- // resolveEnsureSessionActor (workflow-sidecar.ts) via the shared detectCiActor. Without this the
216
- // else-branch would write `record.actor = {runtime:"unknown", session_id:<the whole triple>}` for a
217
- // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
218
- // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
219
- const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
220
- const runtimeSessionId = resolved.source.startsWith("runtime-session-id") ? helper.runtimeSessionId(process.env) : "";
221
- const ancestrySeed = resolved.source === "process-ancestry" ? helper.ancestorActorSeed() : "";
222
- const actor: ActorStruct = resolved.source === "explicit-override"
223
- ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname(), human: null }
224
- : ci && ci.session_id
225
- ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
226
- : runtimeSessionId
227
- ? { runtime: helper.detectRuntime(process.env), session_id: runtimeSessionId, host: os.hostname(), human: null }
228
- : { runtime: helper.detectRuntime(process.env), session_id: ancestrySeed ? `anc-${ancestrySeed}` : resolved.actor, host: os.hostname(), human: null };
229
- return { actor, actorKey: resolved.actor };
207
+ if (!resolved.actorStruct) throw new Error("actor identity resolved without a canonical actor struct");
208
+ return { actor: { ...resolved.actorStruct, human: resolved.actorStruct.human ?? null }, actorKey: resolved.actor };
230
209
  }
231
210
 
232
211
  export function assignmentFilePath(artifactRoot: string, subjectId: string): string {