@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
@@ -46,6 +49,66 @@ console_telemetry_timeout_seconds() {
46
49
  fi
47
50
  }
48
51
 
52
+ # console_post_json <endpoint_url> <body> [connect_timeout] [max_time] [tmp_dir]
53
+ # Shared best-effort JSON POST to a Console records endpoint. BOTH the telemetry mirror
54
+ # (console_telemetry_emit) and the liveness relay (#295, scripts/liveness/relay.sh) go through this
55
+ # ONE core so endpoint-allow, auth (Bearer + tenant), timeouts, temp-file handling, and the detached
56
+ # fire can never drift between the two paths (the #356 shared-not-forked discipline). Reads
57
+ # CONSOLE_TELEMETRY_TOKEN / CONSOLE_TENANT_ID from the environment. Never blocks or fails the caller.
58
+ console_post_json() {
59
+ local endpoint_url="$1"
60
+ local body="$2"
61
+ local connect_timeout max_time tmp_dir curl_config curl_body
62
+ [[ -z "$endpoint_url" ]] && return
63
+ if ! console_telemetry_endpoint_allowed "$endpoint_url"; then
64
+ # Dropped silently before today's change (no signal that the event never
65
+ # left the machine). Warn once per shell process (guarded by a plain,
66
+ # non-local var so it survives across repeated console_post_json calls
67
+ # within the same sourced shell) without changing the drop itself.
68
+ if [[ -z "${_CONSOLE_TELEMETRY_ENDPOINT_WARNED:-}" ]]; then
69
+ printf 'warning: transport.sh: console endpoint dropped by the allowlist (must be https://, or http://localhost|127.0.0.1): %s\n' "$endpoint_url" >&2
70
+ _CONSOLE_TELEMETRY_ENDPOINT_WARNED=1
71
+ fi
72
+ return
73
+ fi
74
+ connect_timeout=$(console_telemetry_timeout_seconds "${3:-2}" 2 30)
75
+ max_time=$(console_telemetry_timeout_seconds "${4:-5}" 5 60)
76
+ tmp_dir="${5:-${TELEMETRY_SESSION_DIR:-${TMPDIR:-/tmp}}}"
77
+ curl_config=$(mktemp "${tmp_dir%/}/console-curl.XXXXXX") || return
78
+ curl_body=$(mktemp "${tmp_dir%/}/console-body.XXXXXX") || {
79
+ rm -f "$curl_config"
80
+ return
81
+ }
82
+ chmod 600 "$curl_config" "$curl_body" 2>/dev/null
83
+ printf '%s' "$body" > "$curl_body" || {
84
+ rm -f "$curl_config" "$curl_body"
85
+ return
86
+ }
87
+
88
+ {
89
+ printf 'url = "%s"\n' "$endpoint_url"
90
+ printf 'request = "POST"\n'
91
+ printf 'connect-timeout = "%s"\n' "$connect_timeout"
92
+ printf 'max-time = "%s"\n' "$max_time"
93
+ printf 'header = "Content-Type: application/json"\n'
94
+ if [[ -n "${CONSOLE_TELEMETRY_TOKEN:-}" ]] && console_telemetry_safe_token "$CONSOLE_TELEMETRY_TOKEN"; then
95
+ printf 'header = "Authorization: Bearer %s"\n' "$CONSOLE_TELEMETRY_TOKEN"
96
+ fi
97
+ if [[ -n "${CONSOLE_TENANT_ID:-}" ]] && console_telemetry_safe_tenant "$CONSOLE_TENANT_ID"; then
98
+ printf 'header = "x-console-tenant-id: %s"\n' "$CONSOLE_TENANT_ID"
99
+ fi
100
+ printf 'data-binary = "@%s"\n' "$curl_body"
101
+ } > "$curl_config" || {
102
+ rm -f "$curl_config" "$curl_body"
103
+ return
104
+ }
105
+
106
+ (
107
+ curl -s --proto =https,http --proto-redir =https,http --config "$curl_config" >/dev/null 2>&1
108
+ rm -f "$curl_config" "$curl_body"
109
+ ) &
110
+ }
111
+
49
112
  # Derive a coarse, path-free project label for console attribution, most-stable-first so the SAME
50
113
  # project resolves to the SAME label across developers and machines (folder names differ between
51
114
  # clones and worktrees; the project manifest and git remote do not). Precedence:
@@ -118,12 +181,79 @@ console_project_label() {
118
181
  printf '%s' "$label"
119
182
  }
120
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
+
121
245
  console_telemetry_emit() {
122
246
  local event="$1"
123
247
  local endpoint_url
124
248
  endpoint_url=$(console_telemetry_endpoint_url)
125
249
  [[ -z "$endpoint_url" ]] && return
126
- console_telemetry_endpoint_allowed "$endpoint_url" || return
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"
127
257
 
128
258
  # Attribution: stamp a coarse, path-free project label (see console_project_label) before redaction
129
259
  # so the console buckets by project consistently across developers. The full context.cwd is still
@@ -143,42 +273,14 @@ console_telemetry_emit() {
143
273
  local processed_event
144
274
  processed_event=$(redact_event "$event" "${CONSOLE_TELEMETRY_REDACT:-${TELEMETRY_CHANNEL_ANALYTICS_REDACT:-}}")
145
275
 
146
- local curl_config curl_body connect_timeout max_time
147
- connect_timeout=$(console_telemetry_timeout_seconds "${CONSOLE_TELEMETRY_CONNECT_TIMEOUT_SECONDS:-2}" 2 30)
148
- max_time=$(console_telemetry_timeout_seconds "${CONSOLE_TELEMETRY_MAX_TIME_SECONDS:-5}" 5 60)
149
- curl_config=$(mktemp "${TELEMETRY_SESSION_DIR}/console-curl.XXXXXX") || return
150
- curl_body=$(mktemp "${TELEMETRY_SESSION_DIR}/console-body.XXXXXX") || {
151
- rm -f "$curl_config"
152
- return
153
- }
154
- chmod 600 "$curl_config" "$curl_body" 2>/dev/null
155
- printf '%s' "$processed_event" > "$curl_body" || {
156
- rm -f "$curl_config" "$curl_body"
157
- return
158
- }
159
-
160
- {
161
- printf 'url = "%s"\n' "$endpoint_url"
162
- printf 'request = "POST"\n'
163
- printf 'connect-timeout = "%s"\n' "$connect_timeout"
164
- printf 'max-time = "%s"\n' "$max_time"
165
- printf 'header = "Content-Type: application/json"\n'
166
- if [[ -n "${CONSOLE_TELEMETRY_TOKEN:-}" ]] && console_telemetry_safe_token "$CONSOLE_TELEMETRY_TOKEN"; then
167
- printf 'header = "Authorization: Bearer %s"\n' "$CONSOLE_TELEMETRY_TOKEN"
168
- fi
169
- if [[ -n "${CONSOLE_TENANT_ID:-}" ]] && console_telemetry_safe_tenant "$CONSOLE_TENANT_ID"; then
170
- printf 'header = "x-console-tenant-id: %s"\n' "$CONSOLE_TENANT_ID"
171
- fi
172
- printf 'data-binary = "@%s"\n' "$curl_body"
173
- } > "$curl_config" || {
174
- rm -f "$curl_config" "$curl_body"
175
- return
176
- }
177
-
178
- (
179
- curl -s --proto =https,http --proto-redir =https,http --config "$curl_config" >/dev/null 2>&1
180
- rm -f "$curl_config" "$curl_body"
181
- ) &
276
+ # Delegate the endpoint-allow gate, auth, timeouts, temp files, and detached POST to the shared
277
+ # core. Timeouts/tmp-dir are passed explicitly so telemetry behavior is byte-for-byte unchanged.
278
+ console_post_json \
279
+ "$endpoint_url" \
280
+ "$processed_event" \
281
+ "${CONSOLE_TELEMETRY_CONNECT_TIMEOUT_SECONDS:-2}" \
282
+ "${CONSOLE_TELEMETRY_MAX_TIME_SECONDS:-5}" \
283
+ "${TELEMETRY_SESSION_DIR:-}"
182
284
  }
183
285
 
184
286
  transport_emit() {
@@ -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