claude-code: write the agent's home as its own account where it has one

On the control node agents are to run as an account that cannot become root
(hq ADR 0266). The runtime keeps running as the operator, so the token, the
identity and the home-scope items go to the agent's home through a helper
started as that account; the licence and the account file follow the move,
what was placed in the operator's home goes once the agent holds the licence,
and the guard refuses every tool of a session running as anyone else,
recording to the journal. An API key is taken only from the agent's home and
only when it is one, here and in the licence manager, so the tool cannot carry
off the runtime's credential. Unchanged where no agent account is named.
This commit is contained in:
jochen
2026-10-09 10:14:18 +02:00
parent 7339ce8556
commit b57e6e8144
17 changed files with 1467 additions and 102 deletions
+27 -1
View File
@@ -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 {