diff --git a/.github/scripts/Set-RepoGovernance.ps1 b/.github/scripts/Set-RepoGovernance.ps1 index 9043a8d..18c04f4 100644 --- a/.github/scripts/Set-RepoGovernance.ps1 +++ b/.github/scripts/Set-RepoGovernance.ps1 @@ -2,7 +2,8 @@ .SYNOPSIS Applies this repository's governance settings on GitHub with the gh CLI: security features, merge policy, Actions permissions, a 'release' - environment, and a ruleset protecting the default branch. Idempotent - + environment with required reviewers, a ruleset protecting the default + branch and a ruleset protecting release tags. Idempotent - re-run after changing it. Run as a repo admin: `task repo:govern`. .DESCRIPTION @@ -20,19 +21,37 @@ opens the generated-docs PR); fork PR workflows need approval from a maintainer for first-time contributors. - 'release' environment (required by release.yaml) restricted to the - default branch - the only place the packages:write token exists. + default branch - the only place the packages:write token exists - and + with a required reviewer (the repository owner, the Operator, or + -ReleaseReviewer): a release job waits for their approval. Self-review + is allowed, so a solo maintainer can still release. - Ruleset 'protect-main': no deletion/force-push, linear history, PRs required with 1 approval incl. code owners, stale reviews dismissed, conversations resolved, required checks validate / lint / tests. Repo admins may bypass *only through a PR* (solo-maintainer escape - hatch: own PRs can be merged without a second human). + hatch: own PRs can be merged without a second human). Kept on + purpose for now (least-privilege step 1, decision 4): the agent's + fine-grained token acts as the Operator, so it can still merge. + - Ruleset 'protect-release-tags': tags 'feature_*' (what the release + workflow publishes: feature__) cannot be deleted, + moved (update) or force-pushed, and can be created only by the + GitHub Actions app (the release workflow) or a repo admin. If the API + refuses the Actions app as a bypass actor, the ruleset is applied with + admins only and a warning says so. .PARAMETER Repo owner/name. Defaults to the repo of the current directory. + +.PARAMETER ReleaseReviewer + GitHub login required to approve runs of the 'release' environment. + Defaults to the repository owner when that is a user (read at run time, + never stored in this file). When neither resolves, reviewers are left as + they are and a warning says so. #> [CmdletBinding(SupportsShouldProcess)] param( - [string]$Repo + [string]$Repo, + [string]$ReleaseReviewer ) $ErrorActionPreference = 'Stop' @@ -99,14 +118,27 @@ if ($PSCmdlet.ShouldProcess($Repo, 'Actions permissions')) { } if ($PSCmdlet.ShouldProcess($Repo, "environment 'release'")) { - Invoke-Gh PUT "repos/$Repo/environments/release" @{ + $envBody = @{ deployment_branch_policy = @{ protected_branches = $false; custom_branch_policies = $true } - } | Out-Null + } + if (-not $ReleaseReviewer) { + $owner = Invoke-Gh GET "repos/$Repo" | ConvertFrom-Json | Select-Object -ExpandProperty owner + if ($owner.type -eq 'User') { $ReleaseReviewer = $owner.login } + } + if ($ReleaseReviewer) { + $reviewerId = [int](Invoke-Gh GET "users/$ReleaseReviewer" | ConvertFrom-Json).id + $envBody.reviewers = @(@{ type = 'User'; id = $reviewerId }) + $envBody.prevent_self_review = $false + } + else { + Write-Warning "No release reviewer resolved (the owner is not a user); pass -ReleaseReviewer . Reviewers left unchanged." + } + Invoke-Gh PUT "repos/$Repo/environments/release" $envBody | Out-Null $existing = Invoke-Gh GET "repos/$Repo/environments/release/deployment-branch-policies" | ConvertFrom-Json if (-not ($existing.branch_policies | Where-Object { $_.name -eq 'main' })) { Invoke-Gh POST "repos/$Repo/environments/release/deployment-branch-policies" @{ name = 'main'; type = 'branch' } | Out-Null } - Write-Host " environment 'release': deployable from main only" + Write-Host " environment 'release': deployable from main only$(if ($ReleaseReviewer) { ', one required reviewer' })" } if ($PSCmdlet.ShouldProcess($Repo, "ruleset 'protect-main'")) { @@ -153,6 +185,37 @@ if ($PSCmdlet.ShouldProcess($Repo, "ruleset 'protect-main'")) { } } +if ($PSCmdlet.ShouldProcess($Repo, "ruleset 'protect-release-tags'")) { + # RepositoryRole 5 = admin. Integration 15368 = the GitHub Actions app, + # which the release workflow's GITHUB_TOKEN acts as. + $admin = @{ actor_id = 5; actor_type = 'RepositoryRole'; bypass_mode = 'always' } + $actions = @{ actor_id = 15368; actor_type = 'Integration'; bypass_mode = 'always' } + $tagRuleset = @{ + name = 'protect-release-tags' + target = 'tag' + enforcement = 'active' + conditions = @{ ref_name = @{ include = @('refs/tags/feature_*'); exclude = @() } } + bypass_actors = @($admin, $actions) + rules = @( + @{ type = 'creation' }, + @{ type = 'update' }, + @{ type = 'deletion' }, + @{ type = 'non_fast_forward' } + ) + } + $all = Invoke-Gh GET "repos/$Repo/rulesets" | ConvertFrom-Json + $current = $all | Where-Object { $_.name -eq 'protect-release-tags' } | Select-Object -First 1 + $method = if ($current) { 'PUT' } else { 'POST' } + $endpoint = if ($current) { "repos/$Repo/rulesets/$($current.id)" } else { "repos/$Repo/rulesets" } + $result = Invoke-Gh $method $endpoint $tagRuleset -IgnoreError + if (-not $result) { + Write-Warning 'The API refused the GitHub Actions app as a bypass actor; applying with repo admins only (the release workflow then cannot create tags: add the actor by hand).' + $tagRuleset.bypass_actors = @($admin) + $result = Invoke-Gh $method $endpoint $tagRuleset + } + Write-Host " ruleset 'protect-release-tags': $(if ($current) { 'updated' } else { 'created' }) (feature_* tags: no delete, update or force-push; created by the release workflow or admins)" +} + Write-Host @' Done. Manual follow-ups GitHub does not expose via API: - Install the DCO app on this repo (https://github.com/apps/dco) - the diff --git a/Taskfile.yml b/Taskfile.yml index 0be74a6..5a285c0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -458,6 +458,6 @@ tasks: - git config --get core.hooksPath repo:govern: - desc: 'Apply the GitHub governance settings (security features, squash-only merges, Actions allowlist, release environment, protect-main ruleset) with gh - see .github/scripts/Set-RepoGovernance.ps1. Idempotent; needs gh logged in as a repo admin.' + desc: 'Apply the GitHub governance settings (security features, squash-only merges, Actions allowlist, release environment with a required reviewer, protect-main and protect-release-tags rulesets) with gh - see .github/scripts/Set-RepoGovernance.ps1. Idempotent; needs gh logged in as a repo admin.' cmds: - pwsh -NoLogo -NoProfile -File '{{.ROOT_DIR}}/.github/scripts/Set-RepoGovernance.ps1' diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 4cf8d45..8206625 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -23,3 +23,4 @@ Drafts the Operator decides on. Before a ruling a proposal carries an "Architect | [OpenBao key file](openbao-key-file.md) | proposed 2026-11-04; a DPAPI key file on the workstation replaces the typed unseal and the Operator's password: five shares (two in the file, three in custody) and an AppRole login with a periodic token, fed by the launcher at every `task dc:*`; the static seal was tested and is the fallback; ten decisions and the architecture review await; build not started | | [Least privilege for agents, ending in one agent Linux user](agent-linux-user.md) | proposed 2026-11-06 as agents as their own Linux users (one per identity and per back); re-scoped the same day to least privilege first (the Operator's ruling; brokers held): an MVP of eight opt-in steps from the inventory, configuration before build: a GitHub ruleset and a narrow fine-grained token, Claude Code's sandbox and locked permissions in the claude-code managed drop-in, write-deny on hooks, `Taskfile.yml`, `.devhost` and the git dir's `config`, the backs' `claude -p` narrowed and the tester under `bwrap`, a per-agent OpenBao AppRole, one `agent` user without `sudo` or `docker`, the shared folder's agent access and no default SSH keys, the Codex equivalent; tests T1 and T3 gate the sandbox step; the first design's per-identity users are under "Later"; accepted 2026-11-08 (all sixteen recommendations; step 2 with the two-list rule after T1 and T3 came out partial); build not started; architecture review 2026-11-06 (of the earlier design): fit with changes | | [Least privilege of agents and backs: an inventory](agent-least-privilege-inventory.md) | draft 2026-11-06; read-only inventory of what the host, a con, each back and a consumer's agent can reach today and need, the existing control for each gap (Claude Code's sandbox and managed settings, Codex's sandbox, GitHub rulesets and narrow tokens, OpenBao AppRoles, no `sudo` and no `docker`), the residuals with no native control, both architecture reviews mapped, an MVP list in delivery order and nine tests; the input for re-scoping agent-linux-user; nothing built | +| [Least privilege: the two consumers' BRDs mapped](agent-least-privilege-brd-mapping.md) | draft 2026-11-08; every requirement and acceptance test of the office's and AOF's BRDs traced onto the accepted least-privilege proposal (covered, partly, gap), where they agree, differ and run into accepted decisions (D7 one agent user, D8, D10, the brokers hold); the revision they imply: step 6 as accepted with a shared `dcf-work` group, then users per identity made at start from the agent list, declared groups, a Claude home per user, per-user ssh-agent keys, the hand-over to back users, a helper gate by task name with the caller from `SO_PEERCRED` (the reused part of #187), `agents check` and `migrate`; owners and versions, twelve open questions, eighteen decisions (six overturn or amend accepted ones); awaits the architecture review and the Operator; agent-linux-user.md not edited; architecture review 2026-11-08: fit with changes | diff --git a/docs/proposals/agent-least-privilege-brd-mapping.md b/docs/proposals/agent-least-privilege-brd-mapping.md new file mode 100644 index 0000000..40d71ae --- /dev/null +++ b/docs/proposals/agent-least-privilege-brd-mapping.md @@ -0,0 +1,893 @@ +# Least privilege: the two consumers' BRDs mapped, and the revision they imply + +draft 2026-11-08 + +Status: a traceability and a proposed revision, for the Operator to rule on. It does not change +[agent-linux-user.md](agent-linux-user.md) (accepted 2026-11-08, "the accepted proposal" below); +the Operator rules on this first, and the accepted proposal is amended after that ruling. Backlog: +`least-privilege-mvp` (step 1 is on draft pull request 190), `agent-linux-user`. Basis: +[agent-least-privilege-inventory.md](agent-least-privilege-inventory.md) (the inventory, with its +test results of 2026-11-08) and the held broker draft (#187, `docs/proposals/brokers.md` on +`docs/broker-pattern/1`). + +**Who this serves.** The Operator, through the host, who rules on the revision and rotates the +OpenBao key file after it; and the maintainers of the two consumers whose agents will run under it: +the office and AOF (agentic-operating-framework). Each sent a business requirements document on +2026-11-08: **the office's BRD (draft 2, 2026-11-08)**, cited here by its identifiers (R1.1 to R8.5, the +decisions BRD-1 to BRD-12, "office acceptance 1" to 12), and **AOF's BRD (2026-11-08)**, cited by +"AOF req 1" to 13, its non-functional requirements by name and "AOF acceptance 1" to 14 (in the +BRD's order). Both BRDs are consumer material: they are not copied here, and this document uses +generic words ("a room", "a client room", "a level") for what they name. + +How to read it: section 1 is the table, then where the two BRDs agree, differ and conflict with an +accepted decision; section 2 the revision, step by step, and the helper gate compared with the +held brokers; section 3 who owns each new element, the versions and what each consumer changes; +section 4 the open questions; section 5 the decisions for the Operator. Words are the lexicon's; +the decisions of the accepted proposal's section 14 are written D1 to D16. + +## 1. Traceability + +Status: **covered** (the accepted proposal meets it as written), **partly** (it meets part of it, +or only for one agent user), **gap** (nothing in the accepted deliverable). "Later" is the accepted +proposal's section 12. "Rev" names the element of section 2 that closes it. A decision in the +last column is an accepted decision the requirement runs into (section 1.3). + +### 1.1 The office's BRD (draft 2, 2026-11-08) + +| Req | Asks, in short | Accepted proposal | Status | Rev | Runs into | +|---|---|---|---|---|---| +| R1.1 | each agent names (or derives) its user in the agent list; a room added later gets user and group without a rebuild | Later: one user per identity, made at start from the list | gap | 6b users from the list, made at start | D7, D8 (install time) | +| R1.2 | housekeepers run as their own users in their level's group | Later: one user per back; a shared group | gap | 6b back users, 6c level groups | D7 | +| R1.3 | subagents inherit their parent's user, said so | 3.3 "cons share their parent" | covered | (NOTES line) | | +| R1.4 | no agent can `sudo`, `su` or join the Operator's groups; the only way up is a named helper | step 6: no `sudo`, no `docker`; but 6.5 makes the agent's primary group the Operator's | partly | 6a `dcf-work` group; 6e helper gate | D10 | +| R1.5 | the Operator starts, resumes, attaches to and stops any agent as that agent; leases per user | 6.2: every adapter verb through `sudo -n -u agent`; leases the caller's | partly (one user) | 6b the switch per user | | +| R1.6 | the process that signs a register entry runs as the key's user | not covered (host and backs share `agent`) | gap | 7b per-user keys | D7 | +| R1.7 | Linux users and uids are per machine, independent of the writer id | users are per container | covered | 6b fixed numbers (section 4, Q5) | | +| R2.1 | the repository by ownership: shared floor parts read by all, floor tooling written by the floor only, a room's exec writes its own room only and reads no other room; the floor reads every room's docs and README only; recordings through helpers; helper-written files owned by the room group; one published read-only folder | step 3: write-deny by path, the same for every agent | gap | 6c level groups, POSIX ACLs; 6e helpers; T2 gates it | | +| R2.2 | the shared folder: a room reads only its own results folder; no agent reads the raw-material folders or the name list; writes only through a named task | step 7: `agentAccess` `none`, `read`, `write` for the whole mount; the office runs `host` mode (review finding 16) | partly | 7a per-group sub-mounts in `mount` mode; `host` mode reported unprotected | D15 (whole mount) | +| R2.3 | the personal folder: the Operator's and the floor's, nothing for rooms | not covered | gap | 7a the same sub-mounts (a second remote) | | +| R2.4 | the state home: each level's machinery in its level's group; work, quarantine and the staff list for helpers only; the app key for one helper | not covered | gap | 6c, 6e; the app key moves into OpenBao (step 5's consumer change) | | +| R2.5 | the office's own read-block hook and deny rules stay | the two-list rule adds, never removes; but `lockPermissions` (`allowManagedPermissionRulesOnly`) would make Claude Code ignore the office's own deny rules | partly | step 2 note: the consumer's rules move into the managed list, or `lockPermissions` stays off | step 2 text | +| R2.6 | mounts that cannot hold ownership are detected, protected another way or reported unprotected | T2 asks the question; nothing detects or reports | gap | 6f the check reports them (BRD-8 decided "report") | | +| R3.1 | a fixed list of task names per agent may run with more rights; data, never `task` itself | Later: custom brokers (held) | gap | 6e helper gate | the brokers hold | +| R3.2 | the helper learns its caller from the kernel uid, refuses outside the caller's scope | not covered | gap | 6e caller by `SO_PEERCRED`; the scope check is the office's task (its section 5) | | +| R3.3 | a path from the caller is resolved without following links out of the room, and read only if the caller could read it | not covered | gap | 6e a path argument is opened on the caller's side and handed over on standard input | | +| R3.4 | files a helper writes into a room end up in the room's group | not covered | gap | 6c setgid folders | | +| R3.5 | helpers print counts and identifiers, never content (except where content is the job) | not covered | partly | the office's tasks; the gate's audit line holds no values | | +| R3.6 | register writes run as the caller with its own key through ssh-agent, not as a helper | not covered | gap | 7b | D7 | +| R4.1 | the key file and the vault's state for the Operator's user only; rotate once after the switch | steps 5, 6; D16 | covered | | | +| R4.2 | secrets through the agent's own vault login (`DCF_SECRET_AS`), a policy per secret, short tokens, no fallback | step 5: an AppRole for the host (`openbao-agent-login`) | partly | 5+ one AppRole per agent and helper user | | +| R4.3 | operator commands refuse agent users, by uid | Later: share tasks refuse by user | gap | 5+ moved in | | +| R4.4 | two signing principals per room (exec, scribe), each key usable only by its user; both append to the room's store | not covered | gap | 7b; 6c group on the store | D7 | +| R5.1 | every agent user commits as the office's bot identity with its trailer; the git wrapper works for each user | git's wrapper is per call and user-neutral; `safe.directory` (6.5) | partly | 6a `core.sharedRepository` with `dcf-work`; a test as a second user | | +| R5.2 | `save` is a privileged helper (fetch, merge, verify, push); a plain `git push` by an agent is refused | not covered (the host pushes with the step 1 token) | gap | 6e; the push token belongs to the helper user, not to agents | | +| R5.3 | a worktree belongs to the agent that opened it; a refusal names the rule | not covered; kernel refusals are bare | partly | 6f the check explains; the office's guard names the rule | | +| R6.1 | each agent user has its own Claude home that survives a rebuild; the Operator's memory unreadable | Later: per-user Claude state | gap | 6d | D8 (volume handed whole) | +| R6.2 | the shared skills linked read-only into each user's skills folder; a dead junction ignored | not covered | gap | 6d | | +| R6.3 | each user logged in once without copying credentials; Remote Control reaches each resident | Later: per-user Claude logins | gap | 6d (section 4, Q3) | | +| R6.4 | hooks fire; a housekeeper runs as its user from a copy the hook hands over, never reading the exec's home | Later: back users and the hand-over (review finding 3) | gap | 6g hand-over through the gate | D7 | +| R6.5 | session-start checks fail soft ("not visible", counts) for restricted users | not covered | partly | the office's own checks; the Feature's check (6f) follows the same rule | | +| R7.1 | tools system-wide and readable by all | the image already | covered | | | +| R7.2 | the model caches shared read-only and kept across a rebuild | not a least-privilege step | gap (out of the Features' scope) | the office's volume; 6f reports presence | | +| R7.3 | a long pipeline run runs as the helper, outlives the session, is visible to agents, stopped only by the Operator | not covered | gap | 6e `detach` entries; the kernel stops other uids from killing it | | +| R8.1 | one switch per machine; off restores today's single user with nothing lost | step 6: `runAs` | partly | 6b `runAs: per-agent` beside `agent` | | +| R8.2 | a check per agent and per room, counts only: paths, helpers allowed or refused, layout mismatches, unprotected paths, caches; an audit extract | Later: `agents boundary` | gap | 6f `agents check` | | +| R8.3 | upgrade in place: ownership moved with a dry run, no content read, root-owned or 777 trees reported | not covered | gap | 6f `agents migrate` | | +| R8.4 | two machines independently; accounts and credentials never through the shared folder; uids do not carry rights across machines | users per container; the shared folder's access by mount options | covered | | | +| R8.5 | no noticeable cost per turn; a rebuild grows by at most a minute | not measured | partly | T19 | | +| Section 5 | out of scope: the office's tasks reading the caller; a container per agent; agent-to-agent exchange (BRD-11) | | | the gate hands the caller to the task; the task's use of it is the office's | | +| Section 7 | R1, R2's principle, R4, R8.1 to R8.3 apply to any stack | | | the revision is one model for both (AOF req 11) | | +| Office acceptance 1 | a room's exec cannot read another room or the raw-material folders by any command | | gap | 6c, 7a; T2 | | +| Office acceptance 2 | no agent reads the key file, the name list, the staff list, the app key | T9 for the key file | partly | T9 extended per user | | +| Office acceptance 3 | helpers work for the caller's room and refuse another; a link out of the room refused | | gap | 6e | | +| Office acceptance 4 | masking a transcript for the exec, who never reads the raw text | | gap | 6e | | +| Office acceptance 5 | exec and scribe each sign with their own key, refused with the other's | | gap | 7b | | +| Office acceptance 6 | `save` merges another room's change and pushes; a plain push refused | | gap | 6e | | +| Office acceptance 7 | a resident starts, is reached by Remote Control, resumed by the Operator | 6.2, T11 (one user) | partly | 6b, 6d, T15 | | +| Office acceptance 8 | a housekeeper writes its entry from the hook's copy, nothing outside its level | | gap | 6g | | +| Office acceptance 9 | the briefing passes for an exec with "not visible" counts | | gap | the office's checks | | +| Office acceptance 10 | after the switch the key file is rotated and the vault unseals | D16 | covered | | | +| Office acceptance 11 | on an ownership-less mount the check lists unprotected paths | | gap | 6f | | +| Office acceptance 12 | the switch off restores the single user, every agent working | `runAs: remote-user` | partly | 6b | | + +### 1.2 AOF's BRD (2026-11-08) + +| Req | Asks, in short | Accepted proposal | Status | Rev | Runs into | +|---|---|---|---|---|---| +| AOF req 1 | one least-privilege user per main agent, per staff member, and for the operator | Later: per identity, per back | gap | 6b | D7 | +| AOF req 2 | no passwordless `sudo` for agents; at most named commands on a list the operator sets, shown by the "Visible" command | step 6 for one user | partly | 6e, 6f | the brokers hold | +| AOF req 3 | each agent signs only as itself; the agent makes its own key; the operator takes only the public key | not covered | gap | 7b, and AOF's own register changes | D7 | +| AOF req 4 | the operator's OpenBao token, the unseal shares on their way in, the key file's secret id and the anchor key out of agents' reach | steps 5, 6, 7: other uid, modes 600/700, no `SSH_AUTH_SOCK` | covered (T9 to list each path) | | | +| AOF req 5 | an AppRole per agent, reading only its SSH key and the instance id | step 5 for the host | partly | 5+ | | +| AOF req 6 | an agent writes only its own worktree, as filesystem permissions | step 3: write-deny by path for all agents alike | gap | 6c per-agent owners on the worktree volume (Q8) | | +| AOF req 7 | shared on purpose, through groups: checkout and git dir, records lock, mailbox and live store, relay volume, the agents' messaging socket | one user shares everything | partly | 6a `dcf-work`, 6c groups; T17 for messaging | D10 | +| AOF req 8 | Claude Code per user; the staff read the turn they record | Later | gap | 6d, 6g | D8 | +| AOF req 9 | the start tasks, the residents at start and `--resume` keep working as each user, nothing typed | 6.2 for one user | partly | 6b | | +| AOF req 10 (SHOULD) | GitHub per agent, or one bot behind a group | step 1: one narrow token for the host | partly | Q2 | D3, D4 | +| AOF req 11 (SHOULD) | the same model in every stack | | covered by this document | the revision | | +| AOF req 12 | a relay user per stack holding the relay key; one shared gid for the relay; a fixed uid range per stack; setgid party folders | not covered | gap | 6b fixed-number registry; the relay user is AOF's | | +| AOF req 13 | least privilege of knowledge: guard configuration, key and unlock procedures, the signing workflow readable only by their maintainers and the operator | not covered | gap (partly reachable) | 6e guard helper; Q9 | | +| Fail closed | a missing user, group or permission stops the start with one line; never `host` | 6.1 falls back to today's user when the user cannot be made | partly | 6b refuse, never fall back | 6.1 text | +| No secret in an argument, the environment or a log | | 6.2's environment deny list (D13) | covered | | | +| Visible | one command per agent: user, groups, writable paths, reachable secrets | Later: `agents boundary` | gap | 6f | | +| Auditable | a register entry or a commit traced to its user | not covered | partly | 7b for entries; commits: Q10 | | +| Idempotent rebuilds | uids, gids and owners stable on volumes; old volumes re-owned once, saying what changed | 6.1: one fixed uid | partly | 6b fixed numbers, 6f migrate | | +| Cost | a start under a second slower; nothing typed | | partly | T19 | | +| Honest limits | the design says what it does not stop | 3.3 | covered | section 2.4 adds the new limits | | +| AOF acceptance 1 | `id` shows the agent's own user; `sudo -n true` fails | T9 (one user) | partly | 6b | | +| AOF acceptance 2 | one agent cannot read another's register key or sign as it | | gap | 7b | | +| AOF acceptance 3 | no agent reads the token helper's file; `bao token lookup` shows its own token | 6.3 openbao drop-in, T9 | covered | | | +| AOF acceptance 4 | an agent's OpenBao identity reads its SSH key and the instance id, nothing else | step 5 (host) | partly | 5+ | | +| AOF acceptance 5 | one agent cannot write another's worktree or folders | | gap | 6c | | +| AOF acceptance 6 | no agent reads another's transcripts; the staff read the turn | | gap | 6d, 6g | | +| AOF acceptance 7 | an agent that does not maintain a control cannot read its internals | | gap | Q9 | | +| AOF acceptance 8 | the start tasks start each agent as its user; residents at start | 6.2 | partly | 6b | | +| AOF acceptance 9 | the staff write ledger and backlog as themselves; verify and test pass | | gap | 6g, 7b | | +| AOF acceptance 10 | two agents writing one register at once both succeed | | gap | 6c, AOF's lock fix | | +| AOF acceptance 11 | push, open a pull request, merge through the queue | step 1 (host) | partly | Q2 | | +| AOF acceptance 12 | the relay delivers; another stack's relay user cannot touch your party folder | | gap | 6b fixed numbers; AOF's relay | | +| AOF acceptance 13 | a rebuild keeps every owner | | gap | 6b, 6f | | +| AOF acceptance 14 | the operator rotates the key file and new custody shares are stored | D16 | covered | | | +| Settled: subagents | sub-agents run as the parent's user, narrowed by tools | 3.3 | covered | | | +| Settled: staff | one user per staff member | | gap | 6b, 6g | D7 | +| Settled: fixed numbers | fixed uids and gids across stacks for the relay; numbers devfeat's | | gap | Q5 | | + +AOF's constraints table (what each part needs from this work) is traced by Feature in section 3.1; +its own code changes are AOF's (section 3.3). + +### 1.3 Agree, differ, conflict + +**Where they agree** (one design serves both): + +- One Linux user per identity, including the background agents (office R1.1, R1.2, BRD-1; AOF + req 1 and its settled staff item), and subagents as their parent (R1.3; AOF's settled item; the + accepted 3.3). +- No `sudo` for agents, with a list of named commands as the only way up, the caller known from + the kernel (R1.4, R3.1, R3.2; AOF req 2). +- Each register key usable only by its own user; the signing process runs as that user (R1.6, + R3.6, R4.4; AOF req 3). +- The vault for the Operator only, an AppRole per agent, rotation after the switch (R4.1 to R4.3; + AOF req 4, 5, acceptance 14). +- Claude Code state per user; the background agent reads a copy of the turn, not the agent's home + (R6.1, R6.4; AOF req 8). +- The Operator starts and resumes everything as each agent, nothing typed (R1.5; AOF req 9). +- Fail closed; a check that shows the model; re-own once with a report (office section 2, R8.2, + R8.3; AOF's "Fail closed", "Visible", "Idempotent rebuilds"). +- The same model in every stack (office section 7; AOF req 11). + +**Where they differ** (the design must allow both): + +| Topic | The office | AOF | Consequence | +|---|---|---|---| +| Grouping | a room group per level: the exec and its housekeepers share one group and write the same documents; the floor reads every room's docs (R2.1, BRD-1, BRD-4) | one user per agent with only its own worktree writable; shared things through groups (req 6, 7) | groups are declared in the agent list, not derived: a level group for the office, a group per shared resource for AOF (6c) | +| Privileged helpers | many, named by task id, each scoped by the caller's room (R3.1 to R3.5, R7.3) | named commands on a list, no shape asked (req 2) | one gate whose entries are task names (6e); AOF may start with none | +| Pushing | no agent pushes; `save` is a helper (R5.2, BRD-3) | agents push, open pull requests and merge through the queue; GitHub per agent or one bot (req 10, acceptance 11) | the push credential belongs to whoever pushes: a helper user for the office, a bot token per stack for AOF (Q2) | +| Commit identity | one bot identity for every agent, with the office's trailer (R5.1) | a commit traceable to the user (Auditable) | the identity stays one bot; the user goes in a trailer written by the consumer's own hook from `id -un` (Q10) | +| Fixed numbers | uids are per machine and carry no rights across machines (R1.7, R8.4) | a fixed uid range per stack and one shared relay gid (req 12) | compatible: fixed numbers keep owners stable on volumes shared between stacks on one workstation; nothing relies on a uid across machines (Q5) | +| A relay user | none | one `relay` user per stack (req 12) | a user the agent list can declare that is not an agent; AOF's relay Feature makes it with a number from the registry | +| Knowledge | not asked | security internals readable only by maintainers (req 13) | partly reachable (Q9) | +| Agent-to-agent messaging | out of scope (section 5, BRD-11) | kept working through a group (req 7) | T17; not a reason to change the model | +| Mounts | the checkout and the shared folder are Windows mounts that may hold no ownership (R2.6) | worktrees on a Docker volume, where ownership holds | the check reports the office's unprotected paths; T2 decides how much of R2.1 the kernel can enforce | + +**Where a BRD runs into an accepted decision** (section 14 of the accepted proposal): + +| Accepted | Runs into | What the revision proposes | +|---|---|---| +| D7: one `agent` user for the host, its cons and the backs | every user-per-identity requirement of both (R1.1, R1.2, R1.6, R4.4, R6.4; AOF req 1, 3, 8, settled staff) | keep `runAs: agent` and add `runAs: per-agent` (decision 1, overturns D7 as the end state) | +| D8: the user made at install time; the Claude state volume handed to it whole | R1.1 (a room added later without a rebuild); R6.1 and AOF req 8 (a home per user) | users made at start from the list in `per-agent` mode; a Claude home per user (decisions 2, 5, partly overturn D8) | +| D10: the agent's primary group is the remote user's own | R1.4 (no agent joins the Operator's groups); AOF req 7 (sharing through named groups) | a shared group `dcf-work` with a fixed gid in both modes (decision 3, overturns D10) | +| D12: an agent cannot start another agent | nothing in either BRD asks for it; the hand-over starts a fixed background job as another user | D12 kept; the hand-over and helpers are gate entries, not `agents start` (decision 14) | +| D15: the shared folder's `agentAccess` for the whole mount | R2.2, R2.3 (per room, per folder) | per-group sub-mounts beside it (decision 12, amends D15) | +| Step 2's text: no `allowUnixSockets` | the gate and per-user ssh-agent sockets must be reachable from a sandboxed Bash call | list exactly those two paths (decision 7, amends step 2) | +| Step 2's `lockPermissions` | R2.5 (the office's own deny rules stay) | the consumer's rules move into the managed list, or `lockPermissions` stays off for that consumer (section 2.1) | +| 6.1's fallback ("agents run as today" when the user cannot be made) | the office's section 2 and AOF's "Fail closed" | refuse, never fall back (decision 11) | +| The brokers hold (2026-11-06 ruling, #187 not part of the MVP) | R3.1 to R3.4, R5.2, R7.3; AOF req 2 | a narrow part of #187, the helper gate, enters the deliverable; the per-tool brokers stay held (decision 6) | + +D1 to D6, D9, D11, D13, D14 and D16 are untouched by either BRD. D16 matters for order: both BRDs' +urgency is the key file, and step 6 as accepted (one agent user) already takes it out of every +agent's reach, so `key-file-rotate` does not wait for the revision (section 2.3). + +## 2. The revision + +The smallest change to the accepted deliverable that meets both BRDs: keep steps 1 to 5 and 8, +ship step 6 as accepted (with the shared group), then extend it with what the BRDs need from +"Later" and a narrow helper gate; change step 7 for per-user sockets and per-group sub-mounts. + +### 2.0 The picture: users and groups + +``` + the Operator's user (remote user) sudo, docker, OpenBao token, rclone.conf, its own Claude home + | member of dcf-work and of every declared group + | + | agents start / attach / resume / stop ==> sudo -n -u (every adapter verb) + v + +======================= group dcf-work (fixed gid): the checkout, the git dir ==================+ + | | + | the office: | + | group "room A" group "room B" group "floor" | + | +------------------------+ +-------------------+ +---------------------+ | + | | agent-roomA (exec) | | agent-roomB | | agent-floor | | + | | back-roomA-scribe | | back-roomB-scribe | | back-floor-tidy | | + | | back-roomA-librarian | | ... | | (ACL: reads each | | + | +------------------------+ +-------------------+ | room's docs only) | | + | +---------------------+ | + | AOF: | + | agent- (owns its worktree) back- | + | group "records" (lock, live store), group "mailbox"; relay (its own user, shared relay gid) | + | | + | each agent and back user: its own Claude home (700), ssh-agent socket and register key, | + | AppRole; no sudo, no docker; cons and subagents run as their parent | + | | + | helper users (no login, no sudo): helper-save, helper-pipe, helper-guard, ... | + | reached only through the gate: one socket each, caller = SO_PEERCRED uid | + +===============================================================================================+ +``` + +### 2.1 Step by step + +| Step | Changed by a BRD? | What changes | +|---|---|---| +| 1 GitHub ruleset, narrow token | no | as accepted. Where a consumer pushes through a helper (the office's `save`), the narrow token belongs to that helper user, not to an agent user (Q2) | +| 2 Sandbox, locked permissions | yes, two settings | as accepted, plus: `sandbox.network.allowUnixSockets` lists exactly the gate's socket folder and the per-user ssh-agent socket folder (decision 7; each socket's own mode keeps other users out); and a consumer with its own deny rules (R2.5) either moves them into the managed list (a new claude-code option for extra `permissions.deny` lines) or keeps `lockPermissions` off. The two-list rule stays | +| 3 Write-deny | no | as accepted; per-level write separation is the kernel's (6c), not more sandbox paths | +| 4 The backs narrowed, the tester under `bwrap` | no | as accepted; 6g adds the hand-over on top | +| 5 Per-agent AppRole | extended (5+) | one AppRole per agent user and per helper user, issued from the agent list (`task openbao:approle:issue` over the list); the share tasks refuse by user (the caller must own `$OPENBAO_STATE_DIR`) beside the variables, moved in from Later (R4.3); the unix listener as accepted | +| 6 One `agent` user | yes | shipped as accepted with one change (6a), then extended (6b to 6g) | +| 7 Shared folder and ssh-agent | yes | 7a per-group sub-mounts; 7b per-user ssh-agent sockets and register keys | +| 8 Codex | no | as accepted | + +**6a. The shared group, in both modes.** A group `dcf-work` with a fixed gid (section 4, Q5) made +at install time; `_REMOTE_USER` joins it at build (review finding 6), every agent and helper user +joins it, and git's `core.sharedRepository=group` uses it. It replaces "the agent's primary group +is the remote user's" (D10), so no agent joins the Operator's groups (R1.4). Umask 002 for agent +users stays. + +**6b. Users per identity.** `runAs` gains `per-agent` beside `remote-user` and `agent`. Each +identity in the agent list gets `agent-` (or the entry's `user:`), each background +agent the list declares gets `back-`, each helper `helper-`; no password, no `sudo`, +no `docker`, home in the image. In `per-agent` mode the start hook makes them as root **at start**, +from the agent list **as committed on the base branch** (or from the Operator's own working copy +when the Operator runs `agents sync` themself), never from a working tree an agent can write, so a +room added later needs no rebuild (R1.1) and an agent cannot widen its own entry. uids and gids: +an entry's `uid:`, else derived inside the stack's range (`uidRange` option) and refused on a +collision, so a rebuild keeps owners (AOF "Idempotent rebuilds"). The switch is 6.2's, per user: +every adapter verb runs as the entry's user; the Operator starts, resumes, attaches and stops as +that user (R1.5, AOF req 9). With `runAs` set and a user, group or socket missing, `agents start` +refuses with one line naming it and never runs as the Operator's user (decision 11). `agent` mode +keeps the install-time user of 6.1. + +**6c. Level groups and modes.** The list declares groups (`groups:` with a gid, and `group:` per +entry). The office declares a group per room and one for the floor (exec and housekeepers in it); +AOF declares a group per shared resource (records lock, live store, mailbox) and none per agent. +Folders a level writes are setgid to its group, so files a helper writes there end up in the +group (R3.4). Reads across levels (the floor reading each room's docs and README, R2.1; the one +published folder, BRD-9) are POSIX ACLs on those folders, the kernel's own feature (NFR-13). All of +this holds only where the filesystem keeps owners, modes and ACLs: on a Docker volume it does; on +the Windows mounts it depends on T2, which runs before 6c is built (section 4, Q7). + +**6d. A Claude home per user.** claude-code's drop-in sets `CLAUDE_CONFIG_DIR` to +`$CLAUDE_CONFIG_DIR/users/` (700, the user's), in the existing volume, so it survives a +rebuild (R6.1, AOF req 8). The volume's root stays the Operator's, with the Operator's memory +(ADR-0049's path for the Operator does not move; the host identity's state is copied once into +`users/agent-host/` by the migration). The consumer's shared skills are linked read-only into each +user's `skills/` (R6.2). The sign-in is Q3. The cost report reads every user's folder as the +Operator's user (review finding 4). + +**6e. The helper gate** (privileged helpers by task id). Its shape is in 2.2. + +**6f. `agents check` and `agents migrate`**, moved in from Later ("`agents boundary`"). `check` +prints, per user, with counts only: its user and groups, which declared paths it can read and +write, each gate entry allowed or refused, every path on a filesystem that cannot hold ownership +(reported unprotected, never counted as protected: R2.6, BRD-8), and what secrets it can reach +(AOF "Visible"); one line per finding and `--format json` (the linter contract's named exception, +review finding 11). Each Feature ships its protected paths in `boundary.d/` (review finding 8); a +consumer adds its expected model (the office from its layout spec). `migrate` re-owns existing +files from a consumer's ownership map, dry run by default, never reading content, reporting +root-owned or 777 trees instead of failing (R8.3). + +**6g. The hand-over to back and staff users.** The `Stop` hook, running as the agent, measures the +stretch's cost, then hands the stretch to the background agent's user through a gate entry +(`handover `), on standard input; the back runs as `back-` and never reads the agent's +home (R6.4, AOF req 8). It is review finding 3 closed the broker way (#187 section 11): one +program, no argument pattern, the caller checked. The devhost here does the same for its four +backs. + +**7a. Per-group sub-mounts of the shared folder.** In `mount` mode, cloud-folder keeps the main +mount the Operator's (`agentAccess: none` where agents need nothing of it) and adds `agentMounts`: +a remote sub-path mounted read-only (or read-write) with a forced gid and modes (`rclone mount`'s +own `--gid`, `--dir-perms`, `--file-perms`, `--allow-other`, `--default-permissions`), for example +a room's results folder for the room's group (R2.2) or the floor's folders of the personal remote +(R2.3). In `host` mode the folder is a Windows bind mount with no owners: the check reports it +unprotected (Q7). + +**7b. Per-user ssh-agent sockets and register keys.** ssh-agent gains a `per-user` mode: the +drop-in starts one agent per user on `/run/agents/ssh/.sock` (the user's, 600) and loads that +user's register key from OpenBao through the user's own AppRole (5+), with a lifetime, memory only, +as `key:load` does today. The signing process is then the key's user (R1.6, R3.6, R4.4; AOF req 3). +Both principals of a room append to its store through the store's group (6c). How a key is made +(AOF: the agent makes it, the Operator's rekey takes the public key) is the register's own code. + +### 2.2 The helper gate, against the held brokers + +Both BRDs ask for the same thing in different words: a short list of named commands that run with +more rights than the caller, the caller known from the kernel (office R3, AOF req 2). The smallest +form: + +``` + agent-roomA (sandboxed Bash call) the gate (helper-pipe's listener) + task VAR=value /run/agents/gate/helper-pipe.sock (0660, dcf-work) + | the consumer's task calls the client | + v v + agent-helper VAR=value ---- socket ----> 1 caller = SO_PEERCRED uid (the kernel) + (opens as the caller, no link 2 entry = allowlist[][] (root-owned, + out of its tree; sends it as the from the agent list on the base branch) + body, R3.3) 3 vars = the entry's named variables, by kind + 4 code = the task from a snapshot of the base + branch, never the working tree + 5 env = fixed: PATH, the helper's AppRole, + AGENT_CALLER_UID and _USER set by the gate + 6 audit = time, caller, id, allowed/refused, exit + 7 run = as helper-pipe, umask 002, in the + caller's level folder; detach if marked + refusal: "agent-helper: refused: : ", exit 3 +``` + +- **Why a socket, not `sudo`.** Step 2 is strict (D5): every Bash call is sandboxed, and the + sandbox sets `no_new_privs`, under which `sudo` cannot start (#187 review finding 9; inventory + section 5, row 6). A listener judged by `SO_PEERCRED` needs no setuid step and works from inside + the sandbox once its socket is listed (decision 7). It is the "caller from the kernel uid" both + BRDs name, literally. +- **One unprivileged listener per helper user**, started at container start; no root daemon. A + helper that must write into a room is a member of that room's group (setgid folders do the + rest); the scope check against the caller's room is the consumer's task, reading + `AGENT_CALLER_UID` that the gate sets (office section 5). If the number of helper and back users + makes per-user listeners costly, one root-started listener that drops to the entry's user is the + fallback (T19). +- **Long jobs** (R7.3): an entry marked `detach` runs under `setsid`; agents read its state with + another entry and cannot signal another uid's process; the Operator stops it. +- **Credentials**: a helper logs in with its own AppRole (5+, `DCF_SECRET_AS`), fail closed; the + office's push token and app key belong to helpers, never to agents. +- **Guards**: a guard helper holds what agents must not read (the redact list, AOF req 13), and the + pre-commit hook pipes the staged diff to it; it reports `forbidden word #N`, never the word, as + `devhost-verify` already does (Q4). + +| From #187 (brokers) | In the gate? | Why | +|---|---|---| +| one shared implementation in `src/common`, vendored (section 6) | reused | the gate's server and client are a shared script; brokers can build on it later | +| the caller from the kernel (section 8), refused when missing or root | reused, as `SO_PEERCRED` (its section 14's fallback) | `sudo` cannot run in the sandbox (its finding 9) | +| the policy fixed outside the checkout, allow-list only, unknown refused (section 1) | reused | the allowlist is root-owned, read from the base branch | +| code run only from the base branch as committed (section 10.3) | reused, for task code | the task file and its scripts are agent-writable in the working tree | +| a fixed environment, helpers by full path (finding 4) | reused | | +| one refusal line, exit 3; one audit line without values (section 6, decision 13) | reused | | +| the refusal-test harness with a planted fault (`dcf-broker-test.sh`) | reused | | +| the broker's own AppRole, fail closed (section 12) | reused, through 5+ | | +| the backs' hand-over as an instance (section 11, finding 18: callers are named users only) | reused (6g) | | +| one registry of fixed numbers (finding 12) | reused (Q5) | | +| a service account per resource (`dcf-git`, `dcf-gh`, kube, az, docker) with manifests | not needed | the unit is a consumer's reviewed task, not a tool's verbs | +| per-verb flag allow-lists, exact spelling, argument kinds of a tool | not needed | arguments are the entry's named variables; the task decides | +| shims on PATH under the tool's name | not needed | agents call the task; the client is `agent-helper` | +| the git dir owned by a broker (section 10) | not needed | the git dir's `config` residual stays as accepted (3.3, step 3); the office pushes through `save` | +| `sudo` per call and its rules (sections 6, 14) | not needed | replaced by the socket | +| `credentials: file` per broker, the docker broker, the MCP front door | not needed | | + +### 2.3 Order + +1. Steps 1 to 5 as accepted (5+ can follow 5 in the same openbao minor). +2. **Step 6 as accepted, with 6a** (`runAs: agent`, one user, `dcf-work`): T9 green, then the + Operator runs `key-file-rotate` (D16). This closes the key file for both consumers early. +3. **6b, 6c, 6d, 6f** (users, groups, homes, check and migrate), after **T2** (modes on the + Windows mounts) and **T15** (sign-in per user). +4. **6e and 6g** (the gate, the hand-over) with **T16** (the gate from a sandboxed call). +5. **7a, 7b** (sub-mounts, per-user ssh-agent) with **T20**. +6. Step 8 as accepted. + +New tests: **T15** a per-user Claude home signed in by a stack token: a resident starts, Remote +Control reaches it, the token rotates; **T16** `SO_PEERCRED` through a socket listed in +`allowUnixSockets` from a sandboxed Bash call, refused for an unlisted caller, and a planted +fault; **T17** Claude's messaging between sessions of two users; **T18** POSIX ACLs and setgid on a +Docker volume and on the Windows mounts; **T19** start cost of making users at start, one gate +call, idle listeners; **T20** a per-user ssh-agent socket from a sandboxed call, refused for +another user. + +### 2.4 What the revision does not stop (added to 3.3) + +- **Anything committed is readable by every user who can read the git objects**: a file hidden by + its mode in the working tree is one `git show` away. Least privilege of knowledge (AOF req 13) + holds only for what lives outside the repository the agents read. +- **On a mount without owners**, per-level separation is reported, not enforced (R2.6), unless T2 + passes or the consumer moves that tree. +- **A gate entry runs what the base branch says**: it is as safe as review of `main`; a task + changed on a topic branch runs privileged only after it is merged. +- **Helpers trust the consumer's own scope check**: the gate gives the caller; refusing another + room's item is the consumer's task. +- **The Operator's user keeps root** (D9), and the docker socket stays root for whoever has it. + +## 3. Owners, versions, what each consumer changes + +### 3.1 Which Feature owns each element + +| Element | Feature | Version (next, numbers taken at build) | Breaking? | +|---|---|---|---| +| `runAs: per-agent`, users and groups from the list at start (`user:`, `group:`, `uid:`, `groups:`, helper and back entries), `uidRange`, refusal instead of fallback, `agents sync`, `agents check`, `agents migrate` | agents (2.1.3) | 2.2.0 for step 6 as accepted; 2.3.0 for 6b to 6g | no: opt-in; the default flip stays Later (3.0.0) | +| `dcf-work` and the fixed-number registry; the gate's server and client (`agent-helper`), its test harness | common (1.0.4), vendored into agents | 1.1.0 | no | +| a Claude home per user, shared skills linked, the sign-in route, `allowUnixSockets` entries, the extra managed deny lines, the `SessionStart` warning | claude-code (1.9.9) | 1.10.0 for steps 2 and 3 as accepted; 1.11.0 for 6d | no: inert unless `runAs` is `per-agent` | +| `core.sharedRepository=group` with `dcf-work`; the wrapper as several users; a NOTES line for a user trailer hook | git (1.4.6) | 1.5.0 | no | +| `per-user` mode: one agent per user, register keys loaded through the user's AppRole | ssh-agent (1.5.0) | 1.6.0 | no | +| an AppRole per agent and helper user from the list; `DCF_SECRET_AS` (`openbao-agent-login`); share tasks refuse by user beside the variables; the token file per user; the unix listener | openbao (1.6.0, unreleased with the SSH CA) | 1.6.0 if it is still unreleased when built, else 1.7.0 | no; dropping the variable check is 2.0.0 (Later) | +| `agentAccess` (step 7 as accepted) and `agentMounts` | cloud-folder (1.3.3) | 1.4.0 | no | +| the per-user `GH_CONFIG_DIR`, held by a helper user where one pushes | gh (1.1.0) | 1.2.0 | no | +| the backs as back users through the gate; the cost report across users; the redact list behind a guard helper | devhost (local) | after 1.15.0 (step 4) | local, not published | + +AOF's constraints, traced: **common-utils** (users and groups per agent, `sudo` for the operator +only): unchanged; the agents Feature makes the users and none of them gets `sudo`. **agents** (a +user per entry, started as that user): 6b. **claude-code** (a config folder per user): 6d. +**agentic-register** (each key readable only by its principal, the live store writable by a group): +7b and 6c here, the code AOF's. **openbao** (the operator token for the operator only, an AppRole +per agent): steps 5, 6, 5+. **ssh-agent** (a socket per user, or for the operator only): 7b. +**git and the hooks** (a shared git dir through a group, hooks working for every user): 6a. +**gh** (per agent or behind a group): Q2. **The staff** (their own users, reading the turns they +record): 6b, 6g. **The relay** (a relay user per stack, a shared gid): the number registry here, the +relay user AOF's. + +### 3.2 What the office changes + +- Its agent list gains users, groups (one per room and the floor), uids, back entries for the + housekeepers and gate entries per agent (its task names of R3.1). +- Its Taskfile routes each privileged task id through `agent-helper`; its tasks read + `AGENT_CALLER_UID` and refuse outside the caller's room (its own section 5). +- Its secrets move to AppRoles (step 5's consumer change, R4.2); the board tool's app key into + OpenBao. +- Its own deny rules into the managed list, or `lockPermissions` off (R2.5). +- An ownership map from its layout spec for `migrate` and `check`; `migrate` dry run, then real. +- Its shared folder: `mount` mode with sub-mounts, or `host` mode reported unprotected (Q7); its + checkout on the Windows mount follows T2. +- A register key per principal; then `key-file-rotate` on each machine. + +### 3.3 What AOF changes + +- Its own code, as its BRD lists (the register's key creation and public-key rekey, the writer + lock in the group, per-user reader caches, a relay user per stack, a process per staff member, + the bridge's contacts): AOF's tooling and host agents, not devfeat. +- Its `devcontainer.json`, start tasks, hooks and staff runner onto the users, with its own ADR. +- Its agent list with users, groups per shared resource and uids from its range; worktrees owned + per agent on their volume (Q8). +- Its relay user from the shared relay gid and its range (Q5). +- Then `key-file-rotate`; the seed-tag key under its own ADR. + +## 4. Open questions, each with a recommendation + +1. **Where the Operator works.** *Recommendation:* the remote user, in VS Code's terminal and + `task dc:shell`, keeping `sudo`, a member of `dcf-work` and of every declared group (added at + build; a group added later reaches the Operator's long-lived processes only after a restart, + review finding 6). Agent users are named `agent-`, so AOF's remote user, which has the + same name as its main agent's identity, stays distinct from `agent-host`. +2. **GitHub: a token per agent or one bot.** *Recommendation:* one bot per stack, held by whoever + pushes: a helper user where the consumer pushes through a helper (the office's `save`), the host + agent's user otherwise (step 1's narrow token, AOF); no token per agent; a GitHub App identity + later (D4's Later), which also removes the Admin bypass. +3. **Claude sign-in: shared or per user.** *Recommendation:* the state per user always (6d: + transcripts and memory are tied to the user's home, not to the sign-in); the sign-in one + long-lived token per stack (`claude setup-token`), kept in OpenBao and read by each user's own + AppRole into its environment at start, nothing at rest (ADR-0048 decision 1); a per-user + interactive login through an adapter verb `login` (review finding 9) only if T15 shows Remote + Control needs it. +4. **Guards and the redact list without agents reading it** (AOF's open question). *Recommendation:* + the list moves out of the checkout into a guard helper user's folder; the pre-commit hook pipes + the staged diff to the gate entry `guard:names`, which answers `forbidden word #N` and the + exit code. Back guards and `devhost-verify` call the same entry. +5. **Fixed uid and gid ranges per stack.** *Recommendation:* one registry in `src/common`'s NOTES + for every fixed number of the collection: shared gids from 19900 (`dcf-work` 19900, the relay 19901), + helper and service users 19950 to 19998, agent users in blocks of 1000 per stack from 20000 + (this repository 20000, then one block per consumer, chosen with `uidRange`); an entry's `uid:` + wins; a collision refuses the start. +6. **The seed: from day one or an option** (AOF asks its seed agent). *Recommendation:* an interview + option, default `runAs: agent` where the planting runs agents, `per-agent` when it has more than + one identity. +7. **Mounts that hold no ownership** (the office's checkout and `host`-mode shared folder). + *Recommendation:* run T2 first; where it fails, report the paths unprotected (BRD-8), offer the + shared folder's `mount` mode with sub-mounts, and leave moving the checkout to a volume to the + office; a forced-owner FUSE view per room (`bindfs`) only if the office asks after T2. +8. **Per-path owners inside one working tree** (AOF: an agent cannot write another's folders in its + own worktree). git rewrites files by replacing them, so a merge that touches a path the agent + cannot write fails. *Recommendation:* the unit of ownership is the worktree (AOF's + `.worktrees//` on a volume); paths inside one tree that belong to another agent are + protected by review and the pre-commit check as today; a merge into a tree goes through a helper + where it must touch them (the office's `save` pattern). +9. **Least privilege of knowledge** (AOF req 13). *Recommendation:* accept it as narrowing, as AOF's + BRD itself does: what must be hidden lives outside the agents' repository (the image, the state + home, OpenBao) and is read through a guard helper; devfeat's own NOTES are published and cannot + be hidden. +10. **Commits traceable to the user** (AOF "Auditable") with one bot identity (office R5.1). + *Recommendation:* keep the bot identity; the consumer's `prepare-commit-msg` hook adds a trailer + from `id -un` (the kernel's answer, not a variable); the git NOTES give the line. +11. **Helper listeners: per helper user, or one root-started.** *Recommendation:* per helper user, + unprivileged; one root-started listener only if T19 shows idle listeners cost too much. +12. **Agent-to-agent messaging across users** (AOF req 7; out of scope for the office). + *Recommendation:* T17 first; if Claude's own messaging does not cross users, through a + group-writable mailbox or a gate entry, as the office's section 5 says. + +## 5. Decisions for the Operator + +Only the revision's decisions; "overturns" marks an accepted decision of section 14 or an accepted +ruling. + +1. **Users per identity enter the deliverable** (`runAs: per-agent`, `agent` kept). *Recommendation: yes.* Overturns D7 as the end state. +2. **In `per-agent` mode users and groups are made at start, from the agent list as committed on the base branch.** *Recommendation: yes.* Partly overturns D8 (install time). +3. **A shared group `dcf-work` with a fixed gid, in both modes, instead of the remote user's primary group.** *Recommendation: yes.* Overturns D10. +4. **Groups declared in the agent list (a level group for the office, groups per shared resource for AOF), setgid folders, ACLs for cross-level reads, after T2.** *Recommendation: yes.* +5. **A Claude home per user in the claude-code volume; the root stays the Operator's.** *Recommendation: yes.* Partly overturns D8 (the volume handed whole). +6. **A helper gate (task names, caller by `SO_PEERCRED`, code from the base branch) enters the deliverable; the per-tool brokers of #187 stay held.** *Recommendation: yes.* Overturns the 2026-11-06 hold for this narrow part. +7. **Step 2's sandbox lists exactly two socket folders in `allowUnixSockets` (the gate, per-user ssh-agent).** *Recommendation: yes.* Amends accepted step 2 text. +8. **The hand-over to back and staff users through the gate, the stretch on standard input, its cost measured before.** *Recommendation: yes.* +9. **One AppRole per agent and helper user; share tasks refuse by user in an openbao minor.** *Recommendation: yes.* +10. **`agents check` and `agents migrate` (dry run first) in the deliverable.** *Recommendation: yes.* +11. **Fail closed: with `runAs` set and a user missing, the start refuses; no fallback to the Operator's user.** *Recommendation: yes.* Amends 6.1's text. +12. **cloud-folder `agentMounts` (per-group sub-mounts in `mount` mode) beside `agentAccess`; `host` mode reported unprotected.** *Recommendation: yes.* Amends D15. +13. **ssh-agent `per-user` mode; register keys loaded through each user's AppRole.** *Recommendation: yes.* +14. **D12 kept: an agent still cannot `agents start` another; the hand-over and helpers are gate entries.** *Recommendation: keep.* +15. **One fixed-number registry in `src/common`, blocks per stack, `uid:` per entry** (Q5). *Recommendation: yes.* +16. **Order: step 6 as accepted (with `dcf-work`) and `key-file-rotate` first; the per-identity steps after T2, T15 and T16.** *Recommendation: yes.* +17. **Claude sign-in: one stack token from OpenBao per user environment; a per-user login only if T15 requires it** (Q3). *Recommendation: yes.* +18. **GitHub: one bot per stack, held by whoever pushes (a helper or the host agent's user); no token per agent** (Q2). *Recommendation: yes.* + +## Architecture review (2026-11-08) + +Reviewed cold against this document; `docs/principles/non-functional-requirements.md`; `docs/adr/` +(0016, 0020, 0032, 0033, 0036, 0040, 0048, 0049, 0050, 0055, 0056, 0059); +`docs/proposals/agents-v2.md` section 14; `LEXICON.md`; the accepted [agent-linux-user.md](agent-linux-user.md) (section 14, its +revision table and its architecture review of 2026-11-06); the inventory with its test results of +2026-11-08; the held `brokers.md` on `docs/broker-pattern/1` with its review; and the two BRDs, by +requirement identifier only. Facts were checked against the tree at 89903cb (`src/agents/agents.sh` +and `install.sh` 2.1.3, `src/claude-code/` 1.9.9, `src/common/` 1.0.4, `src/openbao/` 1.6.0, +`src/cloud-folder/` and `src/ssh-agent/` options) and, read-only, against this devhost: `mount`, the +modes (never contents) of the claude-code volume, the docker socket's mode, and the settings schema +and messages inside the installed Claude Code binary (2.1.311). Nothing was run as another user; +no test of the revision was run. Who it serves: the Operator, who has provisionally accepted the 18 +decisions and rules after this review; the maintainers of the office and of AOF, whose agents will +run under the user model and the helper gate. + +**Verdict:** fit with changes + +The traceability is complete for both BRDs' requirements and acceptance tests, the agreements and +differences are read correctly, and the shape (step 6 first, then users per identity, declared +groups, per-user state, a narrow gate instead of the held brokers) is the right smallest revision. +But four security claims of the helper gate and one of the sandbox do not hold as written, and must +be settled before 6b or 6e is built: the base branch is read from a git dir every agent can write +(finding 1); `allowUnixSockets` is ignored on Linux (finding 2); gate variables reach a shell +through the Taskfile's templating (finding 3); a helper spans every level so the scope check is the +consumer's alone (finding 4); and helpers run and write inside folders the caller controls +(finding 5). Decision 17 cannot meet Remote Control (finding 10), and the key-file claim of 1.3 is +wider than step 6 makes it (finding 11). The rest are corrections, placement and records. + +### Requirements (NFRs), one line each + +- **NFR-1 owns-its-state**: partly met. Claude homes stay in the claude-code volume (6d), per-user + gh folders in gh's (3.1); the gate's audit log, the helper users' AppRole files and the trusted + snapshot of finding 1 have no stated home (2.2). Placement table below. +- **NFR-2 registered-taskfile**: met in plan; the new verbs (boundary, migrate, sync, login) each + need a task with a `desc` (3.1). +- **NFR-3 verified-and-pinned**: applies only to the `acl` package (and `bindfs`, Q7), distribution + packages like git's apt fallback; say so in the agents NOTES. Otherwise nothing is downloaded. +- **NFR-4, NFR-9**: do not apply. +- **NFR-5 idempotent-and-user-neutral**: partly met. The agents Feature's `postStartCommand` runs as + the remote user, not root, so "the start hook makes them as root" (6b) means through the + Operator's `sudo`; and setup must stay never fatal while decision 11 refuses: consistent only if + the refusal is in `agents start`, not in the start hook (6b, 5 decision 11). +- **NFR-6 one-way-to-configure**: partly met. `runAs: per-agent`, `uidRange`, `agentMounts`, + ssh-agent's `per-user` and the new socket setting have no named override variables (3.1). +- **NFR-7 agent-ready**: met, except that the per-user `login` verb (finding 10) opens a browser and + must be `interactive: true`. +- **NFR-8 documented-to-the-standard**: met in plan if each Feature's NOTES carry its drop-in, the + gate entry format and the number registry (Q5); 3.1 does not list the NOTES. +- **NFR-10 tested-on-base-images**: met in plan (T15 to T20, the refusal harness reused from #187); + the Feature scenarios need two users and a planted fault each, and T16 cannot run in CI where the + sandbox does not start. +- **NFR-11 linter-cli-contract**: met as the named exception of 6f (earlier review finding 11). +- **NFR-12 vendor-neutral-contracts**: not met as written: the gate needs each CLI's sandbox to let a + named socket through, and only Claude Code was looked at (finding 19). +- **NFR-13 manages-the-environment-not-the-cli**: met for users, groups, ACLs, rclone's own mount + options, ssh-agent and the gate (it runs a consumer's reviewed task, not a CLI capability). The + socket setting is a Claude-only key and belongs to the claude-code Feature, as 3.1 already places + it; decision 7 should say so. + +### Decisions (ADRs) + +No accepted ADR is contradicted outright, but the revision needs a new ADR (or the slice-0 ADR of +the accepted proposal, widened) that records what it overturns (D7, D8 in part, D10, D15, step 2's +text, 6.1's fallback, the 2026-11-06 brokers hold) and what it amends: **ADR-0036** (the lease +folder's 0777 accepted "for a single-tenant container"; per-agent mode is multi-tenant, finding 7); +**ADR-0033** (`agents.tsv` lives under each user's Claude home, so the adapter's records split per +user, as the accepted 6.2 already handles by running every verb as the user); **ADR-0048** decision 1 +(the helper users' push tokens and the per-user Claude credentials at rest, finding 10) and decision +4 (refusal by user, 5+); **ADR-0049** decision 4 (the host's memory path moves, finding 9); +**ADR-0050** decision 2 (every new role needs a root token from the shares, finding 15); **ADR-0056** +decisions 8 and 9 (finding 11); **ADR-0016** (helper commits, finding 22); **ADR-0032** (managed +settings are machine-wide, finding 23). It leaves **ADR-0020** as it is, with its cost stated +(every agent user sees every repository's git dir under `/git-dirs`); **ADR-0055** decision 6 is +already settled by step 5 as accepted; **ADR-0040** is unaffected here (the consumers' helpers push +for them); **ADR-0059** is consistent (the SSH CA stays the Operator's; register keys are not +certificates). + +### Findings + +1. **"The base branch as committed" is read from a git dir every agent can write (2.2 step 4, 6b, + the #187 table).** Step 3 leaves `objects/` and `refs/` writable, 6a makes the git dir + group-writable for `dcf-work`, and on `/git-dirs` (9p) every folder shows as root 777 (#187 + review finding 20). An agent can then `git update-ref refs/remotes/origin/main ` or + fetch with a refspec onto that ref (#187 review finding 17), and its commit becomes "the base + branch": the gate runs its task code as the helper, and 6b makes the groups and gate entries it + wrote. Running git as root or a helper in that git dir also reads its `config` (#187 review + finding 1: `core.fsmonitor` and the rest run code). #187 could read `origin/main` because its git + dir belonged to `dcf-git`; the table drops that ownership ("not needed") and keeps the read + ("reused"), so the premise is gone. Recommended change: one **trusted snapshot**, made at start + and by the Operator's `agents sync` through `sudo`: resolve the base branch's commit from the + remote, fetch it into a root-owned bare repository outside `/git-dirs` with no repository config + honoured, export it with `git archive` into a root-owned read-only folder that records the + commit, and let both 6b (users, groups, gate entries) and the gate's task code read only that + folder; the gate refuses when it is missing. + +2. **`allowUnixSockets` is ignored on Linux (decision 7, 2.1 step 2, 2.2 "why a socket").** The + settings schema in Claude Code 2.1.311 says: "macOS only: Unix socket paths to allow. Ignored on + Linux (seccomp cannot filter by path)." On Linux the only switch is `allowAllUnixSockets`, which + lifts the socket filter for every socket (and, per the same schema, AF_VSOCK). As written, a + sandboxed Bash call reaches neither the gate nor a per-user ssh-agent socket; the working + alternative leaves the docker socket, the Operator's `agent.sock` and OpenBao's unix listener + fenced only by kernel modes. The binary also prints that socket blocking is disabled when its + seccomp helper is missing and carries on, so accepted step 2's "no unix sockets" (A2) is + fail-open today and untested (T1 did not probe a socket). Recommended change: decision 7 becomes + "`allowAllUnixSockets: true` only when `runAs` is not `remote-user`", every socket an agent must + not reach goes into the two lists (a `denyRead` masks the path), and T16 checks a sandboxed call + reaches the gate, is refused on the docker socket and another user's ssh-agent socket, what + AF_VSOCK reaches on WSL, and, without the switch, that socket blocking is active at all. + +3. **Gate variables reach a shell through the Taskfile's templating (2.2 step 3).** go-task + substitutes `{{.VAR}}` into the command text, so a value carrying `$(...)`, a backtick, `;` or a + newline runs as the helper unless every task author quotes it (this repository's own + `taskfile.yml` files use `shellQuote` for that reason). "By kind" is not defined, and the office + needs free text (R3.5). Recommended change: the gate hands variables to the task as environment + variables, never as task variables; kinds are a closed set with fixed patterns (identifier, + integer, date, a value from the entry's own list); free text travels only on standard input; and + the refusal harness plants a substitution, a backtick and a newline in every kind. + +4. **One helper user spans every level, so the scope check is the consumer's alone (2.2 bullet 2, + 2.4).** A helper in every room group can read and write every room on every call; one injection + or bug in one room's call reaches all of them. An unprivileged listener cannot narrow its own + groups (`setgroups` needs `CAP_SETGID`). R3.2 asks the helper to refuse outside the caller's + scope. Recommended change: where a helper writes a level's files, make one helper user per helper + and level (`helper--`, a member of that level's group only, its listener admitting + only that level's callers), so the kernel holds the scope and the consumer's check is the second + line; a helper that must span levels (the office's `save`) is named in 2.4 as the one that does. + +5. **Helpers run and write inside folders the caller controls (2.2 step 7, R3.3, R3.4).** With the + working folder in the caller's level, relative lookups, `python -m`, dotenv files and the + repository config git finds there are agent-planted code; and a helper writing into a level + follows a symbolic link the caller planted to another level the helper can write + (`protected_symlinks` covers only sticky, world-writable folders). The client's "no link out of + its tree" check is not a control (the caller can skip the client); what makes R3.3 hold is that + the helper only ever receives bytes on standard input. Recommended change: every entry runs with + its working folder in the trusted snapshot (finding 1), the gate resolves the caller's level root + itself from the caller's group and passes it as a variable, and the shared gate code ships a + writer that opens under that root with `openat2` (`RESOLVE_BENEATH`, `RESOLVE_NO_SYMLINKS`) for + every write into a level, which consumer tasks are required to use. + +6. **`SO_PEERCRED` semantics are not written down (2.2 step 1).** The credentials are the connecting + process's at `connect()` and travel with the descriptor; the pid in them can be reused. The + design keys on the uid (right), but nothing says one request per connection, nothing refuses root + or the Operator's uid (they run tasks directly, as #187 section 9 does), the client is not said + to use `SOCK_CLOEXEC`, and a detached job is tracked by pid. A descriptor the Operator's process + connected and an agent inherited would make the agent the Operator. Recommended change: the + gate's specification states one request per connection, refusal of uid 0 and of the remote + user's uid, close-on-exec in the client, and long jobs tracked by pid plus start time (as + ADR-0036's lease does); T16 tests an inherited descriptor and a recycled pid. + +7. **Denial of service between agents (2.2, 6b).** All callers share one listener per helper; there + is no limit on concurrent calls, body size, run time or detached jobs, and the audit log has no + cap. ADR-0036's lease folder is 0777 without the sticky bit (`agents.sh` lines 892 and 899, + `install.sh` line 39), so in per-agent mode one agent user can delete another's lease and make + `agents up` start a second resident. Recommended change: the gate enforces per-caller and + per-helper caps (concurrent calls, body bytes, wall time for entries not marked `detach`, + detached jobs), each refusal naming its cap, caps its audit log as #187 section 6 does, and + per-agent mode gives leases the sticky bit or a folder per user, amending ADR-0036. + +8. **One shared primary group and umask 002 make every agent's files writable by every agent (6a, + 6b).** Every agent, back and helper user joins `dcf-work`, umask 002 stays, and 6b gives users no + private primary group. Files made outside a setgid level folder (a worktree, `.claude/`, the + checkout's root, AOF's per-agent worktree folders) are then writable by all of them, so AOF req + 6 and acceptance 5 and office R5.3 fail; git's `core.sharedRepository=group` sets the git dir's + modes without help from the umask. Recommended change: each agent, back and helper user gets a + private primary group with `dcf-work` supplementary and umask 022; group write comes only from + setgid level folders (and default ACLs where T18 passes); per-agent worktree folders are the + agent's, 2750. + +9. **The Operator's Claude state is readable by every user today (6d).** In this devhost the + claude-code volume's root and `projects/` are 755, memory files 644 and shell snapshots 644 + (modes counted, nothing read). 6d keeps the root the Operator's "with the Operator's memory", so + every agent user would read it (R6.1). Copying the host's state into `users/agent-host/` also + duplicates the login, whose refresh token then breaks on one side; and the host's memory path + does move, against "ADR-0049's path does not move". Recommended change: 6d's migration makes the + volume root 711 and the Operator's own content unreadable to others (checked by the boundary + command), moves rather than copies the host's state, without the credentials file, and names + ADR-0049 decision 4's new path. + +10. **Remote Control needs a full-scope login, which a `setup-token` is not (Q3, decision 17).** + Claude Code 2.1.311 says "Remote Control requires a full-scope login token. Long-lived tokens + (from ...)" and "setup-token sessions default to user:inference only". A stack token therefore + cannot meet R6.3 or office acceptance 7. A token in each user's environment also falls to D13's + `*TOKEN*` deny list and breaks AOF's "No secret in an argument, the environment or a log". + Recommended change: decision 17 becomes "residents that need Remote Control sign in per user + through an adapter verb `login` (earlier review finding 9), the credentials file a named interim + exception to ADR-0048 decision 1; the stack token only for backs and staff, passed through + `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` (present in 2.1.311), never the environment", with T15 + confirming. + +11. **"Step 6 as accepted already takes [the key file] out of every agent's reach" is wider than it + is (1.3, 2.3 item 2).** Step 6 removes the direct reads (T9). It does not stop the agent user's + unsandboxed processes (hooks that run checkout scripts, MCP servers, the backs' runner) from + replacing what the Operator's user runs without asking: the git dir's `config` by rename (the + accepted review's finding 1; root 777 on `/git-dirs`), whose `core.fsmonitor` VS Code's git runs + as the Operator; `.githooks/`; `Taskfile.yml`. A `claude` started as the Operator's user only + warns (D11). `dcf-work` has no bearing on it. Recommended change: keep the order (rotation after + step 6 removes the direct route), state the claim as "direct reads closed; the route through + code the Operator's user runs stays (3.3)", add two probes to T9 as the agent user outside the + sandbox (rename over the git dir's `config`; write in `.githooks/`), and have the git wrapper + pass `core.fsmonitor=false` and the global `core.hooksPath` on the command line + (`GIT_CONFIG_COUNT`) for the Operator's user before the rotation is called the end of ADR-0056 + decision 8. + +12. **setgid, ACLs and Windows writes on the 9p mounts (6c, T18, Q7).** Here the checkout and + `/git-dirs` are 9p drvfs with `metadata` and without `posixacl`, and `setfacl` is not installed + in the image; a file Windows writes (Windows git, an editor, the sync client) carries no + metadata and shows as root 777 (#187 review finding 19, office R2.6). The floor's ACL reads + cannot hold there, setgid owners erode whenever Windows writes, and a per-file ACL on a file git + replaces by rename is lost even on a volume. Recommended change: T18 tests, on this mount, setgid + inheritance, `setfacl` and a file written from Windows; 6c says that on drvfs cross-level reads + are reported unprotected, never by ACL, and that the boundary command re-runs at every start; + ACLs only as default ACLs on folders of volumes, with `acl` installed by the agents Feature. + +13. **The two BRDs differ on fail closed for unprotected paths, and 6f implements one (6f, decision + 11).** AOF's "Fail closed" stops the start when a permission is missing; the office's BRD-8 + chose to report. Recommended change: an agents option `unprotectedPaths` (`refuse`, the default, + or `report`), which the office sets to `report`. + +14. **`agents check` already exists and `agents sync` does not (6b, 6f).** `check` is the list + check (`agents.sh` `cmd_check`, task `agents:check`); there is no `sync` verb; and syncing "from + the Operator's own working copy" reads a file an agent can replace. Recommended change: keep the + accepted name `agents boundary` for the check of 6f, and add `agents sync` as a new verb that + reads only finding 1's snapshot and shows the change to users, groups and gate entries before it + applies. + +15. **5+ issues roles, and each role needs a root token from the shares (5+, decision 9).** + ADR-0050 decision 2: the Operator's token only reissues an existing role's secret. With a user + per exec, housekeeper and helper, a room added later means a root ceremony. Recommended change: 5+ issues + every missing role of the list in one root-token step per list change, and the new ADR cites + ADR-0050 for it. + +16. **7b lets each user read its own register key from OpenBao, so the ssh-agent adds nothing (7b, + decision 13).** If the user's AppRole fetches the key, the key's bytes are in the user's hands; + office R4.4 asks "usable only", AOF req 3 asks "the agent makes its own". Recommended change: in + `per-user` mode a loader run as root at start (through the Operator's `sudo`; ssh-agent accepts + a root peer) fetches each key with its own role and loads it into the user's socket, and the + user's policy has no read on its register key; AOF's own key files stay AOF's. + +17. **The shared folder's helpers and second remote are missing (7a, R2.2, R2.3).** With the main + mount at `none` (FUSE without `allow_other`) no helper user can open the shared folder, yet R2.2 + writes go through helpers; two rclone mounts of one remote folder keep separate caches, so a + reader of a results folder may not see a helper's rewrite; and cloud-folder mounts one remote + (`remoteType`, `remotePath`), so R2.3's "second remote" is a new capability, not a sub-mount. + Recommended change: 7a names a read-write sub-mount for each helper group that writes there, T12 + adds a write through one mount read through another, and R2.3's row reads "cloud-folder: a + second remote" as its own change. + +18. **Two secrets in the environment against a row marked covered (2.2 step 5, Q3).** The gate's + fixed environment carries "the helper's AppRole", and Q3 puts the stack token into each user's + environment; the AOF row "No secret in an argument, the environment or a log" says covered. + Recommended change: the gate passes the helper's AppRole as a file only the helper reads (the + `DCF_SECRET_AS` route), and that row reads partly until it does. + +19. **The gate's reach from each CLI is unexamined (NFR-12, 2.2).** Claude Code on Linux is all or + nothing (finding 2); Codex's `workspace-write` seccomp is undocumented for named sockets; + Copilot and Gemini have no sandbox. Recommended change: agents-v2 section 14.1 gains the rows + "shell tools run under `no_new_privs`" (#187 review finding 9) and "shell tools may connect to a + named unix socket", and T5 checks Codex. + +20. **Callers wider than the callers (6a, 2.2, 6g).** Helpers and back users join `dcf-work`, so + they can connect to every gate socket (#187 review finding 18), and nothing says who may call + `handover ` (the accepted review's finding 3). Recommended change: the sockets' group is a + callers group of agent and back users only, no helper a member; `handover ` is allowed only + from the agent that back serves. + +21. **The hand-over's input is the agent's word, and decision 14 renames an amendment (6g, 2.4).** + The back records the stretch and the cost the agent hands it; a back is an agent in the lexicon, + so a gate entry that starts one is an agent starting an agent. Recommended change: 2.4 gains "a + back records the stretch and its cost as the agent hands them over", and decision 14 is worded + as an amendment of D12 for backs. + +22. **Helper commits fall outside ADR-0016's bot identity (R5.1, Q10).** The office's `save` merges + and commits as its helper, without `CLAUDECODE`, so the wrapper's `commitWhenEnv` identity does + not apply, and Q10's trailer from `id -un` names the helper. Recommended change: the gate sets the + consumer's `commitWhenEnv` variable for entries marked as committing and exports the caller's + user name for the trailer. + +23. **A consumer's per-agent deny rules cannot move into the managed list (2.1 step 2, R2.5).** + Managed settings are one machine-wide file (ADR-0032), so a rule that differs per agent would + apply to every agent. Recommended change: 2.1 says only rules common to every agent move to the + managed list, and a consumer with per-agent rules keeps `lockPermissions` off (the kernel is then + the first line, which is what the BRD asks). + +24. **Derived uids are unspecified and may be reused (6b, Q5).** "Derived inside the stack's range" + names no rule; an order-based one shifts owners when an entry is added, and a removed entry's + uid handed to a new user inherits its files on the volumes. Recommended change: in per-agent mode + `uid:` is required on every entry (the registry's block makes it a lookup), and the boundary + command reports files owned by a uid no entry holds. + +25. **Traceability gaps and one placement contradiction (1.1, 1.3).** The office's decisions are + cited but have no rows: BRD-6 (the floor or the Operator refreshes each room's cloned + repositories) is a write into every room that 6c does not give the floor; office acceptance 1 is + marked closable by 6c without 2.4's limit that committed files are readable through the objects + (which R5.2 accepts). The relay user is both "a user the agent list can declare" and "made by + AOF's relay Feature". Recommended change: add one row per BRD-1 to BRD-12 decision (BRD-6 with a + route: a helper or a write ACL), add the objects limit to R2.1's and acceptance 1's rows, and let + the Feature that runs a non-agent process make its user from the registry (as #187's manifest + did), the agent list declaring only agent, back and helper users. + +### Lexicon + +The document uses the Operator, host, con, back, resident, identity, agent list, stretch, lease and +back identity as defined. Two words collide: **the gate** is defined (OpenBao's unseal and login), +and "the gate" alone is used throughout 2.2 for the helper gate; **Guard** is defined as the back +guard, and 2.2 and Q4 use "guard helper" and "guards" for commit-time content checks. New terms +need entries: **Agent user** and **The Operator's user** (owed by the accepted slice 0), **Back +user**, **Helper user**, **Helper gate** (always in full), **Level group**, and the **trusted +snapshot** of finding 1. "Hand-over" (to a back) sits next to the defined "Hand-off" (a reply); the +entry for Back user should say which is which. + +### Placement + +| Element | Layer | Why | +|---|---|---| +| `runAs: per-agent`, users and groups from the snapshot, refusal, `unprotectedPaths` | agents Feature option and start step (through the Operator's `sudo`) | the environment: who an agent runs as (NFR-13) | +| `user:`, `uid:`, `group:`, `groups:`, gate entries per agent | agent list spec | CLI-neutral facts about an agent; the allowlist is per caller (R3.1) | +| trusted snapshot, `agents sync` | agents Feature, root step | finding 1; one source for 6b and the gate | +| `dcf-work`, callers group, number registry, gate server, client, writer, refusal harness | shared scripts in `src/common`, vendored into agents | several Features depend on them (earlier finding 12) | +| gate audit log, helper AppRole files | agents Feature volume; openbao volume per helper, 700 | NFR-1 | +| socket switch (`allowAllUnixSockets`), extra managed deny lines | claude-code option in its managed drop-in | a Claude-only key (NFR-13), finding 2 | +| Claude home per user, `login` verb | claude-code drop-in and adapter | each Feature knows its own state; earlier finding 9 | +| setgid folders, default ACLs, the `acl` package | agents Feature, from the list | the kernel's own features; finding 12 | +| `agentMounts`, helper mounts, a second remote | cloud-folder | rclone's own options; finding 17 | +| per-user sockets and the root loader | ssh-agent | finding 16 | +| roles per user, share tasks refusing by user | openbao | ADR-0050; finding 15 | +| bot identity for helper commits | git's `commitWhenEnv`, set by the gate | finding 22 | +| a non-agent user (the relay) | the Feature that runs it | finding 25 | +| ownership map, scope check, per-agent deny rules | the consumer | the BRDs' own section 5 | + +### Consistency with the accepted proposal, the inventory and #187 + +- The accepted proposal's review findings 1 (git dir `config`), 3 (who may hand over), 4 (cost + report across users), 5 (memory path), 6 (group at build) and 12 (shared names) come back with + per-agent users; the document cites 3, 4, 6, 8, 9, 11 and 16 but not 1 and 5 (findings 9, 11). +- The inventory's T1 (the sandbox does not wrap the file tools) does not touch the gate (only Bash + reaches a socket); its T3 (the git dir behind a pointer needs explicit lists) applies to every + repository each agent user can reach under `/git-dirs` (ADR-0020). +- From #187 the document keeps the right parts and cites its review findings 4, 9, 12 and 18 (the + last for the hand-over only), but drops the git-dir ownership that its base-branch read relied on + and does not carry its review findings 1 and 17 (finding 1), nor 18 for the gate's sockets + (finding 20). + +### Open questions for the Operator + +- **Decision 7 (finding 2).** Replace the two socket paths, which Linux ignores, by + `allowAllUnixSockets` in per-agent mode with every forbidden socket in the two lists. + *Recommendation: yes; kernel modes are the fence once agents have their own users.* +- **Decision 17 (finding 10).** A per-user interactive login for residents that need Remote + Control, the stack token only for backs and staff through a file descriptor. *Recommendation: + yes.* +- **Helpers per level (finding 4).** One helper user per helper and level, or one helper trusting + the consumer's scope check. *Recommendation: per level for every helper that writes a level; + `save` the named exception.* +- **The trusted snapshot offline (finding 1).** When the remote cannot be reached at start, keep the + last snapshot (its commit shown by the boundary command) or refuse every gate call. + *Recommendation: keep the last snapshot; never fall back to the local refs.* +- **What the rotation closes (finding 11).** Rotate after step 6 as planned, saying the indirect + route stays, or wait for the git wrapper's forced settings. *Recommendation: rotate after step 6 + and T9 with its two new probes; record the indirect route in the ADR-0056 follow-up.* +- **Unprotected paths (finding 13).** *Recommendation: `refuse` by default, `report` for the office.* diff --git a/src/gh/NOTES.md b/src/gh/NOTES.md index 6b345a3..1f7489b 100644 --- a/src/gh/NOTES.md +++ b/src/gh/NOTES.md @@ -97,6 +97,32 @@ refused. The login needs the `admin:ssh_signing_key` scope: `gh auth refresh -s admin:ssh_signing_key`. The `ssh-agent` Feature's `key:rotate` calls it (as `gh-ssh-key-add`) for its GitHub step. +## Logging in with a fine-grained token + +For an agent's host, log in with a fine-grained personal access token instead of your own login +(`gh auth login` in the browser gives gh your full power). Make it at GitHub, Settings, Developer +settings, Fine-grained tokens: **this repository only**, an **expiry** (30 to 90 days), and +Repository permissions **Contents: Read and write**, **Pull requests: Read and write**, +**Metadata: Read-only** (added by itself). Nothing else: no Administration, Workflows, Actions or +Secrets, so the token cannot change the repository's rules or workflows. + +Then, from your own terminal in the devhost (never in an agent's session; the token is read from +stdin, not typed on a command line): + +```bash +gh auth login --with-token < token.txt # or: pbpaste / Get-Clipboard | gh auth login --with-token +gh auth status +``` + +and delete the file. The token rests in `hosts.yml` under `GH_CONFIG_DIR` +(`/var/lib/devcontainer-features/gh`, mode 600, in the Feature's volume). That is a bounded interim +exception to ADR-0048 decision 1 (OpenBao is the master of every token): it ends when +`openbao-agent-login` mints the token instead. Rotate it by logging in again before the expiry. + +What it does not do: the token acts as you. If you are a repository admin and the branch ruleset +lets admins bypass through a pull request, the token can merge an unapproved pull request too; the +ruleset and the narrow permissions stop it changing rules, releasing or deleting tags, not merging. + ## Pairs with - `ghcr.io/devcontainers/features/github-cli` for the apt-installed gh. diff --git a/src/gh/README.md b/src/gh/README.md index 838f413..c509144 100644 --- a/src/gh/README.md +++ b/src/gh/README.md @@ -119,6 +119,32 @@ refused. The login needs the `admin:ssh_signing_key` scope: `gh auth refresh -s admin:ssh_signing_key`. The `ssh-agent` Feature's `key:rotate` calls it (as `gh-ssh-key-add`) for its GitHub step. +## Logging in with a fine-grained token + +For an agent's host, log in with a fine-grained personal access token instead of your own login +(`gh auth login` in the browser gives gh your full power). Make it at GitHub, Settings, Developer +settings, Fine-grained tokens: **this repository only**, an **expiry** (30 to 90 days), and +Repository permissions **Contents: Read and write**, **Pull requests: Read and write**, +**Metadata: Read-only** (added by itself). Nothing else: no Administration, Workflows, Actions or +Secrets, so the token cannot change the repository's rules or workflows. + +Then, from your own terminal in the devhost (never in an agent's session; the token is read from +stdin, not typed on a command line): + +```bash +gh auth login --with-token < token.txt # or: pbpaste / Get-Clipboard | gh auth login --with-token +gh auth status +``` + +and delete the file. The token rests in `hosts.yml` under `GH_CONFIG_DIR` +(`/var/lib/devcontainer-features/gh`, mode 600, in the Feature's volume). That is a bounded interim +exception to ADR-0048 decision 1 (OpenBao is the master of every token): it ends when +`openbao-agent-login` mints the token instead. Rotate it by logging in again before the expiry. + +What it does not do: the token acts as you. If you are a repository admin and the branch ruleset +lets admins bypass through a pull request, the token can merge an unapproved pull request too; the +ruleset and the narrow permissions stop it changing rules, releasing or deleting tags, not merging. + ## Pairs with - `ghcr.io/devcontainers/features/github-cli` for the apt-installed gh.