Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

319 lines
12 KiB
Go

package main
// computer:* — the OBSERVE rung of governed computer use.
//
// The host plans from a MACHINE MAP, not a screenshot. "Get into Fusion 360
// and build me a flowerpot" starts with the host
// reading what this box has installed and, for each app, the deepest way in:
// the app's own scripting door first, the accessibility tree second, a cropped
// screenshot last. These two ops are that map. They READ; they never inject.
//
// computer:inventory — installed applications with version, publisher, path,
// where the fact came from, and `ways_in` (deepest first).
// computer:windows — the top-level windows open right now: title, owning
// process, app, which one has focus.
//
// Both ride the management lane like any other op — signed grant on the pod
// side (default-deny; a deny beats an allow), the op vocabulary in the
// enforcing report — and are dedicated no-exec handlers: nothing here takes
// caller arguments into a shell. Where an OS needs a helper (system_profiler,
// osascript, wmctrl) it is a fixed argv, and the parser is a pure function
// unit-tested from any OS with captured fixtures ("leech, don't adopt": no new
// Go module).
//
// Honesty over coverage: a platform or a box that cannot enumerate says so
// (supported:false + reason) instead of answering an empty list that would be
// read as "nothing installed" or "no windows open".
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/gorilla/websocket"
)
// computerApp is one installed application on this box. Path is the program,
// never a user file. WaysIn is ordered deepest first; the host picks the first
// rung it can drive.
type computerApp struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Publisher string `json:"publisher,omitempty"`
Path string `json:"path,omitempty"`
Source string `json:"source"` // registry | applications | desktop | flatpak | snap
WaysIn []string `json:"ways_in"`
}
// computerWindow is one top-level window open now. PID/App are best effort;
// Title is what a person sees, which is how a recorded step addresses it.
type computerWindow struct {
Title string `json:"title"`
PID int `json:"pid,omitempty"`
App string `json:"app,omitempty"`
Focused bool `json:"focused,omitempty"`
}
// computerReport is the wire shape of both ops. Supported=false carries the
// reason in the user's terms (a missing tool, a permission not granted, a
// headless box) — the host relays it, never guesses at it.
type computerReport struct {
Platform string `json:"platform"`
Scan string `json:"scan"` // which enumerator answered
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
ScannedAt string `json:"scanned_at"`
Apps []computerApp `json:"apps,omitempty"`
Windows []computerWindow `json:"windows,omitempty"`
}
const computerCmdTimeout = 20 * time.Second
// ── ways in: the judgement, platform-neutral, table-driven ───────────────────
// The rung names the host reads. Ordered deepest first when several apply.
const (
wayCLI = "cli" // a command-line entry point (drive it without a window)
wayGUI = "gui" // the accessibility tree, then a cropped screenshot — every app has this rung
)
// scriptDoor is one app family with a known scripting door: a way to hand the
// program a script and have it act, with no clicking. `match` are lowercase
// substrings tested against the app name and its executable's basename;
// `doors` are per-GOOS ("" = every OS), deepest first. This is a table of
// well-known, documented interfaces, not a guess — an entry is added when the
// vendor documents the door.
type scriptDoor struct {
match []string
exclude []string // a name carrying one of these is a sibling, not the app (an updater, a runtime, a theme)
doors map[string][]string
cli bool
}
var scriptDoors = []scriptDoor{
{match: []string{"fusion 360", "fusion360", "autodesk fusion"},
doors: map[string][]string{"": {"script:python (Fusion 360 API — scripts run inside the app)"}}},
{match: []string{"blender"},
doors: map[string][]string{"": {"script:python (bpy)"}}, cli: true},
{match: []string{"freecad"},
doors: map[string][]string{"": {"script:python (FreeCAD API)"}}, cli: true},
{match: []string{"kicad"},
doors: map[string][]string{"": {"script:python (pcbnew)"}}},
{match: []string{"gimp"},
doors: map[string][]string{"": {"script:python-fu / script-fu"}}, cli: true},
{match: []string{"inkscape"},
doors: map[string][]string{"": {"script:actions (inkscape --actions)"}}, cli: true},
{match: []string{"libreoffice", "openoffice", "soffice"},
doors: map[string][]string{"": {"script:python-uno"}}, cli: true},
// Outlook is deliberately absent: the new Outlook has no COM object model.
{match: []string{"microsoft excel", "excel.exe", "microsoft word", "winword", "microsoft powerpoint", "powerpnt"},
doors: map[string][]string{"windows": {"script:com-automation (Office object model)"}, "darwin": {"script:applescript (Office dictionary)"}}},
{match: []string{"adobe photoshop", "adobe illustrator", "adobe indesign", "adobe after effects", "adobe premiere"},
doors: map[string][]string{"": {"script:extendscript / uxp"}}},
{match: []string{"autocad"},
doors: map[string][]string{"windows": {"script:autolisp", "script:com-automation"}, "darwin": {"script:autolisp"}}},
{match: []string{"solidworks"},
doors: map[string][]string{"windows": {"script:com-automation (SolidWorks API)"}}},
{match: []string{"visual studio code", "code.exe", "vscodium", "cursor"}, exclude: []string{"theme", "cursors"},
doors: map[string][]string{}, cli: true},
{match: []string{"google chrome", "chromium", "microsoft edge", "brave"}, exclude: []string{"webview2", "update", "updater"},
doors: map[string][]string{"": {"script:chrome-devtools-protocol (launch with --remote-debugging-port)"}}, cli: true},
{match: []string{"firefox"},
doors: map[string][]string{"": {"script:webdriver-bidi / marionette"}}, cli: true},
{match: []string{"obsidian"},
doors: map[string][]string{"": {"script:uri (obsidian://)"}}},
{match: []string{"notepad++"},
doors: map[string][]string{}, cli: true},
{match: []string{"vlc"},
doors: map[string][]string{"": {"script:lua / http interface"}}, cli: true},
{match: []string{"audacity"},
doors: map[string][]string{"": {"script:mod-script-pipe"}}},
{match: []string{"matlab"}, exclude: []string{"runtime"},
doors: map[string][]string{"": {"script:matlab (-batch)"}}, cli: true},
{match: []string{"docker desktop"},
doors: map[string][]string{}, cli: true},
{match: []string{"terminal", "iterm", "windows terminal", "powershell", "gnome-terminal", "konsole"},
doors: map[string][]string{}, cli: true},
}
func (d scriptDoor) matches(key string) bool {
for _, x := range d.exclude {
if strings.Contains(key, x) {
return false
}
}
for _, m := range d.match {
if strings.Contains(key, m) {
return true
}
}
return false
}
// waysIn is the judgement: given an app's name and program path, the rungs the
// host can drive, deepest first. goos is a parameter so the table is testable
// for every platform from one CI. onPath says whether the program resolves on
// PATH — a CLI rung the table did not know about.
func waysIn(name, path, goos string, onPath bool) []string {
key := strings.ToLower(name) + " " + strings.ToLower(binBase(path)) + " " + strings.ToLower(filepath.Base(path))
var out []string
cli := onPath
for _, d := range scriptDoors {
if !d.matches(key) {
continue
}
out = append(out, d.doors[goos]...)
if goos != "" {
out = append(out, d.doors[""]...)
}
if d.cli {
cli = true
}
}
out = dedupeStrings(out)
if cli {
out = append(out, wayCLI)
}
return append(out, wayGUI)
}
func dedupeStrings(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// execOnPath reports whether a program name (or an absolute path that exists)
// resolves to something runnable — the honest test for a CLI rung.
func execOnPath(p string) bool {
p = strings.TrimSpace(p)
if p == "" {
return false
}
if filepath.IsAbs(p) {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
_, err := exec.LookPath(p)
return err == nil
}
// finishApps dedupes by name (first fact wins — the more authoritative source
// enumerates first on every platform), fills ways_in, and sorts by name so two
// scans of an unchanged box compare equal.
func finishApps(apps []computerApp) []computerApp {
seen := map[string]bool{}
out := make([]computerApp, 0, len(apps))
for _, a := range apps {
a.Name = strings.TrimSpace(a.Name)
if a.Name == "" {
continue
}
k := strings.ToLower(a.Name)
if seen[k] {
continue
}
seen[k] = true
a.WaysIn = waysIn(a.Name, a.Path, runtime.GOOS, execOnPath(a.Path))
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
// ── the two ops ──────────────────────────────────────────────────────────────
func computerInventoryReport() computerReport {
rep := computerReport{Platform: runtime.GOOS, Scan: computerInventoryKind,
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
if !computerObserveSupported {
rep.Reason = "no inventory enumerator for this platform"
return rep
}
apps, err := listInstalledApps()
if err != nil {
rep.Reason = err.Error()
return rep
}
rep.Supported = true
rep.Apps = finishApps(apps)
return rep
}
func computerWindowsReport() computerReport {
rep := computerReport{Platform: runtime.GOOS, Scan: computerWindowsKind,
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
if !computerObserveSupported {
rep.Reason = "no window enumerator for this platform"
return rep
}
wins, err := listWindows()
if err != nil {
rep.Reason = err.Error()
return rep
}
rep.Supported = true
if wins == nil {
wins = []computerWindow{}
}
rep.Windows = wins
return rep
}
// The handlers answer with the JSON report on stdout and exit 0 — an
// unsupported box is a true answer ("I cannot see"), not a refusal (126 is
// reserved for "not permitted"). Caller args are ignored: there are none.
func handleComputerInventory(ws *websocket.Conn, _ string, _ []string) {
writeComputerReport(ws, computerInventoryReport())
}
func handleComputerWindows(ws *websocket.Conn, _ string, _ []string) {
writeComputerReport(ws, computerWindowsReport())
}
func writeComputerReport(ws *websocket.Conn, rep computerReport) {
b, err := json.Marshal(rep)
if err != nil {
sendMgmt(ws, 'R', []byte("report: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
sendMgmt(ws, 'O', append(b, '\n'))
sendMgmtExit(ws, 0)
}
// runFixed runs a fixed argv with the op's timeout and returns stdout. Used by
// the drivers that lean on an OS tool; the argv never carries caller input.
// On failure the *exec.ExitError carries stderr (Output() folds it in); the
// drivers read it from there, never from err.Error(), which says only
// "exit status N".
func runFixed(name string, args ...string) ([]byte, error) {
return runFixedEnv(nil, name, args...)
}
// runFixedEnv is runFixed with extra environment entries (DISPLAY=:0 for a
// service that has no display of its own).
func runFixedEnv(extraEnv []string, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), computerCmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
hideConsole(cmd) // the Windows UIAutomation helper is PowerShell — console_windows.go
if len(extraEnv) > 0 {
cmd.Env = append(os.Environ(), extraEnv...)
}
return cmd.Output()
}