bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
package main
// computer:tree — the ACCESSIBILITY TREE of one application's windows: the
// middle rung of the machine map. Where an app offers no
// scripting door, this is how the host sees it: the app's OWN names for its
// controls, their roles, whether they are enabled and where they are — text,
// not pixels, and exact. It is a READ. Nothing here presses a button.
//
// Each OS has one accessibility API and one fixed helper that walks it:
// Windows UI Automation, through PowerShell's UIAutomationClient assembly
// (ships with .NET Framework on every Windows).
// macOS the AX tree through System Events, in JXA (osascript -l
// JavaScript). Needs the Accessibility permission, like
// computer:windows.
// Linux AT-SPI 2, through python3 + gi (Atspi). Absent → says so.
//
// "Leech, don't adopt": no COM, AX or D-Bus binding lands in the bastion; the
// helper scripts are fixed strings, the target pid and the node cap travel in
// the environment (never in argv, never interpolated), and the helper answers
// JSON that this side re-validates and re-counts.
//
// Args (from the pod, validated here): [pid, max_nodes?]. pid must be a
// positive integer that computer:windows or the agent scan reported — the
// host names a window by its owner, never by guessing.
import (
"encoding/json"
"errors"
"runtime"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
)
// The caps match the pod side (the host's machine map): a tree is answered to
// the host through a 100 KB reply wall, and a real node is ~120 bytes, so
// 500 nodes is what arrives whole. Fewer nodes also keeps the macOS walk (up
// to nine Apple Events per node) inside the op's timeout.
const (
treeDefaultMaxNodes = 500
treeMaxNodes = 800
treeMaxDepth = 40
treeMaxText = 120 // name / id / value are cut to this many characters
)
// computerNode is one element of the tree. Keys are what a recorded step
// addresses a control by: role + name (what a person sees), then the app's
// own id when it has one. Bounds are screen pixels.
type computerNode struct {
Role string `json:"role"`
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Value string `json:"value,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Focused bool `json:"focused,omitempty"`
Offscreen bool `json:"offscreen,omitempty"`
Children []*computerNode `json:"children,omitempty"`
}
// treeReport is the wire shape. Windows are the app's top-level windows,
// each a tree. node_count / truncated are recounted here, not trusted.
type treeReport struct {
Platform string `json:"platform"`
Scan string `json:"scan"`
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
ScannedAt string `json:"scanned_at"`
PID int `json:"pid,omitempty"`
App string `json:"app,omitempty"`
NodeCount int `json:"node_count"`
Truncated bool `json:"truncated,omitempty"`
Windows []*computerNode `json:"windows,omitempty"`
}
// helperTree is what every helper answers: {app, truncated, windows:[node…]}.
type helperTree struct {
App string `json:"app"`
Truncated bool `json:"truncated"`
Error string `json:"error"`
Windows []*computerNode `json:"windows"`
}
func parseTreeArgs(args []string) (pid, maxNodes int, err error) {
if len(args) < 1 {
return 0, 0, errors.New("computer:tree needs the owning pid (from computer:windows)")
}
pid, err = strconv.Atoi(strings.TrimSpace(args[0]))
if err != nil || pid <= 1 {
return 0, 0, errors.New("computer:tree: pid must be a positive integer")
}
maxNodes = treeDefaultMaxNodes
if len(args) > 1 && strings.TrimSpace(args[1]) != "" {
n, err := strconv.Atoi(strings.TrimSpace(args[1]))
if err != nil || n <= 0 {
return 0, 0, errors.New("computer:tree: max_nodes must be a positive integer")
}
if n > treeMaxNodes {
n = treeMaxNodes
}
maxNodes = n
}
return pid, maxNodes, nil
}
// boundTree recounts the helper's answer and enforces the node cap and depth
// cap on THIS side too — the helper is trusted for nothing but the walk.
func boundTree(wins []*computerNode, maxNodes int) (out []*computerNode, count int, truncated bool) {
var walk func(n *computerNode, depth int) *computerNode
walk = func(n *computerNode, depth int) *computerNode {
if n == nil || count >= maxNodes || depth > treeMaxDepth {
truncated = true
return nil
}
count++
c := *n
c.Children = nil
c.Name, c.ID, c.Value = cutRunes(c.Name, treeMaxText), cutRunes(c.ID, treeMaxText), cutRunes(c.Value, treeMaxText)
for _, ch := range n.Children {
if k := walk(ch, depth+1); k != nil {
c.Children = append(c.Children, k)
}
}
return &c
}
for _, w := range wins {
if k := walk(w, 0); k != nil {
out = append(out, k)
}
}
return out, count, truncated
}
func computerTreeReport(args []string) treeReport {
rep := treeReport{Platform: runtime.GOOS, Scan: computerTreeKind,
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
pid, maxNodes, err := parseTreeArgs(args)
if err != nil {
rep.Reason = err.Error()
return rep
}
rep.PID = pid
if !computerObserveSupported {
rep.Reason = "no accessibility-tree reader for this platform"
return rep
}
raw, err := readTree(pid, maxNodes)
if err != nil {
rep.Reason = err.Error()
if strings.Contains(rep.Reason, "signal: killed") {
// The helper hit the op's timeout: a busy app on macOS can cost
// several Apple Events per node. Say what to do, not "killed".
rep.Reason = "reading this app's tree took longer than the op allows — ask again with a smaller max_nodes"
}
return rep
}
var ht helperTree
if err := json.Unmarshal(raw, &ht); err != nil {
rep.Reason = "tree helper answered, not with a tree: " + truncate(err.Error(), 200)
return rep
}
if ht.Error != "" {
rep.Reason = ht.Error
return rep
}
rep.Supported = true
rep.App = ht.App
rep.Windows, rep.NodeCount, rep.Truncated = boundTree(ht.Windows, maxNodes)
rep.Truncated = rep.Truncated || ht.Truncated
if rep.Windows == nil {
rep.Windows = []*computerNode{}
}
return rep
}
func handleComputerTree(ws *websocket.Conn, _ string, args []string) {
rep := computerTreeReport(args)
b, err := json.Marshal(rep)
if err != nil {
sendMgmt(ws, 'R', []byte("report: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
// A bad ARGUMENT is the caller's fault and reads as such (exit 2, the
// usage-error convention); a blind box is still a true answer (exit 0).
if !rep.Supported && rep.PID == 0 {
sendMgmt(ws, 'R', []byte(rep.Reason+"\n"))
sendMgmtExit(ws, 2)
return
}
sendMgmt(ws, 'O', append(b, '\n'))
sendMgmtExit(ws, 0)
}
// cutRunes shortens a label to n characters on a character boundary — control
// names are the user's language, and a byte cut would split a multibyte one.
func cutRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// helperEnv is the only channel a caller value takes into a helper.
func helperEnv(pid, maxNodes int) []string {
return []string{"PRIVATAE_TARGET_PID=" + strconv.Itoa(pid), "PRIVATAE_MAX_NODES=" + strconv.Itoa(maxNodes)}
}