bastion 0.53.2
This commit is contained in:
@@ -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)}
|
||||
}
|
||||
Reference in New Issue
Block a user