diff --git a/README.md b/README.md index 84b8c57..97fdee1 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ and caches it under `${XDG_CACHE_HOME:-$HOME/.cache}/cthulhu/toolchains`. It does not replace system Go or require root. Install a C compiler, `pkg-config`, and the development libraries for GTK3, -AT-SPI, GLib/GIO, GStreamer, X11, XTest, XFixes, XRes, xkbcommon, liblouis, -and BRLTTY/BrlAPI first. The script checks the pkg-config dependencies; +AT-SPI, GLib/GIO, GStreamer, X11, XTest, libXi, XFixes, XRes, xkbcommon, +liblouis, and BRLTTY/BrlAPI first. The script checks the pkg-config dependencies; BrlAPI headers and its library are checked by the compiler during the build. Bootstrapping Go additionally needs `curl`, `tar`, and `sha256sum`. See [Contributing](CONTRIBUTING.md#development-and-architecture) and the diff --git a/build-cthulhu.sh b/build-cthulhu.sh index e18e015..78f32aa 100755 --- a/build-cthulhu.sh +++ b/build-cthulhu.sh @@ -66,7 +66,7 @@ export CGO_ENABLED=${CGO_ENABLED:-1} [[ $CGO_ENABLED == 1 ]] || fail "Cthulhu requires CGO_ENABLED=1 and a C compiler." command -v pkg-config >/dev/null || fail "Install pkg-config and the development libraries listed in CONTRIBUTING.md." missingLibraries=() -for library in gtk+-3.0 atspi-2 gio-2.0 gstreamer-1.0 x11 xtst xfixes xres xkbcommon liblouis; do +for library in gtk+-3.0 atspi-2 gio-2.0 gstreamer-1.0 x11 xtst xi xfixes xres xkbcommon liblouis; do if ! pkg-config --exists "$library"; then missingLibraries+=("$library") fi diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 0569f03..09aa608 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -33,6 +33,7 @@ depends=( libx11 libxres libxtst + libxi libxfixes libxkbcommon xclip diff --git a/distro-packages/Arch-Linux/cthulhu/.SRCINFO b/distro-packages/Arch-Linux/cthulhu/.SRCINFO index 9569ee9..76ad8ed 100644 --- a/distro-packages/Arch-Linux/cthulhu/.SRCINFO +++ b/distro-packages/Arch-Linux/cthulhu/.SRCINFO @@ -24,6 +24,7 @@ pkgbase = cthulhu depends = libx11 depends = libxres depends = libxtst + depends = libxi depends = libxfixes depends = libxkbcommon depends = xclip diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD index ee1fd11..6f1411b 100644 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu/PKGBUILD @@ -30,6 +30,7 @@ depends=( libx11 libxres libxtst + libxi libxfixes libxkbcommon xclip diff --git a/docs/tentacle-remote-authorization.md b/docs/tentacle-remote-authorization.md new file mode 100644 index 0000000..8e71ce6 --- /dev/null +++ b/docs/tentacle-remote-authorization.md @@ -0,0 +1,134 @@ +# Reader-owned Remote authorization + +Implementation increment: 2026-11-03. The reader now has a core consent path, +device-aware sensitive forms, and lock/handoff cancellation. There is still no +registered Remote connection command or reader desktop service. This is not a +usable Remote release, and physical-keyboard approval remains unverified. +Base revisions: reader `testing` at `573d15d`, Tentacles `master` at `63e84e9`; +both still matched their maintained remote tips when checked for this increment. + +## Core ownership and lifetime + +Every production Tentacle manager is bound to the focus gate's `RemotePolicy`. +An I38lock secure-input transition or XTerm handoff synchronously cancels the +current policy context. Restoring availability creates a new context. Neither +unlock nor returning from XTerm revives an old dialog, action or lease. + +The cancellation path takes no session, registry or output lock. Existing +delivery contexts become invalid before session event cleanup can acquire its +lock. Cleanup checks the original access object before retiring it; a delayed +callback cannot revoke a newer epoch. An already-running open/heartbeat can race +with a response, but it cannot restore the cancelled delivery context. Future +desktop owners must still recheck context and their own policy at delivery. + +The core `tentacleRuntime.authorize_remote` procedure resolves an active installed +session and role, checks its install grants, reserves the interaction slot, and +captures a session-bound `RemoteApproval` **before** opening the consent form. +The form focuses Cancel initially and leaves every permission checkbox unchecked. +It describes direction, relay visibility and the limits of peer identities. +Only explicit approval of a nonempty subset issues the existing `remote.action`. +The user can decline keyboard and clipboard permissions independently. + +Tickets are core objects, not RPC parameters. They bind the session, role, +requested grant ceiling and availability lifetime. Reuse, another session, +changed role, increased grants and an intervening lock/handoff are denied. +Opening a replacement consent dialog retires previous access. Cancel, timeout, +session failure/disable and package revocation cancel pending consent too. +In particular, failed update preparation must not leave an earlier dialog able +to issue a new action. The old unrestricted `AuthorizeRemote` hook is rejected +by policy-bound managers; plain development hosts retain it for protocol tests. + +No child RPC, install grant, certificate review, saved key, relay event or +injected shortcut is itself consent. Connection command registration and joining +the external workflow to this core procedure remain the next integration work. + +## Sensitive form input + +`internalUI.OpenLocal` uses `CommandListener.BeginLocalUI`, a separate XI2 +keyboard grab on the existing Xlib owner thread. Unsupported backends fail +without falling back to ordinary forms. Ordinary forms retain their core input +path, bindings, learn mode, text editor and clipboard behavior. + +Local capture derives the master keyboard from the client's pointer attachment; +it does not assume device IDs. It admits an event only when: + +- It is the actual XI2 key event for this capture's master keyboard, rather than + a core event, an XRecord observation, or a SendEvent event. +- Its enabled source is an attached slave keyboard with no `XTEST Device` + property. The atom must exist on the server; unknown capability is denied. +- Its `Device Node` is a bounded `/dev/input/event` path naming a character + device. Sources without a device node remain ineligible. +- It is a fresh transition in this capture, rather than repeat, duplicate down + or unmatched release. A key already held before capture prevents acquisition. + +Source metadata is checked on each transition. Device names and timestamp +matching play no role. Modifier state is reconstructed from admitted keys, +so an injected Shift cannot reverse a local Tab action. Rejected keys do not +enter the chord router, compose renderer, fields, clipboard actions or approval. +Hierarchy changes cancel capture and retire held-key bookkeeping, including +devices whose releases can no longer arrive. X errors during source queries, +capture acquisition or release deny access rather than terminating the reader. + +The X server marks XTEST devices using a non-deletable property whose setter +rejects changes; see the [server implementation](https://github.com/XQuartz/xorg-server/blob/master/Xext/xtest.c). +This protects routing from the planned core XTEST injection path. It is **not** +an OS security boundary against malicious same-user X clients, privileged device +creation, virtual evdev devices, or a compromised X server. A device node alone +does not prove physical hardware. Nested servers, Bluetooth keyboards, layouts, +accessibility input devices and actual hot unplug still require acceptance work. +No Wayland input provenance is claimed. +The production injection owner must retain XTEST virtual-device sourcing; it +must not retarget fake input to a physical source device. + +All transient forms from a Tentacle with remote grants use this path, including +certificate trust and Remember/Forget key forms. Their interaction lifetime is +bound to the original policy context, so a short lock/unlock transition cannot +preserve them even if native polling misses it. Ordinary Tentacle forms remain +unchanged. No public form schema flag lets a child choose its own trust level. + +## Verification and remaining acceptance + +From the reader checkout: + +```sh +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/tentacle ./internal/tentaclewire ./internal/interaction ./sdk/... -count=1 -timeout=240s +GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/app -run 'Tentacle|InternalUI|ScreenLock|XTermHandoff|Remote|CommandLoop' -count=1 -timeout=180s +python3 -B tests/native_remote_input.py +python3 tests/native_internal_ui.py +python3 tests/test_build_script.py -v +``` + +The native input harness uses private Xvfb/D-Bus, ten recovery experiments and +six native Go race tests. It rejects injected navigation, submission, entry, +paste and Escape, rejects pre-held approval, cancels on hierarchy and secure-input +changes, restores ordinary forms, and retains learn-mode and XTerm handoff +regressions. It requires `xinput` as well as the existing X11 test tools. +The separate internal-form harness covers real ordinary/secret entry, action +menus, emoji and clipboard behavior. Neither harness uses the active reader. + +Both SDK process fixtures cover policy cancellation of pending actions and live +leases, stale open/heartbeat denial, revocation events, single-use tickets and +fresh approval after resuming. The app fixture verifies unchecked grants stay +denied and dialogs cannot survive revocation. A held session lock demonstrates +that effect-context cancellation does not wait for worker/event cleanup. + +Existing host/wire/interaction race suites, focused application regressions, +vet, reader build, shellcheck, build-script tests and the external Tentacles +repository's `make check` passed. The complete host race suite took about 124 +seconds. libXi is now an explicit build/package dependency; no package was +installed, published or reloaded. + +Before exposing the connection command, verify actual local keyboard navigation, +approval, cancellation and modifier handling through this capture path on Xorg. +Then exercise certificate review and Remember key with physical input. During +lock and XTerm handoff, verify immediate disconnect, no revival on return, and +no stale trust/approval after a failed update. Recheck ordinary entries, secret +masking/paste, learn mode, remapped commands and application key delivery. +Those manual checks need an exposed connection workflow first; there is still +no Remote desktop session to launch from this increment. + +The current lock integration follows the reader's existing I38lock secure mode. +Other lockers/session switches are not established by this work. Positive +hardware-origin acceptance, audible speech, physical Braille, input injection +recovery/collision handling, typed desktop services and two-desktop/NVDA/Orca +interoperability remain open. Do not mark Phase B or any release gate complete. diff --git a/docs/tentacle-sessions.md b/docs/tentacle-sessions.md index 1123c9a..15504ec 100644 --- a/docs/tentacle-sessions.md +++ b/docs/tentacle-sessions.md @@ -7,10 +7,13 @@ this increment. These are base revisions, not commits containing the new work. This increment supplies negotiation, bounded bidirectional IPC, lifecycle events, install grants, and an inert remote lease service. It does **not** supply desktop -remote control. There is no production consent issuer, connection dialog, -output observer, keyboard service, clipboard service, or Braille session service. -Only private fixtures call the core `Session.AuthorizeRemote` hook. Packaging -Remote itself remains premature. +remote control. A later [reader authorization increment](tentacle-remote-authorization.md) +adds the core consent procedure, sensitive XI2 forms and lock/handoff policy. +Its connection command remains unregistered and physical-keyboard approval is +unverified. There is no output observer, keyboard service, clipboard service, +or Braille session service. The unrestricted `Session.AuthorizeRemote` hook is +limited to development managers; reader managers require a core approval ticket. +Packaging Remote itself remains premature. ## Negotiation and installation @@ -139,8 +142,9 @@ is idempotent and cannot revoke a newer one. Core `RemoteContext(lease, grant)` returns a cancellation context only for a live matching lease with that grant. Close, expiry, process failure, disable and parent cancellation invalidate buffered work. Future effect owners must also enforce -lock, handoff, origin, focus and ordinary output policy at delivery; a context -alone does not implement those policies. No production effects currently use it. +origin, focus and ordinary output policy at delivery; the reader's policy now +cancels these contexts on its existing lock/handoff transitions. A context alone +does not implement all effect-owner policies. No production effects currently use it. Errors are generic and contain no parameters: `-32001` permission denied, `-32602` invalid parameters, `-32601` unsupported operation, `-32603` unavailable. diff --git a/docs/testing/remote-input-feasibility.md b/docs/testing/remote-input-feasibility.md index 23b79ca..8e9a50d 100644 --- a/docs/testing/remote-input-feasibility.md +++ b/docs/testing/remote-input-feasibility.md @@ -83,7 +83,11 @@ bounded IPC and X-server failure handling, stale-work invalidation, explicit error reporting, restart policy and lifecycle tests. Simultaneous helper death or a blocked X server is not recovered by this experiment. -Trusted input origin remains a release blocker. The existing reader event +Trusted input origin remains a release blocker. A subsequent +[authorization increment](../tentacle-remote-authorization.md) now adds separate +XI2 capture for sensitive forms, XTEST/device-node filtering and private rejection +tests. Positive physical-keyboard acceptance and production command integration +remain pending. The ordinary reader event schema contains keycode, transition and timestamp but no source device or core-owned injection identity. Do not grant local consent based on an XRecord event, `send_event`, or a timing-only match. Investigate device-aware events and diff --git a/internal/app/app.go b/internal/app/app.go index 2ded2c5..fdb6f7a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -295,6 +295,9 @@ func Run(ctx context.Context, options Options) error { } tentacles.output = &tentacleOutput{speech: speechMessages, braille: brailleOutput, gate: focusGate, live: live} focusGate.tentacleOutput = tentacles.output + focusGate.remotePolicy = tentacle.NewRemotePolicy() + focusGate.sync_remote_policy() + tentacles.remotePolicy = focusGate.remotePolicy services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop}) } preferences := newPreferencesWorker(logger, savedSettings, settingsUpdater) diff --git a/internal/app/focus_gate.go b/internal/app/focus_gate.go index b496d67..e7853c3 100644 --- a/internal/app/focus_gate.go +++ b/internal/app/focus_gate.go @@ -22,6 +22,7 @@ import ( "sync/atomic" "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/tentacle" ) type commandGrabController interface { @@ -30,6 +31,7 @@ type commandGrabController interface { type focusGate struct { tentacleOutput *tentacleOutput + remotePolicy *tentacle.RemotePolicy ocr *ocrSession mutex sync.Mutex @@ -64,12 +66,21 @@ func (gate *focusGate) SetSuspended(suspended bool) bool { return false } gate.retireTentacleOutput() + gate.sync_remote_policy() if gate.commands != nil { gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load()) } return true } +// Called during startup or with gate.mutex held. Cancellation never calls back +// into the gate or runtime locks. Lock/unlock and handoff cannot restore consent. +func (gate *focusGate) sync_remote_policy() { + if gate.remotePolicy != nil { + gate.remotePolicy.SetAvailable(!gate.secure.Load() && !gate.suspended.Load()) + } +} + func (gate *focusGate) InternalUIActive() bool { return gate != nil && gate.internalUI != nil && gate.internalUI.InternalUIActive() } diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index 42a67ce..b4f489b 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -42,10 +42,11 @@ type internalUINotice struct { value interaction.Presentation } type internalUIRequest struct { - ctx context.Context - session *interaction.Session - result chan internalUIResult - ready chan error + localOnly bool + ctx context.Context + session *interaction.Session + result chan internalUIResult + ready chan error } // internalUI owns one modal session on its worker. The bounded event channel @@ -70,11 +71,21 @@ type internalUI struct { } func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + return ui.open_form(ctx, form, false) +} + +// OpenLocal is core-only. Unknown input backends cannot silently fall back to +// ordinary forms when authorizing remote access or handling its secrets/trust. +func (ui *internalUI) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + return ui.open_form(ctx, form, true) +} + +func (ui *internalUI) open_form(ctx context.Context, form interaction.Form, localOnly bool) (<-chan internalUIResult, error) { session, err := interaction.NewSession(form) if err != nil { return nil, err } - request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)} + request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1), localOnly: localOnly} select { case <-ctx.Done(): return nil, ctx.Err() @@ -202,7 +213,17 @@ func (ui *internalUI) run(ctx context.Context) { continue } var err error - sessionID, err = ui.keys.BeginInternalUI(request.ctx) + if request.localOnly { + if keys, ok := ui.keys.(interface { + BeginLocalUI(context.Context) (uint64, error) + }); ok { + sessionID, err = keys.BeginLocalUI(request.ctx) + } else { + err = errors.New("local approval input unavailable") + } + } else { + sessionID, err = ui.keys.BeginInternalUI(request.ctx) + } if err != nil { request.ready <- err close(request.result) diff --git a/internal/app/screen_lock.go b/internal/app/screen_lock.go index 508b561..65d8ca6 100644 --- a/internal/app/screen_lock.go +++ b/internal/app/screen_lock.go @@ -140,6 +140,7 @@ func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputLis return } gate.retireTentacleOutput() + gate.sync_remote_policy() for _, target := range []any{adapter, listener, commands} { if controller, ok := target.(secureInputController); ok { controller.SetSecureInputActive(active) diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index ab681ab..add864d 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -32,8 +32,15 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo return } defer func() { <-runtime.interactionSlots }() - ctx, cancel := context.WithCancel(ctx) + commandContext := ctx + parent := ctx + if len(command.session.RemoteGrants()) != 0 && runtime.remotePolicy != nil { + parent = runtime.remotePolicy.Context() + } + ctx, cancel := context.WithCancel(parent) defer cancel() + stopCommand := context.AfterFunc(commandContext, cancel) + defer stopCommand() stop := context.AfterFunc(command.session.OutputContext(), cancel) defer stop() var temporaryFiles []string @@ -95,7 +102,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo return } formCtx, closeForm := context.WithCancel(ctx) - replies, openErr := runtime.forms.Open(formCtx, *value.Form) + replies, openErr := runtime.open_tentacle_form(formCtx, command, *value.Form) if openErr != nil { closeForm() runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) @@ -156,6 +163,17 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo } } +func (runtime *tentacleRuntime) open_tentacle_form(ctx context.Context, command tentacleCommand, form interaction.Form) (<-chan internalUIResult, error) { + if len(command.session.RemoteGrants()) != 0 { + local, ok := runtime.forms.(localTentacleForms) + if !ok { + return nil, errors.New("local approval input unavailable") + } + return local.OpenLocal(ctx, form) + } + return runtime.forms.Open(ctx, form) +} + func (runtime *tentacleRuntime) validContinuation(command tentacleCommand, id string) bool { if id == "" { return true diff --git a/internal/app/tentacle_remote_consent.go b/internal/app/tentacle_remote_consent.go new file mode 100644 index 0000000..ea8f0d1 --- /dev/null +++ b/internal/app/tentacle_remote_consent.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type localTentacleForms interface { + OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error) +} + +// authorize_remote is a core entry point, not a registered Tentacle command or +// child RPC. A future connection command must resolve the installed session and +// role before calling it. A synthetic shortcut may request a dialog but cannot +// select permissions or approve it through the local-only capture path. +func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role string, grants []string) (tentaclewire.SessionEvent, error) { + if runtime == nil || runtime.remotePolicy == nil || !tentaclewire.ValidRoleGrants(role, grants) { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + forms, ok := runtime.forms.(localTentacleForms) + if !ok { + return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable + } + grants = slices.Clone(grants) + runtime.mutex.Lock() + var target *tentacle.Session + if !runtime.disabled { + for _, session := range runtime.sessions { + if session.TentacleID() == id { + target = session + break + } + } + } + runtime.mutex.Unlock() + if target == nil { + return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable + } + installed := target.RemoteGrants() + for _, grant := range grants { + if !slices.Contains(installed, grant) { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + } + // Reserve the interaction before creating a ticket, so a second request + // rejected as busy cannot invalidate the dialog already on screen. + select { + case runtime.interactionSlots <- struct{}{}: + default: + return tentaclewire.SessionEvent{}, tentacle.ErrBusy + } + defer func() { <-runtime.interactionSlots }() + approval, err := target.BeginRemoteApproval(role, grants) + if err != nil { + return tentaclewire.SessionEvent{}, err + } + defer approval.Cancel() + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + stopPolicy := context.AfterFunc(approval.Context(), cancel) + defer stopPolicy() + stopSession := context.AfterFunc(target.OutputContext(), cancel) + defer stopSession() + form := remote_consent_form(id, role, grants) + replies, err := forms.OpenLocal(ctx, form) + if err != nil { + return tentaclewire.SessionEvent{}, err + } + select { + case reply, open := <-replies: + if !open || ctx.Err() != nil || reply.Cancelled || reply.Err != nil || reply.Activated != "approve" { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + var selected []string + for _, grant := range grants { + if reply.Values[grant].Checked { + selected = append(selected, grant) + } + } + if len(selected) == 0 { + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } + return target.AuthorizeRemoteWithApproval(approval, role, selected) + case <-ctx.Done(): + return tentaclewire.SessionEvent{}, tentacle.ErrPermission + } +} + +func remote_consent_form(id, role string, grants []string) interaction.Form { + roleText := "control another computer" + if role == "controlled" { + roleText = "allow a helper to access this computer" + } + form := interaction.Form{Title: "Remote access for " + id + ": " + roleText + + ". The relay can read the channel key and session contents. A peer ID does not verify a person's identity. Select access for this session using a local keyboard.", + Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}} + labels := map[string]string{ + "output-observe": "Share local reader output with the remote controller", + "remote-present": "Present output from the controlled computer", + "keyboard-capture": "Forward local keyboard input to the controlled computer", + "keyboard-inject": "Allow the remote controller to type on this computer", + "clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request", + "braille-stream": "Exchange remote braille cells and supported display gestures", + } + for _, grant := range grants { + form.Elements = append(form.Elements, interaction.Element{ID: grant, Label: labels[grant], Kind: interaction.CheckBox}) + } + form.Elements = append(form.Elements, interaction.Element{ID: "approve", Label: "Approve selected access for this session", Kind: interaction.Button}) + return form +} diff --git a/internal/app/tentacle_remote_consent_test.go b/internal/app/tentacle_remote_consent_test.go new file mode 100644 index 0000000..9a516ae --- /dev/null +++ b/internal/app/tentacle_remote_consent_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +type consentFormRequest struct { + ctx context.Context + form interaction.Form + reply chan internalUIResult +} +type consentTestForms struct{ requests chan consentFormRequest } + +func (forms *consentTestForms) Open(context.Context, interaction.Form) (<-chan internalUIResult, error) { + return nil, errors.New("ordinary form used for consent") +} +func (forms *consentTestForms) Notice(context.Context, tentaclewire.Presentation) {} +func (forms *consentTestForms) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) { + reply := make(chan internalUIResult, 1) + forms.requests <- consentFormRequest{ctx, form, reply} + return reply, nil +} + +func consent_runtime_fixture(t *testing.T) (*tentacleRuntime, *tentacle.Session, *consentTestForms, *focusGate) { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := manager.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + policy := tentacle.NewRemotePolicy() + if err := manager.UseRemotePolicy(policy); err != nil { + t.Fatal(err) + } + session, err := manager.Start(t.Context(), tentacle.Spec{ID: "consent-probe", Program: []string{executable, "-test.run=^TestRemoteManagementPeer$", "--", "remote-management-peer"}, SessionEvents: true, RemoteGrants: []string{"output-observe", "clipboard-exchange"}}) + if err != nil { + t.Fatal(err) + } + select { + case <-session.Ready(): + case <-time.After(5 * time.Second): + t.Fatal("peer not ready") + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + forms := &consentTestForms{requests: make(chan consentFormRequest, 1)} + runtime := &tentacleRuntime{remotePolicy: policy, forms: forms, sessions: []*tentacle.Session{session}, interactionSlots: make(chan struct{}, 1)} + return runtime, session, forms, &focusGate{remotePolicy: policy} +} + +func TestRemoteConsentFormDefaultsAndRevocation(t *testing.T) { + for _, action := range []string{"approve", "cancel", "empty", "lock", "handoff", "revoke", "disable"} { + t.Run(action, func(t *testing.T) { + runtime, session, forms, gate := consent_runtime_fixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := runtime.authorize_remote(ctx, "consent-probe", "controlled", []string{"output-observe", "clipboard-exchange"}) + result <- err + }() + var request consentFormRequest + select { + case request = <-forms.requests: + case <-ctx.Done(): + t.Fatal("no consent form") + } + if _, err := interaction.NewSession(request.form); err != nil { + t.Fatal("invalid consent form", err) + } + if request.form.Elements[0].ID != "cancel" { + t.Fatal("approval was default action") + } + for _, element := range request.form.Elements { + if element.Checked { + t.Fatal("permission preselected") + } + } + reply := internalUIResult{Activated: "approve", Values: map[string]interaction.Value{"output-observe": {Checked: true}}} + switch action { + case "cancel": + reply.Activated = "cancel" + case "empty": + reply.Values = nil + case "lock": + gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) + gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{}) + case "handoff": + gate.SetSuspended(true) + gate.SetSuspended(false) + case "revoke": + runtime.revokeManagedRemote("consent-probe") + case "disable": + session.Disable() + } + request.reply <- reply + select { + case err := <-result: + if action == "approve" && err != nil { + t.Fatal(err) + } + if action != "approve" && err == nil { + t.Fatal("stale/denied consent issued action") + } + case <-ctx.Done(): + t.Fatal("consent worker did not finish") + } + if action != "approve" { + return + } + pending, err := session.Invoke(ctx, "open", nil) + if err != nil { + t.Fatal(err) + } + var lease tentaclewire.RemoteLease + select { + case opened := <-pending: + if opened.Err != nil || json.Unmarshal(opened.Value, &lease) != nil { + t.Fatal("approved action failed", opened.Err) + } + case <-ctx.Done(): + t.Fatal("open timed out") + } + delivery, err := session.RemoteContext(lease, "output-observe") + if err != nil { + t.Fatal(err) + } + if _, err := session.RemoteContext(lease, "clipboard-exchange"); err == nil { + t.Fatal("unchecked permission granted") + } + gate.SetSuspended(true) + if delivery.Err() == nil { + t.Fatal("handoff did not synchronously cancel delivery") + } + gate.SetSuspended(false) + if _, err := session.RemoteContext(lease, "output-observe"); err == nil { + t.Fatal("handoff resume revived access") + } + }) + } +} + +func TestRemoteGrantedTentacleUsesLocalForms(t *testing.T) { + runtime, session, forms, _ := consent_runtime_fixture(t) + form := interaction.Form{Title: "Certificate review", Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}} + if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, form); err != nil { + t.Fatal(err) + } + select { + case <-forms.requests: + default: + t.Fatal("remote form used ordinary input") + } +} + +type ordinaryOnlyForms struct{ tentacleForms } + +func TestRemoteConsentCannotFallBackToOrdinaryForms(t *testing.T) { + runtime, session, _, _ := consent_runtime_fixture(t) + runtime.forms = ordinaryOnlyForms{runtime.forms} + if _, err := runtime.authorize_remote(t.Context(), "consent-probe", "controlled", []string{"output-observe"}); err == nil { + t.Fatal("consent fell back to ordinary input") + } + if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, remote_consent_form("consent-probe", "controlled", []string{"output-observe"})); err == nil { + t.Fatal("sensitive form fell back to ordinary input") + } +} + +func TestRemotePolicyAttachedToRuntimeManagers(t *testing.T) { + runtime := &tentacleRuntime{remotePolicy: tentacle.NewRemotePolicy(), newManager: func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }} + manager, err := runtime.makeManager() + if err != nil { + t.Fatal(err) + } + if err := manager.UseRemotePolicy(tentacle.NewRemotePolicy()); err == nil { + t.Fatal("reader manager omitted policy") + } +} diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go index c501625..4f2a81f 100644 --- a/internal/app/tentacles.go +++ b/internal/app/tentacles.go @@ -34,6 +34,7 @@ type tentacleCommand struct { // native publication; mutex protects dispatch snapshots and immediate disable. // Only explicitly granted presentation commands reach the core output owner. type tentacleRuntime struct { + remotePolicy *tentacle.RemotePolicy readerSettings *preferencesWorker extensionChanges chan struct{} forms tentacleForms @@ -421,6 +422,14 @@ func (runtime *tentacleRuntime) report(message string) { // makeManager runs on the lifecycle worker, never on input or GTK callbacks. func (runtime *tentacleRuntime) makeManager() (*tentacle.Manager, error) { + manager, err := runtime.make_manager() + if err == nil && runtime.remotePolicy != nil { + err = manager.UseRemotePolicy(runtime.remotePolicy) + } + return manager, err +} + +func (runtime *tentacleRuntime) make_manager() (*tentacle.Manager, error) { if runtime.newManager != nil { return runtime.newManager() } diff --git a/internal/platform/xorg/commands_local.c b/internal/platform/xorg/commands_local.c new file mode 100644 index 0000000..b3ae015 --- /dev/null +++ b/internal/platform/xorg/commands_local.c @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "commands_native.h" +#include +#include +#include +#include +#include + +// Xorg marks XTEST devices with a non-deletable, read-only property. Device +// names and the core send_event flag are not provenance. Unknown/non-device +// sources stay ineligible; no timing match against XRecord is involved. +static int local_source(CommandDevice *device, int source) { + if (source <= 0 || source == device->localKeyboard) return 0; + int count = 0, allowed = 0; + cthulhu_x11_begin_read(device->display); + XIDeviceInfo *info = XIQueryDevice(device->display, source, &count); + if (info && count == 1 && info->use == XISlaveKeyboard && info->enabled && + info->attachment == device->localKeyboard) { + Atom type = None; + int format = 0; + unsigned long items = 0, remaining = 0; + unsigned char *data = NULL; + Atom xtest = XInternAtom(device->display, "XTEST Device", True); + int result = xtest == None ? BadAtom : XIGetProperty(device->display, + source, xtest, 0, 1, False, AnyPropertyType, &type, &format, + &items, &remaining, &data); + // The atom must exist on the server, but not on this source device. + int synthetic = result != Success || type != None; + if (data) XFree(data); + data = NULL; + Atom node = XInternAtom(device->display, "Device Node", True); + if (!synthetic && node != None && XIGetProperty(device->display, source, + node, 0, 64, False, XA_STRING, &type, &format, &items, + &remaining, &data) == Success && type == XA_STRING && format == 8 && + items > 16 && items < 256 && remaining == 0 && data && + memcmp(data, "/dev/input/event", 16) == 0) { + int valid = 1; + for (unsigned long i = 16; i < items; i++) + if (data[i] < '0' || data[i] > '9') valid = 0; + char path[256]; + memcpy(path, data, items); + path[items] = 0; + struct stat status; + if (valid && stat(path, &status) == 0 && S_ISCHR(status.st_mode)) + allowed = 1; + } + if (data) XFree(data); + } + if (info) XIFreeDeviceInfo(info); + if (cthulhu_x11_end_read(device->display)) allowed = 0; + return allowed; +} + +static int acquire_local_keyboard_unchecked(CommandDevice *device) { + int eventBase, errorBase, major = 2, minor = 0, pointer = 0; + if (device->active || !XQueryExtension(device->display, "XInputExtension", + &device->xiOpcode, &eventBase, &errorBase) || + XIQueryVersion(device->display, &major, &minor) != Success || major < 2 || + !XIGetClientPointer(device->display, None, &pointer)) return -1; + int count = 0, keyboard = 0; + XIDeviceInfo *info = XIQueryDevice(device->display, pointer, &count); + if (info && count == 1 && info->use == XIMasterPointer && info->enabled) + keyboard = info->attachment; + if (info) XIFreeDeviceInfo(info); + if (!keyboard) return -1; + // A key held before the dialog cannot submit it on repeat or provide an + // inherited modifier. The caller may retry after releasing that chord. + char held[32]; + XQueryKeymap(device->display, held); + for (int i = 0; i < 32; i++) if (held[i]) return -1; + unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0}; + XISetMask(bits, XI_KeyPress); + XISetMask(bits, XI_KeyRelease); + XIEventMask mask = {keyboard, sizeof(bits), bits}; + int result = XIGrabDevice(device->display, keyboard, device->root, CurrentTime, + None, GrabModeAsync, GrabModeAsync, False, &mask); + if (result != GrabSuccess) return result ? result : -1; + unsigned char changes[XIMaskLen(XI_LASTEVENT)] = {0}; + XISetMask(changes, XI_HierarchyChanged); + XIEventMask hierarchy = {XIAllDevices, sizeof(changes), changes}; + XISelectEvents(device->display, device->root, &hierarchy, 1); + device->localKeyboard = keyboard; + memset(device->localHeld, 0, sizeof(device->localHeld)); + // Initialize the ordinary text/compose renderer after acquiring XI2. Its + // core-grab branch is skipped because the device already owns capture. + device->active = 1; + acquire_internal_keyboard(device); + XFlush(device->display); + return 0; +} + +int acquire_local_keyboard(CommandDevice *device) { + // The selected master can disappear between query and grab. Like source + // validation, acquisition must turn BadDevice into denial, not reader exit. + cthulhu_x11_begin_read(device->display); + int result = acquire_local_keyboard_unchecked(device); + int error = cthulhu_x11_end_read(device->display); + if (error) { + release_local_keyboard(device); + device->active = 0; + reset_internal_compose(device); + return -1; + } + return result; +} + +// Convert the very same device event to the existing text renderer. Core and +// SendEvent traffic is discarded in this mode, as are XTEST and unknown sources. +int next_local_key(CommandDevice *device, XEvent *event) { + if (event->type != GenericEvent || event->xcookie.extension != device->xiOpcode || + !XGetEventData(device->display, &event->xcookie)) return 0; + int accepted = 0; + XKeyEvent key = {0}; + // Device removal/disable/reattachment invalidates this capture, including + // keys whose release can no longer arrive. Reopen against a fresh hierarchy. + if (!event->xcookie.send_event && event->xcookie.evtype == XI_HierarchyChanged) accepted = -1; + if (event->xcookie.evtype == XI_KeyPress || event->xcookie.evtype == XI_KeyRelease) { + XIDeviceEvent *input = event->xcookie.data; + if (!event->xcookie.send_event && !input->send_event && + input->deviceid == device->localKeyboard && input->detail >= 8 && + input->detail <= 255 && !(input->flags & XIKeyRepeat) && + local_source(device, input->sourceid)) { + int code = input->detail; + // Duplicate downs and releases without a down in this capture + // lifetime cannot navigate or activate a local form. + if ((input->evtype == XI_KeyPress) != !!device->localHeld[code]) { + key.type = input->evtype == XI_KeyPress ? KeyPress : KeyRelease; + key.display = device->display; + key.window = input->event; + key.root = input->root; + key.time = input->time; + key.keycode = code; + key.same_screen = True; + // Ignore modifiers held by an injected source. Build state + // solely from keys admitted during this local capture. + for (int i = 8; i < 256; i++) if (device->localHeld[i]) + key.state |= XkbKeysymToModifiers(device->display, + XkbKeycodeToKeysym(device->display, i, 0, 0)); + device->localHeld[code] = key.type == KeyPress; + accepted = 1; + } + } + } + XFreeEventData(device->display, &event->xcookie); + if (accepted > 0) { memset(event, 0, sizeof(*event)); event->xkey = key; } + return accepted; +} + +void release_local_keyboard(CommandDevice *device) { + if (device->localKeyboard) { + cthulhu_x11_begin_read(device->display); + XIUngrabDevice(device->display, device->localKeyboard, CurrentTime); + unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0}; + XIEventMask mask = {XIAllDevices, sizeof(bits), bits}; + XISelectEvents(device->display, device->root, &mask, 1); + // A removed master has already lost its grab. Still clear our local + // ownership and root event selection when XIUngrab reports BadDevice. + cthulhu_x11_end_read(device->display); + device->localKeyboard = 0; + memset(device->localHeld, 0, sizeof(device->localHeld)); + } +} diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index 9e64801..59aa6a4 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -588,6 +588,11 @@ int next_command_key(CommandDevice *device, CommandKey *key) { key->timestamp=((XkbEvent *)&event)->indicators.time; return 2; } + if (device->localKeyboard) { + int local = next_local_key(device, &event); + if (local < 0) return 3; + if (!local) return 0; + } if (event.type!=KeyPress && event.type!=KeyRelease) return 0; // Events captured before suspension can still be queued in Xlib after // the grabs are released. Discard that old reader input; it must neither @@ -654,6 +659,7 @@ int next_command_key(CommandDevice *device, CommandKey *key) { return 1; } void release_command_keyboard(CommandDevice *device) { + release_local_keyboard(device); XUngrabKeyboard(device->display,CurrentTime); XFlush(device->display); device->active=0; } int replay_command_keys(CommandDevice *device, CommandKey *keys, int count) { diff --git a/internal/platform/xorg/commands_native.h b/internal/platform/xorg/commands_native.h index 1c53fde..32b5a02 100644 --- a/internal/platform/xorg/commands_native.h +++ b/internal/platform/xorg/commands_native.h @@ -20,6 +20,8 @@ typedef struct { int commands; int active; int internalUI; + int localKeyboard, xiOpcode; + unsigned char localHeld[256]; int browse; int nativeButtonFocus; Window browseWindow; @@ -55,6 +57,9 @@ int command_lock_state(CommandDevice *device); void toggle_command_caps_lock(CommandDevice *device); int next_command_key(CommandDevice *device, CommandKey *key); int acquire_internal_keyboard(CommandDevice *device); +int acquire_local_keyboard(CommandDevice *device); +int next_local_key(CommandDevice *device, XEvent *event); +void release_local_keyboard(CommandDevice *device); void reset_internal_compose(CommandDevice *device); void release_command_keyboard(CommandDevice *device); int replay_command_keys(CommandDevice *device, CommandKey *keys, int count); diff --git a/internal/platform/xorg/commands_owner_loop.go b/internal/platform/xorg/commands_owner_loop.go index d213707..c1b498a 100644 --- a/internal/platform/xorg/commands_owner_loop.go +++ b/internal/platform/xorg/commands_owner_loop.go @@ -91,6 +91,21 @@ func (owner *commandOwner) run() { owner.publishLocks(key) continue } + if result == 3 { + // A changed XI2 hierarchy invalidates the local source set. A + // removed device may never release its held keys; retire that chord. + session := owner.uiSession + clear(owner.held) + owner.buffered = nil + owner.router.Reset() + owner.bound, owner.passThrough = false, false + owner.dismiss_internal_ui() + if session != 0 && !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: input.InternalUIDismiss, UISession: session}) { + owner.report_ui_failure(errors.New("internal UI queue overflow")) + return + } + continue + } if !owner.handleKey(key) { return } diff --git a/internal/platform/xorg/commands_ui.go b/internal/platform/xorg/commands_ui.go index f0547f9..c300f96 100644 --- a/internal/platform/xorg/commands_ui.go +++ b/internal/platform/xorg/commands_ui.go @@ -6,6 +6,7 @@ package xorg /* #cgo pkg-config: xkbcommon +#cgo pkg-config: xi #include "commands_native.h" */ import "C" @@ -26,6 +27,7 @@ type uiUpdate struct { ctx context.Context closeSession uint64 result chan uiReply + localOnly bool } func (listener *CommandListener) InternalUISession() uint64 { return listener.uiSession.Load() } @@ -35,7 +37,14 @@ func (listener *CommandListener) InternalUIEchoActive() bool { return listener.u // BeginInternalUI acknowledges exclusive capture without changing X input // focus. EndInternalUI waits for physical releases, and cannot close a newer UI. func (listener *CommandListener) BeginInternalUI(ctx context.Context) (uint64, error) { - return listener.update_internal_ui(ctx, 0) + return listener.update_internal_ui(ctx, 0, false) +} + +// BeginLocalUI admits only device-backed XI2 events and excludes XTEST. This +// is a core API, never a Tentacle-controlled form flag. It fails on unsupported +// servers; ordinary forms keep their existing input path. +func (listener *CommandListener) BeginLocalUI(ctx context.Context) (uint64, error) { + return listener.update_internal_ui(ctx, 0, true) } func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint64) error { if session == 0 { @@ -50,17 +59,17 @@ func (listener *CommandListener) EndInternalUI(ctx context.Context, session uint break } } - _, err := listener.update_internal_ui(ctx, session) + _, err := listener.update_internal_ui(ctx, session, false) return err } -func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64) (uint64, error) { +func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSession uint64, localOnly bool) (uint64, error) { listener.mutex.Lock() done := listener.done listener.mutex.Unlock() if done == nil { return 0, errors.New("command listener is not running") } - update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1)} + update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1), localOnly: localOnly} select { case listener.uiUpdates <- update: case <-ctx.Done(): @@ -76,7 +85,7 @@ func (listener *CommandListener) update_internal_ui(ctx context.Context, closeSe } } -func (owner *commandOwner) begin_internal_ui() (uint64, error) { +func (owner *commandOwner) begin_internal_ui_mode(localOnly bool) (uint64, error) { if !owner.commandGrabsAllowed() || owner.learnMode || owner.ocrReview || owner.uiSession != 0 { return 0, errors.New("internal UI input unavailable") } @@ -86,7 +95,13 @@ func (owner *commandOwner) begin_internal_ui() (uint64, error) { return 0, errors.New("no focused application") } owner.uiWindow = C.read_foreground(owner.device.display).window - if status := C.acquire_internal_keyboard(owner.device); status != 0 { + var status C.int + if localOnly { + status = C.acquire_local_keyboard(owner.device) + } else { + status = C.acquire_internal_keyboard(owner.device) + } + if status != 0 { return 0, fmt.Errorf("internal keyboard capture failed: %d", int(status)) } owner.uiSession = owner.listener.uiSequence.Add(1) @@ -96,6 +111,10 @@ func (owner *commandOwner) begin_internal_ui() (uint64, error) { return owner.uiSession, nil } +func (owner *commandOwner) begin_internal_ui() (uint64, error) { + return owner.begin_internal_ui_mode(false) +} + func (owner *commandOwner) dismiss_internal_ui() { owner.uiSession = 0 owner.listener.uiSession.Store(0) @@ -136,7 +155,7 @@ func (owner *commandOwner) sync_internal_ui() bool { owner.dismiss_internal_ui() } } else { - result.session, result.err = owner.begin_internal_ui() + result.session, result.err = owner.begin_internal_ui_mode(update.localOnly) } } update.result <- result diff --git a/internal/platform/xorg/local_ui_native_test.go b/internal/platform/xorg/local_ui_native_test.go new file mode 100644 index 0000000..d9f28df --- /dev/null +++ b/internal/platform/xorg/local_ui_native_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package xorg + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestNativeLocalUIRejectsInjectedApproval(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_REMOTE_INPUT") != "1" { + t.Skip("requires private Remote input harness") + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + probe := exec.CommandContext(ctx, "xev", "-name", "Local approval probe", "-event", "keyboard") + if err := probe.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() + var window string + for window == "" { + data, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Local approval probe$").Output() + if err == nil { + window = strings.TrimSpace(string(data)) + } + if ctx.Err() != nil { + t.Fatal("probe unavailable") + } + time.Sleep(10 * time.Millisecond) + } + if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { + t.Fatal(err) + } + listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) + if err := listener.Start(ctx); err != nil { + t.Fatal(err) + } + defer listener.Stop(context.Background()) + key := func(args ...string) { + t.Helper() + if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("private injection: %v %s", err, data) + } + } + key("keydown", "Return") + if id, err := listener.BeginLocalUI(ctx); err == nil { + _ = listener.EndInternalUI(ctx, id) + t.Fatal("capture admitted pre-held key") + } + key("keyup", "Return") + id, err := listener.BeginLocalUI(ctx) + if err != nil { + t.Fatal(err) + } + // XTEST has send_event=False. Neither navigation, approval, secret entry, + // clipboard shortcuts nor Escape may enter this local-only form. + key("key", "Tab", "space", "Return", "a", "ctrl+v", "Insert+space", "Insert+shift+a", "Escape") + select { + case event := <-listener.Commands(): + t.Fatalf("injected form command: %+v", event) + case err := <-listener.Failures(): + t.Fatal(err) + case <-time.After(150 * time.Millisecond): + } + if listener.InternalUISession() != id { + t.Fatal("injected Escape dismissed local form") + } + if err := listener.EndInternalUI(ctx, id); err != nil { + t.Fatal(err) + } + ordinary, err := listener.BeginInternalUI(ctx) + if err != nil { + t.Fatal(err) + } + key("key", "Return") + select { + case event := <-listener.Commands(): + if event.Command != input.InternalUIKey || event.UISession != ordinary { + t.Fatal(event) + } + case <-ctx.Done(): + t.Fatal("ordinary form stopped receiving keys") + } + if err := listener.EndInternalUI(ctx, ordinary); err != nil { + t.Fatal(err) + } + // Reopening local capture must not inherit the ordinary form's event path. + id, err = listener.BeginLocalUI(ctx) + if err != nil { + t.Fatal(err) + } + if data, err := exec.CommandContext(ctx, "xinput", "create-master", "CthulhuProbe").CombinedOutput(); err != nil { + t.Fatalf("private hierarchy change: %v %s", err, data) + } + defer func() { + data, err := exec.CommandContext(ctx, "xinput", "list", "--id-only", "CthulhuProbe pointer").Output() + if err == nil { + if out, err := exec.CommandContext(ctx, "xinput", "remove-master", strings.TrimSpace(string(data))).CombinedOutput(); err != nil { + t.Errorf("remove private master: %v %s", err, out) + } + } + }() + select { + case event := <-listener.Commands(): + if event.Command != input.InternalUIDismiss || event.UISession != id { + t.Fatal("hierarchy did not retire capture", event) + } + case <-ctx.Done(): + t.Fatal("hierarchy change left local capture active") + } + if err := listener.EndInternalUI(ctx, id); err != nil { + t.Fatal(err) + } + id, err = listener.BeginLocalUI(ctx) + if err != nil { + t.Fatal(err) + } + listener.SetSecureInputActive(true) + for listener.InternalUISession() != 0 { + if ctx.Err() != nil { + t.Fatal("secure input did not revoke local capture") + } + time.Sleep(time.Millisecond) + } + if err := listener.EndInternalUI(ctx, id); err != nil { + t.Fatal(err) + } + t.Log("XTEST rejected by XI2 source identity; genuine hardware approval remains unverified") +} diff --git a/internal/platform/xorg/xterm_handoff_native_test.go b/internal/platform/xorg/xterm_handoff_native_test.go index fada4b6..cc1d3e3 100644 --- a/internal/platform/xorg/xterm_handoff_native_test.go +++ b/internal/platform/xorg/xterm_handoff_native_test.go @@ -21,7 +21,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() probe := filepath.Join(t.TempDir(), "suspension-probe") - if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil { + if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "commands_local.c", "-o", probe, "-lX11", "-lXtst", "-lXi", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil { t.Fatalf("compile native probe: %v: %s", err, out) } for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} { diff --git a/internal/tentacle/manager.go b/internal/tentacle/manager.go index 796fb33..f6b60eb 100644 --- a/internal/tentacle/manager.go +++ b/internal/tentacle/manager.go @@ -65,6 +65,7 @@ type Manager struct { cleanupError error remoteMutex sync.Mutex remoteOwner *Session + remotePolicy *RemotePolicy // immutable after the first child is started } // NewSupervisedManager launches a private lifetime helper for each Tentacle. diff --git a/internal/tentacle/remote.go b/internal/tentacle/remote.go index 794858d..92538eb 100644 --- a/internal/tentacle/remote.go +++ b/internal/tentacle/remote.go @@ -22,27 +22,37 @@ const remoteApprovalTimeout = 30 * time.Second var ErrPermission = errors.New("tentacle permission denied") +// RemoteGrants returns install permissions, never current consent. +func (session *Session) RemoteGrants() []string { return slices.Clone(session.spec.RemoteGrants) } + // Owned by Session.mutex. No network identity or secret belongs here. type remoteAccess struct { - action string - lease string - epoch uint64 - sequence uint64 - role string - grants []string - ctx context.Context - cancel context.CancelFunc - deadline time.Time - timer *time.Timer + action string + lease string + epoch uint64 + sequence uint64 + role string + grants []string + ctx context.Context + cancel context.CancelFunc + deadline time.Time + timer *time.Timer + stopPolicy func() bool } -// AuthorizeRemote is a core-only hook for a future verified local consent UI. -// It is intentionally not exposed by any child RPC or production command yet. -// Install grants, network readiness and injected shortcuts are not local consent. -// A fresh action revokes the previous lease and consumes a new host-owned epoch. +// AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers +// use RemotePolicy and AuthorizeRemoteWithApproval instead; child RPCs cannot +// call either authorizer. func (session *Session) AuthorizeRemote(role string, grants []string) (tentaclewire.SessionEvent, error) { session.mutex.Lock() defer session.mutex.Unlock() + if session.manager.remotePolicy != nil { + return tentaclewire.SessionEvent{}, ErrPermission + } + return session.authorize_remote_locked(role, grants, session.outputContext) +} + +func (session *Session) authorize_remote_locked(role string, grants []string, parent context.Context) (tentaclewire.SessionEvent, error) { if session.state != Active || session.ctx.Err() != nil || !session.spec.SessionEvents { return tentaclewire.SessionEvent{}, ErrUnavailable } @@ -79,10 +89,19 @@ func (session *Session) AuthorizeRemote(role string, grants []string) (tentaclew return tentaclewire.SessionEvent{}, ErrUnavailable } session.remoteEpoch++ - ctx, cancel := context.WithCancel(session.outputContext) + ctx, cancel := context.WithCancel(parent) access := &remoteAccess{action: hex.EncodeToString(token[:]), epoch: session.remoteEpoch, role: role, grants: slices.Clone(grants), ctx: ctx, cancel: cancel} session.remote = access + // Parent cancellation invalidates delivery immediately. Retirement/event + // cleanup acquires the session lock afterwards and cannot retire a newer epoch. + access.stopPolicy = context.AfterFunc(parent, func() { + session.mutex.Lock() + defer session.mutex.Unlock() + if session.remote == access { + session.revoke_remote_locked("local", true) + } + }) session.refresh_remote_locked(remoteApprovalTimeout) event := tentaclewire.SessionEvent{Kind: "remote.action", Action: access.action, Epoch: access.epoch, Role: role, Grants: slices.Clone(grants)} if !session.remote_event_locked(&event) { @@ -121,12 +140,19 @@ func (session *Session) release_remote_owner() { } func (session *Session) revoke_remote_locked(reason string, notify bool) { + if session.remoteApproval != nil { + session.remoteApproval.cancel() + session.remoteApproval = nil + } access := session.remote if access == nil { return } session.remote = nil access.cancel() + if access.stopPolicy != nil { + access.stopPolicy() + } if access.timer != nil { access.timer.Stop() } diff --git a/internal/tentacle/remote_policy.go b/internal/tentacle/remote_policy.go new file mode 100644 index 0000000..4224054 --- /dev/null +++ b/internal/tentacle/remote_policy.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "context" + "slices" + "sync" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +// RemotePolicy belongs to the reader's focus/lock owner. Its cancellation is +// synchronous and does not acquire a session, command, output or registry lock. +// Restoring availability creates a fresh lifetime; it never revives consent. +type RemotePolicy struct { + mutex sync.Mutex + ctx context.Context + cancel context.CancelFunc + available bool +} + +// RemoteApproval is a single-use core ticket captured BEFORE showing local +// consent. It is not serializable and cannot be supplied by a Tentacle RPC. +type RemoteApproval struct { + policy *RemotePolicy + ctx context.Context + lifetime context.Context + cancel context.CancelFunc + session *Session + role string + grants []string +} + +func NewRemotePolicy() *RemotePolicy { + ctx, cancel := context.WithCancel(context.Background()) + return &RemotePolicy{ctx: ctx, cancel: cancel, available: true} +} + +func (policy *RemotePolicy) SetAvailable(available bool) { + policy.mutex.Lock() + defer policy.mutex.Unlock() + if policy.available == available { + return + } + policy.cancel() + policy.available = available + if available { + policy.ctx, policy.cancel = context.WithCancel(context.Background()) + } +} + +// Context captures the current availability lifetime for sensitive forms. A +// lock/handoff round trip cancels that lifetime even if native polling misses +// the short transition; callers must never replace it with the resumed one. +func (policy *RemotePolicy) Context() context.Context { + policy.mutex.Lock() + defer policy.mutex.Unlock() + return policy.ctx +} + +func (session *Session) BeginRemoteApproval(role string, grants []string) (*RemoteApproval, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + policy := session.manager.remotePolicy + if policy == nil || session.state != Active || session.ctx.Err() != nil || !session.spec.SessionEvents || !tentaclewire.ValidRoleGrants(role, grants) { + return nil, ErrPermission + } + for _, grant := range grants { + if !slices.Contains(session.spec.RemoteGrants, grant) { + return nil, ErrPermission + } + } + policy.mutex.Lock() + defer policy.mutex.Unlock() + if !policy.available { + return nil, ErrPermission + } + session.revoke_remote_locked("replaced", true) + if session.state != Active { + return nil, ErrUnavailable + } + ctx, cancel := context.WithCancel(policy.ctx) + approval := &RemoteApproval{policy: policy, ctx: ctx, lifetime: policy.ctx, cancel: cancel, session: session, role: role, grants: slices.Clone(grants)} + session.remoteApproval = approval + return approval, nil +} + +func (approval *RemoteApproval) Context() context.Context { return approval.ctx } + +// Cancel only retires this pending dialog, never an issued lease or a newer +// dialog. Package changes and session shutdown also cancel pending approvals. +func (approval *RemoteApproval) Cancel() { + approval.session.mutex.Lock() + defer approval.session.mutex.Unlock() + if approval.session.remoteApproval == approval { + approval.session.remoteApproval = nil + } + approval.cancel() +} + +// UseRemotePolicy must run before starting any children. A production manager +// with a policy rejects the unrestricted authorizer used by protocol fixtures. +func (manager *Manager) UseRemotePolicy(policy *RemotePolicy) error { + manager.mutex.Lock() + defer manager.mutex.Unlock() + if policy == nil || manager.remotePolicy != nil || len(manager.sessions) != 0 || manager.closed { + return ErrUnavailable + } + manager.remotePolicy = policy + return nil +} + +func (session *Session) AuthorizeRemoteWithApproval(approval *RemoteApproval, role string, grants []string) (tentaclewire.SessionEvent, error) { + session.mutex.Lock() + defer session.mutex.Unlock() + policy := session.manager.remotePolicy + if policy == nil || approval == nil || approval.policy != policy || session.remoteApproval != approval || approval.session != session { + return tentaclewire.SessionEvent{}, ErrPermission + } + policy.mutex.Lock() + defer policy.mutex.Unlock() + if !policy.available || approval.lifetime != policy.ctx || approval.ctx.Err() != nil || role != approval.role || !tentaclewire.ValidRoleGrants(role, grants) { + return tentaclewire.SessionEvent{}, ErrPermission + } + for _, grant := range grants { + if !slices.Contains(approval.grants, grant) { + return tentaclewire.SessionEvent{}, ErrPermission + } + } + session.remoteApproval = nil + defer approval.cancel() + return session.authorize_remote_locked(role, grants, approval.lifetime) +} diff --git a/internal/tentacle/remote_policy_test.go b/internal/tentacle/remote_policy_test.go new file mode 100644 index 0000000..b780cf8 --- /dev/null +++ b/internal/tentacle/remote_policy_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "encoding/json" + "errors" + "testing" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func TestRemotePolicyRevokesPendingAndLiveSDKAccess(t *testing.T) { + for _, language := range []string{"go", "python"} { + t.Run(language, func(t *testing.T) { + manager := test_manager(t, DefaultLimits(), nil) + policy := NewRemotePolicy() + if err := manager.UseRemotePolicy(policy); err != nil { + t.Fatal(err) + } + session := startSessionSDK(t, manager, language, "policy-probe") + if _, err := session.AuthorizeRemote("controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("fixture authorizer bypassed policy", err) + } + stale, _ := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + policy.SetAvailable(false) + if _, err := session.BeginRemoteApproval("controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("unavailable policy issued ticket", err) + } + policy.SetAvailable(true) + if _, err := session.AuthorizeRemoteWithApproval(stale, "controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("lock round trip revived ticket", err) + } + foreign := &RemoteApproval{policy: NewRemotePolicy()} + if _, err := session.AuthorizeRemoteWithApproval(foreign, "controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("foreign ticket accepted", err) + } + for _, opened := range []bool{false, true} { + approval, _ := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + action, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}) + if err != nil { + t.Fatal(err) + } + if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); !errors.Is(err, ErrPermission) { + t.Fatal("replayed approval", err) + } + invokeSessionSDK(t, session, "event", nil) + var lease tentaclewire.RemoteLease + request := tentaclewire.RemoteOpen{Action: action.Action, Epoch: action.Epoch, Role: action.Role, Grants: action.Grants} + if opened { + value, code := remoteCall(t, session, "remote.open", request) + if code != 0 || json.Unmarshal(value, &lease) != nil { + t.Fatal("open failed", code) + } + } + // Hold the session lock so asynchronous event cleanup cannot run. + // Policy cancellation must already invalidate every delivery context. + session.mutex.Lock() + access := session.remote + policy.SetAvailable(false) + cancelled := access.ctx.Err() != nil + session.mutex.Unlock() + if !cancelled { + t.Fatal("revocation waited for session worker") + } + policy.SetAvailable(true) + if _, code := remoteCall(t, session, "remote.open", request); code != -32001 { + t.Fatal("stale open revived approval", code) + } + if opened { + if _, err := session.RemoteContext(lease, "output-observe"); !errors.Is(err, ErrPermission) { + t.Fatal("delivery survived policy", err) + } + if _, code := remoteCall(t, session, "remote.heartbeat", tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}); code != -32001 { + t.Fatal("heartbeat revived lease", code) + } + var event tentaclewire.SessionEvent + if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Kind != "remote.revoked" || event.Lease != lease.Lease { + t.Fatal("revocation event", event) + } + } + } + // A fresh approval can replace the cancelled epoch even if its + // asynchronous policy callback has not acquired the session lock yet. + fresh, _ := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + if _, err := session.AuthorizeRemoteWithApproval(fresh, "controlled", []string{"output-observe"}); err != nil { + t.Fatal("fresh approval failed", err) + } + }) + } +} diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index cce2135..f7635a4 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -51,30 +51,31 @@ type Session struct { outputContext context.Context revokeOutput context.CancelFunc - mutex sync.Mutex - manager *Manager - spec Spec - identity string - ctx context.Context - cancel context.CancelFunc - state State - failure error - extensions tentaclewire.Extensions - commands []Command - bindings []BindingStatus - sequence uint64 - pending map[string]*pendingCall - outbound chan outgoing - ready chan struct{} - done chan struct{} - stop chan struct{} - revoked chan struct{} - callbacks sync.WaitGroup - queuedBytes int - childSequence uint64 - eventSequence uint64 - remoteEpoch uint64 - remote *remoteAccess + mutex sync.Mutex + manager *Manager + spec Spec + identity string + ctx context.Context + cancel context.CancelFunc + state State + failure error + extensions tentaclewire.Extensions + commands []Command + bindings []BindingStatus + sequence uint64 + pending map[string]*pendingCall + outbound chan outgoing + ready chan struct{} + done chan struct{} + stop chan struct{} + revoked chan struct{} + callbacks sync.WaitGroup + queuedBytes int + childSequence uint64 + eventSequence uint64 + remoteEpoch uint64 + remote *remoteAccess + remoteApproval *RemoteApproval } func (session *Session) Ready() <-chan struct{} { return session.ready } diff --git a/tests/native_remote_input.py b/tests/native_remote_input.py index 88fee7e..69a8c4f 100644 --- a/tests/native_remote_input.py +++ b/tests/native_remote_input.py @@ -395,7 +395,7 @@ def main(): # paths as well as the standalone recovery experiment. subprocess.run([ "go", "test", "-race", "./internal/platform/xorg", "-count=1", "-timeout=60s", - "-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation)$", + "-run", "^TestNative(InternalUICapturesAndReleases|LearnModeKeepsKeysFromFocusedApplication|RemoteInjectedKeyObservation|LocalUIRejectsInjectedApproval|SuspendedQueuedKey|XTermHandoffKeepsKeypadReleased)$", "-v"], cwd=Path(__file__).resolve().parents[1], check=True, env=dict(os.environ, CTHULHU_NATIVE_X11="1", CTHULHU_NATIVE_REMOTE_INPUT="1")) else: