browserrot: Node stealth sidecar + AI self-heal (M-B1..M-B3, LIVE-VM)
Extends the Phase A-tier-1 site-side rotation engine with an optional Node browser-sidecar and a deterministic self-heal tier, all proven LIVE-VM against the local lab change-password form. go-rod stays the default; the sidecar is a swap-in Driver behind the same Session/Driver/Rotator interfaces — no spine change, backup + verify-new-before-revoke-old + proof gates all apply unchanged. M-B1 sidecar parity: internal/browserrot/sidecar/ (index.js JSON-RPC over stdio, patchright real-fingerprint Chromium, playwright-core fallback) + sidecar.go Driver/Session. A shared test harness runs go-rod and the sidecar through the identical live rotation proof. M-B2 stealth: patchright removes navigator.webdriver; humanized input (Bezier mouse paths + per-key typing jitter, no ML trajectory generation per plan §5). M-B3 self-heal: Healer interface + heal-retry fill/click/text wrappers; on a rotted selector, heal.js relocates the field from DOM structure ONLY (never a value) and the engine rewrites + persists the recipe (recipes.go RecipeBook) so the next run is Tier-1 again. Deterministic heuristic is the proven Tier-2; Stagehand LLM strategy is opt-in + UNPROVEN. Leak-check asserts no secret ever reaches a heal request (redacted IPC transcript). Secret seam documented honestly: fill carries base64 secret bytes across exactly one local stdio boundary, typed and wiped, never logged/persisted/sent to a model. MFA/CAPTCHA still degrade to the human worklist (Hard Rule 5). 14 tests green, -race clean; lab/lab-provision-browserrot.sh proves both drivers. M-B4 (first REAL self-owned site) deliberately NOT started — needs per-credential authorization (safe-candidate ladder). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,17 @@ type Driver interface {
|
||||
Open(ctx context.Context) (Session, error)
|
||||
}
|
||||
|
||||
// Healer is the OPTIONAL Tier-2 capability a Session may implement: relocate a field the
|
||||
// deterministic recipe could not find, by NATURAL-LANGUAGE description, and return a fresh
|
||||
// selector. Note the signature — Heal takes only a description and returns a selector, so
|
||||
// a secret CANNOT be passed to the healer (and thus never reaches a model). The engine
|
||||
// calls Heal only when a Tier-1 selector misses, then re-persists the healed selector so
|
||||
// the next run is deterministic again ("learn once"). A Session that does not implement
|
||||
// Healer simply fails closed on a selector miss (Tier-1 only).
|
||||
type Healer interface {
|
||||
Heal(ctx context.Context, description string) (selector string, err error)
|
||||
}
|
||||
|
||||
// Form describes a site's change-password form: the CSS selectors to fill and the
|
||||
// element whose text signals the outcome.
|
||||
type Form struct {
|
||||
@@ -111,6 +122,7 @@ type Rotator struct {
|
||||
policy pwgen.Policy
|
||||
|
||||
username string // captured in Rotate, used by Verify's login (non-secret)
|
||||
healed bool // set true if a Tier-2 heal rewrote any selector this run
|
||||
}
|
||||
|
||||
// New builds a Rotator for a Site backed by drv, minting new passwords with policy.
|
||||
@@ -167,7 +179,7 @@ func (r *Rotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Va
|
||||
return nil, err
|
||||
}
|
||||
|
||||
txt, err := sess.Text(ctx, r.site.Form.SuccessSel)
|
||||
txt, err := r.text(ctx, sess, &r.site.Form.SuccessSel, "the success or error status message")
|
||||
if err != nil {
|
||||
v.Forget(newH)
|
||||
return nil, fmt.Errorf("browserrot: read result: %w", err)
|
||||
@@ -183,33 +195,115 @@ func (r *Rotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Va
|
||||
|
||||
// fillForm injects the old + new secrets into the form fields. Both secrets are read
|
||||
// straight from the vault as bytes and handed to the Driver; they are never copied
|
||||
// into a Go string in this package.
|
||||
// into a Go string in this package. Each field goes through fill/click, which self-heal
|
||||
// on a selector miss (Tier-2) and rewrite r.site.Form so the healed recipe can persist.
|
||||
func (r *Rotator) fillForm(ctx context.Context, sess Session, oldH, newH *vault.Handle, v *vault.Vault) error {
|
||||
oldBuf, err := v.Open(oldH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: open old secret: %w", err)
|
||||
}
|
||||
if err := sess.Fill(ctx, r.site.Form.CurrentSel, oldBuf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill current: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Form.CurrentSel, "the current password input field", oldBuf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
newBuf, err := v.Open(newH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: open new secret: %w", err)
|
||||
}
|
||||
if err := sess.Fill(ctx, r.site.Form.NewSel, newBuf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill new: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Form.NewSel, "the new password input field", newBuf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.site.Form.ConfirmSel != "" {
|
||||
if err := sess.Fill(ctx, r.site.Form.ConfirmSel, newBuf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill confirm: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Form.ConfirmSel, "the confirm-new-password input field", newBuf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := sess.Click(ctx, r.site.Form.SubmitSel); err != nil {
|
||||
return fmt.Errorf("browserrot: submit: %w", err)
|
||||
if err := r.click(ctx, sess, &r.site.Form.SubmitSel, "the submit / save-changes button"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fill sets *selPtr's field to secret; on a selector miss it asks the session's Healer
|
||||
// (Tier-2) to relocate the field by desc, retries, and rewrites *selPtr in place so the
|
||||
// healed recipe persists. The secret is never handed to the healer (see Healer).
|
||||
func (r *Rotator) fill(ctx context.Context, sess Session, selPtr *string, desc string, secret []byte) error {
|
||||
if err := sess.Fill(ctx, *selPtr, secret); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
newSel, herr := r.heal(ctx, sess, desc, err)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
if err2 := sess.Fill(ctx, newSel, secret); err2 != nil {
|
||||
return fmt.Errorf("browserrot: fill %q after heal to %q: %w", desc, newSel, err2)
|
||||
}
|
||||
*selPtr = newSel
|
||||
r.healed = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// click activates *selPtr; heals + rewrites on a miss, same contract as fill.
|
||||
func (r *Rotator) click(ctx context.Context, sess Session, selPtr *string, desc string) error {
|
||||
if err := sess.Click(ctx, *selPtr); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
newSel, herr := r.heal(ctx, sess, desc, err)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
if err2 := sess.Click(ctx, newSel); err2 != nil {
|
||||
return fmt.Errorf("browserrot: click %q after heal to %q: %w", desc, newSel, err2)
|
||||
}
|
||||
*selPtr = newSel
|
||||
r.healed = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// text reads *selPtr's visible text; heals + rewrites on a miss, same contract as fill.
|
||||
func (r *Rotator) text(ctx context.Context, sess Session, selPtr *string, desc string) (string, error) {
|
||||
if txt, err := sess.Text(ctx, *selPtr); err == nil {
|
||||
return txt, nil
|
||||
} else {
|
||||
newSel, herr := r.heal(ctx, sess, desc, err)
|
||||
if herr != nil {
|
||||
return "", herr
|
||||
}
|
||||
txt2, err2 := sess.Text(ctx, newSel)
|
||||
if err2 != nil {
|
||||
return "", fmt.Errorf("browserrot: read %q after heal to %q: %w", desc, newSel, err2)
|
||||
}
|
||||
*selPtr = newSel
|
||||
r.healed = true
|
||||
return txt2, nil
|
||||
}
|
||||
}
|
||||
|
||||
// heal asks the session's Healer (if any) to relocate a field by natural-language desc.
|
||||
// If the session cannot heal, the original miss is returned unchanged (fail closed).
|
||||
func (r *Rotator) heal(ctx context.Context, sess Session, desc string, cause error) (string, error) {
|
||||
h, ok := sess.(Healer)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("browserrot: selector for %s missed and driver cannot self-heal: %w", desc, cause)
|
||||
}
|
||||
newSel, err := h.Heal(ctx, desc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browserrot: heal %s failed: %v (original miss: %w)", desc, err, cause)
|
||||
}
|
||||
if newSel == "" {
|
||||
return "", fmt.Errorf("browserrot: heal %s returned no selector (original miss: %w)", desc, cause)
|
||||
}
|
||||
return newSel, nil
|
||||
}
|
||||
|
||||
// Healed reports whether a Tier-2 heal rewrote any selector during the last Rotate/Verify.
|
||||
// When true, the caller should persist HealedSite() so the next run is deterministic.
|
||||
func (r *Rotator) Healed() bool { return r.healed }
|
||||
|
||||
// HealedSite returns the Site with any healed selectors folded in — the recipe to persist.
|
||||
func (r *Rotator) HealedSite() Site { return r.site }
|
||||
|
||||
// Verify proves the new secret authenticates by re-logging-in through the Site's
|
||||
// Login flow (hard rule 2: verify-new-before-revoke-old). If the Site has no Login
|
||||
// flow, Verify REFUSES — the spine then keeps the old secret and flags the human,
|
||||
@@ -228,21 +322,21 @@ func (r *Rotator) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.
|
||||
return fmt.Errorf("browserrot: goto login: %w", err)
|
||||
}
|
||||
if r.site.Login.UserSel != "" {
|
||||
if err := sess.Fill(ctx, r.site.Login.UserSel, []byte(r.username)); err != nil {
|
||||
return fmt.Errorf("browserrot: fill username: %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Login.UserSel, "the username or email input field", []byte(r.username)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf, err := v.Open(newSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: open new secret (verify): %w", err)
|
||||
}
|
||||
if err := sess.Fill(ctx, r.site.Login.PassSel, buf.Bytes()); err != nil {
|
||||
return fmt.Errorf("browserrot: fill password (verify): %w", err)
|
||||
if err := r.fill(ctx, sess, &r.site.Login.PassSel, "the password input field", buf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.Click(ctx, r.site.Login.SubmitSel); err != nil {
|
||||
return fmt.Errorf("browserrot: submit login: %w", err)
|
||||
if err := r.click(ctx, sess, &r.site.Login.SubmitSel, "the log-in button"); err != nil {
|
||||
return err
|
||||
}
|
||||
txt, err := sess.Text(ctx, r.site.Login.SuccessSel)
|
||||
txt, err := r.text(ctx, sess, &r.site.Login.SuccessSel, "the post-login status message")
|
||||
if err != nil {
|
||||
return fmt.Errorf("browserrot: read login result: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user