Merge pull request 'claude-code: declare the account agents run as and claude-agent (hq ADR 0266)' (#141) from feat/the-agent-account-is-declared into main
This commit was merged in pull request #141.
This commit is contained in:
@@ -9,4 +9,5 @@ modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
|
||||
modules/claude-code/cmd/claude-code/claude-code
|
||||
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
|
||||
modules/messenger/messenger
|
||||
modules/claude-code/claude-code
|
||||
modules/mesh-watcher/mesh-watcher
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/agenthome
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,128 @@
|
||||
// Command agenthome refuses, in every module's manifest, a resource placed under the agent account's home
|
||||
// that the node-engine cannot write without a gap (novox/hq ADR 0266).
|
||||
//
|
||||
// The agent account owns its home and everything in it, so it can put a symbolic or hard link anywhere
|
||||
// there. The node-engine, as root, writes a whole file or a directory below a home through descriptors it
|
||||
// opened itself and refuses a link it finds; it unpacks an archive, and writes into an existing file (`into`:
|
||||
// json or block), only after checking the path, so a link swapped in between the check and the write would
|
||||
// point root's write at any file. No module does either today. This check keeps it so: a resource of type
|
||||
// `archive`, or a `file` with `into`, whose path is under ${machine:agent-home} — written so, reached through
|
||||
// one of the module's own directories (${dir:<id>}), or written as a literal /home path — is refused. The
|
||||
// node-engine refuses the same below the home of any account declared never to become root, whatever the path.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentHome is the machine fact naming the agent account's home.
|
||||
const AgentHome = "${machine:agent-home}"
|
||||
|
||||
// ofDir is where a manifest names one of its own directories: ${dir:<id>}.
|
||||
var ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
// resolve replaces each ${dir:<id>} with that resource's own path, as far as the manifest says it, so a path
|
||||
// reaching the agent's home through one of the module's directories is seen for what it is.
|
||||
func resolve(p string, dirs map[string]string) string {
|
||||
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
|
||||
p = ofDir.ReplaceAllStringFunc(p, func(word string) string {
|
||||
if to, ok := dirs[ofDir.FindStringSubmatch(word)[1]]; ok {
|
||||
return to
|
||||
}
|
||||
return word
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// underAHome says whether a resolved path lies under the agent's home: by its machine fact, or written as a
|
||||
// literal home, which no definition may carry anyway (ADR 0112) and which may well be the agent's.
|
||||
func underAHome(p string) bool {
|
||||
// Cleaned first, so //home/x or /./home/x is seen as the /home/x it is.
|
||||
if p != "" {
|
||||
p = filepath.Clean(p)
|
||||
}
|
||||
return strings.HasPrefix(p, AgentHome) || strings.HasPrefix(p, "/home/") || p == "/home"
|
||||
}
|
||||
|
||||
// Finding is one resource refused.
|
||||
type Finding struct {
|
||||
Module, ID, Why string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
if p, _ := r["path"].(string); id != "" && p != "" {
|
||||
dirs[id] = p
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
p, _ := r["path"].(string)
|
||||
if !underAHome(resolve(p, dirs)) {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
kind, _ := r["type"].(string)
|
||||
into, _ := r["into"].(string)
|
||||
switch {
|
||||
case kind == "archive":
|
||||
out = append(out, Finding{m.Module, id, "an archive unpacked under the agent's home"})
|
||||
case kind == "file" && into != "":
|
||||
out = append(out, Finding{m.Module, id, "a file written into (" + into + ") under the agent's home"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s: %s is %s: the node-engine writes it as root after checking the path, and the agent "+
|
||||
"account could swap a link in between (novox/hq ADR 0266); place a whole file or a directory instead\n",
|
||||
f.Module, f.ID, f.Why)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("agent-home: no archive and no file written into under the agent's home")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, resources string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArchiveOrAFileWrittenIntoUnderTheAgentsHomeIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"theme","type":"archive","path":"${machine:agent-home}/.local/share/x"}`)
|
||||
manifest(t, root, "b", `{"id":"cfg","type":"file","path":"${machine:agent-home}/.claude.json","into":"json","content":"{}"}`)
|
||||
manifest(t, root, "c", `{"id":"dir","type":"directory","path":"${machine:agent-home}/.claude"},`+
|
||||
`{"id":"whole","type":"file","path":"${machine:agent-home}/x","content":"y"},`+
|
||||
`{"id":"op","type":"archive","path":"${machine:account-home}/.local/share/x"}`)
|
||||
manifest(t, root, "d", `{"id":"home","type":"directory","path":"${machine:agent-home}/.claude"},`+
|
||||
`{"id":"inner","type":"directory","path":"${dir:home}/inner"},`+
|
||||
`{"id":"via-dir","type":"archive","path":"${dir:inner}/theme"}`)
|
||||
manifest(t, root, "e", `{"id":"literal","type":"file","path":"/home/agent/.bashrc","into":"block","content":"x"}`)
|
||||
manifest(t, root, "f", `{"id":"unclean","type":"archive","path":"//./home/agent/x"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 5 || found[0].Module != "a" || found[1].Module != "b" || found[2].ID != "via-dir" ||
|
||||
found[3].ID != "literal" || found[4].ID != "unclean" {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("%+v", found)
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,14 @@
|
||||
# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc
|
||||
# that another module's manifest places — modules talk over the bus, not through each other's files.
|
||||
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
|
||||
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
|
||||
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
|
||||
# that home and could swap a link in between.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
|
||||
@@ -8,12 +8,25 @@ configuration, and the consumer side of the Anthropic licence manager (design 39
|
||||
Two directories, declared, so the mesh refuses a second module owning either:
|
||||
|
||||
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
|
||||
- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory —
|
||||
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
||||
account of its own), that account's, `0700`. The module owns the directory —
|
||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||
removes a directory only when it is empty.
|
||||
|
||||
And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||
|
||||
- **the account agents run as**, a `user` named by the machine fact `agent-account` — the operator's
|
||||
account where the machine names none, which declares nothing new of it. Where it names one, the mesh
|
||||
makes the account (its home, its own group, no password, no group of the mesh's) and asks the node-engine
|
||||
to judge `root: never` on every look: that it is not root, is in no group that grants root, has no sudo
|
||||
rule and can read no secret the mesh placed for another account. A way found is this module's condition,
|
||||
and the controller's self-check says it urgently.
|
||||
- `/usr/local/bin/claude-agent`, root's, `0755`: starts the agent as that account — `claude` itself when run
|
||||
as it, otherwise `sudo -iu <account> claude` through the operator's own sudo, carrying the guard's
|
||||
override only when it is set in the operator's shell.
|
||||
|
||||
## What it writes
|
||||
|
||||
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
|
||||
@@ -60,7 +73,7 @@ The agent kept reaching for `ssh <machine> journalctl` while the service manager
|
||||
each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in
|
||||
the next render. Ordered by what the guard here refused most, then the machines' seats, the modules, the
|
||||
mesh's seats; at most sixteen rows, the rest one `mesh_search` away.
|
||||
- **The guard.** A PreToolUse hook on `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit`: this module's own
|
||||
- **The guard.** A PreToolUse hook on every tool, judging `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit` and letting the rest through (but see the account rule below): this module's own
|
||||
binary (`claude-code guard`), copied root's into the plugin with what it judges with (`guard.json`: the
|
||||
machines by name, domain and address, and the replaced commands), run by its path under `/etc/claude-code` so
|
||||
the session cannot change it. It refuses `ssh`, `scp`, `sftp`, `rsync`, `mosh` and `autossh` to a mesh machine
|
||||
@@ -182,11 +195,53 @@ Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
|
||||
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
|
||||
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
|
||||
|
||||
## Where the agent runs as an account of its own (hq ADR 0266)
|
||||
|
||||
A machine may name an **agent account** beside the operator's, at the controller's terminal
|
||||
(`mesh-controller node agent-account <node> <account>`); the control node does. Agents there run as that
|
||||
account, which cannot become root without a person, and the operator keeps their own account and its sudo.
|
||||
The runtime hands this module `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME` — the operator's where none is
|
||||
named, and read as the operator's from a controller that sets neither — and everything it writes for the
|
||||
agent goes to that account's home:
|
||||
|
||||
- the licence token (`~/.claude/.credentials.json`) and the account it is logged in as (`~/.claude.json`);
|
||||
- an API-key licence, in `~/.claude/mesh/`, since the account cannot enter the module's state. Its key-helper
|
||||
is no script there: the managed settings apply to every account's `claude`, the operator's and root's too,
|
||||
so a script the agent owns would run as them. The managed settings carry the command whole instead, and it
|
||||
reads the key only when run as the agent's account;
|
||||
- the items registered at the home scope, recorded per home (`home-placed-<account>.json`).
|
||||
|
||||
The runtime's account cannot enter that home (0700, the agent's), so a path under it is touched by a
|
||||
**helper running as the agent's account**: this module's binary as the plugin carries it
|
||||
(`/etc/claude-code/marketplace/plugins/nox-mesh/guard/guard home-fs`, root's), started once through
|
||||
`sudo -n -u <account>` and kept, one JSON request and answer per line. The escalation goes down only: the
|
||||
runtime's account may become the agent's, never the other way.
|
||||
|
||||
When a machine moves its agent to such an account, the licence follows at once — a binding counts as
|
||||
applied only while the agent's account holds it — and what this module placed under the operator's home
|
||||
goes: the access-token-only credentials file (one holding a refresh token is a login the person made, and
|
||||
stays, said), the home-scope items unless changed by hand, the key kept in the state.
|
||||
|
||||
**The guard there refuses every tool call of a session that does not run as the agent's account** —
|
||||
shell, file edits, reads and the mesh's tools alike — and no override lifts it: the operator's account may become root, and an
|
||||
agent started in it by habit must do nothing. Its record goes to the machine's journal
|
||||
(`journalctl -t mesh-claude-code-guard`), which the session's account may add to and not change;
|
||||
`claude_code_guard` reads it there, keeping only the lines the journal says the guard's own binary wrote. That
|
||||
is not proof: the account can run the guard's binary itself and add lines in the guard's words. What it
|
||||
cannot do is remove one.
|
||||
|
||||
**An API key is taken only from the agent's own home.** `claude_code_add_api_key` reads a file and removes it,
|
||||
and the runtime runs as the operator: a path anywhere would hand whoever calls it the runtime's credential or
|
||||
the operator's keys. So the file must lie under the home of the account the agent runs as, reached through no
|
||||
symbolic link, is read as that account, must be at most 4 KiB and hold an Anthropic API key (`sk-ant-…`) —
|
||||
otherwise nothing is handed over and nothing removed, and the refusal never repeats what the file holds. The
|
||||
licence manager refuses a key of another shape too.
|
||||
|
||||
## Escalation
|
||||
|
||||
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
|
||||
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
|
||||
is written.
|
||||
is written. The same sudo starts the helper as the agent's account, where there is one.
|
||||
|
||||
## Code
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// Where the agent runs as an account of its own (novox/hq ADR 0266).
|
||||
//
|
||||
// The control node names an agent account beside the operator's: agents run there as an account that cannot
|
||||
// become root without a person. The controller hands this module both — MESH_AGENT_ACCOUNT and its home, and
|
||||
// the operator's — and everything this module writes for the agent goes to the agent's: the licence token, the
|
||||
// account it is logged in as, an API key and its helper, the items registered at the home scope (homefs.go).
|
||||
//
|
||||
// Two things follow when a machine moves its agents to such an account:
|
||||
//
|
||||
// - **the licence follows at once.** The binding applied is the same, so nothing would fetch the token again
|
||||
// until the licence rotates; the agent's new home would hold none. A binding counts as applied only while
|
||||
// the account the agent runs as holds it.
|
||||
// - **what the module placed in the operator's home goes** (ADR 0182: the mesh owns what it places). The
|
||||
// credentials file the module wrote there holds an access token and never a refresh token — so a file that
|
||||
// holds no refresh token is the module's, and is removed — only once the agent's own home holds the licence,
|
||||
// so a move that has not fetched it yet leaves the operator's in place — with the API key and helper it
|
||||
// kept in its state.
|
||||
// A file holding a refresh token is a login the person made there: theirs, left, and said. The items placed
|
||||
// at the home scope there go as unregistering takes them — each unless the person changed it since.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// holdsApplied says whether the account the agent runs as holds a licence of this kind.
|
||||
func holdsApplied(p Paths, kind string) bool {
|
||||
if kind == "api-key" {
|
||||
_, err := fsys.Stat(p.apiKey())
|
||||
return err == nil
|
||||
}
|
||||
return GrantOf(ReadCredentials(p.credentials())) != nil
|
||||
}
|
||||
|
||||
// holdsAnyLicence says whether the agent's account holds a token or an API key now.
|
||||
func holdsAnyLicence(p Paths) bool {
|
||||
return holdsApplied(p, "subscription") || holdsApplied(p, "api-key")
|
||||
}
|
||||
|
||||
// RetireOperatorHome takes back what this module placed for the agent under the operator's home, once the agent
|
||||
// runs as an account of its own; answers what it did, or nothing.
|
||||
func RetireOperatorHome(p Paths) []string {
|
||||
if !p.Confined() || p.OperatorHome == "" || filepath.Clean(p.OperatorHome) == filepath.Clean(p.Home) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json")
|
||||
if creds := ReadCredentials(file); creds != nil {
|
||||
switch {
|
||||
case HoldsLogin(creds):
|
||||
out = append(out, "left "+file+": it holds a login the person made, which is theirs; the agent here runs as "+
|
||||
p.Agent+" and no longer reads it")
|
||||
case GrantOf(creds) != nil && !holdsAnyLicence(p):
|
||||
out = append(out, "kept the token this module placed in "+file+" until "+p.Agent+"'s home holds the licence")
|
||||
case GrantOf(creds) != nil:
|
||||
if err := os.Remove(file); err != nil {
|
||||
out = append(out, "the token this module placed in "+file+" could not be removed: "+err.Error())
|
||||
} else {
|
||||
out = append(out, "removed the token this module placed in "+file+": the agent here runs as "+p.Agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The items placed at the home scope in the operator's home: each removed unless the person changed it,
|
||||
// as unregistering would; they are placed in the agent's home now, from a record of its own.
|
||||
record := filepath.Join(p.State, "home-placed.json")
|
||||
if _, err := os.Stat(record); err == nil {
|
||||
done, left := placeHomeIn(filepath.Join(p.OperatorHome, ".claude"), record, nil)
|
||||
for _, line := range done {
|
||||
out = append(out, "operator's home "+line)
|
||||
}
|
||||
for _, line := range left {
|
||||
out = append(out, "operator's home "+line)
|
||||
}
|
||||
var still Placed
|
||||
if readJSON(record, &still) && len(still) == 0 {
|
||||
_ = os.Remove(record)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"api-key", "api-key-helper"} {
|
||||
old := filepath.Join(p.State, name)
|
||||
if name == "api-key" && !holdsApplied(p, "api-key") {
|
||||
// Kept, like the token above, until the agent's own home holds the key.
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(old); err == nil {
|
||||
if err := os.Remove(old); err == nil {
|
||||
out = append(out, "removed "+old+": the key and its helper are kept where "+p.Agent+" reads them")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
package main
|
||||
|
||||
// Where the agent runs as an account of its own (novox/hq ADR 0266): the home touched through the helper, the
|
||||
// licence that follows the account, what goes from the operator's home, and the guard that refuses a session
|
||||
// running as anyone else.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// inProcess is a helper served in this process over pipes, counting what it was asked; kill ends it, as a
|
||||
// helper that died would.
|
||||
type inProcess struct {
|
||||
asked atomic.Int64
|
||||
mu sync.Mutex
|
||||
kill func()
|
||||
}
|
||||
|
||||
func (c *inProcess) die() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.kill()
|
||||
}
|
||||
|
||||
func helperInProcess(account string, counted *inProcess) *Helper {
|
||||
return &Helper{Account: account, Start: func() (io.WriteCloser, io.Reader, func(), error) {
|
||||
reqR, reqW := io.Pipe()
|
||||
ansR, ansW := io.Pipe()
|
||||
counting := &countingReader{r: reqR, n: &counted.asked}
|
||||
go func() { _ = ServeHelper(counting, ansW); _ = ansW.Close() }()
|
||||
counted.mu.Lock()
|
||||
counted.kill = func() { _ = reqR.Close(); _ = ansW.Close() }
|
||||
counted.mu.Unlock()
|
||||
return reqW, ansR, func() { _ = reqW.Close() }, nil
|
||||
}}
|
||||
}
|
||||
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n *atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n.Add(int64(bytes.Count(p[:n], []byte("\n"))))
|
||||
return n, err
|
||||
}
|
||||
|
||||
func agentFiles(t *testing.T) (*Files, string, *inProcess) {
|
||||
t.Helper()
|
||||
home := filepath.Join(t.TempDir(), "agent")
|
||||
if err := os.MkdirAll(home, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counted := &inProcess{}
|
||||
return &Files{Home: home, Account: "agent", Helper: helperInProcess("agent", counted)}, home, counted
|
||||
}
|
||||
|
||||
func TestTheAgentsHomeIsTouchedThroughTheHelper(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
file := filepath.Join(home, ".claude", "skills", "x", "SKILL.md")
|
||||
if err := f.MkdirAll(filepath.Dir(file), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.WriteFile(file, []byte("hello"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := f.ReadFile(file); err != nil || string(got) != "hello" {
|
||||
t.Fatalf("read back %q, %v", got, err)
|
||||
}
|
||||
info, err := f.Stat(file)
|
||||
if err != nil || info.Size() != 5 || info.Mode().Perm() != 0o600 || info.IsDir() {
|
||||
t.Fatalf("stat: %v %v", info, err)
|
||||
}
|
||||
if err := f.Chmod(file, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, _ := f.Lstat(file); info.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("chmod did not take: %v", info.Mode())
|
||||
}
|
||||
if err := f.CreateExclusive(file, []byte("again"), 0o600); !errors.Is(err, fs.ErrExist) {
|
||||
t.Fatalf("a file that is there is said to exist, got %v", err)
|
||||
}
|
||||
if _, err := f.ReadFile(filepath.Join(home, "absent")); !os.IsNotExist(err) {
|
||||
t.Fatalf("an absent file is said not to exist, as the filesystem says it, got %v", err)
|
||||
}
|
||||
entries, err := f.ReadDir(filepath.Join(home, ".claude", "skills"))
|
||||
if err != nil || len(entries) != 1 || entries[0].Name() != "x" || !entries[0].IsDir() {
|
||||
t.Fatalf("readdir: %v %v", entries, err)
|
||||
}
|
||||
var walked []string
|
||||
if err := f.WalkDir(filepath.Join(home, ".claude"), func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(home, path)
|
||||
walked = append(walked, rel)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(walked, ",") != ".claude,.claude/skills,.claude/skills/x,.claude/skills/x/SKILL.md" {
|
||||
t.Fatalf("walked %v", walked)
|
||||
}
|
||||
moved := filepath.Join(home, "moved")
|
||||
if err := f.Rename(file, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Remove(moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.RemoveAll(filepath.Join(home, ".claude")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".claude")); !os.IsNotExist(err) {
|
||||
t.Fatal("remove-all left the directory")
|
||||
}
|
||||
if counted.asked.Load() < 12 {
|
||||
t.Fatalf("every operation under the home goes through the helper; it was asked %d times", counted.asked.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathOutsideTheAgentsHomeIsNeverTheHelpers(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
state := t.TempDir()
|
||||
if err := f.WriteFile(filepath.Join(state, "x"), []byte("y"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.ReadFile(filepath.Join(state, "x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A sibling whose name begins with the home's is not under it.
|
||||
if f.through(home+"-other/x") || !f.through(home) || !f.through(home+"/.claude.json") {
|
||||
t.Fatal("the home is the home and nothing beside it")
|
||||
}
|
||||
if err := f.Rename(filepath.Join(state, "x"), filepath.Join(home, "x")); err == nil {
|
||||
t.Fatal("a move between the home and elsewhere is refused: no one account may do both")
|
||||
}
|
||||
if counted.asked.Load() != 0 {
|
||||
t.Fatalf("the helper was asked %d times for paths that are not the agent's", counted.asked.Load())
|
||||
}
|
||||
if (&Files{}).through(home) {
|
||||
t.Fatal("with no agent account of its own, nothing goes through a helper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHelperThatDiedIsStartedAgain(t *testing.T) {
|
||||
f, home, counted := agentFiles(t)
|
||||
if err := f.WriteFile(filepath.Join(home, "a"), []byte("1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counted.die()
|
||||
if got, err := f.ReadFile(filepath.Join(home, "a")); err != nil || string(got) != "1" {
|
||||
t.Fatalf("after the helper died the next request is answered by a new one: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHelperRefusesARelativePathAndAnUnknownOperation(t *testing.T) {
|
||||
if a := serveOne(Op{Op: "read", Path: "relative"}); a.Err == "" {
|
||||
t.Fatal("a relative path is refused")
|
||||
}
|
||||
if a := serveOne(Op{Op: "exec", Path: "/"}); !strings.Contains(a.Err, "no such operation") {
|
||||
t.Fatalf("only the filesystem's operations are served, got %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the paths --------------------------------------------------------------------------------------------
|
||||
|
||||
func envOf(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||
|
||||
func TestTheAgentsAccountAndHomeComeFromTheRuntime(t *testing.T) {
|
||||
base := map[string]string{"MESH_CLAUDE_CODE_STATE": "/s", "MESH_CLAUDE_CODE_FACTS": "/s/f", "MESH_CLAUDE_CODE_SETTINGS": "/s/x",
|
||||
"MESH_NODE": "anchor", "MESH_OPERATOR_ACCOUNT": "operator", "MESH_OPERATOR_HOME": "/home/operator"}
|
||||
p, ok := PathsFrom(envOf(base))
|
||||
if !ok || p.Home != "/home/operator" || p.Agent != "operator" || p.Confined() {
|
||||
t.Fatalf("a controller that names no agent account: the operator's, as before; got %+v", p)
|
||||
}
|
||||
with := map[string]string{"MESH_AGENT_ACCOUNT": "agent", "MESH_AGENT_HOME": "/home/agent"}
|
||||
for k, v := range base {
|
||||
with[k] = v
|
||||
}
|
||||
p, ok = PathsFrom(envOf(with))
|
||||
if !ok || p.Home != "/home/agent" || p.Agent != "agent" || p.OperatorHome != "/home/operator" || !p.Confined() {
|
||||
t.Fatalf("an agent account of its own: its home; got %+v", p)
|
||||
}
|
||||
if p.keyDir() != "/home/agent/.claude/mesh" || p.placed() != "/s/home-placed-agent.json" {
|
||||
t.Fatalf("the key and the record follow the agent's account: %s %s", p.keyDir(), p.placed())
|
||||
}
|
||||
with["MESH_AGENT_HOME"] = ""
|
||||
if _, ok := PathsFrom(envOf(with)); ok {
|
||||
t.Fatal("the agent's account without its home was taken as the operator's")
|
||||
}
|
||||
with["MESH_AGENT_ACCOUNT"], with["MESH_AGENT_HOME"] = "operator", "/home/operator"
|
||||
if p, _ = PathsFrom(envOf(with)); p.Confined() || p.keyDir() != "/s" || p.placed() != "/s/home-placed.json" {
|
||||
t.Fatalf("the agent account named as the operator's is the operator's: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the licence follows the account ----------------------------------------------------------------------
|
||||
|
||||
func TestABindingAppliedIsFetchedAgainWhenTheAgentsHomeHoldsNoToken(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||
asked := 0
|
||||
ask := func(string, any) (json.RawMessage, error) { asked++; return json.RawMessage(`{}`), nil }
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(map[string]string{})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Fatalf("a home with no token is asked for one, though the binding is the one applied; asked %d", asked)
|
||||
}
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`)
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(map[string]string{})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Fatal("a home that holds the token applied is not asked again")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- what goes from the operator's home -------------------------------------------------------------------
|
||||
|
||||
func confined(t *testing.T) Paths {
|
||||
t.Helper()
|
||||
p, _ := node(t, "anchor")
|
||||
p.Agent, p.Operator = "agent", "operator"
|
||||
p.OperatorHome = filepath.Join(filepath.Dir(p.Home), "operator")
|
||||
if err := os.MkdirAll(filepath.Join(p.OperatorHome, ".claude", "rules"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestTheTokenTheModulePlacedInTheOperatorsHomeGoes(t *testing.T) {
|
||||
p := confined(t)
|
||||
file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json")
|
||||
writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`)
|
||||
writeFile(t, filepath.Join(p.State, "api-key"), "k\n")
|
||||
if said := strings.Join(RetireOperatorHome(p), "\n"); !strings.Contains(said, "until agent's home holds") {
|
||||
t.Fatalf("while the agent's home holds no licence the operator's token stays: %s", said)
|
||||
}
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatal("the operator's token went before the agent's home held one")
|
||||
}
|
||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"b","expiresAt":1}}`)
|
||||
said := strings.Join(RetireOperatorHome(p), "\n")
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatalf("the access-token-only file is the module's and goes: %s", said)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, "api-key")); err != nil {
|
||||
t.Fatal("the key kept in the state went before the agent's home held one")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p.apiKey()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, p.apiKey(), "k\n")
|
||||
RetireOperatorHome(p)
|
||||
if _, err := os.Stat(filepath.Join(p.State, "api-key")); !os.IsNotExist(err) {
|
||||
t.Fatal("the key kept where the agent's account cannot read it goes once the agent's home holds it")
|
||||
}
|
||||
}
|
||||
|
||||
// The managed settings apply to every account's claude, root's included: where the agent runs as an account of
|
||||
// its own, the key-helper they name is never a file under any home, and reads the key only as that account.
|
||||
func TestTheKeyHelperIsNeverAFileTheAgentOwns(t *testing.T) {
|
||||
p := confined(t)
|
||||
cmd := p.helperCommand()
|
||||
if strings.HasPrefix(cmd, "/") || strings.HasPrefix(cmd, p.Home) || strings.HasPrefix(cmd, filepath.Dir(p.Home)) {
|
||||
t.Fatalf("the key-helper is a path the agent may own: %q", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd, `"$(id -un)" = 'agent'`) || !strings.Contains(cmd, "exec cat -- '"+p.apiKey()+"'") {
|
||||
t.Fatalf("the key-helper reads the key only as the agent's account: %q", cmd)
|
||||
}
|
||||
p.Agent = p.Operator
|
||||
if p.helperCommand() != p.helper() {
|
||||
t.Fatalf("where the agent runs as the operator the helper stays in the state: %q", p.helperCommand())
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginThePersonMadeInTheOperatorsHomeStays(t *testing.T) {
|
||||
p := confined(t)
|
||||
file := filepath.Join(p.OperatorHome, ".claude", ".credentials.json")
|
||||
writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":1}}`)
|
||||
said := strings.Join(RetireOperatorHome(p), "\n")
|
||||
if _, err := os.Stat(file); err != nil || !strings.Contains(said, "login the person made") {
|
||||
t.Fatalf("a refresh token is a person's login: left and said; said %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatWasPlacedInTheOperatorsHomeGoesUnlessChanged(t *testing.T) {
|
||||
p := confined(t)
|
||||
dir := filepath.Join(p.OperatorHome, ".claude")
|
||||
writeFile(t, filepath.Join(dir, "rules", "a.md"), "placed")
|
||||
writeFile(t, filepath.Join(dir, "rules", "b.md"), "changed by hand")
|
||||
raw, _ := json.Marshal(Placed{"rules/a.md": digest("placed"), "rules/b.md": digest("placed")})
|
||||
writeFile(t, filepath.Join(p.State, "home-placed.json"), string(raw))
|
||||
RetireOperatorHome(p)
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "a.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("an item placed and unchanged goes, as unregistering takes it")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "rules", "b.md")); err != nil {
|
||||
t.Fatal("an item the person changed is theirs now and stays")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, "home-placed.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("the operator's home's record goes once it holds nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingGoesWhereTheAgentRunsAsTheOperator(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
p.OperatorHome = p.Home
|
||||
file := p.credentials()
|
||||
writeFile(t, file, `{"claudeAiOauth":{"accessToken":"a","expiresAt":1}}`)
|
||||
if said := RetireOperatorHome(p); len(said) != 0 {
|
||||
t.Fatalf("said %v", said)
|
||||
}
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatal("the token stays where the agent reads it")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the guard --------------------------------------------------------------------------------------------
|
||||
|
||||
func TestASessionNotRunningAsTheAgentsAccountDoesNothing(t *testing.T) {
|
||||
g := Guard{Data: GuardData{Node: "anchor", Agent: "agent"}}
|
||||
echo := HookInput{ToolName: "Bash", ToolInput: map[string]any{"command": "echo hello"}}
|
||||
v := g.JudgeAs("operator", echo)
|
||||
if !v.Refuse || v.Rule != "account" || v.Overridable || !strings.Contains(v.Message, "claude-agent") {
|
||||
t.Fatalf("a session as the operator is refused whatever it runs, with no override: %+v", v)
|
||||
}
|
||||
if v := g.JudgeAs("operator", HookInput{ToolName: "Write", ToolInput: map[string]any{"file_path": "/tmp/x"}}); !v.Refuse {
|
||||
t.Fatal("a file edit too")
|
||||
}
|
||||
if v := g.JudgeAs("agent", echo); v.Refuse {
|
||||
t.Fatalf("the agent's own account runs as before: %+v", v)
|
||||
}
|
||||
if v := (Guard{Data: GuardData{Node: "laptop"}}).JudgeAs("operator", echo); v.Refuse {
|
||||
t.Fatal("where agents run as the operator, the operator's session runs as before")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereTheAgentHasItsOwnAccountTheRecordIsTheJournal(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
if d := GuardDataOf(p, nil); d.Agent != "" || strings.HasPrefix(d.Log, JournalRecord) {
|
||||
t.Fatalf("as the operator: the module's own file; got %+v", d)
|
||||
}
|
||||
p.Agent, p.Operator = "agent", "operator"
|
||||
d := GuardDataOf(p, nil)
|
||||
if d.Agent != "agent" || d.Log != JournalRecord+GuardJournalID {
|
||||
t.Fatalf("as an account of its own: the journal, and the account named; got %+v", d)
|
||||
}
|
||||
|
||||
socket := filepath.Join(t.TempDir(), "journal")
|
||||
conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: socket, Net: "unixgram"})
|
||||
if err != nil {
|
||||
t.Skipf("no unix datagram socket here: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
was := journalSocket
|
||||
journalSocket = socket
|
||||
t.Cleanup(func() { journalSocket = was })
|
||||
if err := RecordGuard(d.Log, GuardEntry{Decision: "refused", Rule: "account", Tool: "Bash", Command: "a\nb"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(buf[:n]), "\n"), "\n")
|
||||
if len(lines) != 3 || lines[0] != "SYSLOG_IDENTIFIER="+GuardJournalID || !strings.HasPrefix(lines[2], "MESSAGE={") {
|
||||
t.Fatalf("one line per field, the entry as one line of JSON: %q", lines)
|
||||
}
|
||||
|
||||
readWas := readJournal
|
||||
t.Cleanup(func() { readJournal = readWas })
|
||||
readJournal = func(id string, last int) ([]byte, error) {
|
||||
if id != GuardJournalID {
|
||||
t.Fatalf("read %s", id)
|
||||
}
|
||||
msg, _ := json.Marshal(strings.TrimPrefix(lines[2], "MESSAGE="))
|
||||
forged, _ := json.Marshal(`{"decision":"overridden","rule":"ssh","tool":"Bash","command":"forged"}`)
|
||||
return []byte(`{"MESSAGE":` + string(msg) + `,"_EXE":"` + HelperPath() + `"}` + "\n" +
|
||||
`{"MESSAGE":` + string(forged) + `,"_EXE":"/usr/bin/logger"}` + "\n"), nil
|
||||
}
|
||||
if got := ReadGuardLog(d.Log, 10); len(got) != 1 || got[0].Rule != "account" || got[0].Command != "a\nb" {
|
||||
t.Fatalf("the record reads back from the journal: %+v", got)
|
||||
}
|
||||
if err := RecordGuard(JournalRecord+"x", GuardEntry{}); err != nil && !strings.Contains(err.Error(), "journal") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
journalSocket = filepath.Join(t.TempDir(), "absent")
|
||||
if err := RecordGuard(d.Log, GuardEntry{}); err == nil {
|
||||
t.Fatal("a journal that cannot be reached is an error, so an override is not honoured unrecorded")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- an API key is taken only from the agent's own home ---------------------------------------------------
|
||||
|
||||
func TestAnAPIKeyFileOutsideTheAgentsHomeIsRefusedAndKept(t *testing.T) {
|
||||
p, _ := node(t, "anchor")
|
||||
asked := 0
|
||||
ask := func(string, any) (json.RawMessage, error) { asked++; return json.RawMessage(`{}`), nil }
|
||||
outside := filepath.Join(filepath.Dir(p.Home), "runtime-credential")
|
||||
writeFile(t, outside, "sk-ant-api03-secret-0123456789abcdefghij\n")
|
||||
inside := filepath.Join(p.Home, "not-a-key")
|
||||
writeFile(t, inside, "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n")
|
||||
linked := filepath.Join(p.Home, "linked")
|
||||
if err := os.Symlink(outside, linked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range []string{outside, p.Home + "/../runtime-credential", inside, linked, "relative", p.Home} {
|
||||
_, err := AddAPIKey(p, "api", file, false, ask)
|
||||
if err == nil || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("%s: refused, without repeating what it holds; got %v", file, err)
|
||||
}
|
||||
}
|
||||
for _, file := range []string{outside, inside} {
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatalf("%s was removed though it was refused", file)
|
||||
}
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Fatal("nothing refused reaches the licence manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing.T) {
|
||||
if GuardMatcher != "*" {
|
||||
t.Fatalf("the guard is asked about every tool, got %q", GuardMatcher)
|
||||
}
|
||||
g := Guard{Data: GuardData{Node: "anchor", Agent: "agent"}}
|
||||
for _, tool := range []string{"Read", "Grep", "WebFetch", "mcp__mesh__mesh_call", "Task"} {
|
||||
if v := g.JudgeAs("operator", HookInput{ToolName: tool}); !v.Refuse {
|
||||
t.Fatalf("%s as another account is refused", tool)
|
||||
}
|
||||
if v := g.JudgeAs("agent", HookInput{ToolName: tool}); v.Refuse {
|
||||
t.Fatalf("%s as the agent's account passes", tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
||||
p, w := node(t, "anchor")
|
||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||
ask := func(address string, args any) (json.RawMessage, error) {
|
||||
key := args.(map[string]any)["public_key"].(string)
|
||||
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
|
||||
box, _ := Seal(string(g), key)
|
||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
|
||||
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
|
||||
}
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creds(t, p)["accessToken"] != "at" {
|
||||
t.Fatal("the token is in the new home")
|
||||
}
|
||||
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" {
|
||||
t.Fatalf("the account file names the account whose token the new home holds, got %+v", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerPastTheBoundIsRefused(t *testing.T) {
|
||||
long := strings.Repeat("x", 100) + "\n"
|
||||
if _, err := readLine(bufio.NewReaderSize(strings.NewReader(long), 16), 50); err == nil {
|
||||
t.Fatal("a line past the bound is an error")
|
||||
}
|
||||
if got, err := readLine(bufio.NewReaderSize(strings.NewReader(long), 16), 200); err != nil || string(got) != long {
|
||||
t.Fatalf("a line within it is read whole across the buffer: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A FIFO, a device or a link to one in the agent's home is answered at once, an error: it never holds the
|
||||
// helper, and every request behind it (review of 2026-10-09).
|
||||
func TestTheHelperReadsOnlyARegularFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fifo := filepath.Join(dir, ".credentials.json")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Skip("no FIFO here:", err)
|
||||
}
|
||||
zero := filepath.Join(dir, "zero")
|
||||
if err := os.Symlink("/dev/zero", zero); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{fifo, zero} {
|
||||
done := make(chan Answer, 1)
|
||||
go func() { done <- serveOne(Op{Op: "read", Path: path}) }()
|
||||
select {
|
||||
case a := <-done:
|
||||
if a.Err == "" || len(a.Data) != 0 {
|
||||
t.Fatalf("%s was read: %q", path, a.Data)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("reading %s held the helper", path)
|
||||
}
|
||||
}
|
||||
big := filepath.Join(dir, "big")
|
||||
if err := os.WriteFile(big, make([]byte, helperReadMax+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := serveOne(Op{Op: "read", Path: big}); a.Err == "" {
|
||||
t.Fatal("a file past the bound was read")
|
||||
}
|
||||
}
|
||||
|
||||
// helperAnswering is a helper that answers every request with answer, or never when answer is empty.
|
||||
func helperAnswering(account, answer string) *Helper {
|
||||
return &Helper{Account: account, Start: func() (io.WriteCloser, io.Reader, func(), error) {
|
||||
reqR, reqW := io.Pipe()
|
||||
ansR, ansW := io.Pipe()
|
||||
go func() {
|
||||
r := bufio.NewReader(reqR)
|
||||
for {
|
||||
if _, err := r.ReadBytes('\n'); err != nil {
|
||||
_ = ansW.Close()
|
||||
return
|
||||
}
|
||||
if answer != "" {
|
||||
_, _ = ansW.Write([]byte(answer + "\n"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
return reqW, ansR, func() { _ = reqR.Close(); _ = ansW.Close() }, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// What the helper answers is the agent's word: an entry that is not a name in the directory is refused, so no
|
||||
// path joined from it leaves the home and is read as the runtime's account.
|
||||
func TestAnEntryThatLeavesTheDirectoryIsRefused(t *testing.T) {
|
||||
for _, name := range []string{"../operator/.ssh/id_ed25519", "..", ".", "", "a/b"} {
|
||||
raw, _ := json.Marshal(Answer{Entries: []*Info{{Name: name}}})
|
||||
f := &Files{Home: "/home/agent", Account: "agent", Helper: helperAnswering("agent", string(raw))}
|
||||
if _, err := f.ReadDir("/home/agent/.claude"); err == nil {
|
||||
t.Fatalf("the entry %q was taken", name)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(Answer{Entries: []*Info{{Name: "settings.json"}}})
|
||||
f := &Files{Home: "/home/agent", Account: "agent", Helper: helperAnswering("agent", string(raw))}
|
||||
if got, err := f.ReadDir("/home/agent/.claude"); err != nil || len(got) != 1 {
|
||||
t.Fatalf("a name in the directory was refused: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A helper that does not answer fails the request in time, said, and is not asked again for it.
|
||||
func TestAHelperThatDoesNotAnswerFailsTheRequestInTime(t *testing.T) {
|
||||
was := helperAnswerWithin
|
||||
helperAnswerWithin = 200 * time.Millisecond
|
||||
defer func() { helperAnswerWithin = was }()
|
||||
h := helperAnswering("agent", "")
|
||||
start := time.Now()
|
||||
_, err := h.Do(Op{Op: "read", Path: "/home/agent/.claude.json"})
|
||||
if err == nil || !errors.Is(err, errHelperSlow) {
|
||||
t.Fatalf("a silent helper answered: %v", err)
|
||||
}
|
||||
if time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("the request waited %s", time.Since(start))
|
||||
}
|
||||
}
|
||||
@@ -417,7 +417,7 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
manager, _ := GenerateKeyPair()
|
||||
file := filepath.Join(p.Home, "api-key")
|
||||
writeFile(t, file, "sk-ant-api03-secret\n")
|
||||
writeFile(t, file, "sk-ant-api03-secret-0123456789abcdefghij\n")
|
||||
var sent []string
|
||||
ask := func(address string, args any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
@@ -427,7 +427,7 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
||||
return json.Marshal(map[string]any{"public_key": manager.PublicKey})
|
||||
case "seat:anthropic-licence-manager.adopt":
|
||||
box := args.(map[string]any)["sealed"].(SealedBox)
|
||||
if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret" {
|
||||
if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret-0123456789abcdefghij" {
|
||||
t.Fatalf("the manager opened %q, %v", key, err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"adopted": true})
|
||||
|
||||
@@ -419,11 +419,11 @@ func (v *ConfigView) Items() map[string]Item {
|
||||
// an older snapshot never lands after a newer one.
|
||||
func (v *ConfigView) writeThroughLocked() bool {
|
||||
now, _ := indented(v.items)
|
||||
before, _ := os.ReadFile(v.p.config())
|
||||
before, _ := fsys.ReadFile(v.p.config())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.config(), now, 0o600)
|
||||
_ = fsys.WriteFile(v.p.config(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -479,8 +479,11 @@ type GuardFiles struct {
|
||||
Data GuardData
|
||||
}
|
||||
|
||||
// GuardMatcher is what the guard is asked about: the shell, and the agent's own file edits.
|
||||
const GuardMatcher = "Bash|Edit|Write|MultiEdit|NotebookEdit"
|
||||
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
|
||||
// edits and let every other tool through; the rule of the account agents run as (novox/hq ADR 0266) refuses
|
||||
// every tool of a session running as another, reading and the mesh's tools included, so that session can do
|
||||
// nothing at all.
|
||||
const GuardMatcher = "*"
|
||||
|
||||
// GuardCommand is the hook's command line: by its path under the managed directory, root's — never the
|
||||
// copy the agent may keep of a plugin, which the session could change.
|
||||
@@ -673,9 +676,13 @@ const deletedByHand = "deleted-by-hand"
|
||||
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
|
||||
// it left alone, and why.
|
||||
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
return placeHomeIn(filepath.Join(p.Home, ".claude"), p.placed(), want)
|
||||
}
|
||||
|
||||
// placeHomeIn is PlaceHome for one agent directory and its own record of what was placed there.
|
||||
func placeHomeIn(dir, record string, want map[string]string) (done []string, left []string) {
|
||||
var placed Placed
|
||||
if !readJSON(p.placed(), &placed) {
|
||||
if !readJSON(record, &placed) {
|
||||
placed = Placed{}
|
||||
}
|
||||
paths := make([]string, 0, len(want))
|
||||
@@ -690,7 +697,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
left = append(left, rel+": "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
current, err := fsys.ReadFile(full)
|
||||
exists := err == nil
|
||||
ours, wasPlaced := placed[rel]
|
||||
switch {
|
||||
@@ -712,7 +719,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
if err := fsys.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
@@ -720,7 +727,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
if isScript(rel, content) {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
if err := fsys.WriteFile(full, []byte(content), mode); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
@@ -740,13 +747,13 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
left = append(left, rel+": no longer registered, but "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
current, err := fsys.ReadFile(full)
|
||||
if err == nil && digest(string(current)) != ours {
|
||||
left = append(left, rel+": no longer registered, but changed by hand, left alone")
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
if err := fsys.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
@@ -756,7 +763,7 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
done = append(done, rel+": removed")
|
||||
}
|
||||
raw, _ := indented(placed)
|
||||
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
|
||||
if err := fsys.WriteFile(record, raw, 0o600); err != nil {
|
||||
left = append(left, "the record of what was placed could not be saved: "+err.Error())
|
||||
}
|
||||
return done, left
|
||||
@@ -769,7 +776,7 @@ func linkedParent(dir, rel string) string {
|
||||
at := dir
|
||||
for _, seg := range parts[:len(parts)-1] {
|
||||
at = filepath.Join(at, seg)
|
||||
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
if info, err := fsys.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return at + " is a symbolic link"
|
||||
}
|
||||
}
|
||||
@@ -779,7 +786,7 @@ func linkedParent(dir, rel string) string {
|
||||
// removeEmptyParents removes empty directories from dir up to, not including, root.
|
||||
func removeEmptyParents(root, dir string) {
|
||||
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
if err := fsys.Remove(dir); err != nil {
|
||||
return
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
@@ -795,7 +802,7 @@ func HomeConflict(p Paths, it Item) string {
|
||||
if _, ours := placed[rel]; ours {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
|
||||
if _, err := fsys.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
|
||||
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
|
||||
}
|
||||
}
|
||||
@@ -843,12 +850,12 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
||||
if entries, err := fsys.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
|
||||
body, err := fsys.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
|
||||
if err != nil {
|
||||
continue // the vendor's own synced folders carry none at their top
|
||||
}
|
||||
@@ -856,7 +863,7 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
}
|
||||
}
|
||||
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||
entries, err := fsys.ReadDir(filepath.Join(dir, sub))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -864,11 +871,11 @@ func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
|
||||
body, _ := fsys.ReadFile(filepath.Join(dir, sub, e.Name()))
|
||||
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
||||
if body, err := fsys.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
||||
add(KindMemory, memoryName, "CLAUDE.md", string(body))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -887,12 +894,12 @@ func ImportFromHome(p Paths, kind, name string) (Item, error) {
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
root := filepath.Join(dir, "skills", name)
|
||||
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
err := fsys.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
raw, err := os.ReadFile(full)
|
||||
raw, err := fsys.ReadFile(full)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -904,7 +911,7 @@ func ImportFromHome(p Paths, kind, name string) (Item, error) {
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
|
||||
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
|
||||
raw, err := fsys.ReadFile(filepath.Join(dir, sub, name+".md"))
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
@@ -72,7 +71,7 @@ func (c Credentials) oauth() map[string]any {
|
||||
|
||||
// ReadCredentials reads the file, keeping numbers as written; nil when there is none.
|
||||
func ReadCredentials(path string) Credentials {
|
||||
raw, err := os.ReadFile(path)
|
||||
raw, err := fsys.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -172,7 +171,7 @@ func ReplacedBy(local Credentials, g Grant) Credentials {
|
||||
|
||||
// WriteCredentials writes atomically at 0600: a partial credentials file must never be read as a whole one.
|
||||
func WriteCredentials(path string, c Credentials) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := indented(c)
|
||||
@@ -180,10 +179,10 @@ func WriteCredentials(path string, c Credentials) error {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
return fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// indented is JSON as the agent's own files are written: two-space indent, a trailing newline, nothing
|
||||
|
||||
@@ -41,6 +41,9 @@ type GuardData struct {
|
||||
Machines []Machine `json:"machines"`
|
||||
Replacements []Replacement `json:"replacements"`
|
||||
Log string `json:"log"`
|
||||
// Agent is the account every session here must run as, where the machine names an agent account of its
|
||||
// own (novox/hq ADR 0266); empty where agents run as the operator.
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
// HookInput is what the agent hands a PreToolUse hook.
|
||||
@@ -54,7 +57,7 @@ type HookInput struct {
|
||||
// Verdict is the guard's answer.
|
||||
type Verdict struct {
|
||||
Refuse bool `json:"refuse"`
|
||||
Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named
|
||||
Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named | account
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Overridable is false for a refusal no override lifts: the agent naming the override itself.
|
||||
@@ -88,6 +91,29 @@ type Guard struct {
|
||||
Resolve func(string) []string
|
||||
}
|
||||
|
||||
// JudgeAs is the verdict on one tool call of a session running as an account. **On a machine whose agents run
|
||||
// as an account of their own, a session running as any other does nothing** (novox/hq ADR 0266): the
|
||||
// operator's account there may become root without a person, and an agent started in it by habit would carry
|
||||
// that into every command. Refused whatever the command, and no override lifts it — the operator's own shell is
|
||||
// how the operator acts as themselves.
|
||||
func (g Guard) JudgeAs(account string, in HookInput) Verdict {
|
||||
if g.Data.Agent != "" && account != g.Data.Agent {
|
||||
return Verdict{Refuse: true, Rule: "account", Message: fmt.Sprintf("Refused: on %s agents run as %s, an "+
|
||||
"account that cannot become root without a person, and this session runs as %s (novox/hq ADR 0266). "+
|
||||
"No tool it calls here is let through. End it, and start the agent with `claude-agent`, which runs it as "+
|
||||
"%s; what needs the operator's own rights is the operator's to run in their own shell.",
|
||||
orThisMachine(g.Data.Node), g.Data.Agent, account, g.Data.Agent)}
|
||||
}
|
||||
return g.Judge(in)
|
||||
}
|
||||
|
||||
func orThisMachine(node string) string {
|
||||
if node == "" {
|
||||
return "this machine"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// Judge is the verdict on one tool call.
|
||||
func (g Guard) Judge(in HookInput) Verdict {
|
||||
switch in.ToolName {
|
||||
|
||||
@@ -10,8 +10,9 @@ package main
|
||||
// process, which is fixed at exec. A command's `export`, an `env` key in a settings file, a variable the
|
||||
// session sets for its tools — none of them reaches it. A nested session the agent starts with the
|
||||
// variable is refused before it starts: the command names the override;
|
||||
// - **recorded**: every override is a line in the module's guard.log, with why, the command and the
|
||||
// machine — and an override that cannot be recorded is not honoured. Refusals are recorded the same way.
|
||||
// - **recorded**: every override is a line in the module's guard.log — or, where the agent runs as an account
|
||||
// of its own, in the machine's journal (novox/hq ADR 0266) — with why, the command and the machine; an
|
||||
// override that cannot be recorded is not honoured. Refusals are recorded the same way.
|
||||
//
|
||||
// A guard that fails — a fault in this code — exits 1, which the agent shows and does not block on: a guard
|
||||
// that refused every command would stop the session the operator needs to repair it.
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -55,11 +57,26 @@ type GuardEntry struct {
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// JournalRecord is the guard's record where the agent runs as an account of its own (novox/hq ADR 0266): the
|
||||
// session's account cannot enter the module's state, and must not be able to rewrite what it was refused, so
|
||||
// each line goes to the machine's journal, which the account may add to and not change. The record's
|
||||
// location is then this prefix and the journal's identifier.
|
||||
const JournalRecord = "journal:"
|
||||
|
||||
// GuardJournalID is the identifier the guard's lines carry in the journal.
|
||||
const GuardJournalID = "mesh-claude-code-guard"
|
||||
|
||||
// journalSocket is where the journal takes a line; a test points it at its own.
|
||||
var journalSocket = "/run/systemd/journal/socket"
|
||||
|
||||
// RecordGuard appends one line to the record.
|
||||
func RecordGuard(path string, e GuardEntry) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("no record is kept here")
|
||||
}
|
||||
if id, ok := strings.CutPrefix(path, JournalRecord); ok {
|
||||
return recordInJournal(id, e)
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > GuardLogMax {
|
||||
_ = os.Rename(path, path+".1")
|
||||
}
|
||||
@@ -75,9 +92,70 @@ func RecordGuard(path string, e GuardEntry) error {
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// recordInJournal says one line to the journal by its native protocol: the entry as JSON in MESSAGE, one line,
|
||||
// so nothing in a command can add a field of its own.
|
||||
func recordInJournal(id string, e GuardEntry) error {
|
||||
raw, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := net.Dial("unixgram", journalSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the journal cannot be reached: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("SYSLOG_IDENTIFIER=" + id + "\nPRIORITY=5\nMESSAGE=" + string(raw) + "\n"))
|
||||
return err
|
||||
}
|
||||
|
||||
// readJournal is the journal's last lines of the guard's record, read as root through the runtime account's
|
||||
// sudo where this process is not root: what other accounts said is not the runtime account's to read. As JSON,
|
||||
// with the field the journal itself sets for the program that wrote the line (_EXE), which no writer chooses —
|
||||
// and matched on it in the query itself, so lines another program wrote under the guard's identifier cannot
|
||||
// push the guard's own past the last ones read.
|
||||
var readJournal = func(id string, last int) ([]byte, error) {
|
||||
args := []string{"journalctl", "-q", "--no-pager", "-o", "json", "--output-fields=MESSAGE,_EXE", "-t", id,
|
||||
"_EXE=" + HelperPath(), "-n", strconv.Itoa(last)}
|
||||
if os.Geteuid() != 0 {
|
||||
args = append([]string{"sudo", "-n"}, args...)
|
||||
}
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// guardsOwnLines keeps, of the journal's JSON lines, the messages the guard's own binary wrote (novox/hq ADR
|
||||
// 0266). The agent's account may add to the journal under any identifier — `logger -t` — and the journal
|
||||
// says which program wrote each line; a line another program wrote under the guard's identifier is not the
|
||||
// guard's record. The agent can still run the guard's binary itself, so the record is the guard's words, not
|
||||
// proof that each refusal happened in a session: what it cannot do is remove one.
|
||||
func guardsOwnLines(raw []byte, exe string) []byte {
|
||||
var out []byte
|
||||
for _, line := range bytes.Split(raw, []byte("\n")) {
|
||||
var f struct {
|
||||
Message any `json:"MESSAGE"`
|
||||
Exe string `json:"_EXE"`
|
||||
}
|
||||
if json.Unmarshal(line, &f) != nil || f.Exe != exe {
|
||||
continue
|
||||
}
|
||||
if m, ok := f.Message.(string); ok {
|
||||
out = append(append(out, m...), '\n')
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReadGuardLog is the record's last lines, newest last.
|
||||
func ReadGuardLog(path string, last int) []GuardEntry {
|
||||
raw, err := os.ReadFile(path)
|
||||
read := os.ReadFile
|
||||
if id, ok := strings.CutPrefix(path, JournalRecord); ok {
|
||||
read = func(string) ([]byte, error) {
|
||||
raw, err := readJournal(id, last)
|
||||
return guardsOwnLines(raw, HelperPath()), err
|
||||
}
|
||||
}
|
||||
raw, err := read(path)
|
||||
if err != nil {
|
||||
return []GuardEntry{}
|
||||
}
|
||||
@@ -183,6 +261,14 @@ func isAgent(cmdline []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// currentAccount is the account the session runs as; a test sets its own.
|
||||
var currentAccount = func() string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
return "uid " + strconv.Itoa(os.Getuid())
|
||||
}
|
||||
|
||||
// runGuard is the hook: the tool call in, the verdict out as the exit code.
|
||||
func runGuard(dataPath string, stdin io.Reader, stderr io.Writer) (code int) {
|
||||
defer func() {
|
||||
@@ -201,7 +287,7 @@ func runGuard(dataPath string, stdin io.Reader, stderr io.Writer) (code int) {
|
||||
_ = json.Unmarshal(raw, &data)
|
||||
}
|
||||
g := Guard{Data: data, SSH: sshDashG, Resolve: quickLookup}
|
||||
v := g.Judge(in)
|
||||
v := g.JudgeAs(currentAccount(), in)
|
||||
if !v.Refuse {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi
|
||||
return nil, nil, errors.New(why)
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(full)
|
||||
info, err := fsys.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil, fmt.Errorf("%s is not in this home", full)
|
||||
@@ -113,7 +113,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, nil, fmt.Errorf("%s is not a file", full)
|
||||
}
|
||||
raw, err := os.ReadFile(full)
|
||||
raw, err := fsys.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi
|
||||
if !info.IsDir() {
|
||||
return nil, nil, fmt.Errorf("%s is not a folder", full)
|
||||
}
|
||||
err = filepath.WalkDir(full, func(at string, d fs.DirEntry, err error) error {
|
||||
err = fsys.WalkDir(full, func(at string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.Fi
|
||||
return fmt.Errorf("%s is not a file", at)
|
||||
}
|
||||
in, _ := filepath.Rel(full, at)
|
||||
raw, err := os.ReadFile(at)
|
||||
raw, err := fsys.ReadFile(at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
|
||||
day := filepath.Join(p.State, RemovedDir, now.Format("2006-01-02"))
|
||||
kept := filepath.Join(day, now.Format("150405")+"-"+kind+"-"+name)
|
||||
for n := 2; ; n++ {
|
||||
if _, err := os.Lstat(kept); os.IsNotExist(err) {
|
||||
if _, err := fsys.Lstat(kept); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
kept = filepath.Join(day, fmt.Sprintf("%s-%s-%s-%d", now.Format("150405"), kind, name, n))
|
||||
@@ -244,13 +244,13 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
|
||||
names := map[string]KeptFile{}
|
||||
for in, content := range files {
|
||||
to := filepath.Join(copyRoot, filepath.FromSlash(in))
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
|
||||
if err := fsys.MkdirAll(filepath.Dir(to), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(to, []byte(content), modes[in]|0o600); err != nil {
|
||||
if err := fsys.WriteFile(to, []byte(content), modes[in]|0o600); err != nil {
|
||||
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
|
||||
}
|
||||
if back, err := os.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
|
||||
if back, err := fsys.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
|
||||
return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in)
|
||||
}
|
||||
names[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])}
|
||||
@@ -258,7 +258,7 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
|
||||
r := Removal{Action: "removed", At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
|
||||
Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names}
|
||||
note, _ := indented(r)
|
||||
if err := os.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
|
||||
if err := fsys.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("the copy's note could not be written, nothing removed: %w", err)
|
||||
}
|
||||
|
||||
@@ -273,9 +273,9 @@ func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any,
|
||||
}
|
||||
}
|
||||
if folder {
|
||||
err = os.RemoveAll(full)
|
||||
err = fsys.RemoveAll(full)
|
||||
} else {
|
||||
err = os.Remove(full)
|
||||
err = fsys.Remove(full)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err)
|
||||
@@ -392,7 +392,7 @@ func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
|
||||
if why := linkedParent(dir, rel+"/x"); why != "" {
|
||||
return nil, errors.New(why + ", nothing restored")
|
||||
}
|
||||
if _, err := os.Lstat(full); err == nil {
|
||||
if _, err := fsys.Lstat(full); err == nil {
|
||||
return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
@@ -405,10 +405,10 @@ func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
|
||||
written := []string{}
|
||||
undo := func() {
|
||||
for _, w := range written {
|
||||
_ = os.Remove(w)
|
||||
_ = fsys.Remove(w)
|
||||
}
|
||||
if isFolder {
|
||||
_ = os.RemoveAll(full)
|
||||
_ = fsys.RemoveAll(full)
|
||||
}
|
||||
}
|
||||
for in, content := range files {
|
||||
@@ -421,28 +421,22 @@ func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
|
||||
if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 {
|
||||
mode = os.FileMode(m).Perm()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
||||
if err := fsys.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
||||
undo()
|
||||
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
|
||||
}
|
||||
f, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
if err := fsys.CreateExclusive(to, []byte(content), mode); err != nil {
|
||||
undo()
|
||||
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
|
||||
return nil, fmt.Errorf("%s could not be restored: %w", to, err)
|
||||
}
|
||||
_, werr := f.WriteString(content)
|
||||
if cerr := f.Close(); werr != nil || cerr != nil {
|
||||
undo()
|
||||
return nil, fmt.Errorf("%s could not be restored", to)
|
||||
}
|
||||
_ = os.Chmod(to, mode) // the umask may have taken bits the file had
|
||||
_ = fsys.Chmod(to, mode) // the umask may have taken bits the file had
|
||||
written = append(written, to)
|
||||
}
|
||||
|
||||
back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name,
|
||||
Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files}
|
||||
note, _ := indented(back)
|
||||
_ = os.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
|
||||
_ = fsys.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
|
||||
logged := logRemoval(p, back)
|
||||
say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At)
|
||||
answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At,
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
package main
|
||||
|
||||
// The agent's home, touched as the agent's account (novox/hq ADR 0266).
|
||||
//
|
||||
// On a machine that names an agent account of its own — the control node — agents run as that account, not
|
||||
// as the operator's, so that no agent there can become root without a person. Its home is its own, 0700:
|
||||
// the node's tool runtime, which runs this module as the operator's account, can neither read nor write it.
|
||||
// And this module must: the licence token and the account the agent is logged in as, the items registered
|
||||
// at the home scope, and the home's own items the status and the removal tools read.
|
||||
//
|
||||
// So every path under that home is touched by a helper running as the agent's account: this module's own
|
||||
// binary, as the plugin carries it under the managed directory (root's, so the agent cannot change it),
|
||||
// started once through `sudo -n -u <agent>` and kept, speaking one JSON request and one JSON answer per line
|
||||
// on its stdin and stdout. Once, not per file: a credentials file is looked at every five seconds, and sudo
|
||||
// says every start in the journal. **The escalation goes down, never up**: the runtime's account may become
|
||||
// the agent's; nothing here lets the agent's become anything.
|
||||
//
|
||||
// Everything else — the module's state, the managed directory, a machine whose agents run as the operator —
|
||||
// is the plain filesystem, as before. Files routes each path; with no agent account of its own it routes none.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HelperArg is the argument that makes this binary the helper (as the agent's account).
|
||||
const HelperArg = "home-fs"
|
||||
|
||||
// Files is the filesystem as this module touches it: a path under the agent's home through the helper when
|
||||
// the agent's account is not this process's, every other path as it is.
|
||||
type Files struct {
|
||||
// Home is the agent's home and Account its login; Helper starts the helper, nil for none.
|
||||
Home string
|
||||
Account string
|
||||
Helper *Helper
|
||||
}
|
||||
|
||||
// fsys is the module's: plain until the launch says whose home the agent's is.
|
||||
var fsys = &Files{}
|
||||
|
||||
// FilesFor is the filesystem for these paths: through the helper when the agent's account is another than the
|
||||
// one this process runs as.
|
||||
func FilesFor(p Paths) *Files {
|
||||
if p.Agent == "" || p.Home == "" {
|
||||
return &Files{}
|
||||
}
|
||||
if me, err := user.Current(); err == nil && me.Username == p.Agent {
|
||||
return &Files{}
|
||||
}
|
||||
return &Files{Home: filepath.Clean(p.Home), Account: p.Agent, Helper: &Helper{Account: p.Agent, Exe: HelperPath()}}
|
||||
}
|
||||
|
||||
// HelperPath is the helper's binary: the guard's copy of this module's binary in the plugin, root's and
|
||||
// readable by every account, so the agent's account runs exactly what the mesh placed.
|
||||
func HelperPath() string {
|
||||
return filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir, "guard")
|
||||
}
|
||||
|
||||
// through says whether a path is touched as the agent.
|
||||
func (f *Files) through(path string) bool {
|
||||
if f == nil || f.Helper == nil || f.Home == "" {
|
||||
return false
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
return path == f.Home || strings.HasPrefix(path, f.Home+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// ---- the operations ---------------------------------------------------------------------------------------
|
||||
|
||||
func (f *Files) ReadFile(path string) ([]byte, error) {
|
||||
if !f.through(path) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
a, err := f.Helper.Do(Op{Op: "read", Path: path})
|
||||
return a.Data, err
|
||||
}
|
||||
|
||||
func (f *Files) WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||
if !f.through(path) {
|
||||
return os.WriteFile(path, data, mode)
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "write", Path: path, Data: data, Mode: uint32(mode.Perm())})
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateExclusive writes a file that must not be there yet.
|
||||
func (f *Files) CreateExclusive(path string, data []byte, mode os.FileMode) error {
|
||||
if !f.through(path) {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, werr := file.Write(data)
|
||||
return errors.Join(werr, file.Close())
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "create", Path: path, Data: data, Mode: uint32(mode.Perm())})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Files) Rename(from, to string) error {
|
||||
if f.through(from) != f.through(to) {
|
||||
return fmt.Errorf("%s cannot be moved to %s: one is the agent's home and the other is not", from, to)
|
||||
}
|
||||
if !f.through(from) {
|
||||
return os.Rename(from, to)
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "rename", Path: from, To: to})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Files) Remove(path string) error {
|
||||
if !f.through(path) {
|
||||
return os.Remove(path)
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "remove", Path: path})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Files) RemoveAll(path string) error {
|
||||
if !f.through(path) {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "remove-all", Path: path})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Files) MkdirAll(path string, mode os.FileMode) error {
|
||||
if !f.through(path) {
|
||||
return os.MkdirAll(path, mode)
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "mkdir", Path: path, Mode: uint32(mode.Perm())})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Files) Chmod(path string, mode os.FileMode) error {
|
||||
if !f.through(path) {
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
_, err := f.Helper.Do(Op{Op: "chmod", Path: path, Mode: uint32(mode.Perm())})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Files) Stat(path string) (fs.FileInfo, error) {
|
||||
if !f.through(path) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
a, err := f.Helper.Do(Op{Op: "stat", Path: path})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Info.info(), nil
|
||||
}
|
||||
|
||||
func (f *Files) Lstat(path string) (fs.FileInfo, error) {
|
||||
if !f.through(path) {
|
||||
return os.Lstat(path)
|
||||
}
|
||||
a, err := f.Helper.Do(Op{Op: "lstat", Path: path})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Info.info(), nil
|
||||
}
|
||||
|
||||
func (f *Files) ReadDir(path string) ([]fs.DirEntry, error) {
|
||||
if !f.through(path) {
|
||||
return os.ReadDir(path)
|
||||
}
|
||||
a, err := f.Helper.Do(Op{Op: "readdir", Path: path})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fs.DirEntry, 0, len(a.Entries))
|
||||
for _, e := range a.Entries {
|
||||
// A name is one entry of path, never a way out of it: the helper runs as the agent's account, so what it
|
||||
// answers is the agent's word, and a name joined to path is read as the runtime's when it leaves the home.
|
||||
if e == nil || !entryName(e.Name) {
|
||||
return nil, fmt.Errorf("readdir %s as %s: the helper answered an entry that is not a name in it", path,
|
||||
f.Helper.Account)
|
||||
}
|
||||
out = append(out, fs.FileInfoToDirEntry(e.info()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WalkDir is filepath.WalkDir, through the helper under the agent's home: lexical order, a directory before
|
||||
// what is in it, a symbolic link reported and never followed.
|
||||
func (f *Files) WalkDir(root string, fn fs.WalkDirFunc) error {
|
||||
if !f.through(root) {
|
||||
return filepath.WalkDir(root, fn)
|
||||
}
|
||||
info, err := f.Lstat(root)
|
||||
if err != nil {
|
||||
err = fn(root, nil, err)
|
||||
} else {
|
||||
err = f.walk(root, fs.FileInfoToDirEntry(info), fn)
|
||||
}
|
||||
if err == filepath.SkipDir || err == filepath.SkipAll {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// entryName says whether name is one entry of a directory: not empty, not . or .., and no separator.
|
||||
func entryName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && !strings.ContainsRune(name, '/') && !strings.ContainsRune(name, 0)
|
||||
}
|
||||
|
||||
func (f *Files) walk(path string, d fs.DirEntry, fn fs.WalkDirFunc) error {
|
||||
if err := fn(path, d, nil); err != nil || !d.IsDir() {
|
||||
if err == filepath.SkipDir && d.IsDir() {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
entries, err := f.ReadDir(path)
|
||||
if err != nil {
|
||||
if err = fn(path, d, err); err != nil {
|
||||
if err == filepath.SkipDir {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := f.walk(filepath.Join(path, e.Name()), e, fn); err != nil {
|
||||
if err == filepath.SkipDir {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the wire ---------------------------------------------------------------------------------------------
|
||||
|
||||
// Op is one request to the helper.
|
||||
type Op struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
To string `json:"to,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Mode uint32 `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
// Answer is the helper's to one Op. Err is said by its kind, so the caller's os.IsNotExist and errors.Is
|
||||
// read it as they would read the filesystem's own.
|
||||
type Answer struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Info *Info `json:"info,omitempty"`
|
||||
Entries []*Info `json:"entries,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
Kind string `json:"kind,omitempty"` // not-exist | exist | permission
|
||||
}
|
||||
|
||||
// Info is a file's information as the wire carries it.
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Mode uint32 `json:"mode"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
}
|
||||
|
||||
func infoOf(i fs.FileInfo) *Info {
|
||||
return &Info{Name: i.Name(), Size: i.Size(), Mode: uint32(i.Mode()), ModTime: i.ModTime()}
|
||||
}
|
||||
|
||||
func (i *Info) info() fs.FileInfo {
|
||||
if i == nil {
|
||||
return fileInfo{}
|
||||
}
|
||||
return fileInfo{*i}
|
||||
}
|
||||
|
||||
type fileInfo struct{ Info }
|
||||
|
||||
func (i fileInfo) Name() string { return i.Info.Name }
|
||||
func (i fileInfo) Size() int64 { return i.Info.Size }
|
||||
func (i fileInfo) Mode() fs.FileMode { return fs.FileMode(i.Info.Mode) }
|
||||
func (i fileInfo) ModTime() time.Time { return i.Info.ModTime }
|
||||
func (i fileInfo) IsDir() bool { return i.Mode().IsDir() }
|
||||
func (i fileInfo) Sys() any { return nil }
|
||||
|
||||
func errorOf(op, path string, a Answer) error {
|
||||
if a.Err == "" {
|
||||
return nil
|
||||
}
|
||||
var kind error
|
||||
switch a.Kind {
|
||||
case "not-exist":
|
||||
kind = fs.ErrNotExist
|
||||
case "exist":
|
||||
kind = fs.ErrExist
|
||||
case "permission":
|
||||
kind = fs.ErrPermission
|
||||
default:
|
||||
return errors.New(a.Err)
|
||||
}
|
||||
// The kind itself, unwrapped: os.IsNotExist compares rather than unwraps.
|
||||
return &fs.PathError{Op: op, Path: path, Err: kind}
|
||||
}
|
||||
|
||||
// ---- the client -------------------------------------------------------------------------------------------
|
||||
|
||||
// Helper is the helper as this module keeps it: started at the first request, restarted once when it is gone.
|
||||
type Helper struct {
|
||||
Account string
|
||||
Exe string
|
||||
// Start is how it is started; nil is through sudo as the account. A test starts it in the process.
|
||||
Start func() (io.WriteCloser, io.Reader, func(), error)
|
||||
|
||||
mu sync.Mutex
|
||||
in io.WriteCloser
|
||||
out *bufio.Reader
|
||||
close func()
|
||||
}
|
||||
|
||||
// helperAnswerWithin bounds how long one request may take. The helper's own reads cannot block (serveOne), so
|
||||
// a helper this slow has gone wrong: it is stopped and the request fails, said, never retried, so no request
|
||||
// holds every later one behind it.
|
||||
var helperAnswerWithin = 30 * time.Second
|
||||
|
||||
// errHelperSlow is a helper that did not answer in time.
|
||||
var errHelperSlow = errors.New("the helper did not answer in time")
|
||||
|
||||
// Do answers one request, starting the helper when it is not running.
|
||||
func (h *Helper) Do(op Op) (Answer, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var last error
|
||||
for try := 0; try < 2; try++ {
|
||||
if h.in == nil {
|
||||
if err := h.start(); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
}
|
||||
a, err := h.roundTrip(op)
|
||||
if err == nil {
|
||||
return a, errorOf(op.Op, op.Path, a)
|
||||
}
|
||||
last = err
|
||||
h.stop()
|
||||
if errors.Is(err, errHelperSlow) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return Answer{}, fmt.Errorf("%s %s as %s: the helper did not answer: %w", op.Op, op.Path, h.Account, last)
|
||||
}
|
||||
|
||||
func (h *Helper) roundTrip(op Op) (Answer, error) {
|
||||
raw, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
type read struct {
|
||||
line []byte
|
||||
err error
|
||||
}
|
||||
done := make(chan read, 1)
|
||||
in, out := h.in, h.out
|
||||
go func() {
|
||||
if _, err := in.Write(append(raw, '\n')); err != nil {
|
||||
done <- read{err: err}
|
||||
return
|
||||
}
|
||||
line, err := readLine(out, helperAnswerMax)
|
||||
done <- read{line, err}
|
||||
}()
|
||||
var line []byte
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
return Answer{}, r.err
|
||||
}
|
||||
line = r.line
|
||||
case <-time.After(helperAnswerWithin):
|
||||
// The goroutine ends when stop closes the pipes and the helper goes.
|
||||
return Answer{}, fmt.Errorf("%w (%s)", errHelperSlow, helperAnswerWithin)
|
||||
}
|
||||
var a Answer
|
||||
if err := json.Unmarshal(line, &a); err != nil {
|
||||
return Answer{}, fmt.Errorf("the helper said something that is not an answer: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// helperAnswerMax bounds one answer of the helper: far above the largest file this module reads from a home
|
||||
// (an item is at most 256 KiB), so an answer past it is a helper gone wrong, not a file.
|
||||
const helperAnswerMax = 16 << 20
|
||||
|
||||
// readLine reads one line of at most max bytes; a longer one is an error and the helper is started again.
|
||||
func readLine(r *bufio.Reader, max int) ([]byte, error) {
|
||||
var line []byte
|
||||
for {
|
||||
chunk, err := r.ReadSlice('\n')
|
||||
line = append(line, chunk...)
|
||||
if len(line) > max {
|
||||
return nil, fmt.Errorf("the helper's answer is larger than %d bytes", max)
|
||||
}
|
||||
if err == bufio.ErrBufferFull {
|
||||
continue
|
||||
}
|
||||
return line, err
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Helper) start() error {
|
||||
start := h.Start
|
||||
if start == nil {
|
||||
start = h.sudo
|
||||
}
|
||||
in, out, closer, err := start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.in, h.out, h.close = in, bufio.NewReaderSize(out, 1<<20), closer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Helper) stop() {
|
||||
if h.in != nil {
|
||||
_ = h.in.Close()
|
||||
}
|
||||
if h.close != nil {
|
||||
h.close()
|
||||
}
|
||||
h.in, h.out, h.close = nil, nil, nil
|
||||
}
|
||||
|
||||
// sudo starts the helper as the agent's account: the runtime's account may become it (the sudo module's
|
||||
// rule for the operator's account names every account), and nothing the agent's account holds lets it back.
|
||||
func (h *Helper) sudo() (io.WriteCloser, io.Reader, func(), error) {
|
||||
if _, err := os.Stat(h.Exe); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("the helper that touches %s's home is not placed yet (%s): the managed "+
|
||||
"directory is written first, and the next render reaches the home", h.Account, h.Exe)
|
||||
}
|
||||
cmd := exec.Command("sudo", "-n", "-u", h.Account, "--", h.Exe, HelperArg)
|
||||
cmd.Dir = "/"
|
||||
// One language for what sudo says when it refuses, so its words are read the same on every machine.
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &limited{w: &stderr, left: 4096}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("the helper could not be started as %s: %w", h.Account, err)
|
||||
}
|
||||
return in, out, func() {
|
||||
done := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
// sudo passes a SIGTERM on to the helper; a SIGKILL would end sudo alone and leave it running.
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
if s := strings.TrimSpace(stderr.String()); s != "" {
|
||||
say("the helper as %s said: %s", h.Account, s)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type limited struct {
|
||||
w io.Writer
|
||||
left int
|
||||
}
|
||||
|
||||
func (l *limited) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
if l.left <= 0 {
|
||||
return n, nil
|
||||
}
|
||||
if len(p) > l.left {
|
||||
p = p[:l.left]
|
||||
}
|
||||
l.left -= len(p)
|
||||
_, _ = l.w.Write(p)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ---- the helper -------------------------------------------------------------------------------------------
|
||||
|
||||
// helperReadMax bounds a file the helper reads: its answer, as JSON, stays within helperAnswerMax.
|
||||
const helperReadMax = 10 << 20
|
||||
|
||||
// readRegular reads a regular file of at most helperReadMax bytes. Opened without blocking and judged by what was
|
||||
// opened, so a FIFO, a device or a link to one in the agent's home answers an error at once instead of holding
|
||||
// the helper, and every request behind it, or filling its memory.
|
||||
func readRegular(path string) ([]byte, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file (%s)", path, info.Mode().Type())
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, helperReadMax+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > helperReadMax {
|
||||
return nil, fmt.Errorf("%s is larger than %d bytes", path, helperReadMax)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ServeHelper is the helper: one answer per request, until its input ends. It runs as the agent's account and
|
||||
// does only what that account may, on its own home: the account's permissions are the boundary.
|
||||
func ServeHelper(in io.Reader, out io.Writer) error {
|
||||
dec := json.NewDecoder(bufio.NewReaderSize(in, 1<<20))
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var op Op
|
||||
if err := dec.Decode(&op); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := enc.Encode(serveOne(op)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveOne(op Op) Answer {
|
||||
if !filepath.IsAbs(op.Path) || (op.To != "" && !filepath.IsAbs(op.To)) {
|
||||
return Answer{Err: "a path must be absolute"}
|
||||
}
|
||||
mode := os.FileMode(op.Mode).Perm()
|
||||
var a Answer
|
||||
var err error
|
||||
switch op.Op {
|
||||
case "read":
|
||||
a.Data, err = readRegular(op.Path)
|
||||
case "write":
|
||||
err = os.WriteFile(op.Path, op.Data, mode)
|
||||
case "create":
|
||||
var f *os.File
|
||||
if f, err = os.OpenFile(op.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode); err == nil {
|
||||
_, werr := f.Write(op.Data)
|
||||
err = errors.Join(werr, f.Close())
|
||||
}
|
||||
case "rename":
|
||||
err = os.Rename(op.Path, op.To)
|
||||
case "remove":
|
||||
err = os.Remove(op.Path)
|
||||
case "remove-all":
|
||||
err = os.RemoveAll(op.Path)
|
||||
case "mkdir":
|
||||
err = os.MkdirAll(op.Path, mode)
|
||||
case "chmod":
|
||||
err = os.Chmod(op.Path, mode)
|
||||
case "stat", "lstat":
|
||||
var i fs.FileInfo
|
||||
if op.Op == "stat" {
|
||||
i, err = os.Stat(op.Path)
|
||||
} else {
|
||||
i, err = os.Lstat(op.Path)
|
||||
}
|
||||
if err == nil {
|
||||
a.Info = infoOf(i)
|
||||
}
|
||||
case "readdir":
|
||||
var entries []fs.DirEntry
|
||||
entries, err = os.ReadDir(op.Path)
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, e := range entries {
|
||||
if i, ierr := e.Info(); ierr == nil {
|
||||
a.Entries = append(a.Entries, infoOf(i))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return Answer{Err: "no such operation: " + op.Op}
|
||||
}
|
||||
if err != nil {
|
||||
a = Answer{Err: err.Error()}
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
a.Kind = "not-exist"
|
||||
case errors.Is(err, fs.ErrExist):
|
||||
a.Kind = "exist"
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
a.Kind = "permission"
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -8,7 +8,6 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Identity is an account as the agent's state file names it.
|
||||
@@ -19,7 +18,7 @@ type Identity struct {
|
||||
}
|
||||
|
||||
func readState(path string) (map[string]any, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
raw, err := fsys.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
@@ -54,7 +53,7 @@ func ReadIdentity(path string) *Identity {
|
||||
func WriteIdentity(path string, id Identity) (bool, error) {
|
||||
m, ok := readState(path)
|
||||
if !ok {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if _, err := fsys.Stat(path); err == nil {
|
||||
return false, nil
|
||||
}
|
||||
m = map[string]any{}
|
||||
@@ -77,8 +76,8 @@ func WriteIdentity(path string, id Identity) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, os.Rename(tmp, path)
|
||||
return true, fsys.Rename(tmp, path)
|
||||
}
|
||||
|
||||
@@ -234,7 +234,11 @@ func status(p Paths) map[string]any {
|
||||
for n := range Registered(p) {
|
||||
names = append(names, n)
|
||||
}
|
||||
return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
||||
agent := map[string]any{"account": p.Agent, "home": p.Home, "own_account": p.Confined()}
|
||||
if !p.Confined() {
|
||||
agent["note"] = "agents here run as the operator's account"
|
||||
}
|
||||
return map[string]any{"node": p.Node, "agent": agent, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
||||
"managed": managed, "registered": names}
|
||||
}
|
||||
|
||||
@@ -372,10 +376,28 @@ func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
||||
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
||||
}
|
||||
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
|
||||
// module through sudo: one answer per request on stdin, until it ends.
|
||||
if len(os.Args) == 2 && os.Args[1] == HelperArg {
|
||||
if err := ServeHelper(os.Stdin, os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
p, launched := PathsFrom(os.Getenv)
|
||||
if launched {
|
||||
fsys = FilesFor(p)
|
||||
}
|
||||
if !launched {
|
||||
// Outside a launch — a build, a check — it serves nothing and says why.
|
||||
say("not launched by the runtime with this module's words; serving no tools")
|
||||
if (os.Getenv("MESH_AGENT_ACCOUNT") == "") != (os.Getenv("MESH_AGENT_HOME") == "") {
|
||||
say("the runtime named the agent's account without its home, or its home without the account " +
|
||||
"(MESH_AGENT_ACCOUNT, MESH_AGENT_HOME); serving no tools rather than writing the operator's home " +
|
||||
"for an agent that runs as another account")
|
||||
} else {
|
||||
say("not launched by the runtime with this module's words; serving no tools")
|
||||
}
|
||||
if err := stdio.Serve("", nil); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -515,7 +537,7 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
report()
|
||||
var last string
|
||||
for range time.Tick(5 * time.Second) {
|
||||
info, err := os.Stat(p.credentials())
|
||||
info, err := fsys.Stat(p.credentials())
|
||||
now := "absent"
|
||||
if err == nil {
|
||||
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
|
||||
|
||||
@@ -258,6 +258,10 @@ func KeepMeshTools(p Paths, m MeshTools) bool {
|
||||
// GuardDataOf is what the guard judges with on this machine.
|
||||
func GuardDataOf(p Paths, m *MeshTools) GuardData {
|
||||
d := GuardData{Node: p.Node, Log: filepath.Join(p.State, "guard.log"), Machines: []Machine{}, Replacements: []Replacement{}}
|
||||
if p.Confined() {
|
||||
// The session's account cannot write the module's state, and must not rewrite its own record (ADR 0266).
|
||||
d.Agent, d.Log = p.Agent, JournalRecord+GuardJournalID
|
||||
}
|
||||
if m != nil {
|
||||
d.Machines, d.Replacements = m.Machines, m.Replacements
|
||||
}
|
||||
@@ -314,7 +318,10 @@ func GuardStatus(p Paths, last int) map[string]any {
|
||||
"writing /etc/hosts or /etc/resolv.conf, by the shell or the agent's Edit and Write",
|
||||
"HOSTALIASES, named anywhere in a command",
|
||||
"any command naming " + OverrideVar,
|
||||
"where the machine names an agent account of its own (novox/hq ADR 0266): every command and file edit of a " +
|
||||
"session that does not run as it, whatever it is, and no override lifts it",
|
||||
},
|
||||
"agent_account": map[bool]string{true: p.Agent, false: "none of its own: agents run as the operator's account"}[p.Confined()],
|
||||
"override": OverrideVar + "=<why>, set in the operator's own shell before the session starts; read from the session's " +
|
||||
"environment as it was started, recorded with why, and not honoured when it cannot be recorded",
|
||||
"hook": GuardCommand(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -46,9 +47,12 @@ func Fingerprint(s string) string {
|
||||
|
||||
// Paths are where this node's files are, from the module's words (ADR 0192).
|
||||
//
|
||||
// Agent, Operator, AgentHome and OperatorHome are the accounts the runtime names (novox/hq ADR 0266:
|
||||
// MESH_AGENT_ACCOUNT, MESH_OPERATOR_ACCOUNT, their homes). Read here only to judge whose home the home scope
|
||||
// writes (novox/hq issue 340); absent, the agent runs as the operator.
|
||||
// Home is the home of the account the agent runs as, and Agent that account (novox/hq ADR 0266): the runtime's
|
||||
// MESH_AGENT_ACCOUNT and MESH_AGENT_HOME, which the controller sets to the operator's account where the node
|
||||
// names no agent account of its own — and which a controller older than that record does not set, so the
|
||||
// operator's are read then. Operator is the operator's account: Confined says the agent's is another.
|
||||
// AgentHome and OperatorHome are the runtime's words as given, read to judge whose home the home scope writes
|
||||
// (novox/hq issue 340).
|
||||
type Paths struct {
|
||||
State, Facts, Settings, Home, Node string
|
||||
Agent, Operator, AgentHome, OperatorHome string
|
||||
@@ -57,9 +61,17 @@ type Paths struct {
|
||||
// PathsFrom reads them, or answers false outside a launch.
|
||||
func PathsFrom(env func(string) string) (Paths, bool) {
|
||||
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
||||
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE"),
|
||||
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_AGENT_HOME"), Node: env("MESH_NODE"),
|
||||
Agent: env("MESH_AGENT_ACCOUNT"), Operator: env("MESH_OPERATOR_ACCOUNT"),
|
||||
AgentHome: env("MESH_AGENT_HOME"), OperatorHome: env("MESH_OPERATOR_HOME")}
|
||||
if (p.Home == "") != (p.Agent == "") {
|
||||
// Half of the agent's account is not "the operator's": taken so, the agent's account rule of the guard
|
||||
// would be off and its home never written, and nothing would say so. Not launched, said (main).
|
||||
return p, false
|
||||
}
|
||||
if p.Home == "" {
|
||||
p.Home, p.Agent, p.AgentHome = env("MESH_OPERATOR_HOME"), p.Operator, env("MESH_OPERATOR_HOME")
|
||||
}
|
||||
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
|
||||
}
|
||||
|
||||
@@ -112,11 +124,45 @@ func HomeScopeRefused(p Paths, target string) string {
|
||||
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
||||
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
||||
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
||||
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
|
||||
func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") }
|
||||
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
|
||||
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
|
||||
|
||||
// helperCommand is the managed settings' apiKeyHelper: the key-helper's path in the module's state where the agent
|
||||
// runs as the operator. Where it runs as an account of its own, never a file under that account's home: the
|
||||
// managed settings apply to every account's `claude`, the operator's and root's included, so a script the agent
|
||||
// owns there would run as them at their next start (novox/hq ADR 0266, review of 2026-10-09). It is a command
|
||||
// written whole in the root-owned managed settings instead, which reads the key only when run as the agent's
|
||||
// account, and as it.
|
||||
func (p Paths) helperCommand() string {
|
||||
if !p.Confined() {
|
||||
return p.helper()
|
||||
}
|
||||
return "[ \"$(id -un)\" = " + shellQuote(p.Agent) + " ] && exec cat -- " + shellQuote(p.apiKey())
|
||||
}
|
||||
|
||||
// shellQuote is s as one word of a POSIX shell.
|
||||
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
||||
|
||||
// keyDir is where an API-key licence lives: the module's state, with the key-helper the agent runs from it as
|
||||
// the operator — or, where the agent runs as an account of its own, which cannot enter the module's state, a
|
||||
// directory of the module's in that account's agent directory (novox/hq ADR 0266), and no helper there: the
|
||||
// managed settings carry its command (helperCommand). Either way readable by the account the agent runs as,
|
||||
// and by it alone.
|
||||
func (p Paths) keyDir() string {
|
||||
if p.Confined() {
|
||||
return filepath.Join(p.Home, ".claude", "mesh")
|
||||
}
|
||||
return p.State
|
||||
}
|
||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
|
||||
func (p Paths) placed() string {
|
||||
if p.Confined() {
|
||||
// One record per home: what was placed in the operator's is not in the agent's (ADR 0266).
|
||||
return filepath.Join(p.State, "home-placed-"+p.Agent+".json")
|
||||
}
|
||||
return filepath.Join(p.State, "home-placed.json")
|
||||
}
|
||||
|
||||
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||
type Ask func(address string, args any) (json.RawMessage, error)
|
||||
@@ -125,7 +171,7 @@ type Ask func(address string, args any) (json.RawMessage, error)
|
||||
type WriteManaged func(name, content string) (string, error)
|
||||
|
||||
func readJSON(path string, into any) bool {
|
||||
raw, err := os.ReadFile(path)
|
||||
raw, err := fsys.ReadFile(path)
|
||||
return err == nil && json.Unmarshal(raw, into) == nil
|
||||
}
|
||||
|
||||
@@ -133,20 +179,20 @@ func readJSON(path string, into any) bool {
|
||||
// moving to this binary keeps the key it had.
|
||||
func Keypair(p Paths) (KeyPair, error) {
|
||||
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
|
||||
if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
||||
if _, err := fsys.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
||||
if err := fsys.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
||||
if err := fsys.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
}
|
||||
a, err1 := os.ReadFile(priv)
|
||||
b, err2 := os.ReadFile(pub)
|
||||
a, err1 := fsys.ReadFile(priv)
|
||||
b, err2 := fsys.ReadFile(pub)
|
||||
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
|
||||
}
|
||||
|
||||
@@ -186,7 +232,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
if mesh != nil {
|
||||
mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log)
|
||||
}
|
||||
files := RenderWithMesh(facts, settings, binding, p.helper(), Registered(p), config, mesh)
|
||||
files := RenderWithMesh(facts, settings, binding, p.helperCommand(), Registered(p), config, mesh)
|
||||
tree, _ := json.Marshal(MarketplaceWith(config, guardFiles(p, mesh)))
|
||||
files[MarketplaceDir+"/"] = string(tree)
|
||||
names := make([]string, 0, len(files))
|
||||
@@ -234,6 +280,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
for _, line := range leftOut {
|
||||
out = append(out, "left out of the terminal's shared text: "+line)
|
||||
}
|
||||
// Where the agent moved to an account of its own, what this module placed for it under the operator's home
|
||||
// goes (ADR 0266).
|
||||
out = append(out, RetireOperatorHome(p)...)
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
@@ -279,7 +328,7 @@ type Holdings struct {
|
||||
func HoldingsOf(p Paths) Holdings {
|
||||
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
|
||||
creds := ReadCredentials(p.credentials())
|
||||
if info, err := os.Stat(p.credentials()); err == nil {
|
||||
if info, err := fsys.Stat(p.credentials()); err == nil {
|
||||
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
h.ChangedAt = &at
|
||||
}
|
||||
@@ -297,7 +346,7 @@ func HoldingsOf(p Paths) Holdings {
|
||||
}{Fingerprint(g.AccessToken), g.ExpiresAt}
|
||||
}
|
||||
kind := ""
|
||||
if _, err := os.Stat(p.apiKey()); err == nil {
|
||||
if _, err := fsys.Stat(p.apiKey()); err == nil {
|
||||
kind = "api-key"
|
||||
} else if h.Access != nil {
|
||||
kind = "subscription"
|
||||
@@ -371,7 +420,8 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
||||
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||
}
|
||||
var applied Binding
|
||||
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence {
|
||||
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
|
||||
holdsApplied(p, applied.Kind) {
|
||||
return "", nil
|
||||
}
|
||||
out, err := Pull(p, ask, write)
|
||||
@@ -398,13 +448,21 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
switched := !had || previous.Licence != c.Licence
|
||||
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
|
||||
if c.Kind == "api-key" {
|
||||
if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
||||
if err := fsys.MkdirAll(p.keyDir(), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil {
|
||||
if err := fsys.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(p.helper(), 0o700)
|
||||
if p.Confined() {
|
||||
// No script under the agent's home: the managed settings carry the command whole (helperCommand).
|
||||
_ = fsys.Remove(p.helper())
|
||||
} else {
|
||||
if err := fsys.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat "+shellQuote(p.apiKey())+"\n"), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = fsys.Chmod(p.helper(), 0o700)
|
||||
}
|
||||
} else {
|
||||
var g Grant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
@@ -426,10 +484,12 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
|
||||
}
|
||||
// Away from the API key: it goes, with its helper.
|
||||
_ = os.Remove(p.apiKey())
|
||||
_ = os.Remove(p.helper())
|
||||
_ = fsys.Remove(p.apiKey())
|
||||
_ = fsys.Remove(p.helper())
|
||||
}
|
||||
if switched && c.Identity != nil && c.Identity.AccountUUID != "" {
|
||||
// The account file follows the token: on a switch, and wherever the agent's account file names no account
|
||||
// yet — a home the agent has just moved to (ADR 0266) — so the agent shows the account whose token it holds.
|
||||
if (switched || ReadIdentity(p.account()) == nil) && c.Identity != nil && c.Identity.AccountUUID != "" {
|
||||
changed, err := WriteIdentity(p.account(), *c.Identity)
|
||||
if err == nil {
|
||||
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
|
||||
@@ -440,7 +500,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
gen = previous.Generation
|
||||
}
|
||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
||||
if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The key-helper comes or goes with the licence's kind.
|
||||
@@ -452,19 +512,66 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// apiKeyShape is an Anthropic API key; apiKeyFileMax the most a file holding one is read for.
|
||||
var apiKeyShape = regexp.MustCompile(`^sk-ant-[A-Za-z0-9_-]{20,300}$`)
|
||||
|
||||
const apiKeyFileMax = 4096
|
||||
|
||||
// agentsOwnFile is a path under the home of the account the agent runs as, with no symbolic link between the
|
||||
// home and it, the file itself included; refused otherwise.
|
||||
func agentsOwnFile(p Paths, file string) (string, error) {
|
||||
if p.Home == "" || !filepath.IsAbs(file) {
|
||||
return "", fmt.Errorf("%s: the key file is named by its full path, or ~/…, in the agent's home", file)
|
||||
}
|
||||
home, clean := filepath.Clean(p.Home), filepath.Clean(file)
|
||||
rel, err := filepath.Rel(home, clean)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%s is not in the agent's home (%s): the key file is taken only from there", file, home)
|
||||
}
|
||||
at := home
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
at = filepath.Join(at, part)
|
||||
info, err := fsys.Lstat(at)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the key file: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("%s is a symbolic link: the key file is taken only from the agent's home itself", at)
|
||||
}
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the
|
||||
// manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With
|
||||
// useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is
|
||||
// never an argument.
|
||||
//
|
||||
// **The file is the agent's, never the runtime's** (novox/hq ADR 0266). The tool reads a file and removes it,
|
||||
// and the runtime runs as the operator's account: a path anywhere would let whoever calls it take the runtime's
|
||||
// bus credential or the operator's keys off the machine, sealed, and delete the original. So the file must lie
|
||||
// under the home of the account the agent runs as, reached through no symbolic link, and is read as that
|
||||
// account; what it holds must be an Anthropic API key, or nothing is handed over and nothing removed.
|
||||
func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
file, err := agentsOwnFile(p, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the key is read from a file on this node: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
raw, err := fsys.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the key is read from a file in the agent's home: %w", err)
|
||||
}
|
||||
if len(raw) > apiKeyFileMax {
|
||||
return nil, fmt.Errorf("%s is not a key file: larger than %d bytes; nothing handed over", file, apiKeyFileMax)
|
||||
}
|
||||
key := strings.TrimSpace(string(raw))
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("%s is empty", file)
|
||||
}
|
||||
if !apiKeyShape.MatchString(key) {
|
||||
// Never the content: the file may hold a secret of another kind.
|
||||
return nil, fmt.Errorf("%s does not hold an Anthropic API key (sk-ant-…); nothing handed over, nothing removed", file)
|
||||
}
|
||||
answer, err := ask(SeatVerb("public-key"), map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -485,7 +592,7 @@ func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]an
|
||||
}
|
||||
out := map[string]any{"adopted": json.RawMessage(adopted)}
|
||||
// Taken: the key now lives encrypted in the manager's store alone.
|
||||
if err := os.Remove(file); err != nil {
|
||||
if err := fsys.Remove(file); err != nil {
|
||||
out["file"] = "could not be removed: " + err.Error()
|
||||
} else {
|
||||
out["file"] = "removed"
|
||||
@@ -575,11 +682,11 @@ func (v *ServerView) Effective() Servers {
|
||||
|
||||
func (v *ServerView) writeThrough() bool {
|
||||
now, _ := indented(v.Effective())
|
||||
before, _ := os.ReadFile(v.p.registry())
|
||||
before, _ := fsys.ReadFile(v.p.registry())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.registry(), now, 0o600)
|
||||
_ = fsys.WriteFile(v.p.registry(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -84,12 +84,26 @@
|
||||
"path": "/etc/claude-code",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/claude-agent",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this\n# machine. Written whole at every push: an edit here is overwritten.\nagent='${machine:agent-account}'\nif [ \"$(id -un)\" = \"$agent\" ]; then\n\texec claude \"$@\"\nfi\n# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The\n# operator's override of the guard travels only when it is set in this shell, as it would reach claude.\nif [ -n \"$MESH_GUARD_OVERRIDE\" ]; then\n\texec sudo -iu \"$agent\" env MESH_GUARD_OVERRIDE=\"$MESH_GUARD_OVERRIDE\" claude \"$@\"\nfi\nexec sudo -iu \"$agent\" claude \"$@\"\n"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
"type": "user",
|
||||
"name": "${machine:agent-account}",
|
||||
"home": "${machine:agent-home}",
|
||||
"root": "${machine:agent-root}"
|
||||
},
|
||||
{
|
||||
"id": "agent-home",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.claude",
|
||||
"path": "${machine:agent-home}/.claude",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}"
|
||||
"owner": "${machine:agent-account}"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
|
||||
@@ -640,6 +640,10 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]
|
||||
|
||||
var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
|
||||
|
||||
// apiKeyShape is what an Anthropic API key looks like. A key of another shape is refused before it is kept,
|
||||
// so a file that is not a key (a credential, a private key) is never adopted as one (novox/hq ADR 0266).
|
||||
var apiKeyShape = regexp.MustCompile(`^sk-ant-[A-Za-z0-9_-]{20,300}$`)
|
||||
|
||||
// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6).
|
||||
func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
@@ -666,6 +670,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
||||
if key == "" {
|
||||
return nil, errors.New("the key is empty")
|
||||
}
|
||||
if !apiKeyShape.MatchString(key) {
|
||||
// Never the content: what was handed may be a secret of another kind.
|
||||
return nil, errors.New("what was handed over is not an Anthropic API key (sk-ant-…): nothing adopted")
|
||||
}
|
||||
held, err := m.Store.Licence(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -230,7 +230,7 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "key")
|
||||
_ = os.WriteFile(file, []byte("sk-ant-api-key\n"), 0o600)
|
||||
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -403,13 +403,13 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
|
||||
func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
box, _ := Seal("sk-ant-api03-test\n", mm.keys.PublicKey)
|
||||
box, _ := Seal("sk-ant-api03-test-0123456789abcdefghij\n", mm.keys.PublicKey)
|
||||
r, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop")
|
||||
if err != nil || r["adopted"] != true {
|
||||
t.Fatalf("%v %v", r, err)
|
||||
}
|
||||
l, _ := mm.store.Licence(ctx, "api")
|
||||
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test" {
|
||||
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
||||
t.Fatalf("stored %q", plain)
|
||||
}
|
||||
other, _ := GenerateKeyPair()
|
||||
@@ -438,3 +438,19 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
|
||||
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
||||
}
|
||||
}
|
||||
|
||||
// What is not an API key is never adopted as one, and the refusal never repeats it (novox/hq ADR 0266).
|
||||
func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
for _, notAKey := range []string{"-----BEGIN OPENSSH PRIVATE KEY-----\nabc", "SUAOPERATORNKEYSEED", "sk-ant-short"} {
|
||||
box, _ := Seal(notAKey, mm.keys.PublicKey)
|
||||
_, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop")
|
||||
if err == nil || strings.Contains(err.Error(), notAKey) {
|
||||
t.Fatalf("%q: refused without repeating it, got %v", notAKey, err)
|
||||
}
|
||||
}
|
||||
if l, _ := mm.store.Licence(ctx, "api"); l != nil {
|
||||
t.Fatal("nothing was kept")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,8 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||
a, _ := NewCrypt("one key")
|
||||
b, _ := NewCrypt("another key")
|
||||
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
||||
if strings.Contains(sealed, "rt") {
|
||||
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
||||
if strings.Contains(sealed, "refreshToken") {
|
||||
t.Fatal("stored in the clear")
|
||||
}
|
||||
if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -432,7 +433,9 @@ type Restored struct {
|
||||
}
|
||||
|
||||
// Restore puts a module's data from a restore point BESIDE the live data: each directory as
|
||||
// <path>.restored-<stamp>. A target that already exists is refused, never overwritten.
|
||||
// <path>.restored-<stamp> — or, where a directory on the way belongs to another account, in this module's
|
||||
// state as restored/<stamp>/<module>/<path> (besideIsUnsafe). A target that already exists is refused, never
|
||||
// overwritten.
|
||||
func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*Restored, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -468,6 +471,17 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
|
||||
r := &Restored{Module: module, From: chosen, Live: "untouched — swapping it in is a person's act"}
|
||||
for _, p := range paths {
|
||||
target := p + ".restored-" + stamp
|
||||
if why := b.besideIsUnsafe(ctx, target); why != "" {
|
||||
// Root writes the restore. Beside a path another account controls, that account could put a
|
||||
// symbolic link where root is about to write and have root write wherever it points (novox/hq ADR
|
||||
// 0266: the agent's own home is such a place). Kept in this module's state instead, which only
|
||||
// root and the runtime's account reach.
|
||||
target = filepath.Join(b.Where.State, "restored", stamp, module) + p
|
||||
if _, err := b.Run(ctx, "mkdir", "-p", "-m", "0700", filepath.Dir(target)); err != nil {
|
||||
return nil, fmt.Errorf("%s: %s, and the place in this module's state could not be made: %w", p, why, err)
|
||||
}
|
||||
b.Say("%s: %s; restoring into this module's state instead", p, why)
|
||||
}
|
||||
if b.exists(ctx, target) {
|
||||
return nil, fmt.Errorf("%s already exists; nothing is restored over anything", target)
|
||||
}
|
||||
@@ -480,6 +494,45 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// besideIsUnsafe says why root must not restore at target, beside the live data: a directory on the way to it
|
||||
// is a symbolic link, belongs to an account other than root and the one this process runs as, or may be written
|
||||
// by its group or everyone — any of which could swap a link in while root writes. Empty when every directory on the way is root's or the runtime's.
|
||||
func (b *Backups) besideIsUnsafe(ctx context.Context, target string) string {
|
||||
me := strconv.Itoa(os.Getuid())
|
||||
dir := filepath.Dir(filepath.Clean(target))
|
||||
var chain []string
|
||||
for d := dir; ; d = filepath.Dir(d) {
|
||||
chain = append(chain, d)
|
||||
if d == "/" || d == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
out, err := b.Run(ctx, "stat", "-c", "%u %a %F", chain[i])
|
||||
if err != nil {
|
||||
return chain[i] + " could not be read"
|
||||
}
|
||||
owner, rest, _ := strings.Cut(strings.TrimSpace(out), " ")
|
||||
perm, kind, _ := strings.Cut(rest, " ")
|
||||
if kind == "symbolic link" {
|
||||
return chain[i] + " is a symbolic link"
|
||||
}
|
||||
if owner != "0" && owner != me {
|
||||
return chain[i] + " belongs to another account"
|
||||
}
|
||||
// Another account may write into a directory open to its group or to everyone, sticky or not, and so
|
||||
// put a link where root is about to write.
|
||||
mode, err := strconv.ParseUint(perm, 8, 32)
|
||||
if err != nil {
|
||||
return chain[i] + "'s mode could not be read"
|
||||
}
|
||||
if mode&0o022 != 0 {
|
||||
return chain[i] + " may be written by other accounts"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// restoreOne puts one kept path at target. A directory is restored as the snapshot's subfolder, so
|
||||
// its contents land directly in target; a single file cannot be — restic restores a subfolder, not a
|
||||
// file (a settings file refused with "not a directory" on the first restore of an app, 2026-10-05) —
|
||||
|
||||
@@ -190,8 +190,13 @@ func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) {
|
||||
settings := filepath.Join(root, "settings.xml")
|
||||
must(t, os.WriteFile(settings, []byte("<Config>kept</Config>\n"), 0o600))
|
||||
where := placed(t, "# mail\npath "+store+"\npath "+settings+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n")
|
||||
// As whoever runs the test, against its own repository: no sudo.
|
||||
// As whoever runs the test, against its own repository: no sudo. The test's directory lies in a temporary
|
||||
// directory everyone may write, which a real restore is right to refuse beside; the directories above the
|
||||
// test's own are said to be root's here, so the restore beside is what is tested.
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "stat" && len(args) > 0 && strings.HasPrefix(root, strings.TrimSuffix(args[len(args)-1], "/")+"/") {
|
||||
return "0 755 directory\n", nil
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, name, args...).Output()
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return string(out), errors.New(string(ee.Stderr))
|
||||
@@ -284,3 +289,44 @@ func TestWhetherADirectoryIsThereIsAskedAsRoot(t *testing.T) {
|
||||
t.Errorf("asked %v", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// Beside a directory another account owns, root does not restore: a link swapped in there would point root's
|
||||
// write anywhere (novox/hq ADR 0266). The restore goes to the module's state instead.
|
||||
func TestARestoreBesideAnotherAccountsDirectoryGoesToTheState(t *testing.T) {
|
||||
state := t.TempDir()
|
||||
var ran []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
if name == "stat" {
|
||||
if args[len(args)-1] == "/home/agent" {
|
||||
return "1001 700 directory\n", nil
|
||||
}
|
||||
if args[len(args)-1] == "/srv/shared" {
|
||||
return "0 1777 directory\n", nil
|
||||
}
|
||||
return "0 755 directory\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
b := &Backups{Where: Where{State: state}, Run: run, Now: time.Now, Say: func(string, ...any) {}}
|
||||
if why := b.besideIsUnsafe(context.Background(), "/home/agent/.claude.restored-x"); !strings.Contains(why, "another account") {
|
||||
t.Fatalf("an agent's home is unsafe beside: %q", why)
|
||||
}
|
||||
if why := b.besideIsUnsafe(context.Background(), "/srv/data.restored-x"); why != "" {
|
||||
t.Fatalf("root's directories are safe beside: %q", why)
|
||||
}
|
||||
if why := b.besideIsUnsafe(context.Background(), "/srv/shared/data.restored-x"); !strings.Contains(why, "other accounts") {
|
||||
t.Fatalf("a directory everyone may write is unsafe beside, sticky or not: %q", why)
|
||||
}
|
||||
run2 := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "stat" && args[len(args)-1] == "/home" {
|
||||
return "0 777 symbolic link\n", nil
|
||||
}
|
||||
return "0 755 directory\n", nil
|
||||
}
|
||||
b.Run = run2
|
||||
if why := b.besideIsUnsafe(context.Background(), "/home/agent/x"); !strings.Contains(why, "symbolic link") {
|
||||
t.Fatalf("a link on the way is unsafe: %q", why)
|
||||
}
|
||||
_ = ran
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func tools(b *Backups) []stdio.Tool {
|
||||
}
|
||||
return map[string]any{"started": started, "follow": Seat + ".backed-up"}, nil
|
||||
}),
|
||||
verb("restore", "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as <path>.restored-<date>. Swapping it in is a person's act.",
|
||||
verb("restore", "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as <path>.restored-<date>, or in this module's state when a directory on the way belongs to another account. Swapping it in is a person's act.",
|
||||
map[string]any{
|
||||
"module": str("the module"),
|
||||
"snapshot": str("the restore point (the newest when omitted)"),
|
||||
|
||||
@@ -6,7 +6,10 @@ Privilege escalation as a module (novox/hq to-be 42 Phase 1, research 027).
|
||||
|
||||
- The `sudo` package.
|
||||
- `/etc/sudoers.d/10-mesh-operator`, root's, mode 0440, written whole:
|
||||
`<operator account> ALL=(ALL:ALL) NOPASSWD: ALL`.
|
||||
`<operator account> ALL=(ALL:ALL) NOPASSWD: ALL`, and `Defaults use_pty`: a command run through sudo gets a
|
||||
terminal of its own, so an agent the operator starts as the agent account (hq ADR 0266) cannot push
|
||||
keystrokes into the operator's shell. The rule names the operator account and nobody else: the agent
|
||||
account has no sudo.
|
||||
|
||||
That one line is what the mesh's acting tools assume: the packet filter, the service manager and the
|
||||
intrusion prevention tools act through `sudo -n` as the operator account (to-be 38 WP4). Before this
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestTheDropInGrantsExactlyTheOperatorAccountAndParses(t *testing.T) {
|
||||
rules = append(rules, l)
|
||||
}
|
||||
}
|
||||
if len(rules) != 1 || rules[0] != "${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL" {
|
||||
if len(rules) != 2 || rules[0] != "Defaults use_pty" || rules[1] != "${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL" {
|
||||
t.Fatalf("rules: %q", rules)
|
||||
}
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/sudoers.d/10-mesh-operator",
|
||||
"mode": "0440",
|
||||
"content": "# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates\n# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it;\n# until this file, every machine said so only in a line set by hand in /etc/sudoers.\n# Written whole at every push: an edit here is overwritten. A file of this directory whose name\n# holds a dot or ends in ~ is not read by sudo; this name holds neither.\n${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL\n"
|
||||
"content": "# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates\n# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it;\n# until this file, every machine said so only in a line set by hand in /etc/sudoers.\n# Written whole at every push: an edit here is overwritten. A file of this directory whose name\n# holds a dot or ends in ~ is not read by sudo; this name holds neither.\n# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator\n# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI).\nDefaults use_pty\n${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user