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) or "read" (reads.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"` } 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 } _ = 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 }