fix(verifier): a secure field's typed value never reaches the record

A folio login run wrote the account email and password in cleartext into
llm-calls.jsonl, 166 times in one run, beside screenshots of the same
screens. Three sites rendered it: the recent-action memory, the candidate
list, and the trace. One helper now covers all three so a fourth cannot
bypass it, and the driver still receives the real text.

Android redacts every typed value because it cannot tell a secure field
from any other. That asymmetry is deliberate and documented: safe by
default on the target that cannot tell.
This commit is contained in:
pj committed 2026-08-18 17:17:03 +05:30
1 parent b1e95739ad
commit 38d328df90
12 files changed
+418 -16

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
package verifier
import (
"github.com/dop251/goja"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// RedactedInputText stands in for a typed value in every record. It is fixed,
// so it gives away neither the value nor its length.
const RedactedInputText = "[redacted]"
// secureFact is what a target says about being a secure text entry. `reported`
// separates "the platform says this is not one" from "the platform says
// nothing", which are the two cases the redaction rule has to tell apart.
type secureFact struct {
reported bool
secure bool
}
// secureFactFromHandle reads an element handle's own report. The web host
// injects handles built in the page, which carry no selector to resolve against
// the goja-side tree, so the handle is the only thing that knows.
func secureFactFromHandle(object *goja.Object) secureFact {
value := object.Get("secure")
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return secureFact{}
}
return secureFact{reported: true, secure: value.ToBoolean()}
}
func secureFactOf(element *hierarchy.Element) secureFact {
if element == nil {
return secureFact{}
}
return secureFact{reported: element.SecureReported(), secure: element.Secure}
}
// RecordedActionText renders the typed value of an action for anything that is
// persisted or sent, resolving the action's target in the tree it was chosen
// against.
func RecordedActionText(action Action, tree *hierarchy.Tree) string {
if action.Kind != ActionKindInputText {
return action.Text
}
var target *hierarchy.Element
if tree != nil && action.On != "" {
target = tree.Find(action.On)
}
return recordedInputText(action.Text, secureFactOf(target))
}
// recordedInputText is the one place a typed value is rendered for a record.
// The prompt's recent-action memory, the numbered candidate list and the trace
// all go through it, so a fourth record added later cannot publish a value the
// other three withhold. The driver dispatch reads Action.Text directly and is
// the only reader of the real value, which is what keeps the app receiving the
// keystrokes a user would have produced.
//
// A target the platform reports as a secure entry is redacted, and so is a
// target carrying no report at all. iOS and web state the fact on every
// editable element, so a missing one means Android, whose native tree mapper
// drops uiautomator's password attribute before the sidecar sees it. There a
// password field cannot be told from a search box, and the target that cannot
// be told apart is treated as the credential.
func recordedInputText(text string, target secureFact) string {
if target.reported && !target.secure {
return text
}
return RedactedInputText
}