277 lines
9.0 KiB
Go
277 lines
9.0 KiB
Go
package main
|
|
|
|
// agentask.go — how something running as a person asks the machine's bastion
|
|
// to stop an agent, when the bastion is not theirs.
|
|
//
|
|
// The bastion installed for the whole machine keeps its state in its own
|
|
// account. An agent running as the person cannot write a request there, so
|
|
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
|
|
// managing the bastion for the person must be able to do (2026-10-30).
|
|
// This is the context socket's mechanism (contextsock.go) for a second
|
|
// question: any local account may connect; WHO connected comes from the
|
|
// kernel, never from the request; it may only ASK. The ask lands in the
|
|
// owner's factor as a pending request (stepup_machine.go), shown on Waiting
|
|
// for you and answered with the owner's code. Nothing here approves anything.
|
|
//
|
|
// Who asked is written into the request from the kernel's record of the
|
|
// connecting process: its account, and on Linux its pid, walked up to the
|
|
// agent it belongs to, so the owner reads "asked from Claude Code session
|
|
// notes-b2", not a sentence the asker wrote.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
askSockName = "ask.sock"
|
|
askMaxBytes = 4 << 10
|
|
askSockInFlight = 16
|
|
askMaxPendingPer = 5 // waiting requests one account may have at once
|
|
)
|
|
|
|
type agentAskRequest struct {
|
|
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go), "read" (reads.go) or "act" (owneracts.go)
|
|
PID int `json:"pid"`
|
|
Request string `json:"request,omitempty"` // "status": the id `stop` printed
|
|
// "read": which one, and its flags (readOpts).
|
|
Read string `json:"read,omitempty"`
|
|
Since string `json:"since,omitempty"`
|
|
Prompts bool `json:"prompts,omitempty"`
|
|
Host string `json:"host,omitempty"`
|
|
Machine bool `json:"machine,omitempty"`
|
|
// "act": approve, deny or switch (owneracts.go), and what it acts on.
|
|
Act string `json:"act,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
Family string `json:"family,omitempty"`
|
|
Off bool `json:"off,omitempty"`
|
|
}
|
|
|
|
type agentAskReply struct {
|
|
OK bool `json:"ok"`
|
|
Request string `json:"request,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
User string `json:"user,omitempty"`
|
|
URL string `json:"url,omitempty"` // "console": the link that signs the owner in
|
|
// "status": where the ask is (askStatus) and, once carried out, what
|
|
// happened.
|
|
State string `json:"state,omitempty"`
|
|
Outcome string `json:"outcome,omitempty"`
|
|
}
|
|
|
|
func askSockPath() string { return filepath.Join(contextSockDir, askSockName) }
|
|
|
|
// askPeerPIDFn is the kernel's pid for the connecting process: SO_PEERCRED on
|
|
// Linux, 0 where the platform does not say (then only the account is named).
|
|
var askPeerPIDFn = askPeerPID
|
|
|
|
// askPeerIdentityFn is the kernel's account for the connecting process; a
|
|
// variable so a suite run as root can stand in for the owner's account.
|
|
var askPeerIdentityFn = contextPeerIdentity
|
|
|
|
func startAskSock(done <-chan struct{}) {
|
|
if runtime.GOOS == "windows" {
|
|
return // a named pipe, as context.sock has, is not built yet
|
|
}
|
|
p := askSockPath()
|
|
ln, err := contextSockListen(p)
|
|
if err != nil {
|
|
uilogf("ask socket not available (%v) — an agent running as another account cannot ask "+
|
|
"this bastion to stop anything\n", err)
|
|
return
|
|
}
|
|
go func() {
|
|
<-done
|
|
_ = ln.Close()
|
|
_ = os.Remove(p)
|
|
}()
|
|
go func() {
|
|
slots := make(chan struct{}, askSockInFlight)
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
select {
|
|
case slots <- struct{}{}:
|
|
go func() {
|
|
defer func() { <-slots }()
|
|
askSockServe(c)
|
|
}()
|
|
default:
|
|
_ = c.Close()
|
|
}
|
|
}
|
|
}()
|
|
uilogf("ask socket listening on %s\n", p)
|
|
}
|
|
|
|
var askMu sync.Mutex // one ask filed at a time: the per-account count is read then written
|
|
|
|
func askSockServe(c net.Conn) {
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
|
|
uid, user, err := askPeerIdentityFn(c)
|
|
if err != nil {
|
|
_ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" +
|
|
err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."})
|
|
return
|
|
}
|
|
var req agentAskRequest
|
|
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&req); err != nil {
|
|
_ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
|
|
return
|
|
}
|
|
if req.Op == "console" {
|
|
r := agentAskReply{User: user}
|
|
if link, err := consoleLaneEntry(uid, user); err != nil {
|
|
r.Why = err.Error()
|
|
} else {
|
|
r.OK, r.URL = true, link
|
|
}
|
|
_ = json.NewEncoder(c).Encode(r)
|
|
return
|
|
}
|
|
if req.Op == "status" {
|
|
_ = json.NewEncoder(c).Encode(askStatus(existingMachineFactorDir(), req.Request, user, false))
|
|
return
|
|
}
|
|
if req.Op == "read" {
|
|
_ = c.SetDeadline(time.Now().Add(readDeadline))
|
|
_ = json.NewEncoder(c).Encode(serveRead(uid, user, req))
|
|
return
|
|
}
|
|
if req.Op == "act" {
|
|
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
|
_ = json.NewEncoder(c).Encode(serveAct(uid, user, req))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c)))
|
|
}
|
|
|
|
// fileAsk turns one ask into a pending request of the machine, or says why not.
|
|
func fileAsk(req agentAskRequest, user string, peerPID int) agentAskReply {
|
|
r := agentAskReply{User: user}
|
|
if req.Op != "stop" {
|
|
r.Why = "this bastion is only asked to stop agents"
|
|
return r
|
|
}
|
|
dir := existingMachineFactorDir()
|
|
if !stepupEnrolled(dir) {
|
|
r.Why = "the owner of this machine has not set up their code, so nothing can be asked of it yet"
|
|
return r
|
|
}
|
|
now := time.Now()
|
|
if until := askHeldUntil(dir, user, now); !until.IsZero() {
|
|
r.Why = askHoldRefusal(user, until)
|
|
return r
|
|
}
|
|
// A refusal that is the asker's own doing counts toward its hold.
|
|
refuse := func(why string) agentAskReply {
|
|
askStrike(dir, user, now)
|
|
r.Why = why
|
|
return r
|
|
}
|
|
observed := observedAgentMap()
|
|
if why := stopRefusal(req.PID, observed); why != "" {
|
|
return refuse(why)
|
|
}
|
|
a := observed[req.PID]
|
|
purpose, err := purposeForLocalStop(req.PID, a.Signature)
|
|
if err != nil {
|
|
r.Why = err.Error()
|
|
return r
|
|
}
|
|
view := buildAgentViews([]agentObs{a}, processTree())[0]
|
|
details := map[string]any{"kind": localStopKind, "pid": strconv.Itoa(req.PID), "signature": a.Signature,
|
|
"agent": familyLabel(a.Signature)}
|
|
if view.Folder != "" {
|
|
details["folder"] = view.Folder
|
|
}
|
|
if view.Self != nil {
|
|
details["session"], details["title"] = view.Self.Name, view.Self.Title
|
|
}
|
|
by := "a program running as " + user
|
|
if peerPID > 1 {
|
|
if who, ok := askingAgent(peerPID, observed); ok {
|
|
by = who + ", running as " + user
|
|
}
|
|
}
|
|
details["asked_by"] = by
|
|
details["asker"], details["target"] = askParties(user, peerPID, req.PID, observed)
|
|
askMu.Lock()
|
|
defer askMu.Unlock()
|
|
// Asking again for a stop this account already has waiting is the same
|
|
// request (stepupNewRequest hands it back), not a new one over the limit.
|
|
if n, same := askPendingBy(dir, user, purpose); n >= askMaxPendingPer && !same {
|
|
return refuse("you already have " + strconv.Itoa(askMaxPendingPer) + " requests waiting for the owner; " +
|
|
"wait for them to be answered")
|
|
}
|
|
details["asked_by_account"] = user
|
|
id, err := stepupNewRequest(dir, purpose, details)
|
|
if errors.Is(err, errRefusedRecently) {
|
|
return refuse("the machine's owner refused this same stop within the last 24 hours, so it cannot be " +
|
|
"asked again yet")
|
|
}
|
|
if err != nil {
|
|
r.Why = err.Error()
|
|
return r
|
|
}
|
|
r.OK, r.Request, r.Text = true, id[:8], describeRequest(stepupRequest{Details: details})
|
|
auditLocalStopAsk(dir, req.PID, a.Signature, details)
|
|
return r
|
|
}
|
|
|
|
// auditLocalStopAsk records that an ask was filed, naming who asked for what.
|
|
func auditLocalStopAsk(dir string, pid int, signature string, details map[string]any) {
|
|
dcnAuditWith(dir, localStopAskOp, 0, strconv.Itoa(pid)+" "+signature,
|
|
"asked to stop "+describeStopTarget(details), askPartiesOf(details))
|
|
}
|
|
|
|
const localStopAskOp = "local_stop_ask"
|
|
|
|
// askPendingBy is how many requests user has waiting, and whether one of them
|
|
// is for purpose.
|
|
func askPendingBy(dir, user, purpose string) (n int, same bool) {
|
|
stepupMu.Lock()
|
|
m := requestsLoad(dir)
|
|
stepupMu.Unlock()
|
|
for _, id := range sortedPending(m) {
|
|
if a, _ := m[id].Details["asked_by_account"].(string); a == user {
|
|
n++
|
|
same = same || m[id].Purpose == purpose
|
|
}
|
|
}
|
|
return n, same
|
|
}
|
|
|
|
// askViaSock is the asking side: `stop` run where it cannot write the
|
|
// machine's state itself.
|
|
func askViaSock(req agentAskRequest) (agentAskReply, error) {
|
|
if runtime.GOOS == "windows" {
|
|
return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet")
|
|
}
|
|
c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second)
|
|
if err != nil {
|
|
return agentAskReply{}, err
|
|
}
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(15 * time.Second))
|
|
if err := json.NewEncoder(c).Encode(req); err != nil {
|
|
return agentAskReply{}, err
|
|
}
|
|
var r agentAskReply
|
|
err = json.NewDecoder(io.LimitReader(c, askMaxBytes*4)).Decode(&r)
|
|
return r, err
|
|
}
|