Skip to content

fix(logs): sanitize the shadow helper marker at the logging layer - #2166

Merged
lidge-jun merged 26 commits into
devfrom
codex/absorb-shadow-helper-attribution
Aug 20, 2026
Merged

fix(logs): sanitize the shadow helper marker at the logging layer#2166
lidge-jun merged 26 commits into
devfrom
codex/absorb-shadow-helper-attribution

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Summary

Absorbs #2163 by @Ingwannu. Stack layer 6 — base is codex/absorb-openai-chat-padding-repeats (#2165), not dev.

What it fixes. When the opt-in shadow-call route intercepts a Codex App helper request and rewrites it to an operator-selected model, the original helper model was lost. shadowCallRewrittenFrom was written through an untyped cast at core.ts, but RequestLogContext/RequestLogEntry never declared it and addFinalRequestLog rebuilds its row field by field — so the marker reached neither /api/logs nor usage.jsonl, and could not survive restart hydration. Recurring helper spend showed up as an unattributable row (#2157).

@Ingwannu's typing, persistence projection, and hydration work is carried unchanged in principle. Both of the author's commits are preserved.

The correction. The original sanitizes at the one call site that populates the field today, which leaves the in-memory /api/logs row carrying whatever the caller sent. The value originates in an upstream-supplied model id, so an unsanitized newline lets a single field forge a record boundary in any line-oriented log viewer, and nothing bounded its length on that path. addFinalRequestLog now runs it through sanitizeLogMetadataString itself — a future caller cannot reintroduce the hole by forgetting to sanitize first, and the in-memory row matches what usage.jsonl already stored.

Scope note. This is the backend attribution half. The dashboard does not yet render or filter on the field (gui/src/pages/Logs.tsx), so #2157 stays open until that lands — closing it on this PR would claim a UI affordance that does not exist.

Closes #2163.

Verification

  • bun run typecheck — clean.
  • bun test --isolate tests/request-log.test.ts -t 'shadow' — 2 pass / 0 fail.
  • RED-first. Reverting the implementation leaves the author's persistence test green and fails the new sanitizer test (1 pass / 1 fail). That split is the evidence: the original fixture is a safe short slug, so it passes identically whether the sanitizer runs or not. The new one writes an unsafe overlong marker and asserts the newline is gone and the 64-character bound holds on both paths.
  • bun run test at this tip — 13564 pass / 10 skip / 0 fail across 857 files.
  • bun run privacy:scan — passed.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Only a model-id scalar is stored, through the shared control-stripping, secret-redacting, 64-character sanitizer. No request content is logged.


Stack map

Merge bottom-up; each layer's base is the branch below it.

# PR Branch Absorbs
1 #2134 codex/fix-subagent-roster-truncation maintainer fix (base dev)
2 #2160 codex/absorb-opencode-free-static-headers #2067 @waw4303
3 #2162 codex/absorb-agentrouter-language-framing #2082 @yzxcj797
4 #2164 codex/absorb-opencode-go-quota-siblings #2027 @yzxcj797
5 #2165 codex/absorb-openai-chat-padding-repeats #2155 @waw4303
6 this codex/absorb-shadow-helper-attribution #2163 @Ingwannu

No file overlap with any layer below — this sits at the top for a single clean chain, not because the code requires it.

Summary by CodeRabbit

  • New Features

    • Shadow-call rewrites now record the original helper model in usage and request logs.
    • This metadata persists across restarts without retaining request content.
  • Bug Fixes

    • Log metadata is sanitized consistently before being stored, preventing newline-based log injection.
    • Rewritten model identifiers are bounded and safely redacted.
  • Documentation

    • Documented opt-in shadow-call rewrite tracking and its privacy safeguards.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 20, 2026 01:32
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 945cb140-52fb-4335-aab6-e9586d9d27b5

📥 Commits

Reviewing files that changed from the base of the PR and between 41689b3 and d1f0c43.

📒 Files selected for processing (7)
  • devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md
  • src/server/request-log.ts
  • src/server/responses/core.ts
  • src/usage/log.ts
  • structure/05_gui-and-management-api.md
  • tests/request-log.test.ts
  • tests/responses-shadow-intercept.test.ts

📝 Walkthrough

Walkthrough

The change records the original helper model for opt-in shadow-call rewrites. It adds sanitization across request logs and usage persistence, restores the field after restart, extends tests, updates usage documentation, and records residual backlog dispositions.

Changes

Shadow-call source logging

Layer / File(s) Summary
Logging contract and persistence
src/server/request-log.ts, src/usage/log.ts
Adds shadowCallRewrittenFrom to request-log and persisted-usage types. Sanitizes the value before in-memory retention and JSONL persistence. Restores it during hydration.
Rewrite propagation and validation
src/server/responses/core.ts, tests/request-log.test.ts, tests/responses-shadow-intercept.test.ts
Passes the original model through shadow-call interception. Tests newline and length sanitization, persistence, projection, and restart hydration.
Usage and backlog documentation
structure/05_gui-and-management-api.md, devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md
Documents the persisted shadow-call marker and records the remaining backlog dispositions and PR stack state.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: ingwannu

Sequence Diagram(s)

sequenceDiagram
  participant ShadowCallRewrite
  participant handleResponses
  participant addFinalRequestLog
  participant UsageLog
  ShadowCallRewrite->>handleResponses: original helper model in RequestLogContext
  handleResponses->>addFinalRequestLog: shadowCallRewrittenFrom
  addFinalRequestLog->>UsageLog: sanitized usage metadata
  UsageLog-->>addFinalRequestLog: persisted marker available after hydration
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/absorb-shadow-helper-attribution

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 20, 2026

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logging-layer direction is correct, but one public ingress still bypasses the new boundary on the current head (70b947550).

addRequestLog(entry) calls retainRequestLogEntry(entry) before rebuilding the persisted row. A direct caller can therefore place the raw shadowCallRewrittenFrom value in the in-memory /api/logs ring, while appendUsageEntry() sanitizes only the disk copy. I reproduced this at the exact head: control/secret-shaped content remained in memory at length 111, the persisted value was sanitized to length 37, and the two values differed. The focused tests/request-log.test.ts suite still passes (51/51), so the existing tests do not cover this ingress.

Please sanitize a copied entry at the start of addRequestLog, then use that same sanitized entry for both retainRequestLogEntry and persistence. Add a direct-addRequestLog regression covering control characters, secret-shaped content, the 64-character cap, and memory/disk equality. Keeping the normalization at this shared ingress will also protect relay and future direct callers, instead of relying on addFinalRequestLog alone.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 52 / 80

#2157 백엔드 반쪽임. 섀도 인터셉트가 헬퍼를 다른 모델로 바꿔도 shadowCallRewrittenFromRequestLogContext에 타입도 없고 addFinalRequestLog가 필드를 다시 조립해서 /api/logsusage.jsonl에 안 남음. 재시작 하이드레이션도 없음. luna 헬퍼 비용이 그냥 행으로 보임.

#2163을 가져옴. 원본은 넣는 한 군데만 sanitize. 메모리 로그는 호출자가 준 값을 그대로 둠. 업스트림 모델 id라 개행이 라인 로그를 쪼갤 수 있음. 길이도 없음. 이번엔 addFinalRequestLogsanitizeLogMetadataString을 탐. 미래 호출자가 빼먹어도 구멍 안 열림. 메모리 행이랑 jsonl이 같음.

대시보드 gui/src/pages/Logs.tsx는 아직 이 필드를 안 그림. #2157을 여기서 닫으면 UI가 있는 척임. 본문이 그거 말함. 맞음. 열어두기. 스택 레이어 6. 베이스 #2165. types.ts/config.ts 안 건드림. #2163 리베이스하지 말 것.

점수는 52임. 라벨이 있어야 필터가 됨. 2.28. UI 후속 필요.

해결방안: #2165 다음. #2157은 Logs 필터 붙이면 닫기. 개행/64자 테스트 유지.

이 댓글은 grok-bot이 작성했습니다

@Ingwannu

Copy link
Copy Markdown
Owner

Current head is still 70b947550; no code changed after the changes-requested review.

The statement that the in-memory row and usage.jsonl row are identical is currently true only for callers that enter through addFinalRequestLog. The exported direct addRequestLog(entry) path still retains the caller-provided entry first and sanitizes only the later persistence copy, which is the exact memory/disk mismatch reproduced in the review.

The review remains blocked until the copied entry is sanitized at the start of addRequestLog, before retainRequestLogEntry, and that direct ingress has a regression asserting control/secret redaction, the 64-character cap, and memory/disk equality.

lidge-jun and others added 22 commits August 20, 2026 11:33
…abled

GET /api/subagent-models built `available` purely from currently-pickable
models, so a featured model disabled elsewhere vanished from it. The dashboard
filters `chosen` against `available` and then PUTs exactly the rows it holds,
which turned a hide into a delete: the next Save wrote the truncated roster to
config.json, and the user read it as "ocx service lost my subagent models".

Retain a chosen id in `available` when it is not otherwise selectable, appended
after the selectable set and deduplicated. Models that are disabled and NOT in
the roster stay excluded, so the picker behavior is unchanged for every model
the user has not deliberately featured.

The combo test asserted the old truncating behavior; it now asserts retention
while a roster slot is held, and full exclusion once the slot is released.

Closes #2133
…claims

opencode-free sent no User-Agent, so Zen saw the bare runtime default
(Bun/x.y.z) and rate-limited it harder than a client that identifies
itself. Adds "User-Agent: opencode" alongside the existing
x-opencode-client: desktop marker.

The value is deliberately unversioned. OmniRoute, an independent
open-source broker against the same Zen upstream, defaults to exactly
this pair and reached it by retreating from its own earlier
opencode-cli/1.0.0 pin: a pinned version is a claim about an install we
do not have, and it goes stale on the vendor's schedule.

The registry edit alone would have shipped to nobody. staticHeaders is
documented as merged into every upstream request, but it was only ever
copied at seed time, so any config written before a header existed --
or carrying any header of its own -- never received it.
routedProviderConfig and buildModelsRequest now fill registry static
headers beneath user headers, matched case-insensitively so an override
replaces rather than duplicates: spreading "User-Agent" over a user's
"user-agent" leaves both keys, which Headers serializes as one
comma-joined value.

Model discovery gets the same treatment because a provider identified
as opencode when it completes but anonymous when it lists its own
models reads as two different clients to a rate limiter.
…non-English

AgentRouter answers 400 content-blocked when the first user message is
not in English (#2074) while the identical English request returns 200.
The gateway inspects the opening user content, so an Anthropic system
string never reaches the filter -- the framing has to sit in that turn.

Two corrections on top of @yzxcj797's #2082.

The host test was hostname.includes("agentrouter"), which also matches
notagentrouter.example and agentrouter.org.attacker.example. A prompt
mutation keyed on a provider's identity has to be keyed on that identity
exactly, so this matches agentrouter.org or a real subdomain of it.

The original spliced the marker into the user's own string. That edits
what the user wrote: logs, retries, and any upstream echo then show a
sentence the user never typed as if they had. The framing is now its own
leading text block, so the original text survives byte-for-byte.

Idempotence is keyed on the leading block being exactly the marker
rather than a substring test, so a user who quotes the marker later in
their prompt does not suppress their own framing.
…t shape

Review on #2162 asked for the three branches that are the actual safety
boundary here: this code writes into the outbound first user turn, so
"does it ever duplicate, drop, or reorder what the caller sent" has to be
pinned per content shape rather than only for a plain string.

Adds: an already-framed turn stays single and ordered; image-only content
keeps its image block behind the preamble rather than losing or
reordering it; assistant-only block content keeps its tail and is
followed by the synthesized [PREAMBLE, "(continue)"] user turn.

All three pass against the existing implementation, which is the point --
they are guards on a prompt mutation, not a fix.
…amed

A multi-account setup points several provider rows at the same OpenCode
Go endpoint under names the registry has never heard of --
opencode-go-2 through -5. Quota dispatch gated on the literal name
"opencode-go", so those rows had no dashboard quota panel and no report
in `ocx provider quota --refresh --json` even though each one holds a
working key for the same upstream (#1924).

Identity is now answered by registryEntryForProviderDestination, the
predicate this repository already uses for renamed fixed-key rows: it
matches on normalized endpoint plus adapter plus key auth. A bare URL
comparison would have been enough for the reported symptom but would
also probe a row that points at that host through a different adapter,
which speaks a different protocol and is not the provider whose quota
shape we parse.

The defensive canonical-URL check inside fetchOpenCodeGoQuota stays.
Whether an API key may be sent to a host must not depend on the dispatch
gate above it being correct.

Absorbed from #2027 by @yzxcj797.
…d is known

Some OpenAI-compatible streamers repeat an already-sent id, name, or
arguments as a non-string placeholder on a continuation delta rather than
as null. Validation ran before the pending-call lookup, so the whole turn
died with a 502 and the tool never ran -- even though the value being
repeated was already held in canonical form.

The lookup now happens first and tolerance is per field, keyed on that
field's own provenance. Two corrections on top of @waw4303's #2155.

It gated arguments acceptance on the call having a canonical NAME. A name
says nothing about whether arguments was ever sent as a string, so a real
argument payload could be silently dropped. PendingToolCall now carries
sawArgumentsString; an empty string counts, because it proves the upstream
sent the field with the right wire type.

It also left a non-string repeated id unconditionally terminal even after
a canonical id was stored. Ids now follow the same rule as the other two.

Diagnostics are passed from the rejection site instead of rescanned.
A stateless rescan stops at the first structurally odd value, so a stream
carrying accepted padding on call 0 and a real defect on call 1 blamed
call 0.
Builds on @Ingwannu's #2163, which types and persists
shadowCallRewrittenFrom so an intercepted helper request keeps its
original model in usage.jsonl and across restart hydration.

The original sanitized the value at the single call site that populates
it today, which left the in-memory /api/logs row carrying whatever the
caller sent. The marker originates in an upstream-supplied model id, so
an unsanitized newline lets one field forge a record boundary in any
line-oriented log viewer, and nothing bounded its length on that path.

addFinalRequestLog now runs it through sanitizeLogMetadataString itself.
A future caller cannot reintroduce the hole by forgetting to sanitize
first, and the in-memory row matches what usage.jsonl already stored.

The added regression writes an unsafe overlong marker and asserts the
newline is gone and the 64-character bound holds on both paths. The
original test used a safe short slug, so it passed identically whether
the sanitizer ran or not.
@lidge-jun
lidge-jun force-pushed the codex/absorb-shadow-helper-attribution branch from 70b9475 to 70cebd4 Compare August 20, 2026 02:55
@lidge-jun
lidge-jun changed the base branch from codex/absorb-openai-chat-padding-repeats to dev August 20, 2026 02:56
…lper-attribution

# Conflicts:
#	devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md
#	tests/anthropic-agentrouter-language-framing.test.ts
@lidge-jun
lidge-jun merged commit 5fbe655 into dev Aug 20, 2026
19 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants