@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.
- package/.github/workflows/ci.yml +8 -0
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/build/src/cli/assignment-provider.js +4 -18
- package/build/src/cli/continuation-adapter.d.ts +10 -8
- package/build/src/cli/continuation-adapter.js +22 -4
- package/build/src/cli/validate-workflow-artifacts.js +21 -1
- package/build/src/cli/workflow-sidecar.js +15 -20
- package/build/src/cli/workflow.js +58 -11
- package/build/src/continuation-driver.d.ts +27 -3
- package/build/src/continuation-driver.js +97 -14
- package/build/src/index.d.ts +2 -2
- package/build/src/index.js +1 -1
- package/build/src/tools/build-universal-bundles.js +52 -25
- package/build/src/tools/validate-source-tree.js +5 -2
- package/context/scripts/hooks/lib/continuation-turn-authority.js +386 -0
- package/context/scripts/hooks/stop-goal-fit.js +244 -73
- package/context/scripts/hooks/workflow-steering.js +24 -15
- package/context/scripts/telemetry/lib/pricing.sh +18 -6
- package/context/scripts/telemetry/lib/transport.sh +139 -37
- package/context/scripts/telemetry/lib/usage.sh +282 -32
- package/context/scripts/telemetry/lib/usage_model_guard.sh +42 -0
- package/context/scripts/telemetry/telemetry.sh +76 -1
- package/docs/coordination-guide.md +21 -5
- package/docs/fixture-ownership.md +2 -1
- package/docs/public-workflow-cli.md +44 -0
- package/docs/spec/runtime-hook-surface.md +8 -4
- package/evals/acceptance/prove-capture-teeth-declared.sh +13 -0
- package/evals/acceptance/prove-capture-teeth.sh +13 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/codex-legacy-agents/5273878130bdafc8a024a650bb5b66c9b003f1f859b5dc6e5b588cbf4ab23228.md +25 -0
- package/evals/fixtures/telemetry/usage-transcript-adversarial.jsonl +2 -0
- package/evals/fixtures/telemetry/usage-transcript-malformed-sandwich.jsonl +3 -0
- package/evals/fixtures/telemetry/usage-transcript-negative-tokens.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-oversized-prefix.jsonl +201 -0
- package/evals/fixtures/telemetry/usage-transcript-oversized-tokens.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-lower.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-prefixed-secret-upper.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-secret-model-jwt.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-secret-model.jsonl +1 -0
- package/evals/fixtures/telemetry/usage-transcript-torn-trailing.jsonl +2 -0
- package/evals/integration/test_actor_identity.sh +63 -0
- package/evals/integration/test_assignment_provider_local_file.sh +34 -0
- package/evals/integration/test_builder_entry_enforcement.sh +5 -1
- package/evals/integration/test_bundle_install.sh +106 -17
- package/evals/integration/test_bundle_lifecycle.sh +17 -3
- package/evals/integration/test_captured_fail_reconciliation.sh +11 -0
- package/evals/integration/test_ci_actor_identity.sh +2 -2
- package/evals/integration/test_command_log_concurrency.sh +11 -0
- package/evals/integration/test_command_log_fork_classification.sh +11 -0
- package/evals/integration/test_command_log_integrity.sh +11 -0
- package/evals/integration/test_current_json_per_actor.sh +416 -21
- package/evals/integration/test_enforcer_expects_driven.sh +11 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +39 -0
- package/evals/integration/test_evidence_capture_hook.sh +25 -6
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flowdef_union_floor_regression.sh +11 -0
- package/evals/integration/test_gate_bypass_chain.sh +11 -0
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_goal_fit_ghost_session.sh +11 -0
- package/evals/integration/test_goal_fit_hook.sh +257 -9
- package/evals/integration/test_goal_fit_rederive.sh +11 -0
- package/evals/integration/test_install_merge.sh +141 -3
- package/evals/integration/test_liveness_conflict_injection.sh +39 -3
- package/evals/integration/test_liveness_heartbeat.sh +46 -9
- package/evals/integration/test_phase_map_and_gate_claim.sh +10 -1
- package/evals/integration/test_public_workflow_cli.sh +6 -6
- package/evals/integration/test_session_resume_roundtrip.sh +30 -0
- package/evals/integration/test_stop_hook_release.sh +111 -11
- package/evals/integration/test_telemetry_tool_usage.sh +394 -0
- package/evals/integration/test_verify_hold.sh +64 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +10 -1
- package/evals/integration/test_workflow_steering_hook.sh +20 -4
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +123 -0
- package/package.json +1 -1
- package/packaging/README.md +21 -0
- package/packaging/codex-legacy-agents-fingerprints.json +33 -0
- package/scripts/README.md +2 -1
- package/scripts/audit-codex-legacy-agents.js +43 -0
- package/scripts/classify-codex-legacy-agents.js +71 -0
- package/scripts/hooks/evidence-capture.js +18 -6
- package/scripts/hooks/lib/actor-identity.js +62 -29
- package/scripts/hooks/lib/continuation-turn-authority.js +386 -0
- package/scripts/hooks/lib/current-pointer.js +114 -12
- package/scripts/hooks/lib/liveness-heartbeat.js +15 -12
- package/scripts/hooks/stop-goal-fit.js +244 -73
- package/scripts/hooks/workflow-steering.js +24 -15
- package/scripts/install-codex-home.sh +8 -7
- package/scripts/telemetry/lib/pricing.sh +18 -6
- package/scripts/telemetry/lib/transport.sh +71 -0
- package/scripts/telemetry/lib/usage.sh +282 -32
- package/scripts/telemetry/lib/usage_model_guard.sh +42 -0
- package/scripts/telemetry/telemetry.sh +76 -1
- package/src/cli/assignment-provider.ts +5 -26
- package/src/cli/builder-flow-runtime.test.mjs +93 -6
- package/src/cli/continuation-adapter.ts +33 -7
- package/src/cli/continuation-driver.test.mjs +741 -2
- package/src/cli/public-api.test.mjs +1 -0
- package/src/cli/validate-workflow-artifacts.ts +19 -1
- package/src/cli/workflow-sidecar.ts +18 -23
- package/src/cli/workflow.ts +71 -11
- package/src/continuation-driver.ts +125 -19
- package/src/index.ts +4 -0
- package/src/tools/build-universal-bundles.ts +61 -25
- 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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
| (
|
|
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
|