Skip to content

Commit 775eddd

Browse files
thymikeeclaude
andauthored
feat: session-scoped echo protection for parameterized recorded inputs (#2013)
* feat: session-scoped echo protection for parameterized recorded inputs Extends ADR 0017's fill-step-scoped guarantee to the whole recording session (#1398). After #1349, a later read-only action (`wait`, `is`, `get`) can independently observe and record an app-rendered echo of an already-parameterized `fill --record-as` value in its own result or target-v1 identity evidence, re-leaking the literal even though the originating fill was protected. - SessionState gains a small, ephemeral, never-serialized literal->placeholder registry populated only from explicit `--record-as` pairs, owned by session-action-recorder.ts. - Result/event payload fields get content-aware substring redaction (reusing the fill boundary's recursive scrub) for every literal registered so far in the session, longest-literal-first. - target-v1/targets-v1 identity evidence is never silently text-substituted while still claiming a trustworthy identity (replay compares against the live tree, which re-renders the real value). A landmark-mode (wait) echo is dropped to no annotation, exactly like #1349's existing identity-empty case, so an echoing landmark can no longer serve as an ADR 0016 destination guard. Action-mode evidence (get/is/mutating actions) redacts the label and downgrades verification to "unverifiable" instead, since ADR 0012/0016 forbid dropping required identity evidence. - Amends ADR 0017 (new mechanism), ADR 0012 (#1349/writer-invariant cross-references), and ADR 0016 (destination guard cross-reference). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui * fix: placeholder-safe single-pass multi-literal redaction Addresses review feedback on #2013: sequential single-literal replacement (register somethinglong -> ${ABC}, then ABC -> ${OTHER}) could rewrite a placeholder produced by an earlier pass, corrupting it to ${${OTHER}}. Replaces the per-pair sequential loop with one placeholder-safe left-to-right multi-literal pass (parameterizeAgainstLiteralMap): it never re-scans text it has already emitted, so no literal can be matched inside another pair's placeholder token in either direction. A registered literal is matched before checking for an existing placeholder token, so a value that itself happens to look like ${SOMETHING} is still redacted correctly. The scan uses a sticky regex instead of slicing per character, and literal pairs are sorted once per payload/evidence walk instead of once per string leaf. parameterizeRecordedFillPayload/parameterizeBackendOutput are generalized to take injected leaf-transform/carries callbacks so the single-pair fill-boundary path (with its existing whitespace-collapse behavior) and the new multi-pair session-wide path share one structural traversal. Adds regression coverage for both result payloads and action-mode target evidence, plus the placeholder-shaped-literal edge case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui * fix: unexport parameterizeAgainstLiteralMap (CI: fallow dead-code gate) Only used internally within this file (by parameterizeRecordedResultEcho and parameterizeTargetEvidenceEcho); the export had no consumer outside the module, which the fallow audit correctly flags as dead code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RarRVX34ZW25TJejBZJ2Ui --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50f460c commit 775eddd

12 files changed

Lines changed: 1076 additions & 43 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
- Parameterized `fill --record-as` protection is now recording-session-scoped instead of
6+
fill-step-scoped (ADR 0017 amendment): a later, unrelated recorded action (`wait`, `is`, `get`) can no
7+
longer re-serialize an app-rendered echo of an already-parameterized value into its own result or
8+
`target-v1` identity evidence. An echoing `wait` landmark no longer qualifies as an ADR 0016
9+
destination guard, so `session save-script` refuses it and directs the author to a stable landmark
10+
instead of silently publishing the secret. The protection uses one small, explicit, ephemeral,
11+
never-serialized per-session map populated only from values the author already opted to parameterize;
12+
ordinary non-parameterized recordings are unaffected (#1398).
513
- Android covered-state publication now has one owner: same-window surfaces remain visible for
614
diagnosis, while the daemon marks exactly ordered covered controls non-actionable with
715
`interactionBlocked: "covered"`. Helper-only `drawing-order` stays private rather than entering

docs/adr/0012-interactive-replay.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ in force, a parent-only payload fits arithmetically, so the downgrade branch is
188188
not an expected path. The record-time self-check (step 5 below) runs against the reduced tuple, so a
189189
`verified` claim is always honest for exactly what was written.
190190

191+
> **Amendment (#1398, ADR 0017):** action-mode evidence gains a second, independent cause for this same
192+
> `"unverifiable"` downgrade — a matched node whose label carries an app-rendered echo of a literal the
193+
> recording session already parameterized. Unlike the size-overflow case, the label is also redacted to
194+
> its placeholder before the downgrade, so the literal itself never reaches the payload either. Landmark
195+
> mode (`wait`) does not use this downgrade; it drops the annotation entirely (see the #1349 amendment
196+
> above). See ADR 0017's session-scoped echo protection amendment for the full mechanism.
197+
191198
**Local identity.** Two nodes share local identity when both carry `id` and the normalized ids are equal;
192199
or, when the recording carries no `id`, when their normalized roles are equal and their normalized labels
193200
are equal (label absent on both sides counts as equal; label present on exactly one side is a mismatch).
@@ -266,6 +273,11 @@ A recorded `id` never matches a node without that id.
266273
> identity is near-vacuous, and an unannotated wait keeps its existing selector-existence semantics
267274
> instead of failing closed on evidence that never discriminated anything. ADR 0016's destination
268275
> guard consumes exactly this: a qualifying guard is a selector wait with a `verified` annotation.
276+
> **Amendment (#1398, ADR 0017):** the same no-annotation outcome now also fires when the matched
277+
> node's identity carries an app-rendered echo of a literal the recording session already
278+
> parameterized via `fill --record-as` — a placeholder written into a recorded label could never
279+
> verify against the live tree's real value at replay time, so the evidence is dropped rather than
280+
> published unverified. See ADR 0017's session-scoped echo protection amendment for the mechanism.
269281
> - **`get` — unchanged**; already covered by the pre-dispatch path and the post-resolution guard.
270282
> - **`is` (all predicates except `exists`) — covered, `pre-dispatch`, the `get` pattern end-to-end.**
271283
> `is` resolves a unique node immediately, so pre-action verification is semantically valid; the

docs/adr/0016-active-session-script-publication.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ Normative summary; the binding contracts and refusal cases are in [Decision](#de
2626
what lets a one-shot client keep the daemon alive on a close-less handoff.
2727
- Sensitive `fill` inputs must be recorded as placeholders via `fill --record-as <VAR>`
2828
(ADR 0017, shipped for #1348); unparameterized `fill`/`type` values persist literally into the
29-
artifact, so a secret entered without `--record-as` is published.
29+
artifact, so a secret entered without `--record-as` is published. The protection is
30+
recording-session-scoped (ADR 0017's #1398 amendment): a later action's own recorded evidence can
31+
never re-serialize an app-rendered echo of an already-parameterized value either.
3032

3133
## Context
3234

@@ -186,6 +188,13 @@ record one. V1 does not infer a screen identity from a snapshot or synthesize an
186188
> `identity-mismatch` `REPLAY_DIVERGENCE` before the wait reports success. The reshuffled-screen
187189
> false-pass below is covered by a provider-scenario regression (record → publish → replay against a
188190
> reshuffled tree whose same-label node sits under a different id/ancestry).
191+
>
192+
> **Amendment (#1398, ADR 0017).** An identity-empty landmark is not the only case that fails to
193+
> qualify: a selector wait whose only identity is an app-rendered echo of a literal the recording
194+
> session already parameterized via `fill --record-as` also records no annotation (ADR 0017's
195+
> session-scoped echo protection), so it does not qualify as a destination guard either. Publication
196+
> refuses it with the same recovery hint, directing the author to a stable, non-value-bearing landmark
197+
> — an enforced version of exactly the fix issue #1398's motivating scenario applied by hand.
189198
190199
The original selector-level caveat, retained as context: the guard proved that an element matching its
191200
selector exists, not that it is the same landmark element observed while authoring, so a reshuffled
@@ -229,6 +238,11 @@ This is opt-in and fill-only. Unparameterized fill/type inputs remain literal ar
229238
authors must use ADR 0017 for each sensitive fill and avoid secret-bearing `type` steps. CLI help states
230239
both the safe workflow and the remaining literal-input warning next to publication guidance.
231240

241+
ADR 0017's #1398 amendment extends this protection to the whole recording session: a later, unrelated
242+
recorded action can no longer re-serialize an app-rendered echo of an already-parameterized literal into
243+
its own result or `target-v1` evidence, so a value protected once by `--record-as` stays protected for
244+
every action recorded after it in the same session, not only its own originating fill.
245+
232246
### Artifact contract
233247

234248
The published `.ad`:

docs/adr/0017-parameterized-recorded-inputs.md

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,99 @@ or whitespace. If the replay is itself being recorded or repaired, the recorder
8686
original placeholder rather than serializing the expanded value. Embedded interpolation remains
8787
ordinary script input; safe authoring emits one complete placeholder token.
8888

89+
### Session-scoped echo protection (amendment, #1398)
90+
91+
> **Status: accepted.** Amends the data-flow boundary above from fill-step-scoped to
92+
> recording-session-scoped, per issue [#1398](https://github.com/callstack/agent-device/issues/1398).
93+
94+
The boundary above protects only the originating fill's own request/recording path. After
95+
[#1349](https://github.com/callstack/agent-device/issues/1349), a later read-only action —
96+
`wait`'s landmark-mode evidence, `is`, `get` — records `target-v1` identity evidence and a result
97+
payload independently, computed from whatever the app renders at that later step. That capture has no
98+
memory of an earlier fill's literal, so an app-rendered echo of it (the filled field's own displayed
99+
value; a search result, validation message, or confirmation label containing it; a destination landmark
100+
whose accessible label includes it) can re-enter session state and publication through a completely
101+
different, unparameterized action — even though the originating fill was parameterized.
102+
103+
**The guarantee is now recording-session-scoped, not fill-step-scoped.** For the lifetime of one
104+
recording session, no later recorded action's own result payload or `target-v1`/`targets-v1` identity
105+
evidence may re-serialize an app-rendered echo of a literal the session already parameterized.
106+
107+
The mechanism is the smallest explicit, ephemeral state that can recognize a later echo:
108+
`SessionState.recordedFillLiterals`, an in-memory `Map<literal, placeholder>` populated only by the SAME
109+
(literal, placeholder) pair a `fill --record-as` entry already computes for its own boundary above — one
110+
entry per parameterized fill, added only after that fill's own entry has been recorded. It is never
111+
serialized (not to the script, the session event log, or diagnostics), has no read API beyond the
112+
recorder that owns it, and disappears with the session.
113+
114+
This is not a reversal of the "no secret-to-name table" rule in the Mapping contract above. That rule
115+
rejects retaining state to influence a *naming* decision — comparing a new `--record-as` value against
116+
prior ones to infer or deduplicate a variable name. `recordedFillLiterals` never informs a naming
117+
decision: every entry's placeholder is exactly the name the author already chose, and the map is
118+
consulted only to recognize that same, already-named value reappearing in unrelated later evidence — a
119+
redaction lookup, not a secret registry with a naming or comparison API.
120+
121+
**Two treatments, by data class:**
122+
123+
- **Result/event/backend-output fields** (display data, never compared at replay): content-aware
124+
substring redaction, reusing the same recursive backend-output scrub the fill boundary already applies
125+
to its own entry, generalized over every literal registered so far in the session. The substitution
126+
itself is one placeholder-safe left-to-right pass over each string, not N sequential full-string passes
127+
— a naive sequential pass over the same value can corrupt an EARLIER pair's just-inserted placeholder
128+
when a LATER pair's literal happens to be a substring of it (register `somethinglong -> ${ABC}`, then
129+
`ABC -> ${OTHER}`, and a second pass over a value already rewritten to `${ABC}` matches "ABC" *inside*
130+
that token, producing `${${OTHER}}`). The single pass tries every registered literal longest-first at
131+
each position — so one registered value that is a substring of another (a username that is a prefix of
132+
a password) is never partially consumed by the shorter pair — and never re-scans text it has already
133+
emitted, so no literal can ever be matched inside another pair's placeholder token in either direction.
134+
- **`target-v1`/`targets-v1` identity evidence** (`label`, `ancestry[].label`, `scrollRegion.label` — the
135+
fields replay's own classification compares): never silently text-substituted while still claiming a
136+
trustworthy identity. Replay compares recorded identity against the *live* tree, which legitimately
137+
re-renders the real value again at replay time; a placeholder written into a recorded identity field
138+
could therefore never verify correctly. Instead:
139+
- **Landmark mode (`wait`).** An echo is treated exactly like #1349's existing identity-empty case: no
140+
annotation is recorded, and the wait keeps its selector-existence semantics. Because ADR 0016's
141+
destination guard requires `verification: "verified"`, a landmark whose only identity is a
142+
parameterized-value echo simply stops qualifying as a guard — `session save-script` refuses it with
143+
the existing "record a selector-targeted wait on a labeled or id-bearing landmark" recovery,
144+
reproducing the motivating scenario's real resolution (switching the guard to the stable `Apps`
145+
landmark) as an enforced outcome rather than an authoring convention.
146+
- **Action mode (`get`, `is`, mutating element-targeting actions).** ADR 0012/0016 require identity
147+
evidence for every element-targeting recorded action and forbid silently dropping it, so an echo here
148+
is never dropped. The literal-bearing label(s) are redacted to the placeholder (content-aware,
149+
substring-based — unlike the exact-match fill-boundary redaction, because a cross-step echo is
150+
typically surrounded by app-authored text such as "Welcome, `<value>`") and `verification` is
151+
downgraded to `"unverifiable"` — the same fail-closed downgrade decision 3's writer-parser invariant
152+
already uses for an oversized payload. That fails the action's replay loudly
153+
(`identity-unverifiable`) rather than silently weakening it to selector-only matching or publishing a
154+
label that could never match again.
155+
156+
**Explicit scope limits:**
157+
158+
- A whitespace-only or empty resolved fill value is excluded from the session-wide registry; it keeps
159+
only the existing fill-step-scoped protection above. Collapsing arbitrary later strings on a value with
160+
no discriminating content is a disproportionate readability cost for a value that reveals nothing
161+
distinctive if echoed, and would corrupt unrelated short incidental substrings throughout the rest of
162+
the recording.
163+
- The originating fill's own recorded entry is protected only by the existing exact-match fill-boundary
164+
pass; it is never passed through the coarser, substring-based session-wide pass using the pair it just
165+
contributed. The session-wide guarantee is for later, distinct actions, exactly as #1398 frames it.
166+
- An author's own explicitly typed selector or positional text is not scanned — only derived, resolved
167+
evidence and result payloads are. An author who types the secret directly into a new selector is making
168+
the same choice as an unparameterized fill/type value; that remains literal script content.
169+
- The registry is keyed by literal, so two distinct `--record-as` names that happen to share the same
170+
typed value (a password/confirm-password pair, say) are genuinely indistinguishable from a later
171+
echo's perspective — the literal alone cannot say which fill produced it. The first-registered name
172+
wins deterministically; the literal is redacted either way, so this only affects which placeholder
173+
name a later echo is attributed to, never whether the value is protected.
174+
89175
## Consequences
90176

91177
- Secret-bearing login/bootstrap scripts can use ADR 0016 active publication safely.
92178
- The caller must opt in for every sensitive fill; unparameterized fill/type values remain literal
93179
script content and help warns accordingly.
94180
- `type` and mutating `find ... fill|type` parameterization are not added by this decision. They require
95181
their own surface and provenance design if needed.
96-
- No new publication format, variable store, secret registry, or sensitivity heuristic is introduced.
182+
- No new publication format, variable store, or sensitivity heuristic is introduced. The #1398 amendment
183+
adds one ephemeral, non-serialized, per-session redaction map with no naming or lookup-by-name API —
184+
never a persistent secret registry.

scripts/layering/daemon-modularity.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly<Record<string, number>> = {
1414

1515
export const DAEMON_MODULARITY_BASELINE = {
1616
sessionState: {
17-
writerOwnedFields: 21,
18-
ownerFileClaims: 25,
17+
// #1398: +1 field (`recordedFillLiterals`), +1 owner claim (its single owner,
18+
// src/daemon/session-action-recorder.ts).
19+
writerOwnedFields: 22,
20+
ownerFileClaims: 26,
1921
},
2022
largestTypeCycle: {
2123
zoneMembers: LARGEST_TYPE_CYCLE_ZONE_CEILINGS,

scripts/layering/session-state.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly<Record<string, readonly string
8585
// a second durable owner as the execution seam stays package-bound.
8686
lease: ['src/daemon/handlers/session-open-execution.ts'],
8787
deviceClaim: ['src/daemon/handlers/session-open-execution.ts'],
88+
89+
// #1398 (ADR 0017 session-scoped echo protection amendment): the ephemeral
90+
// literal->placeholder registry is populated and consulted only at the
91+
// recorder's single choke point.
92+
recordedFillLiterals: ['src/daemon/session-action-recorder.ts'],
8893
};
8994

9095
/**

0 commit comments

Comments
 (0)