fix(runner): a secure field's value does not reach state.lastAction either

folio extracts lastAction, and extractor values are persisted as
extractor_changes, so the password still reached the run directory
through the spec after the three render sites were closed.

The wrap sits in the runner rather than in lastActionFields because the
hosts hold the next step's tree, not the one the action was chosen
against: a field that stops being secure between the two would publish
what the trace withheld. Live and replay now agree byte for byte.
This commit is contained in:
pj committed 2026-08-18 17:32:28 +05:30
1 parent 38d328df90
commit 66fd5bce5d
7 files changed
+181 -8

No files matched your search

+63
View File
@@ -2,8 +2,10 @@ package runner
import (
"context"
"fmt"
"strings"
"testing"
"time"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
@@ -129,6 +131,67 @@ func TestApplyActionTypesTheRealValueIntoEveryField(t *testing.T) {
}
}
// lastActionExtractorSpec is what examples/folio/sanderling/spec.ts does with
// the previous step's action: it extracts state.lastAction whole. Extractor
// values are written to the trace as extractor_changes, so a typed value that
// reaches state.lastAction.text lands in the run directory through the spec
// rather than through the runner.
const lastActionExtractorSpec = `
import { actions, always, extract, InputText } from "@sanderling/spec";
const reported = extract("lastAction", state => state.lastAction);
globalThis.properties = {
theActionReachesTheSpec: always(() => reported.current !== undefined),
};
globalThis.actions = actions(() => [InputText({ into: "%s", text: "` + typedCredential + `" })]);
`
func TestTheTraceNeverCarriesATypedSecretThroughALastActionExtractor(t *testing.T) {
for _, testCase := range []struct {
name string
treeJSON string
selector string
}{
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
{"web secure field", webLoginTreeJSON, "id:login-password"},
{"android field reported as neither", androidLoginTreeJSON, "id:login_email"},
} {
t.Run(testCase.name, func(t *testing.T) {
fastFocusSettle(t)
state := newHarnessWithSpec(t, fmt.Sprintf(lastActionExtractorSpec, testCase.selector))
state.mock.HierarchyJSON = testCase.treeJSON
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
steps := traceSteps(t, state.writer.Directory())
if len(steps) < 2 {
t.Fatalf("trace holds %d step(s), want the step that reports the action back", len(steps))
}
change, ok := steps[1].ExtractorChanges["lastAction"]
if !ok {
t.Fatalf("step 2 recorded no reading of state.lastAction: %+v", steps[1])
}
if strings.Contains(string(change.Curr), typedCredential) {
t.Errorf("the trace carries the typed value through state.lastAction: %s", change.Curr)
}
if !strings.Contains(string(change.Curr), verifier.RedactedInputText) {
t.Errorf("state.lastAction reported %s, want the typed value redacted in place",
change.Curr)
}
})
}
}
const secureLoginSpec = `
import { llm, always, actions, InputText, taps, typing, weighted } from "@sanderling/spec";
globalThis.properties = { ok: always(() => true) };
+2 -2
View File
@@ -451,7 +451,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
// the effect committed. Reporting no action here would let a
// property convict the app for an effect with no cause, so the
// action is reported with its fate unknown instead.
unconfirmed := nextAction
unconfirmed := verifier.RecordedAction(nextAction, tree)
lastAction = &unconfirmed
} else if notDispatched != "" {
// The action was chosen but nothing reached the driver, so the
@@ -466,7 +466,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
lastAction = nil
} else {
consecutiveApplyFailures = 0
applied := nextAction
applied := verifier.RecordedAction(nextAction, tree)
applied.Applied = true
lastAction = &applied
}