Skip to content

fix(core): redact typed fill text from stored Observe timeline events - #730

Merged
Lykhoyda merged 4 commits into
mainfrom
fm/fix-observe-fill-text-redaction-v2
Aug 10, 2026
Merged

fix(core): redact typed fill text from stored Observe timeline events#730
Lykhoyda merged 4 commits into
mainfrom
fm/fix-observe-fill-text-redaction-v2

Conversation

@Lykhoyda

@Lykhoyda Lykhoyda commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Intent

Fix the shipped rn-dev-agent Observe timeline confidentiality defect identified during independent PR 680 Android verification: device_fill must never leave args.text verbatim in stored Observe timeline events, and a distinctive secret canary must be provably absent. Follow the existing redaction approach rather than introducing a parallel or generalized framework. Redact rather than delete so event ordering, target identity, text length and status metadata, and every non-secret diagnostic field remain available. Do not change exact-fill behavior or unrelated event types. Add causal regression coverage that fails on the pre-fix implementation and passes after the fix, keeping the complete confidentiality argument small and reviewable. Do not claim or modify the exclusively owned NUC Android lane; rely on unit or integration evidence (or a local Mac simulator only if truly needed), and stop rather than contend for shared resources. Do not post GitHub issues, comments, or preserved feedback drafts. Ship the correction through a green PR for captain review, but do not merge it.

What Changed

  • mapObservation in packages/rn-dev-agent-core/src/observability/events.ts now runs fill text through a redaction step before events are stored: device_fill.text, device_batch steps with action: 'fill', and cdp_interact typeText/setFieldValue values are replaced with [REDACTED:<type>] plus a sibling <key>Length field, so ordering, target identity, text length, and status metadata stay visible while the plaintext does not.
  • The same redaction is applied to device_fill's result payload, closing the iOS fill timeout-recovery path that echoed the typed text back in its success payload where args-only redaction would not have reached it.
  • Added test/unit/observe-fill-redaction.test.ts with causal coverage (fails on the pre-fix build, passes after — verified in the Test phase by swapping in the base-commit build), plus a changeset and updated README / observe.mdx security-posture wording.

Exact-fill behavior and unrelated event types are untouched; the Review phase left one open info note that the changeset sentence still names only device_fill even though device_batch and cdp_interact are now covered.

Risk Assessment

✅ Low: The round-1 sibling-leak gap is closed at the same single choke point with an explicit, enumerable per-tool policy, no mutation of caller state, and canary coverage that also pins visible labels as still readable; only the changeset wording lags the shipped scope.

Testing

I proved causality by rebuilding against the pre-fix events.ts, where the new observe-fill-redaction test fails with the canary stored verbatim in args, in the device_fill result payload, and in device_batch/cdp_interact args; on the fixed tree all three cases pass. Beyond unit tests I seeded a live Recorder + Observe server and captured screenshots of the actual Observe web UI, which now renders "text": "[REDACTED:string]" with textLength: 26 in both the ARGS and PAYLOAD panes of device_fill, inside the device_batch fill step, and for cdp_interact typeText, while testID, exact, typed/recovered/verification, error text, event ordering and durations remain fully readable and plain byText selectors stay unredacted. Neighbouring observability and fill suites (88 tests) pass, confirming exact-fill behavior and other event types are untouched; the worktree was left clean and the temporary server/browser shut down.

  • Evidence: Observe UI — device_fill event detail: args and echoed result payload both redacted, diagnostics intact (local file: /var/folders/wy/khrzvmhd0ss969ydn32ghccm0000gn/T/no-mistakes-evidence/01KZM8JA26AB2H1JY1XV2Z0FNT/observe-ui-device_fill-redacted.png)
  • Evidence: Observe UI — device_batch fill step redacted while the byText find step stays readable (local file: /var/folders/wy/khrzvmhd0ss969ydn32ghccm0000gn/T/no-mistakes-evidence/01KZM8JA26AB2H1JY1XV2Z0FNT/observe-ui-device_batch-redacted.png)
  • Evidence: Observe UI — cdp_interact typeText redacted, failure error text preserved (local file: /var/folders/wy/khrzvmhd0ss969ydn32ghccm0000gn/T/no-mistakes-evidence/01KZM8JA26AB2H1JY1XV2Z0FNT/observe-ui-cdp_interact-redacted.png)
Evidence: Stored Observe timeline BEFORE fix (canary present)

CANARY_PRESENT_IN_STORED_TIMELINE=true

[
  {
    "seq": 1,
    "ts": 1786314028997,
    "tool": "device_press",
    "family": "interaction",
    "args": {
      "testID": "open-login",
      "text": "Sign in"
    },
    "ok": true,
    "durationMs": 21,
    "summary": "device_press open-login"
  },
  {
    "seq": 2,
    "ts": 1786314028997,
    "tool": "device_fill",
    "family": "interaction",
    "args": {
      "testID": "password-input",
      "text": "OBSERVE-FILL-CANARY-x7pz3q",
      "exact": true
    },
    "ok": true,
    "durationMs": 340,
    "summary": "device_fill password-input",
    "payload": {
      "typed": true,
      "text": "OBSERVE-FILL-CANARY-x7pz3q",
      "recovered": true,
      "verification": "exact-readback"
    }
  },
  {
    "seq": 3,
    "ts": 1786314028997,
    "tool": "device_batch",
    "family": "interaction",
    "args": {
      "steps": [
        {
          "action": "find",
          "text": "Email"
        },
        {
          "action": "fill",
          "testID": "email-input",
          "text": "OBSERVE-FILL-CANARY-x7pz3q"
        }
      ]
    },
    "ok": true,
    "durationMs": 512,
    "summary": "device_batch"
  },
  {
    "seq": 4,
    "ts": 1786314028997,
    "tool": "cdp_interact",
    "family": "interaction",
    "args": {
      "action": "typeText",
      "testID": "otp-input",
      "text": "OBSERVE-FILL-CANARY-x7pz3q"
    },
    "ok": false,
    "durationMs": 88,
    "summary": "cdp_interact otp-input ✗",
    "error": {
      "message": "exact read-back failed"
    }
  }
]
CANARY_PRESENT_IN_STORED_TIMELINE=true
Evidence: Stored Observe timeline AFTER fix (canary absent)

CANARY_PRESENT_IN_STORED_TIMELINE=false

[
  {
    "seq": 1,
    "ts": 1786314037912,
    "tool": "device_press",
    "family": "interaction",
    "args": {
      "testID": "open-login",
      "text": "Sign in"
    },
    "ok": true,
    "durationMs": 21,
    "summary": "device_press open-login"
  },
  {
    "seq": 2,
    "ts": 1786314037912,
    "tool": "device_fill",
    "family": "interaction",
    "args": {
      "testID": "password-input",
      "text": "[REDACTED:string]",
      "exact": true,
      "textLength": 26
    },
    "ok": true,
    "durationMs": 340,
    "summary": "device_fill password-input",
    "payload": {
      "typed": true,
      "text": "[REDACTED:string]",
      "recovered": true,
      "verification": "exact-readback",
      "textLength": 26
    }
  },
  {
    "seq": 3,
    "ts": 1786314037912,
    "tool": "device_batch",
    "family": "interaction",
    "args": {
      "steps": [
        {
          "action": "find",
          "text": "Email"
        },
        {
          "action": "fill",
          "testID": "email-input",
          "text": "[REDACTED:string]",
          "textLength": 26
        }
      ]
    },
    "ok": true,
    "durationMs": 512,
    "summary": "device_batch"
  },
  {
    "seq": 4,
    "ts": 1786314037912,
    "tool": "cdp_interact",
    "family": "interaction",
    "args": {
      "action": "typeText",
      "testID": "otp-input",
      "text": "[REDACTED:string]",
      "textLength": 26
    },
    "ok": false,
    "durationMs": 88,
    "summary": "cdp_interact otp-input ✗",
    "error": {
      "message": "exact read-back failed"
    }
  }
]
CANARY_PRESENT_IN_STORED_TIMELINE=false
Evidence: New regression test failing on the pre-fix implementation
✖ stored Observe timeline redacts device_fill text while retaining diagnostic metadata (1.584583ms)
✖ stored Observe timeline redacts fill text echoed back in the device_fill result payload (0.175583ms)
✖ stored Observe timeline redacts fill text routed through device_batch and cdp_interact (0.191ms)
ℹ tests 3
ℹ suites 0
ℹ pass 0
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 81.573166

✖ failing tests:

test at test/unit/observe-fill-redaction.test.ts:7:1
✖ stored Observe timeline redacts device_fill text while retaining diagnostic metadata (1.584583ms)
  AssertionError [ERR_ASSERTION]: fill text must never be stored
      at TestContext.<anonymous> (file:///Users/antonlykhoyda/.no-mistakes/worktrees/6368e539b7ff/01KZM8JA26AB2H1JY1XV2Z0FNT/packages/rn-dev-agent-core/test/unit/observe-fill-redaction.test.ts:30:10)
      at Test.runInAsyncScope (node:async_hooks:228:14)
      at Test.run (node:internal/test_runner/test:1118:25)
      at Test.start (node:internal/test_runner/test:1015:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:358:17) {
    generatedMessage: false,
    code: 'ERR_ASSERTION',
    actual: '[{"seq":1,"ts":1786314014907,"tool":"device_press","family":"interaction","args":{"ref":"@e12","text":"Visible label"},"ok":true,"durationMs":7,"summary":"device_press @e12"},{"seq":2,"ts":1786314014907,"tool":"device_fill","family":"interaction","args":{"ref":"@e53","testID":"wizard-title-input","text":"OBSERVE-FILL-CANARY-x7pz3q","waitForKeyboardMs":100},"ok":false,"durationMs":42,"summary":"device_fill wizard-title-input ✗","error":{"message":"exact read-back failed"}}]',
    expected: /OBSERVE-FILL-CANARY-x7pz3q/,
    operator: 'doesNotMatch',
    diff: 'simple'
  }

test at test/unit/observe-fill-redaction.test.ts:51:1
✖ stored Observe timeline redacts fill text echoed back in the device_fill result payload (0.175583ms)
  AssertionError [ERR_ASSERTION]: fill text must never be stored, not even echoed back through the result payload
      at TestContext.<anonymous> (file:///Users/antonlykhoyda/.no-mistakes/worktrees/6368e539b7ff/01KZM8JA26AB2H1JY1XV2Z0FNT/packages/rn-dev-agent-core/test/unit/observe-fill-redaction.test.ts:72:10)
      at Test.runInAsyncScope (node:async_hooks:228:14)
      at Test.run (node:internal/test_runner/test:1118:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:787:18)
      at Test.postRun (node:internal/test_runner/test:1247:19)
      at Test.run (node:internal/test_runner/test:1175:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:358:3) {
    generatedMessage: false,
    code: 'ERR_ASSERTION',
    actual: '[{"seq":1,"ts":1786314014908,"tool":"device_fill","family":"interaction","args":{"testID":"wizard-title-input","text":"OBSERVE-FILL-CANARY-x7pz3q","exact":true},"ok":true,"durationMs":9,"summary":"device_fill wizard-title-input","payload":{"typed":true,"text":"OBSERVE-FILL-CANARY-x7pz3q","recovered":true,"verification":"exact-readback"}}]',
    expected: /OBSERVE-FILL-CANARY-x7pz3q/,
    operator: 'doesNotMatch',
    diff: 'simple'
  }

test at test/unit/observe-fill-redaction.test.ts:88:1
✖ stored Observe timeline redacts fill text routed through device_batch and cdp_interact (0.191ms)
  AssertionError [ERR_ASSERTION]: fill text must never be stored, whichever tool carried it
      at TestContext.<anonymous> (file:///Users/antonlykhoyda/.no-mistakes/worktrees/6368e539b7ff/01KZM8JA26AB2H1JY1XV2Z0FNT/packages/rn-dev-agent-core/test/unit/observe-fill-redaction.test.ts:121:10)
      at Test.runInAsyncScope (node:async_hooks:228:14)
      at Test.run (node:internal/test_runner/test:1118:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:787:18)
      at Test.postRun (node:internal/test_runner/test:1247:19)
      at Test.run (node:internal/test_runner/test:1175:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) {
    generatedMessage: false,
    code: 'ERR_ASSERTION',
    actual: '[{"seq":1,"ts":1786314014908,"tool":"device_batch","family":"interaction","args":{"steps":[{"action":"find","text":"Visible label"},{"action":"fill","testID":"wizard-title-input","text":"OBSERVE-FILL-CANARY-x7pz3q"}]},"ok":true,"durationMs":11,"summary":"device_batch"},{"seq":2,"ts":1786314014908,"tool":"cdp_interact","family":"interaction","args":{"action":"typeText","testID":"wizard-title-input","text":"OBSERVE-FILL-CANARY-x7pz3q"},"ok":true,"durationMs":12,"summary":"cdp_interact wizard-title-input"},{"seq":3,"ts":1786314014908,"tool":"cdp_interact","family":"interaction","args":{"action":"setFieldValue","name":"title","value":"OBSERVE-FILL-CANARY-x7pz3q"},"ok":true,"durationMs":13,"summary":"cdp_interact"},{"seq":4,"ts":1786314014908,"tool":"cdp_interact","family":"interaction","args":{"action":"press","text":"Visible label"},"ok":true,"durationMs":14,"summary":"cdp_interact Visible label"}]',
    expected: /OBSERVE-FILL-CANARY-x7pz3q/,
    operator: 'doesNotMatch',
    diff: 'simple'
  }
Evidence: Same test passing after the fix
✔ stored Observe timeline redacts device_fill text while retaining diagnostic metadata (2.035708ms)
✔ stored Observe timeline redacts fill text echoed back in the device_fill result payload (0.207166ms)
✔ stored Observe timeline redacts fill text routed through device_batch and cdp_interact (0.244667ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 89.832167
Evidence: Reusable Observe seed/serve script used for the manual UI capture
import { Recorder } from '/Users/antonlykhoyda/.no-mistakes/worktrees/6368e539b7ff/01KZM8JA26AB2H1JY1XV2Z0FNT/packages/rn-dev-agent-core/dist/observability/recorder.js';
import { ObservabilityServer } from '/Users/antonlykhoyda/.no-mistakes/worktrees/6368e539b7ff/01KZM8JA26AB2H1JY1XV2Z0FNT/packages/rn-dev-agent-core/dist/observability/server.js';

const CANARY = 'OBSERVE-FILL-CANARY-x7pz3q';
const rec = new Recorder();
rec.record({ tool: 'device_press', params: { testID: 'open-login', text: 'Sign in' }, status: 'PASS', latencyMs: 21 });
rec.record({
  tool: 'device_fill',
  params: { testID: 'password-input', text: CANARY, exact: true },
  status: 'PASS',
  latencyMs: 340,
  result: { content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { typed: true, text: CANARY, recovered: true, verification: 'exact-readback' } }) }] },
});
rec.record({ tool: 'device_batch', params: { steps: [{ action: 'find', text: 'Email' }, { action: 'fill', testID: 'email-input', text: CANARY }] }, status: 'PASS', latencyMs: 512 });
rec.record({ tool: 'cdp_interact', params: { action: 'typeText', testID: 'otp-input', text: CANARY }, status: 'FAIL', latencyMs: 88, error: 'exact read-back failed' });

const timeline = rec.snapshot();
const json = JSON.stringify(timeline, null, 2);
console.log(json);
console.log('CANARY_PRESENT_IN_STORED_TIMELINE=' + json.includes(CANARY));

if (process.env.SERVE === '1') {
  const srv = new ObservabilityServer(rec, undefined, undefined, undefined, { sessionId: 'evidence-session', claimEpoch: 1, instanceId: 'evidence-instance', capability: 'evidence-capability' });
  const { url } = await srv.start();
  console.log('URL=' + url);
}
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 2 runs (12m3s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ packages/rn-dev-agent-core/src/observability/events.ts:179 - The redaction is gated on o.tool === &#39;device_fill&#39;, but two sibling tools put the same user-typed secret into the same stored Observe timeline. Concrete path: device_batch({steps:[{action:&#39;fill&#39;, testID:&#39;x&#39;, text: SECRET}]})addToolObserver (index.ts:386) → recorder.recordmapObservation — tool name is device_batch, so redactDeviceFillText is skipped, and redact() (src/util/redact.ts) has no rule for a text key, so args.steps[0].text is stored and served verbatim. Same for cdp_interact with action:&#39;typeText&#39; (top-level text, index.ts:2069) and setFieldValue (value). device_batch's own schema says fill "calls the same exact-fill coordinator as device_fill" (index.ts:3077), so this is the same secret through the same coordinator, not an unrelated event type. Earliest supported shared boundary: extend the existing redactDeviceFillText guard in mapObservation to cover fill-bearing steps/actions, rather than adding another per-tool patch later. Flagged as ask-user because the intent says not to change unrelated event types.
  • ℹ️ packages/rn-dev-agent-core/src/observability/events.ts:168 - summarize() uses args.testID ?? args.ref ?? args.text ?? ... for its label, so a device_fill observation lacking both testID and ref now summarizes as device_fill [REDACTED:string]. This is the correct confidentiality outcome (pre-fix the summary echoed the secret) and device_fill requires a ref in practice, so no action is needed — noting it only so the flatter timeline label is not mistaken for a regression.

🔧 Fix: redact fill text from batch and interact events
1 info still open:

  • ℹ️ .changeset/quiet-observe-fill-text.md:6 - The changeset still says "Redact device_fill text", but HEAD also redacts device_batch fill steps and cdp_interact typeText/setFieldValue values (src/observability/events.ts:99-122). This line ships verbatim to the published CHANGELOG, so a consumer auditing which surfaces are now confidential would under-read the fix. Widen it to one sentence covering fill text routed through device_fill, device_batch fill steps, and cdp_interact typeText/setFieldValue (keeping the single-sentence changeset convention).
🔧 **Test** - 1 issue found → auto-fixed ✅
  • ⚠️ packages/rn-dev-agent-core/src/observability/events.ts:204 - Residual verbatim leak of the same secret in a stored Observe event: on the iOS fill main-thread-timeout recovery path, device_fill's success payload echoes text: args.text (src/runners/rn-fast-runner-client.ts:1696). mapObservation only redacts args, so the stored event has args.text = "[REDACTED:string]" while payload.text still contains the canary verbatim. Reproduced directly via mapObservation with that result shape (see ios-timeout-recovery-payload-check.txt). Whether to extend the redaction to this payload field, or to stop echoing the text there, is a scope decision for the author.
  • npx tsc (clean build of rn-dev-agent-core, no dist drift)
  • node --test test/unit/observe-fill-redaction.test.ts — 2/2 pass on the fixed build
  • Causal check: swapped in base-commit dist/observability/events.js (7419435d) and re-ran the same test — 2/2 fail (exit 1); restored the fixed build — 2/2 pass (exit 0)
  • End-to-end: drove an instrumented device_fill carrying secret hunter2-CANARY-9f3ax through the production Recorder, started the real ObservabilityServer, and read GET /api/stream — the snapshot the Observe UI renders — before and after the fix
  • Regression set: node --test test/unit/observability-events.test.js test/unit/observability-recorder.test.js test/unit/observability-observer.test.js test/unit/observability-observe-tool.test.js test/unit/audit-h1-redact-before-truncate.test.js test/unit/gh-581-narrow-fill.test.ts test/unit/gh-391-fill-ladder.test.ts test/unit/fill-verify.test.js test/unit/device-fill-jsfirst.test.js test/unit/observability-server.test.js — 84/84 pass
  • Manual probe of the iOS fill timeout-recovery result shape through mapObservation to check payload-side exposure

🔧 Fix: redact fill text echoed in device_fill result payload
✅ Re-checked - no issues remain.

  • node --test test/unit/observe-fill-redaction.test.ts against the pre-fix events.ts (base 7419435d, rebuilt) — all 3 cases fail with the canary present in stored events (causal proof)
  • node --test test/unit/observe-fill-redaction.test.ts on the fixed tree — 3/3 pass
  • node --test test/unit/observability-events.test.js test/unit/observability-recorder.test.js test/unit/observability-observe-tool.test.js test/unit/observability-server.test.js test/unit/fill-verify.test.js test/unit/device-fill-jsfirst.test.js test/unit/gh-391-fill-ladder.test.ts test/unit/gh-581-narrow-fill.test.ts test/unit/story-04-fill-batch-settle.test.js — 88/88 pass (exact-fill and unrelated event types unchanged)
  • Manual: seeded a real Recorder with device_press / device_fill (canary in args + echoed in result payload) / device_batch fill step / cdp_interact typeText, dumped recorder.snapshot() on both the pre-fix and post-fix builds (CANARY_PRESENT_IN_STORED_TIMELINE=truefalse)
  • Manual: started ObservabilityServer on that recorder and drove the real Observe web UI with chrome-devtools-axi, expanding each timeline row and screenshotting the ARGS/PAYLOAD detail panes
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e74d9de42b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

function redactFillResultText(tool: string, payload: unknown): unknown {
if (tool !== 'device_fill') return payload;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact cdp_interact result payloads

When cdp_interact successfully runs typeText or setFieldValue, the injected helper echoes the entered data under data.text or data.value; mapObservation then stores that object as the event payload. Because this function only redacts results for device_fill, the arguments are hidden but the same typed content remains verbatim in the Observe timeline for every successful CDP interaction. Apply the corresponding action-aware result redaction to cdp_interact as well.

Useful? React with 👍 / 👎.

@Lykhoyda
Lykhoyda merged commit b2c8cc8 into main Aug 10, 2026
15 checks passed
@Lykhoyda
Lykhoyda deleted the fm/fix-observe-fill-text-redaction-v2 branch August 10, 2026 08:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant