Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200b30b721 | ||
|
|
ff74bde424 | ||
|
|
85e9fcd894 | ||
|
|
fec416d1df |
No files matched your search
@@ -1,671 +0,0 @@
|
||||
# Roles, rails and hiring
|
||||
|
||||
Date: 2026-11-10. Lane org-roles-design, issue #1873. Status: proposed, doc
|
||||
only. Nothing here changes `AGENTS.md`, `roles/`, `contracts/`, rights policy
|
||||
or code. The slices in section 13 that touch role or rights policy land only
|
||||
with Jason's approval.
|
||||
|
||||
Round 2 follows ruling 13. Merge and policy decisions stay with the arbiter
|
||||
agent. The rails split into system rails and role rails (section 3), and a
|
||||
per-organization autonomy setting decides where each class of decision goes
|
||||
(section 9).
|
||||
|
||||
Read with `docs/plans/2026-11-10_agent-levels-design.md` (levels, isolation,
|
||||
Phase A) and `docs/plans/2026-11-10_accounts-design.md` (rights, host trust
|
||||
boundary). This design replaces agent-levels section 7 and the A1 roles patch,
|
||||
which widened the chief's `hireable` list. That patch is withdrawn and nothing
|
||||
here extends it.
|
||||
|
||||
Line numbers are against `origin/refactor` at `f98f278fb`. Its code is the
|
||||
same as at `50be33355`.
|
||||
|
||||
## 1. Rulings
|
||||
|
||||
Jason, 2026-11-10:
|
||||
|
||||
1. Who hires: the chief asks, a human approves. A hire is a decision, not a direct write.
|
||||
2. Which roles, verbatim: "Chief should be capable of hiring ANY role with any capacity. This will include custom roles invented by the user and the chief at any time."
|
||||
3. No cap on the number of hires.
|
||||
4. Timing: lands with A4.
|
||||
5. Templates, verbatim: "Mosaic Stack should ship role templates for the most common roles. These stay within the system as templates. Chief and user have the capability to import the roles from template and customize them as needed. New roles outside of the supplied templates must follow a set of rules from within the system to prevent an absolute destruction of the system's workings. We need a set of primitives available to the agent roles to allow for adherence. A new role constructed by chief and the user must plugin and follow the rules with the RBAC and other tools available. The agent will have an arbitrary set of tools specified by chief and/or the user. The agent will also have a personality and other traits specified by the user. The system must remain unopinionated upon the opinionated basal code provided. We provide the operational rails and allow an open system to build upon it."
|
||||
6. Ceiling: the approval is the ceiling. A hire may carry grants above the chief's own, but the decision lists those grants separately and flags them.
|
||||
7. Approver: a new grantable right `org.hire` (owner yes, member no) in the M1 rights table.
|
||||
8. Only the chief raises hires. No hire chains: a hired agent cannot hire.
|
||||
9. Rails, summarized (full text in the lead tracker; ask nothing, design from this): a base of inalienable rails the system needs to function, with everything else customizable on top. The smallest working system is user, assistant and chief; the chief staffs the rest from the user's plan. Start with the fewest agents, add agents as roles are needed, and keep configurations apart with organizations and projects. Full extensibility: modules and plugins, preconfigured systems for any use (software factory, writing workshop, video production, business, home, health), installed from GitHub, a zip or another source, and maybe a marketplace later. Flexibility comes from a set of primitives controlled by routing, agent configuration and plugins. Jason: "The Venn diagram for usability and security/rails needs to be understood."
|
||||
10. Credentials: the hire decision lists each credential and scope the agent needs; approving the decision binds them.
|
||||
11. Template updates: an imported role is a frozen copy. When the shipped template changes, the system offers a diff; it never changes the copy by itself.
|
||||
12. Personality: the chief may draft personality and traits inside the hire decision. After the hire only a human changes them.
|
||||
13. Trust, verbatim, answering whether merge and policy decisions should go to a person instead of the arbiter agent: "I need to be able to trust the agents to do their jobs. The agent role should establish the rails they operate upon. Tooling and instructions should be clear. We are aiming for a move from "Human in the loop" to "Human near the loop" to "Human out of the loop" operations. We are currently HEAVILY in Human in the loop." They stay with the arbiter.
|
||||
|
||||
Also binding, Jason 2026-11-07: the system is modules joined by documented
|
||||
calls. Swapping or disabling one must not break core, and adding one must not
|
||||
need a core rewrite (`docs/plans/2026-11-07_organizations-owner-rulings.md:43-47`).
|
||||
|
||||
## 2. Today
|
||||
|
||||
- A role is `roles/<name>.json` plus its `.md` contract. Version 2 allows
|
||||
only known keys, so an unknown field such as `personality` refuses
|
||||
(`packages/organization/src/role.mjs:21,190`). Tools come from one closed
|
||||
core list (`role.mjs:24-29`, `packages/organization/src/vocabulary.mjs:142`).
|
||||
Actions come from `ACTIONS` (`vocabulary.mjs:11-65`); an unknown one
|
||||
refuses, and so does one outside `ROUTED`, because those are hard waits the
|
||||
human always decides (`role.mjs:42-43`, `vocabulary.mjs:109`).
|
||||
- The chief hires by writing directly. `instance.hire` checks that the caller
|
||||
is the instance named in `launch.by` (`packages/bus/src/broker.mjs:6193`)
|
||||
and holds `role.hire`, refuses kept names (`broker.mjs:6200`), then runs
|
||||
`#hireable` (`broker.mjs:6291-6306`). That needs a version 2 definition whose
|
||||
every action is on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`, checked by
|
||||
`unhireableAction` at `vocabulary.mjs:127`), named in the chief's own
|
||||
`hireable` list, and not in `launch.noHire`.
|
||||
- The shipped chief's list is empty (`roles/chief-of-staff.json:13`), so the
|
||||
chief can hire nothing today. Its contract says so
|
||||
(`roles/chief-of-staff.md:17`).
|
||||
- Creative definitions are human-only. The writer refuses them for the chief
|
||||
(`packages/organization/src/instance-settings.mjs:365-367`) and the role
|
||||
check refuses them in a `hireable` list (`role.mjs:86-97`).
|
||||
- A hire writes `roles[name] = { definition, vars? }` and appends the name to
|
||||
`launch.instances` (`instance-settings.mjs:354-391`). Its vars may set only
|
||||
`model` and `limits.*` (`broker.mjs:216-221`). So `launch` refuses the new
|
||||
instance with `no-route` until a human sets harness, model and thinking
|
||||
(`packages/harness/src/bundle.mjs:96`, `roles/chief-of-staff.md:53-58`).
|
||||
- Fire revokes the instance's service credentials and delists it
|
||||
(`instance-settings.mjs:396-414`).
|
||||
- Rights for people are a separate table: `RIGHTS`, the `ROLE_RIGHTS`
|
||||
bundles and `canGrant` (`packages/organization/src/org-rights.mjs:6-17,21-26,122-129`).
|
||||
It has no hire right yet.
|
||||
|
||||
## 3. The rails
|
||||
|
||||
Rails come in two layers. System rails hold for every role at every autonomy
|
||||
level, and only a reviewed commit changes one. Role rails are what each role's
|
||||
definition says its agent may do. Ruling 13 asks for both: agents trusted to
|
||||
do their jobs, inside rails their role sets.
|
||||
|
||||
### System rails
|
||||
|
||||
Nothing changes these while the system runs: no role, template, plugin,
|
||||
approval or autonomy setting. Each row says where the code enforces the rail
|
||||
today and which slice closes the gap.
|
||||
|
||||
| # | Rail | Enforced today | Gap, and the slice that closes it |
|
||||
|---|---|---|---|
|
||||
| R1 | Secrets never enter the repository, images, role files, decision text or the trail. A credential reaches an agent only through a human-approved binding, scoped and revocable. | Message and record bodies pass `credentials.assertClean` (`packages/bus/src/runtime.mjs:833`, `broker.mjs:840,1313,2081`). Credential verbs are human-only (`broker.mjs:6144`). `credential.mint` decisions route to the human (`broker.mjs:2266-2268,2368`). Fire revokes (`instance-settings.mjs:396`). | Role definitions, personality text and hire decisions are not scanned. P1 runs the same check on every string in a composition and a hire decision. |
|
||||
| R2 | The trail and run records are write-once. | `events` and `decision_events` refuse update, delete and replace (`packages/bus/src/migrations/3b.sql:131-132,137-138,144,147`). | None. Hire, deny, expiry, binding, setting and reverse entries use the same tables (A4, AU). |
|
||||
| R3 | Fail closed: an unknown action, tool, right, plugin kind, role field or autonomy value refuses. | Role keys, actions and tools (`role.mjs:190,42,24-29`). Rights (`org-rights.mjs:122-129`). Plugin kinds (`packages/plugins/src/kinds.mjs:147-148`). | Inline roles and plugin-declared tools and actions have no check yet. P1 and PL1. AU refuses an unknown class or level. |
|
||||
| R4 | Hard waits stay with the human. A role can never hold an action outside `ROUTED`. | `vocabulary.mjs:109`, `role.mjs:43`. This covers `git.push.protected`, `git.push.remote`, `host.sudo`, `credential.mint`, `spend`, `deploy` and `data.delete` (`vocabulary.mjs:22,26,29,49,50,51,55`). | The brief's system-rail list did not name it, and "any capacity" must not dissolve it. A hard wait leaves the list only when a PRD version moves it into `ROUTED` (REQ-WAIT-3, `vocabulary.mjs:67-69`), never through the autonomy setting. P1 keeps it for every composition. |
|
||||
| R5 | No agent widens its own authority. No agent resolves a decision it raised, a decision whose subject is itself or a definition it runs, or anything that changes the autonomy setting or a rail. | An arbiter's own raise goes to the human (`broker.mjs:7075`). PRD approval refuses an arbiter that is the PM or an author (`broker.mjs:1060`). | Rewritten in round 2. Round 1 also sent merge and policy decisions to a person; ruling 13 reverses that, and REQ-DEC-2 stays as ratified. Nothing checks a decision's subject against its resolver today. AU adds that check, which sends such a decision to a person. |
|
||||
| R6 | Every agent runs sandboxed as the instance uid, with only the tools its role names. A tool exists only if core or an installed, enabled plugin declares it. | bwrap with user, pid and ipc namespaces and a read-only root (`packages/seat/src/sandbox.mjs:76,176`). Tools are a closed core list (`vocabulary.mjs:142`) and the typed bus tools are fixed in core (`packages/harness/src/tools.mjs:96`). | No plugin declares a tool today. PL1 adds plugin-declared tools. A sandbox escape holds the instance uid, which equals root for the instance (accounts design, "Host trust boundary"). |
|
||||
| R7 | Organizations are isolated. Cross-organization reads go only through the assistant's Console read broker. | Each organization has its own bus and every non-assistant agent's scope is one organization (agent-levels design section 4). | The read broker is not built. It is A6. Until then nothing reads across organizations. |
|
||||
| R8 | No grant beyond the granter, except a flagged line the owner approves. This holds at every autonomy level. | People: `canGrant` subset (`org-rights.mjs:122-129`). Agents: a hire is bounded by `HIRE_ALLOWED` and the chief's list (`broker.mjs:6303-6305`). | A4 replaces that bound with the approval itself. The broker computes the flagged lines; the chief cannot omit them. |
|
||||
| R9 | No hire chains. Only the chief raises hires, and a hired agent cannot hire. | `role-not-chief` (`broker.mjs:6193`). `role.hire` is not on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`). | From ruling 8. P1 refuses `role.hire` and `role.fire` in any hired composition, so the rail survives `HIRE_ALLOWED` going away. |
|
||||
| R10 | Plugin code runs with the host's authority, so installing one is an instance-level human act. An organization's setting cannot move it. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | The loader says a module "runs with the host's authority" (`loader.mjs:5`). Install from GitHub or a zip has to keep that visible. No hash pin exists. PL1. |
|
||||
| R11 | System config is written only by bootstrap. | `scripts/bootstrap.sh:5`, AGENTS.md invariant 2. | None. Nothing in this design writes system config. |
|
||||
| R12 | Rails change only by reviewed commit, and an organization's autonomy setting only by its owner. | The vocabulary and `roles/` change by reviewed commit (AGENTS.md invariant 7). | New in round 2. The setting does not exist yet; AU adds it as an owner-only Console verb. |
|
||||
|
||||
Round 1 changed R5 and R6 from the brief. Round 2 rewrites R5 again and adds
|
||||
R12.
|
||||
|
||||
### Role rails
|
||||
|
||||
A role's definition is its rails. Each part says what that agent may do, and
|
||||
the broker enforces it:
|
||||
|
||||
| Part | What it says | Enforced today | Gap |
|
||||
|---|---|---|---|
|
||||
| `tools` | The tools the agent has | Typed bus tools follow the held actions (`tools.mjs:97`); built-ins follow the policy (`packages/harness/src/bundle.mjs:368`) | None |
|
||||
| `authority.withinRole` | Actions the agent takes alone | The broker's classification (`broker.mjs:7048`) | None |
|
||||
| `authority.crossRole` | Actions it raises to its domain's arbiter | `broker.mjs:7072-7075` | The raiser picks the domain (`broker.mjs:7067`), so it picks its arbiter. RR fixes the domain for merge and policy. |
|
||||
| `decision.resolve.<domain>` in `withinRole` | Which decisions it may resolve for other agents | Only for PRD approval (`broker.mjs:1059`) | An ordinary resolve checks only that the caller's role is the route (`broker.mjs:7212-7215`). The organization's `arbiters` map names the arbiter, not its role (`org-template.json:5`), and the default arbiter, the chief, holds no resolve right (`roles/chief-of-staff.json:8-9`). RR makes an arbiter hold the right for its domain. |
|
||||
| `credentials` | Each service and scope | Bound by a person only (`broker.mjs:6144`) | None |
|
||||
| `messaging` | Whom it may message | `#checkMessaging` (`broker.mjs:7082`) | None |
|
||||
|
||||
**The validator carries them.** P1's `checkComposition` checks every part
|
||||
against the registry, and treats every `decision.resolve.*` action as a
|
||||
flagged line, because it lets the holder decide for other agents.
|
||||
|
||||
**The instructions state them plainly.** The contract keeps the duties and
|
||||
says what to do on a refusal. The list of what the agent may do is generated
|
||||
from the resolved definition into its prompt (section 10), so the wording
|
||||
cannot drift from what the broker enforces. Every shipped contract has an
|
||||
`## Authority` section today; RR makes the role check refuse a version 2
|
||||
contract without one (`role.mjs:146-157`).
|
||||
|
||||
## 4. The primitives
|
||||
|
||||
A role is a composition of these parts and nothing else.
|
||||
|
||||
**Core set.** Shipped in `vocabulary.mjs`, versioned, changed only by reviewed
|
||||
commit:
|
||||
|
||||
- `tools`: core built-ins and `web` (`vocabulary.mjs:142`).
|
||||
- `network`: one of `NETWORKS`.
|
||||
- `authority.withinRole` and `authority.crossRole`: actions from `ROUTED`
|
||||
(`vocabulary.mjs:73-102`).
|
||||
- `credentials`: a known service with grantable scopes. Gitea `admin` and
|
||||
`all` are never granted (`GITEA_SCOPE_CATEGORIES`).
|
||||
- `messaging.sendTo` and `messaging.receiveFrom`.
|
||||
- `skills`.
|
||||
- `instructions`: the contract text, today the `.md` file.
|
||||
- `personality`: new. Bounded text fields (summary, traits, voice), no
|
||||
authority. Ruling 5 and ruling 12.
|
||||
|
||||
**Plugin-declared set.** From an enabled plugin's manifest (section 7):
|
||||
tools, actions and credential services. A plugin action is always routed. A
|
||||
plugin cannot declare a hard wait, cannot reuse a core name, and every name
|
||||
carries the plugin's prefix (`<plugin>.<name>`).
|
||||
|
||||
**The validator.** P1 adds `checkComposition(role, registry, chief)`. It
|
||||
replaces `HIRE_ALLOWED` and `unhireableAction` at hire time. It:
|
||||
|
||||
1. refuses any unknown key, as `role.mjs:190` does;
|
||||
2. refuses any action, tool, network, service or scope the registry lacks,
|
||||
where the registry is the core set plus the plugins enabled in that
|
||||
organization;
|
||||
3. refuses any action outside `ROUTED` or a plugin's routed set (R4);
|
||||
4. refuses `role.hire` and `role.fire` (R9);
|
||||
5. returns every `decision.resolve.*` action as a flagged line (role rails);
|
||||
6. refuses any string that fails `assertClean` (R1), and any field over its
|
||||
length bound;
|
||||
7. returns the flagged lines: every action, tool, network, credential scope
|
||||
and messaging target the chief's own resolved role does not hold (R8).
|
||||
|
||||
The validator accepts every composition that passes. There is no allowlist of
|
||||
roles and no list of hireable definitions.
|
||||
|
||||
**What "any role with any capacity" means.** The chief can propose any name,
|
||||
any mix of registered tools and routed actions, any credential scope a service
|
||||
grants, any personality, any number of agents. A human approves each one.
|
||||
|
||||
**What it cannot mean.** A hired agent cannot hold a hard wait, hire, resolve
|
||||
its own request or a decision about itself, read another organization, name a tool nothing declares,
|
||||
carry a secret in its text, or run outside the sandbox. A new capability that
|
||||
needs new enforcement, for example a new kind of external write, needs a
|
||||
plugin that declares and enforces it. A name with no code behind it gives an
|
||||
agent nothing.
|
||||
|
||||
## 5. Templates and organization roles
|
||||
|
||||
**Templates.** The shipped files in `roles/` become templates. They stay
|
||||
there, versioned by reviewed commit. A template's identity is its name plus
|
||||
the sha256 of its JSON and contract together, so no new field goes into
|
||||
`roles/`. Plugins may ship templates too (section 7).
|
||||
|
||||
**Organization roles.** An organization's own definitions live in its file
|
||||
under a new `definitions` key, one entry per name:
|
||||
|
||||
```json
|
||||
"definitions": {
|
||||
"release-writer": {
|
||||
"rev": 3,
|
||||
"from": { "template": "coder", "sha256": "<64 hex>" },
|
||||
"role": { "tools": ["read", "edit"], "authority": { "withinRole": ["record.write"] } },
|
||||
"instructions": "...",
|
||||
"personality": { "summary": "...", "traits": ["..."], "voice": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An instance names `definition: "org:release-writer"` instead of a template
|
||||
name. Project agents use the organization's definitions. Root agents keep
|
||||
their own `definitions` in `<configDir>/agents.json`.
|
||||
|
||||
- **Import** copies a template into `definitions` with `from` set. The copy is
|
||||
frozen: nothing changes it except an approved change.
|
||||
- **Customize** edits the copy. **Invent** writes a definition with no `from`.
|
||||
Both pass `checkComposition` before they are written.
|
||||
- **Who writes.** A human holding `org.hire` drafts or edits a definition in
|
||||
the Console. A draft no agent uses does nothing. A change to a definition a
|
||||
hired agent uses takes effect only through a change decision (section 6).
|
||||
The chief proposes a definition only inside a decision.
|
||||
- **Template changes.** When a template's current hash differs from a copy's
|
||||
`from.sha256`, the Console shows a diff and offers apply or keep. Apply is a
|
||||
change decision (section 6). Nothing applies by itself (ruling 11).
|
||||
- **Versioning.** `rev` counts up on every write. Every hire and change
|
||||
decision records the definition's `rev` and hash.
|
||||
- **A running agent.** Its sandbox, tools and contract are fixed at launch
|
||||
from the bundle. A change that narrows authority takes effect at the broker
|
||||
on the next call. Anything else takes effect at the next launch, and the
|
||||
Console shows the agent as running an older `rev`.
|
||||
- **Creative templates** are templates like the others. They still get no
|
||||
host grant (`broker.mjs:2415`).
|
||||
|
||||
## 6. Hire as a decision
|
||||
|
||||
One decision action covers hire, fire and change, at every level.
|
||||
|
||||
**Fields.** The chief supplies:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `op` | `hire`, `fire` or `change` |
|
||||
| `level` | `org`, `project` (with the project name) or `root` |
|
||||
| `name` | the agent's instance name, unused, not kept or retired |
|
||||
| `role` | `{ template, sha256 }`, `{ definition, rev }` or `{ inline }` |
|
||||
| `credentials` | each service and scope the agent needs |
|
||||
| `personality` | the chief's draft |
|
||||
| `route` | a proposed harness, model and thinking level |
|
||||
| `reason` | 1 to 500 characters |
|
||||
|
||||
The broker adds, and the chief cannot set or edit:
|
||||
|
||||
- `flagged`: the lines `checkComposition` returns above the chief's own role;
|
||||
- `credentialLines`: one line per service and scope;
|
||||
- `digest`: a hash over the whole decision, as the Console resolution path
|
||||
already checks (`broker.mjs:7200-7204`);
|
||||
- `expiresAt`: seven days.
|
||||
|
||||
**Raise.** Among agents, only the instance in `launch.by` may raise one, as
|
||||
today (`broker.mjs:6193`). It routes by the organization's autonomy setting
|
||||
for authority changes (section 9). At the default, in the loop, it goes to a
|
||||
person, so the arbiter path at `broker.mjs:7074` does not apply. A hire needs no
|
||||
approval count and has no cap (ruling 3). Spend stays bounded by the
|
||||
organization's limits.
|
||||
|
||||
**Approve, in the loop.** A human whose membership in that organization holds
|
||||
`org.hire` approves in the Console, against the current digest. Two limits,
|
||||
at every autonomy level:
|
||||
|
||||
- a decision with any flagged line or any credential line needs the owner.
|
||||
This mirrors `decide` stopping short of credential mints (accounts design,
|
||||
section 3 "Rights"). A member with `org.hire` approves plain hires.
|
||||
- a root-level decision needs the instance owner, since the root file covers
|
||||
every organization (agent-levels section 7).
|
||||
|
||||
The human may change the route, narrow the role or drop credential lines
|
||||
before approving. Widening means a new decision.
|
||||
|
||||
**Near and out of the loop.** Only a plain decision moves: no flagged line, no
|
||||
credential line, organization or project level. An instance whose role holds
|
||||
`decision.resolve.authority` resolves it. It is never the chief that raised it
|
||||
and never the agent the decision names (R5). Near the loop, the owner is told
|
||||
and can reverse it (section 9).
|
||||
|
||||
**What approval writes.** One writer call, with the human as actor and the
|
||||
decision id in the context:
|
||||
|
||||
- org level: the organization writer, extended from `prepareHire`
|
||||
(`instance-settings.mjs:354`). It writes the definition, the instance with
|
||||
its route, and `launch.instances`;
|
||||
- project level: the A2 project-agent writer, behind the projects fence;
|
||||
- root level: the A2 root writer.
|
||||
|
||||
Credential bindings then go through the existing human-only credential path
|
||||
(`broker.mjs:6144`). Until every binding succeeds, `launch` refuses the agent
|
||||
with `credential-unbound` and names the missing one, as A3 does for unbound
|
||||
copies. Because approval sets the route, launch no longer refuses `no-route`
|
||||
for an approved hire.
|
||||
|
||||
**Deny, expire, withdraw.** Deny and expiry write nothing but the trail entry.
|
||||
The chief may withdraw its own decision (`broker.mjs:7205-7211`).
|
||||
|
||||
**Fire and change** are the same decision with `op` set. Fire revokes and
|
||||
delists as today (`instance-settings.mjs:396`).
|
||||
|
||||
**A person starting a hire.** A human holding `org.hire` may start a hire,
|
||||
fire or change in the Console. It is still a decision, with the same fields,
|
||||
broker-computed lines, digest and trail, raised and resolved in one step by
|
||||
that person. Flagged and credential lines still need the owner. I read ruling
|
||||
8 as limiting which agent raises hires, not as stopping a person from hiring.
|
||||
|
||||
**Trail.** Raise, each resolution, the writer receipt and each binding are
|
||||
events in the write-once tables (R2).
|
||||
|
||||
**What this replaces.** The direct `instance.hire` and `instance.fire` paths
|
||||
(`broker.mjs:6185-6280`) and the `hireable` check (`broker.mjs:6291-6306`) go
|
||||
away. The chief's `hire` tool (`packages/harness/src/tools.mjs:225-226`)
|
||||
raises the decision instead of writing. Agent-levels section 7 had three
|
||||
paths, one per level. Now there is one decision with a `level` field, so A4
|
||||
and A5 become one slice, with the root level waiting for A2's root writer.
|
||||
|
||||
## 7. Plugins and modules
|
||||
|
||||
**Today.** Plugin kinds are a closed core list (`kinds.mjs:4-5,22-105`). The
|
||||
loader reads only `packages/plugins/<kind>/<name>/index.mjs` in its own tree,
|
||||
checks symlinks, exports and interface version, and pins no hash
|
||||
(`loader.mjs:1-5,41-72`). A refused module keeps the kind's last good set
|
||||
(`loader.mjs:7-9`). Enabling is `plugins.set` under `org.setup`
|
||||
(`org-rights.mjs:74`), owner-only.
|
||||
|
||||
**Manifest.** Each plugin gets a `plugin.json`: name, version, kinds,
|
||||
interface versions, a sha256 over its files, and what it declares: tools
|
||||
(name, description, argument schema, whether it runs in the sandbox or on the
|
||||
host), routed actions, credential services, and role templates.
|
||||
|
||||
**Install.** Sources are a GitHub URL at a commit, a zip, or a local
|
||||
directory. The instance owner installs at the host with the expected hash.
|
||||
The install refuses a hash mismatch, a core name, a hard-wait action or an
|
||||
unknown kind. Installed plugins live read-only in an instance-level plugin
|
||||
root, listed with their hashes in a lock file. That reads a path outside the
|
||||
loader's own tree, which changes lead decision 143 (`loader.mjs:3-5`). Jason
|
||||
decides that in PL1.
|
||||
|
||||
**Pin and load.** Every load checks the hash in the lock file. A mismatch
|
||||
refuses the plugin and logs it. The loader's existing refusal handling keeps
|
||||
core running.
|
||||
|
||||
**Enable.** Per organization, by the owner, through `plugins.set`. Install is
|
||||
instance-wide because the code runs as the instance uid for every
|
||||
organization (R10).
|
||||
|
||||
**A disabled or missing plugin.** Core keeps running. An agent whose role
|
||||
names the plugin's tools or actions refuses at launch with
|
||||
`plugin-disabled` and the plugin's name. A running agent's calls to those
|
||||
tools refuse with the same code. Nothing is fired or rewritten.
|
||||
|
||||
**Preconfigured systems** such as a software factory or a writing workshop
|
||||
are plugins that ship templates, a system type and tools. Installing one hires
|
||||
nobody. The chief still raises a decision for each agent.
|
||||
|
||||
**Marketplace.** Out of scope. The design must not preclude one, so the
|
||||
manifest keeps the source URL, version and hash, and reserves a `signature`
|
||||
field. The install screen already lists every tool that runs on the host.
|
||||
|
||||
## 8. The minimal base
|
||||
|
||||
A new organization already starts with the human, a chief and an assistant
|
||||
(`org-template.json:4,6`). That matches ruling 9. A6 later moves the
|
||||
assistant to the root level (agent-levels section 9).
|
||||
|
||||
The chief reads the user's plan and raises one hire decision per agent it
|
||||
needs, each citing the plan in its reason. It starts with the fewest agents
|
||||
and adds one when a task needs a role nobody holds. Separate concerns go in
|
||||
separate organizations or projects.
|
||||
|
||||
Every other shipped organization role becomes a template only: coder, pm,
|
||||
reviewer, security-reviewer, merge-gate, cto, the five chiefs (executive,
|
||||
financial, information, information security, marketing) and the six creative
|
||||
roles (companion, continuity, fact-checker, ideas, proofreader, research).
|
||||
`researcher.json` is a version 1 worker role (`roles/researcher.json:2`), which
|
||||
a hire refuses (`broker.mjs:6303`), and `conductor-policy.json` is not a role.
|
||||
Neither changes.
|
||||
|
||||
## 9. Autonomy, usability and security
|
||||
|
||||
Read this first. It says who decides what, and what changes as trust grows.
|
||||
|
||||
Ruling 13 sets the direction: from a person in the loop, to near the loop, to
|
||||
out of the loop. Today most decisions sit in the loop.
|
||||
|
||||
**The three levels.**
|
||||
|
||||
- **In the loop.** A person approves before anything happens.
|
||||
- **Near the loop.** An agent whose role holds the resolve right decides, and
|
||||
the decision takes effect. A person is told in the Console and can reverse
|
||||
it within a window the owner sets. I propose 24 hours as the default.
|
||||
- **Out of the loop.** An agent whose role holds the resolve right decides.
|
||||
The trail records it, and nobody is told.
|
||||
|
||||
For a class that routes to an arbiter today (cross-role work, merge, policy),
|
||||
the arbiter resolves at every level, as PRD 1.5 REQ-DEC-2 and acceptance 314
|
||||
step 5 require. The level only decides what happens after the arbiter
|
||||
resolves. In the loop, the resolution waits for a person to confirm it before
|
||||
it takes effect. A person who declines sends it back to the arbiter with a
|
||||
reason. Near and out of the loop, it takes effect at once. No level makes a
|
||||
person the resolver of these classes.
|
||||
|
||||
**The setting.** An organization's file gets an `autonomy` key with one level
|
||||
per class below. A missing key means today's routing. Only the owner changes
|
||||
it, in the Console: the verb maps to `null` in `VERB_RIGHTS`, the explicit
|
||||
owner-only mark (`org-rights.mjs:28-29`), and it is not on the tester's verb
|
||||
list (`broker.mjs:192`). Each change is a trail event. No agent changes it at
|
||||
any level (R12). The owner can pull a class back toward the loop at any time
|
||||
with no preconditions.
|
||||
|
||||
**Classes and levels.** "Default" marks where each class sits today.
|
||||
|
||||
| Class | In the loop | Near the loop | Out of the loop |
|
||||
|---|---|---|---|
|
||||
| Cross-role work, such as a task scope change | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default, `broker.mjs:7072-7075`) |
|
||||
| Merge, `git.merge.protected` | The technical arbiter resolves; a person confirms before it lands | The technical arbiter resolves; a person is told | The technical arbiter resolves (default). Acceptance 314 step 5 has the CTO do it (`docs/acceptance/314.md:116-120`) |
|
||||
| Policy, `policy.change` | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default). No shipped role holds this action |
|
||||
| Plain hire, fire or role change | A person with `org.hire` approves (default, ruling 1) | An agent holding `decision.resolve.authority` resolves; the owner is told | The same agent resolves |
|
||||
| Flagged grants and credential bindings | The owner approves (default) | Does not move (R8) | Does not move |
|
||||
| Root-level agents | The instance owner (default) | Not an organization setting | Not an organization setting |
|
||||
| Plugin install | The instance owner, at the host (default) | Not an organization setting (R10) | Not an organization setting |
|
||||
| Hard waits: push, deploy, spend, mint, delete, sudo | A person, every time (R4) | Not a setting | Not a setting |
|
||||
| The autonomy setting | The owner (R12) | Never | Never |
|
||||
|
||||
PRD approval already has its own switch: a request in `autonomous` mode sends
|
||||
it to the technical arbiter, and one in `guided` mode to a person
|
||||
(`packages/bus/src/request-lifecycle.mjs:308`, `broker.mjs:1057-1060`). A
|
||||
person sets the mode (`broker.mjs:5918-5919`). This design leaves it as is.
|
||||
|
||||
**What reverse can undo.** Near the loop, reverse is a one-step act by the
|
||||
owner, recorded against the decision. After the window it is an ordinary new
|
||||
decision.
|
||||
|
||||
| Class | Reverse does | It cannot undo |
|
||||
|---|---|---|
|
||||
| Cross-role work | Restores the task's prior scope or state | Messages sent, records written (write-once, R2), work done in the window |
|
||||
| Merge | Lands a revert commit through the normal path | The merge in history (no force push, AGENTS.md invariant 8), or anything that ran or shipped the merged code in the window |
|
||||
| Policy | Restores the prior policy | Actions taken under the new policy in the window |
|
||||
| Hire | Fires the agent, which revokes its credentials (`instance-settings.mjs:396-414`) | What the agent did or spent in the window |
|
||||
| Fire | Rehires the same definition `rev` | The run and session state the fire ended, and the credential bindings the fire revoked. Each needs a new binding the owner approves |
|
||||
| Role change | Returns the definition to the prior `rev`, and relaunches the agent if it runs the new one. A narrowing back takes effect at the next broker call (section 5) | What the agent did under the new `rev` |
|
||||
| Credential binding | Not offered: it does not move | Each use of the token at the service. That is why it stays in the loop |
|
||||
|
||||
**Before a class moves a level.** The organization needs:
|
||||
|
||||
- to move any class to near the loop: a resolver instance whose role holds
|
||||
that class's resolve right and is not the raiser (RR), the class's reverse
|
||||
built and tested (AU), and Console notices on for the owner;
|
||||
- to move any class to out of the loop: time at near the loop, and the
|
||||
owner's review of its reversals in the trail;
|
||||
- for merge, in addition: a reviewer role whose approve verdict a landing
|
||||
needs, and the land gate's tests. Acceptance 314 has both, which is why
|
||||
merge stays out of the loop by default;
|
||||
- for policy, in addition: a reviewer role that sees the policy diff first;
|
||||
- for plain hires, in addition: organization spend limits that cover a new
|
||||
agent, and a resolver other than the chief. Out of the loop also needs
|
||||
per-agent limits in every definition the resolver can approve.
|
||||
|
||||
Flagged grants, credential bindings, root-level agents and plugin installs do
|
||||
not move in this design. Moving one needs a new ruling.
|
||||
|
||||
**A person changes directly.** Organizations and projects (owner). Drafts of
|
||||
role copies and invented roles, which do nothing until a decision uses them
|
||||
(`org.hire`). An agent's model, limits and route (`org.hire`). An agent's
|
||||
personality and traits after hire (ruling 12). Enabling a plugin in an
|
||||
organization (owner). Installing a plugin (instance owner, at the host).
|
||||
|
||||
**Only a reviewed code change.** The rails in section 3, the core list of
|
||||
tools and actions, the shipped role templates, the rights table including
|
||||
`org.hire`, plugin kinds, and system config, which only bootstrap writes.
|
||||
|
||||
**What never moves, at any level:**
|
||||
|
||||
- An agent cannot push to a protected branch, push to a remote, deploy, spend,
|
||||
mint a credential, delete data or use sudo by itself. A person decides each
|
||||
of those every time.
|
||||
- An agent cannot resolve its own request or a decision about itself, and it
|
||||
cannot change the autonomy setting or a rail.
|
||||
- An agent cannot grant more than the chief holds, or bind a credential,
|
||||
without the owner.
|
||||
- Only the chief raises hires, and a hired agent cannot hire.
|
||||
- An agent cannot see another organization's work. The assistant's read
|
||||
across organizations is planned (A6) and not built yet.
|
||||
- A plugin runs with the same power as the Mosaic service itself. Install
|
||||
only code you trust.
|
||||
|
||||
## 10. Tooling and instructions
|
||||
|
||||
An agent trusted to work without a person has to know its own limits without
|
||||
asking one. Three things today fall short.
|
||||
|
||||
**Its rails, readable at runtime.** The prompt names the agent's built-in and
|
||||
bus tools (`packages/harness/src/bundle.mjs:283-284`) and the held actions
|
||||
that have no bus tool (`bundle.mjs:308`). It does not say which actions the
|
||||
agent takes alone, which go to which arbiter, which go to a person, or what it
|
||||
may resolve. Six contracts tell the agent "the launch record you received
|
||||
shows the resolved list" (`roles/coder.md:41`, `roles/cto.md:46`,
|
||||
`roles/merge-gate.md:54`, `roles/pm.md:109`, `roles/reviewer.md:38`,
|
||||
`roles/security-reviewer.md:27`). No session receives one. Launch records are
|
||||
host-side, for principal checks (`packages/bus/src/runtime.mjs:885-901`,
|
||||
`broker.mjs:2047`).
|
||||
|
||||
Fix, in TI: the prompt gets a generated "## Your rails" section from the
|
||||
resolved definition. It lists each held action with where it goes (alone,
|
||||
which arbiter instance, or a person), each resolve right with its class's
|
||||
autonomy level, the network, each credential service and scope by name, and
|
||||
the messaging targets. A read-only `my_rails` bus tool returns the same from
|
||||
the broker, since a narrowing takes effect at the next call (section 5). The
|
||||
six contracts drop the launch-record sentence and point at that section.
|
||||
|
||||
**Refusals that say what to do next.** The broker's `fail(code)` throws a
|
||||
bare code (`broker.mjs:251-253`), and 653 lines of `broker.mjs` call it. A
|
||||
reason travels only where a call builds one (`broker.mjs:243-249`, for
|
||||
example `941`, `961` and `6468`). The prompt says only that "a refusal names
|
||||
its reason" (`bundle.mjs:284`).
|
||||
|
||||
Fix, in TI: one table maps each refusal code to the rail or role rail it
|
||||
enforces and the next step, for example "raise a decision for the delivery
|
||||
arbiter" or "a person decides this; raise it with blocking false". The
|
||||
envelope carries both. A test fails when a `fail` code has no entry.
|
||||
|
||||
**The tools it holds.** These are covered. The prompt names each tool
|
||||
(`bundle.mjs:283-284`) and the bundle writes each tool's description and
|
||||
schema to `tools.json` for the harness (`bundle.mjs:372`). What was missing
|
||||
is the routing behind each tool, which the rails section adds.
|
||||
|
||||
## 11. Threat model
|
||||
|
||||
| Threat | What stops it | What is left |
|
||||
|---|---|---|
|
||||
| A prompt-injected chief proposes a hostile role | The broker computes the flagged and credential lines; the chief cannot hide them. Hard waits, hiring and unknown parts refuse (R4, R9, R3). A human approves against the digest. | A human can approve a hostile role. Flagged and credential lines need the owner. |
|
||||
| A custom role names an unknown tool | `checkComposition` refuses it at the decision and at the writer. Launch refuses it again (R3). | None. |
|
||||
| A plugin from an untrusted source | Instance owner only; hash pin; the install screen lists host-run tools. | Approved plugin code runs as the instance uid (R10). A pin does not make code safe. |
|
||||
| A credential scope wider than the task | Each scope is its own line; owner-only approval; the approver may drop lines; admin and all never granted. | The service's own scope granularity. |
|
||||
| An arbiter agent resolves a bad merge | Its role must hold `decision.resolve.technical` (RR). It never resolves its own raise (R5). The review verdict and the land gate come first (acceptance 314). The owner can pull merge back toward the loop. | A wrong merge is reverted, not undone. |
|
||||
| An agent widens its own authority through a decision | No agent resolves its own raise or a decision about itself (R5). Flagged grants and credentials stay with the owner at every level (R8). Only the owner changes the setting (R12). | One agent raises a plain change for another. Plain means within the chief's own authority, and near the loop tells the owner. |
|
||||
| A prompt-injected resolver near the loop | The owner is told and can reverse within the window. | What reverse cannot undo (section 9). |
|
||||
| Approval fatigue on flagged grants | Flagged lines shown apart from plain ones; credential and flagged decisions go to the owner; no batch approval; seven-day expiry. | A busy owner who approves without reading. A later slice could limit how many flagged decisions one approval screen holds. |
|
||||
| An imported template drifts from its source | The copy is frozen with its source hash. A changed template produces a diff offer, never a silent change. | An owner who keeps an old copy keeps its old behaviour. That is the ruling. |
|
||||
|
||||
## 12. Invariant 7 amendment
|
||||
|
||||
Proposed only. Jason approves it through a reviewed commit.
|
||||
|
||||
Replace AGENTS.md invariant 7 (`AGENTS.md:45-47`) with:
|
||||
|
||||
> 7. **Policy**: missions govern tasks; a task narrows, never widens. The
|
||||
> rails, the core vocabulary of tools and actions, and the shipped role
|
||||
> templates in `roles/` change only by reviewed commit. An organization's
|
||||
> own roles are copies or compositions of known parts. One takes effect
|
||||
> only through a decision, resolved where the organization's owner-set
|
||||
> autonomy level sends it, by default a human with `org.hire`. The
|
||||
> decision lists every grant above the chief's own as a flagged line,
|
||||
> which only the owner approves. Only the chief raises hires, and a hired
|
||||
> agent cannot hire.
|
||||
|
||||
Touched role-model lines:
|
||||
|
||||
- `AGENTS.md:107`: keep "policy/role expansion" as a protected act, and add
|
||||
"an approved hire decision is that authority for the one agent it names".
|
||||
- `AGENTS.md:209`: change to "`roles/`: shipped role templates and the
|
||||
conductor policy".
|
||||
|
||||
## 13. Slices
|
||||
|
||||
Phase A after A3, in order: R1, P1, T1, A4, RR, TI, AU, PL1. A6 and A7 stay
|
||||
as agent-levels section 9 has them.
|
||||
|
||||
Slices that change `roles/`: A4 (the chief), RR (the chief, and any role an
|
||||
organization names as delivery arbiter) and TI (six contracts). Slices that
|
||||
change rights policy: R1 and AU. P1 changes no file in `roles/` but defines
|
||||
authority policy. All of them need Jason's approval.
|
||||
|
||||
**R1. The `org.hire` right.** Add `'org.hire': flag(true)` to `RIGHTS` and a
|
||||
verb entry for the hire resolution in `VERB_RIGHTS`. The owner bundle picks it
|
||||
up from `Object.keys(RIGHTS)` (`org-rights.mjs:22`); member, collaborator and
|
||||
approver do not get it. Rights policy, so Jason's approval
|
||||
(accounts design, `Grant needed` at line 1391). Tests: the owner has it; the
|
||||
three templates lack it; `canGrant` lets an owner grant it to a member; an
|
||||
unknown right still refuses.
|
||||
|
||||
**P1. Primitives validator.** `checkComposition` and the registry, beside
|
||||
`role.mjs`. `HIRE_ALLOWED` stays until A4 switches over. It changes no role
|
||||
file but it defines authority policy, so Jason's approval. Tests: an unknown
|
||||
key, action, tool, service or scope refuses; each hard wait refuses;
|
||||
`role.hire` and `role.fire` refuse; a secret in instructions or personality
|
||||
refuses; a `decision.resolve.*` action comes back as a flagged line;
|
||||
flagged lines match a hand-built expected set for coder against the
|
||||
shipped chief.
|
||||
|
||||
**T1. Organization definitions and templates.** The `definitions` key,
|
||||
`org:` references, import, the human edit verbs under `org.hire`, the
|
||||
personality fields, the template hash and the diff read. Tests: import pins
|
||||
the hash; editing a template file changes no copy; the diff read shows the
|
||||
change; an `org:` reference to a missing definition refuses at launch; an
|
||||
agent cannot call the edit verbs.
|
||||
|
||||
**A4. Hire as a decision, all levels.** The decision action, raise, Console
|
||||
approval, the writer calls, credential lines, expiry, fire and change, the
|
||||
Console path that raises and resolves in one step, and removal of the direct
|
||||
path and `#hireable`. Every hire decision sits in the loop until AU. Org and project land first; root
|
||||
waits for A2's root writer. This edits `roles/chief-of-staff.json` and `.md`
|
||||
(drops `hireable`, rewords hire), the vocabulary and AGENTS.md invariant 7,
|
||||
so Jason's approval. Tests: a hired agent's raise refuses `role-not-chief`; a
|
||||
chief decision cannot carry its own `flagged`; a member with `org.hire`
|
||||
approving a credential or flagged decision refuses; a stale digest refuses;
|
||||
deny and expiry write nothing; approval writes the role, the route and the
|
||||
trail, and launch refuses `credential-unbound` until binding; fire revokes;
|
||||
the old `instance.hire` verb refuses; a cross-role `git.merge.protected`
|
||||
decision still routes to the technical arbiter and the CTO resolves it, as
|
||||
acceptance 314 step 5 expects; a hire a person starts in the Console leaves a decision record
|
||||
with its flagged lines.
|
||||
|
||||
**RR. Role rails and resolve rights.** An arbiter must hold
|
||||
`decision.resolve.<domain>` for its domain, checked when the organization
|
||||
file is written and again at resolve, which extends `broker.mjs:1059` to
|
||||
every decision. The vocabulary adds `decision.resolve.delivery`. A fixed
|
||||
domain per action for `git.merge.protected` (technical) and `policy.change`
|
||||
(delivery), so a raiser cannot pick its arbiter. The chief gets both resolve
|
||||
rights, since the template names it arbiter for both
|
||||
(`org-template.json:5`). The role check refuses a version 2 contract with no
|
||||
`## Authority` section. Changes `roles/chief-of-staff.json` and the
|
||||
vocabulary, so Jason's approval. Tests: naming an arbiter without the right
|
||||
refuses when written; an arbiter whose role lost the right refuses
|
||||
`not-resolver`; a merge raised with domain `delivery` still routes to the
|
||||
technical arbiter; acceptance 314 step 5, the CTO resolving the merge, still
|
||||
passes; a contract with no `## Authority` refuses.
|
||||
|
||||
**TI. Tooling and instructions.** The generated "## Your rails" prompt
|
||||
section, the read-only `my_rails` tool, the refusal table and envelope
|
||||
fields, and the six contract edits (section 10). Changes `roles/*.md`, so
|
||||
Jason's approval. Tests: coder's prompt lists each held action with its
|
||||
route; `my_rails` matches the broker after a narrowing; every `fail` code in
|
||||
`broker.mjs` has a table entry; a refused cross-role call's envelope names
|
||||
its rail and next step.
|
||||
|
||||
**AU. Autonomy setting and routing.** The `autonomy` key, the owner-only
|
||||
Console verb, routing per class, the subject check that sends a decision
|
||||
about its resolver to a person (R5), `decision.resolve.authority`, the
|
||||
Console notice, reverse per class with its window, and the trail events.
|
||||
Needs A4 and RR. Changes rights policy (a `VERB_RIGHTS` entry) and the
|
||||
vocabulary, but no role file, so Jason's approval. Acceptance tests:
|
||||
|
||||
- with no `autonomy` key, routing is exactly today's: a merge goes to the
|
||||
technical arbiter, a hire to a person;
|
||||
- a member, an agent and the dev tester each refuse to change the setting;
|
||||
an unknown class or level refuses;
|
||||
- with merge in the loop, the technical arbiter still resolves the merge
|
||||
decision, a person's resolve refuses `not-resolver`, and the merge does not
|
||||
land until a person confirms; a decline returns it to the arbiter;
|
||||
- with merge near the loop, the arbiter's resolve takes effect, the owner
|
||||
gets a notice, and reverse within the window lands a revert commit
|
||||
recorded against the decision;
|
||||
- with hires near the loop, an instance holding `decision.resolve.authority`
|
||||
resolves a plain hire; a hire with a flagged or credential line still goes
|
||||
to the owner; the chief cannot resolve its own raise; no agent resolves a
|
||||
change that names itself;
|
||||
- reverse after the window refuses and names the ordinary decision path;
|
||||
- each setting change is a trail event with the owner as actor;
|
||||
- no setting value moves a hard wait, a flagged grant or a credential
|
||||
binding away from a person.
|
||||
|
||||
**PL1. Plugin manifest, install and declared tools.** Manifest, pinned
|
||||
install root, lock file, the registry merge, and `plugin-disabled` refusals.
|
||||
Changes lead decision 143 and the loader's trust rule, so Jason's approval.
|
||||
Tests: a hash mismatch refuses and core keeps running; a manifest naming a
|
||||
core tool or a hard wait refuses; disabling a plugin makes its agents refuse
|
||||
at launch with its name; a running agent's call to a disabled tool refuses.
|
||||
@@ -1,2 +0,0 @@
|
||||
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/accounts.mjs";
|
||||
@@ -1298,32 +1298,3 @@ shows selected, active and inactive ids. The owner replaces the selection with
|
||||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||||
inactive. Project settings changes are append-only and require human proof.
|
||||
|
||||
## Accounts
|
||||
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
mosaic accounts list --json
|
||||
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
|
||||
mosaic accounts user disable u-0123456789ab --revision 2
|
||||
mosaic accounts user enable u-0123456789ab --revision 3
|
||||
mosaic accounts user signout u-0123456789ab --revision 4
|
||||
mosaic accounts user remove u-0123456789ab --revision 5
|
||||
```
|
||||
|
||||
Init creates one operator account containing every configured `console.sso`
|
||||
subject and refuses if accounts.json already exists. Add mints its id and defaults
|
||||
to instance role `user`; `--role operator` creates another operator. Writes require
|
||||
the file revision from list. Disable, enable and signout advance `authGen`, and
|
||||
the last active operator cannot be disabled or removed. Removal scans ownership
|
||||
while holding the registry then accounts locks, refuses if the account owns an
|
||||
organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
@@ -1,109 +0,0 @@
|
||||
// Phase M2: the local, instance-uid account administration exception. No org
|
||||
// rights commands and no bus transport: account init must work before orgs exist.
|
||||
import { basename, join } from 'node:path';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
|
||||
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ACCOUNTS_USAGE = [
|
||||
'mosaic accounts init [--name NAME]',
|
||||
'mosaic accounts list [--json]',
|
||||
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
|
||||
'mosaic accounts user disable|enable|signout|remove ID --revision N',
|
||||
].join('\n');
|
||||
function parse(args) {
|
||||
const words = [], values = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) { words.push(arg); continue; }
|
||||
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
|
||||
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
|
||||
if (arg === '--json') flags.add(arg);
|
||||
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
|
||||
else throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
}
|
||||
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
|
||||
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
|
||||
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
|
||||
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const revision = verb === 'init' ? 0 : Number(values['--revision']);
|
||||
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
|
||||
return { verb, values, flags, revision, id: words[2] };
|
||||
}
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
|
||||
return owner;
|
||||
}
|
||||
export async function accountsCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
const parsed = parse(args);
|
||||
const dir = deps.configDir ?? configDir(io.env);
|
||||
// This is deliberately before withRegistry as well as withAccounts.
|
||||
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
|
||||
const providers = deps.system.console?.sso?.providers ?? [];
|
||||
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
|
||||
if (parsed.verb === 'list') {
|
||||
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
|
||||
const repairs = accountsRepairs(doc);
|
||||
// Invites are not administered here. Never expose bindings or token hashes.
|
||||
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
|
||||
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
let added;
|
||||
const mutate = doc => {
|
||||
if (parsed.verb === 'init') {
|
||||
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
|
||||
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
if (parsed.verb === 'user.add') {
|
||||
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
|
||||
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
const u = doc.users.find(u => u.id === parsed.id);
|
||||
if (!u) throw new CliError('refused: account not found', 2);
|
||||
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
|
||||
else if (parsed.verb === 'user.signout') u.authGen++;
|
||||
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
|
||||
};
|
||||
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
|
||||
let result;
|
||||
if (parsed.verb === 'user.remove') {
|
||||
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
|
||||
const user = doc.users.find(u => u.id === parsed.id);
|
||||
if (!user) throw new CliError('refused: account not found', 2);
|
||||
// Even disabled accounts keep ownership. A real create reserves before
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
mutate(doc);
|
||||
}), { write: false });
|
||||
} else result = await withAccounts(ctx, opts, mutate);
|
||||
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
|
||||
if (e.committed) error.committed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError, CliExit } from "./errors.mjs";
|
||||
@@ -53,7 +52,6 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
${ORG_USAGE}
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
@@ -747,7 +745,6 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
||||
function setup(t) {
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
||||
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
||||
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
||||
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
||||
}
|
||||
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
||||
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
||||
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
||||
});
|
||||
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
||||
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
||||
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
||||
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
||||
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
||||
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
||||
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
||||
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
||||
});
|
||||
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
||||
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
||||
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
||||
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
||||
}
|
||||
});
|
||||
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
||||
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
||||
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
||||
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
||||
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
||||
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
||||
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
||||
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
||||
});
|
||||
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
||||
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
||||
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
||||
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
||||
});
|
||||
|
||||
|
||||
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
||||
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
|
||||
|
||||
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
||||
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
||||
process.getuid = () => effectiveUid + 1;
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
@@ -7,9 +7,8 @@ stay as the old names: each module there re-exports the module of the same name
|
||||
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
|
||||
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||||
addenda A and B. Configuration readers refuse on any problem. Runtime
|
||||
organization and accounts stores provide explicit locked writes; this package
|
||||
never opens a token file.
|
||||
addenda A and B. The package reads files and refuses on any problem. It
|
||||
never writes, and it never opens a token file.
|
||||
|
||||
```sh
|
||||
scripts/mosaic org config validate mosaic-stack
|
||||
@@ -584,44 +583,3 @@ The optional `plugins.system-type` list enables installed system-type modules fo
|
||||
an organization. Missing means none. Unknown ids refuse validation. Changes
|
||||
are owner-only; project selections are separate bus settings and a selection
|
||||
whose type is disabled remains recorded as inactive.
|
||||
|
||||
## Instance accounts (Phase M2)
|
||||
|
||||
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
|
||||
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
|
||||
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
|
||||
configured `console.sso.providers` objects (including issuer). Reads refuse an
|
||||
unsafe config directory, unsafe file, unknown field or malformed row. Account
|
||||
ids may be organization humans. `accountsRepairs(document)` reports unknown
|
||||
roles and rights retained on read for rollback; these rows must grant nothing.
|
||||
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
|
||||
|
||||
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
|
||||
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
|
||||
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
|
||||
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
|
||||
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
|
||||
revision 0 and an absent file. Other writes require an existing valid file. Writes
|
||||
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
|
||||
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
|
||||
|
||||
For a caller already holding accounts.lock,
|
||||
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
|
||||
reuses that live opaque handle. Never acquire an earlier lock from the callback.
|
||||
The config path must have no symlink ancestors, including the home directory.
|
||||
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
|
||||
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
|
||||
the lock and rereads its persisted snapshot without relocking. A changed registry
|
||||
or an instance mismatch refuses before account publication. Callbacks cannot
|
||||
reuse a released handle, or run two writes at once through one handle.
|
||||
|
||||
Each committed write appends and fsyncs one receipt under the accounts lock in
|
||||
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
|
||||
account ids with changed field names, and before/after membership rights, scope
|
||||
and revisions. No account
|
||||
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
|
||||
store preflights the receipt destination before replacing accounts. As with
|
||||
other two-file journals, a crash or IO failure between replacement and receipt
|
||||
append can leave committed state without its receipt; an observed append failure
|
||||
has `committed: true` and must be inspected before retrying. It never rolls back
|
||||
committed authority silently or fabricates a success receipt.
|
||||
@@ -1,266 +0,0 @@
|
||||
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { dirname, join, parse, resolve } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OrgError, readRegistryLocked } from './org-registry.mjs';
|
||||
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
|
||||
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
|
||||
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
const bad = message => fail('accounts-invalid', message);
|
||||
const USER_ID = /^u-[0-9a-f]{12}$/;
|
||||
const HEX32 = /^[0-9a-f]{32}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
|
||||
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
|
||||
const text = (x, at) => {
|
||||
requireString(x, at, { max: 255 });
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
|
||||
return x;
|
||||
};
|
||||
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
|
||||
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
|
||||
function time(x, at) {
|
||||
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
|
||||
}
|
||||
function providerMap(context) {
|
||||
const providers = new Map();
|
||||
for (const p of array(context?.providers, 'accounts context.providers')) {
|
||||
text(p.id, 'provider.id');
|
||||
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
|
||||
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
|
||||
if (providers.has(p.id)) bad('duplicate configured provider');
|
||||
providers.set(p.id, p);
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
function subject(s, at, providers) {
|
||||
keys(s, ['provider', 'sub'], at);
|
||||
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
|
||||
text(s.sub, `${at}.sub`);
|
||||
}
|
||||
function scope(s, at) {
|
||||
keys(s, ['projects', 'instances', 'expires'], at);
|
||||
for (const key of ['projects', 'instances']) {
|
||||
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
|
||||
if (x === '*') return;
|
||||
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
|
||||
else requireId(x, `${at}.instances`);
|
||||
}, { nonEmpty: true });
|
||||
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
|
||||
}
|
||||
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
|
||||
}
|
||||
function rowRights(row, at) {
|
||||
requireId(row.role, `${at}.role`);
|
||||
if (row.role === 'owner') bad(`${at}.role is not grantable`);
|
||||
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
|
||||
text(r, `${at}.rights`);
|
||||
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
|
||||
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
|
||||
}, { nonEmpty: true });
|
||||
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
|
||||
scope(row.scope, `${at}.scope`);
|
||||
requireId(row.org, `${at}.org`);
|
||||
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
|
||||
}
|
||||
|
||||
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
|
||||
// accountsRepairs exposes them without changing the persisted schema.
|
||||
export function checkAccounts(document, context) {
|
||||
const providers = providerMap(context);
|
||||
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
|
||||
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
|
||||
requirePositiveInt(document.revision, 'revision');
|
||||
const users = new Set(), subjects = new Set(); let operators = 0;
|
||||
array(document.users, 'users').forEach((u, i) => {
|
||||
const at = `users[${i}]`;
|
||||
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
|
||||
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
|
||||
text(u.name, `${at}.name`);
|
||||
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
|
||||
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
|
||||
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
|
||||
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
|
||||
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
|
||||
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
|
||||
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
|
||||
});
|
||||
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
|
||||
});
|
||||
if (!operators) bad('at least one active operator must exist');
|
||||
const memberships = new Set();
|
||||
array(document.memberships, 'memberships').forEach((m, i) => {
|
||||
const at = `memberships[${i}]`;
|
||||
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
|
||||
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
|
||||
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
|
||||
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
|
||||
});
|
||||
const invites = new Set(), hashes = new Set();
|
||||
array(document.invites, 'invites').forEach((invite, i) => {
|
||||
const at = `invites[${i}]`;
|
||||
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
|
||||
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
|
||||
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
|
||||
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
|
||||
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
|
||||
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
|
||||
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
|
||||
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
|
||||
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
|
||||
requireObject(invite.bind, `${at}.bind`);
|
||||
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
|
||||
else subject(invite.bind, `${at}.bind`, providers);
|
||||
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
|
||||
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
|
||||
});
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
export function accountsRepairs(doc) {
|
||||
const result = [];
|
||||
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
|
||||
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
|
||||
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
|
||||
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check the host boundary before any read or lock. Every ancestor must be a real
|
||||
// directory; configDir itself belongs to this uid and is not publicly writable.
|
||||
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part); const st = lstatSync(current);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
|
||||
}
|
||||
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
|
||||
assertAccountsOwner(dirname(file), { uid });
|
||||
const doc = readJsonFile(file, 'accounts file', st => {
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
|
||||
});
|
||||
return checkAccounts(doc, context);
|
||||
}
|
||||
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
|
||||
const heldAccounts = new WeakMap();
|
||||
|
||||
// Low-level helper for callers already holding the earlier lifecycle/registry
|
||||
// locks. A handle is valid only within this callback and serializes all writes.
|
||||
export async function withAccountsLock(ctx, fn) {
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { fd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
|
||||
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
|
||||
try { return await fn(handle); }
|
||||
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
|
||||
}
|
||||
export async function withAccounts(ctx, options, fn) {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
|
||||
}
|
||||
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
|
||||
function generations(next, before, verb) {
|
||||
for (const u of next.users) {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
if (!old) { u.authGen = 1; continue; }
|
||||
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
|
||||
if (changed) u.authGen = old.authGen + 1;
|
||||
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
|
||||
if (u.created !== old.created) bad('account created time is immutable');
|
||||
}
|
||||
for (const m of next.memberships) {
|
||||
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
|
||||
// A rollback row grants nothing, but must not block unrelated account
|
||||
// revocation or the repair of another row. Preserve unchanged rows exactly.
|
||||
if (old && same(m, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
|
||||
m.rights = [...normalizeRights(m.rights)];
|
||||
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
|
||||
}
|
||||
for (const i of next.invites) {
|
||||
const old = before?.invites.find(x => x.id === i.id);
|
||||
if (old && same(i, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
|
||||
i.rights = [...normalizeRights(i.rights)];
|
||||
}
|
||||
}
|
||||
// Receipt construction has no free-form text: no names, subjects, bindings,
|
||||
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
|
||||
function receipt(before, after, { verb, actor }, at) {
|
||||
if (!VERBS.has(verb)) bad('unsupported receipt verb');
|
||||
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
|
||||
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
|
||||
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
|
||||
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
|
||||
userChanges: after.users.flatMap(u => {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
|
||||
return changed.length ? [{ id: u.id, changed }] : [];
|
||||
}),
|
||||
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
|
||||
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
|
||||
}
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Re-read under the lock, compare expected revision, validate and atomically
|
||||
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
|
||||
// A post-rename receipt failure reports committed=true: retrying is not safe.
|
||||
export async function withAccountsLocked(handle, options, fn) {
|
||||
const held = heldAccounts.get(handle);
|
||||
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
|
||||
held.writing = true;
|
||||
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
let before;
|
||||
if (options.init) {
|
||||
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
if (options.revision !== 0) fail('revision-conflict');
|
||||
} else before = loadAccounts(file, ctx);
|
||||
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
|
||||
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
|
||||
await fn(next);
|
||||
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
|
||||
const checked = checkAccounts(next, ctx);
|
||||
const line = receipt(before, checked, options, new Date().toISOString());
|
||||
// Refuse unsafe receipt destinations before publishing the account change.
|
||||
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
|
||||
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
|
||||
await options.beforeReplace?.();
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
// Defend against a non-cooperating writer even though cooperative writers
|
||||
// already serialize through accounts.lock.
|
||||
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
|
||||
// Init must never replace a target, including one created by a writer that
|
||||
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
|
||||
if (before) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before) await fs.unlink(temp);
|
||||
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return checked;
|
||||
} catch (e) { if (committed) e.committed = true; throw e; }
|
||||
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
|
||||
}
|
||||
@@ -41,6 +41,34 @@ function noBoundKeys(entry, at) {
|
||||
}
|
||||
}
|
||||
|
||||
// A var value and every leaf it holds, as a var check's message quotes them (JSON.stringify).
|
||||
function quoted(value, out = []) {
|
||||
if (Array.isArray(value)) value.forEach((v) => quoted(v, out));
|
||||
else if (isPlainObject(value)) Object.values(value).forEach((v) => quoted(v, out));
|
||||
if (value !== undefined) out.push(JSON.stringify(value));
|
||||
return out;
|
||||
}
|
||||
|
||||
// checkVars, but a refusal never carries a value. Var checks quote the value they refuse, a value
|
||||
// can be a secret pasted in the wrong place, and these refusals reach the Console. Each key is
|
||||
// rechecked alone to name the one at fault; a message that quotes its value is replaced by one
|
||||
// that names the key only. A rule across keys (the fallback set) names keys only already.
|
||||
function checkVarsNamingKeys(vars, layer, where) {
|
||||
try { return checkVars(vars, layer, where); }
|
||||
catch (error) {
|
||||
if (error?.name !== "BusinessError" || !isPlainObject(vars)) throw error;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
try { checkVars({ [key]: value }, layer, where); }
|
||||
catch (one) {
|
||||
if (one?.name !== "BusinessError") throw one;
|
||||
if (quoted(value).some((text) => one.message.includes(text))) refuse(`${where}: variable ${key} has a value it can't take (the value is not shown)`);
|
||||
throw one;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The root file's document. `where` names it in messages, never with a host path.
|
||||
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
|
||||
requireObject(document, where);
|
||||
@@ -66,7 +94,7 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
|
||||
agents[name] = {
|
||||
definition: entry.definition,
|
||||
title: definition.title,
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
|
||||
};
|
||||
}
|
||||
@@ -121,7 +149,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
if (Object.hasOwn(entry, "definition")) {
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache);
|
||||
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
|
||||
@@ -129,7 +157,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
for (const key of Object.keys(entry.vars)) {
|
||||
if (!INTERSECT.includes(key)) refuse(`${at}.vars: a project can only narrow ${INTERSECT.join(", ")}, not ${JSON.stringify(key)}`);
|
||||
}
|
||||
out[name] = { form: "narrow", vars: checkVars(entry.vars, "project", `${at}.vars`) };
|
||||
out[name] = { form: "narrow", vars: checkVarsNamingKeys(entry.vars, "project", `${at}.vars`) };
|
||||
}
|
||||
return deepFreeze(out);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
for (const [harness, model, thinking] of ROUTES) {
|
||||
// A missing model refuses no-route at launch, not here.
|
||||
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
|
||||
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
|
||||
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
|
||||
}
|
||||
const currentEntry = proposed.launch?.models?.[vars[model]];
|
||||
const previousEntry = old?.launch?.models?.[before[model]];
|
||||
@@ -149,12 +149,12 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
}
|
||||
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
|
||||
const name = vars[harness];
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
|
||||
if (vars[thinking] === undefined) continue;
|
||||
const applies = levels(name);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
|
||||
if (!applies.includes(vars[thinking])) {
|
||||
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
|
||||
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
|
||||
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
|
||||
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
|
||||
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
|
||||
if (!existing || existing.module) {
|
||||
if (unchanged) continue;
|
||||
|
||||
@@ -36,29 +36,14 @@ export async function readRegistry({ configDir }) {
|
||||
return validateRegistry(doc, file);
|
||||
}
|
||||
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
|
||||
const heldRegistries = new WeakMap();
|
||||
// Only a live withRegistry document may be reused by a later accounts-lock
|
||||
// holder. Recheck the persisted snapshot without reacquiring this lock.
|
||||
export async function readRegistryLocked(registry, ctx) {
|
||||
const held = heldRegistries.get(registry);
|
||||
if (!held) fail('org-locked', 'a live registry lock document is required');
|
||||
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
|
||||
const current = await readRegistry(held.ctx);
|
||||
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
|
||||
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
|
||||
}
|
||||
export async function withRegistry(ctx, fn, { write = true } = {}) {
|
||||
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
|
||||
if (!fd) fail('org-locked', 'registry lock is held', lock);
|
||||
try {
|
||||
const persisted = await readRegistry(ctx);
|
||||
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
|
||||
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
|
||||
let result;
|
||||
try { result = await fn(registry); }
|
||||
finally { heldRegistries.delete(registry); }
|
||||
const result = await fn(registry);
|
||||
if (write) {
|
||||
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
|
||||
|
||||
@@ -205,7 +205,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
|
||||
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
|
||||
}
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} variable model selects an unknown model`);
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
|
||||
// No upper bound: limits have no ceiling in code (Q150).
|
||||
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
|
||||
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
||||
import { isPlainObject, requireString } from "./util.mjs";
|
||||
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
||||
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
||||
@@ -29,7 +29,7 @@ function string(max = 200) {
|
||||
|
||||
function oneOf(values) {
|
||||
return (v, key) => {
|
||||
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")}`);
|
||||
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
@@ -37,13 +37,13 @@ function oneOf(values) {
|
||||
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
|
||||
// module exists or is switched on is checked at launch and at a write, never at load.
|
||||
function moduleName(v, key) {
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name`);
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function integer(min) {
|
||||
return (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min}`);
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function baseUrl(v, key) {
|
||||
try {
|
||||
url = new URL(v);
|
||||
} catch {
|
||||
refuse(`variable ${key} must be an http or https URL`);
|
||||
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
|
||||
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
|
||||
@@ -70,31 +70,21 @@ function searxngUrl(v, key) {
|
||||
baseUrl(v, key);
|
||||
const url = new URL(v);
|
||||
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
||||
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback`);
|
||||
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function branch(v, key) {
|
||||
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name`);
|
||||
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function list(check, { nonEmpty = true } = {}) {
|
||||
return (v, key) => {
|
||||
if (!Array.isArray(v)) refuse(`variable ${key} must be an array`);
|
||||
if (nonEmpty && v.length === 0) refuse(`variable ${key} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (const item of v) {
|
||||
check(item, `${key} entry`);
|
||||
if (seen.has(item)) refuse(`duplicate entry in variable ${key}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
};
|
||||
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
|
||||
}
|
||||
|
||||
function boolean(v, key) {
|
||||
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false`);
|
||||
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -135,10 +125,7 @@ export const REGISTRY = Object.freeze({
|
||||
"bus.tester": def(["business"], boolean, { default: false }),
|
||||
|
||||
// Project.
|
||||
"tracker.project": def(["project"], (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < 1) refuse(`variable ${key} must be a positive integer`);
|
||||
return v;
|
||||
}),
|
||||
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
||||
// On: the tracker project is the root of a tree, and sync reads its descendants too.
|
||||
"tracker.descendants": def(["project"], boolean, { default: false }),
|
||||
"git.workingBranch": def(["project"], branch),
|
||||
@@ -174,17 +161,16 @@ export const REGISTRY = Object.freeze({
|
||||
// Limits narrow the role definition's ceilings. limits.authority is an
|
||||
// allowlist: a role action it doesn't name becomes gated.
|
||||
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
|
||||
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool`);
|
||||
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
|
||||
}, { nonEmpty: false }), { merge: "intersect" }),
|
||||
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
|
||||
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
|
||||
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action`);
|
||||
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
|
||||
}, { nonEmpty: false }), { merge: "intersect" }),
|
||||
});
|
||||
|
||||
// Check one layer's vars object. `where` names the file and path for the
|
||||
// message. Returns a frozen copy with checked values. Validators name the key
|
||||
// and the rule, never a value: refusals can reach the Console and the trail.
|
||||
// message. Returns a frozen copy with checked values.
|
||||
export function checkVars(vars, layer, where) {
|
||||
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
|
||||
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
|
||||
import { templateRender } from '../src/org-service.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { REPO_ROLES } from './helpers.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
|
||||
|
||||
export const OP = 'u-000000000001', USER = 'u-000000000002';
|
||||
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
|
||||
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
|
||||
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
|
||||
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
|
||||
function setup(t, doc = document()) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
|
||||
return { configDir: dir, instance: 'test', ...context, file };
|
||||
}
|
||||
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
|
||||
|
||||
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
|
||||
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
|
||||
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
|
||||
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
|
||||
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
|
||||
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
|
||||
});
|
||||
|
||||
test('unknown role and right load as repair rows; account may be a business human', () => {
|
||||
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
|
||||
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
|
||||
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
|
||||
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
|
||||
});
|
||||
|
||||
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
|
||||
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
|
||||
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
|
||||
}
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
|
||||
});
|
||||
|
||||
test('rights/scope revisions and security generations are maintained by the writer', async t => {
|
||||
const d = document(); d.memberships.push(member()); const s = setup(t, d);
|
||||
let rev = 1;
|
||||
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
|
||||
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
|
||||
}
|
||||
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
|
||||
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
|
||||
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
|
||||
assert.equal(result.users[1].authGen, 6);
|
||||
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
|
||||
assert.equal(statSync(s.file).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('concurrent writes serialize and the later stale write changes nothing', async t => {
|
||||
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
|
||||
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
|
||||
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
|
||||
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
|
||||
assert.equal(loadAccounts(s.file, s).revision, 2);
|
||||
});
|
||||
|
||||
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
|
||||
const s = setup(t); let held;
|
||||
await assert.rejects(readRegistryLocked({}), /lock/);
|
||||
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
|
||||
await withRegistry(s, async registry => {
|
||||
assert.equal(await readRegistryLocked(registry), registry);
|
||||
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
|
||||
});
|
||||
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
|
||||
const before = readFileSync(s.file, 'utf8');
|
||||
await withRegistry(s, async registry => {
|
||||
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
|
||||
}, { write: false });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before);
|
||||
});
|
||||
|
||||
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
|
||||
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
|
||||
const s = setup(t); const secret = 'subject-secret';
|
||||
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
|
||||
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
|
||||
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
|
||||
});
|
||||
|
||||
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
|
||||
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
|
||||
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
|
||||
});
|
||||
function invitation() {
|
||||
const { user: ignored, rev, added, via, ...row } = member();
|
||||
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
|
||||
}
|
||||
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
|
||||
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
|
||||
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
|
||||
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
|
||||
}
|
||||
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
|
||||
});
|
||||
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
|
||||
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
|
||||
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
|
||||
});
|
||||
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
|
||||
const s = setup(t), other = setup(t);
|
||||
await withAccountsLock(s, async handle => {
|
||||
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
});
|
||||
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
|
||||
assert.equal(loadAccounts(s.file, s).revision, 3);
|
||||
});
|
||||
test('invite token hashes and subject bindings never reach receipts', async t => {
|
||||
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
|
||||
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
|
||||
});
|
||||
|
||||
test('a failed rename preserves published bytes and appends no receipt', async t => {
|
||||
const s = setup(t), before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
|
||||
});
|
||||
|
||||
|
||||
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
|
||||
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
|
||||
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
|
||||
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
|
||||
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
|
||||
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
|
||||
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
|
||||
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
|
||||
});
|
||||
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
|
||||
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
|
||||
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
|
||||
assert.throws(() => checkAccounts(d, context));
|
||||
}
|
||||
});
|
||||
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
|
||||
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
|
||||
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
|
||||
});
|
||||
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
|
||||
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
|
||||
});
|
||||
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
|
||||
const s = setup(t); let error;
|
||||
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
|
||||
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
|
||||
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
|
||||
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
|
||||
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
|
||||
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
|
||||
});
|
||||
@@ -123,7 +123,7 @@ test("malformed root definitions refuse", () => {
|
||||
|
||||
test("a refused var names its key, never its value", () => {
|
||||
const secret = "sk-secret-marker-7731";
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key}`) && !error.message.includes(secret));
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key} has a value it can't take`) && !error.message.includes(secret));
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
|
||||
|
||||
@@ -251,7 +251,7 @@ test("names that are Object.prototype properties don't count as declared", () =>
|
||||
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
|
||||
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/);
|
||||
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
|
||||
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
|
||||
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BusinessError, checkVars, loadBusiness, loadProject, projectFilePath, systemVars,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
import { checkRoutes, launchSettings } from "../src/launch-settings.mjs";
|
||||
import { OrganizationError } from "../src/organization.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
|
||||
function hidesValue(fn, key, reason) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError);
|
||||
assert.equal(error.name, "BusinessError");
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.ok(error.message.includes(`variable ${key}`), error.message);
|
||||
if (reason) assert.match(error.message, reason);
|
||||
assert.ok(!error.message.includes(SECRET), "refusal disclosed a var value");
|
||||
assert.ok(!String(error).includes(SECRET), "rendered error disclosed a var value");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
for (const [scope, key, value] of [
|
||||
["business", "tracker.kind", SECRET],
|
||||
["agent", "thinking", SECRET],
|
||||
["agent", "model", SECRET],
|
||||
]) {
|
||||
test(`organization file ${scope} ${key} refusal names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
if (scope === "agent") doc.roles.coder.vars[key] = value;
|
||||
else doc.vars[key] = value;
|
||||
writeJson(join(root, "businesses/acme.json"), doc);
|
||||
hidesValue(() => loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }), key, key === "model" ? /unknown model/ : /one of/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const scope of ["project", "role"]) {
|
||||
test(`project file ${scope} var refusal names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const vars = { "tracker.project": SECRET };
|
||||
writeJson(projectFilePath(root), {
|
||||
projectVersion: 1, id: "stack",
|
||||
...(scope === "project" ? { vars } : { roles: { coder: { vars } } }),
|
||||
});
|
||||
hidesValue(() => loadProject(root), "tracker.project", /positive integer/);
|
||||
});
|
||||
}
|
||||
|
||||
test("systemVars accepts user values and refuses malformed ones without quoting them", () => {
|
||||
const config = {
|
||||
environment: "development", dataRoot: "/d",
|
||||
execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" },
|
||||
};
|
||||
for (const key of ["environment", "dataRoot", "execution.backend", "execution.provider", "execution.model", "execution.adapter"]) {
|
||||
const bad = structuredClone(config);
|
||||
const value = { pasted: SECRET };
|
||||
if (key.startsWith("execution.")) bad.execution[key.slice("execution.".length)] = value;
|
||||
else bad[key] = value;
|
||||
hidesValue(() => systemVars(bad), key);
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise every validator that used to quote a scalar, a nested leaf or a duplicate.
|
||||
// Removing any of their redactions must fail here even if a caller sanitizes its error.
|
||||
for (const [key, layer, value, reason] of [
|
||||
["tracker.kind", "business", SECRET, /one of/],
|
||||
["harness", "agent", `${SECRET}!`, /harness module name/],
|
||||
["fallback.harness", "agent", `${SECRET}!`, /harness module name/],
|
||||
["thinking", "agent", { pasted: [SECRET] }, /one of/],
|
||||
["fallback.thinking", "agent", SECRET, /one of/],
|
||||
["tracker.reconcileMinutes", "business", SECRET, /integer of at least 1/],
|
||||
["tracker.pollSeconds", "project", SECRET, /integer of at least 10/],
|
||||
["tracker.baseUrl", "business", SECRET, /http or https URL/],
|
||||
["gitea.baseUrl", "business", SECRET, /http or https URL/],
|
||||
["web.searxng", "business", `http://${SECRET}.example`, /https, or http on loopback/],
|
||||
["git.workingBranch", "project", `${SECRET}!`, /git branch name/],
|
||||
["git.protectedBranches", "project", [SECRET, `${SECRET}!`], /git branch name/],
|
||||
["git.protectedBranches", "project", [SECRET, SECRET], /duplicate entry/],
|
||||
["suites", "project", [SECRET, SECRET], /duplicate entry/],
|
||||
["tracker.descendants", "project", SECRET, /true or false/],
|
||||
["bus.tester", "business", SECRET, /true or false/],
|
||||
["tracker.project", "project", SECRET, /positive integer/],
|
||||
["limits.tools", "agent", ["read", SECRET], /unsupported tool/],
|
||||
["limits.authority", "agent", ["message.send", SECRET], /unknown action/],
|
||||
["limits.network", "agent", SECRET, /one of/],
|
||||
]) {
|
||||
test(`checkVars hides refused ${key}: ${reason.source}`, () => {
|
||||
hidesValue(() => checkVars({ [key]: value }, layer, "fixture vars"), key, reason);
|
||||
});
|
||||
}
|
||||
|
||||
for (const prefix of ["", "fallback."]) {
|
||||
test(`route writer unknown ${prefix}model names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
writeJson(join(root, "businesses/acme.json"), doc);
|
||||
const settings = launchSettings({ dir: root, rolesDir: REPO_ROLES });
|
||||
const route = { harness: "pi", model: Object.keys(doc.launch.models)[0], thinking: "high", fallback: null };
|
||||
if (prefix) route.fallback = { harness: "pi", model: SECRET, thinking: "high" };
|
||||
else route.model = SECRET;
|
||||
assert.throws(() => settings.setRoutes("acme", {
|
||||
revision: settings.writer.read("acme").revision, routes: { coder: route },
|
||||
}, "jason"), error => {
|
||||
assert.ok(error instanceof OrganizationError);
|
||||
assert.equal(error.code, "unknown-model");
|
||||
assert.ok(!String(error).includes(SECRET));
|
||||
assert.ok(error.message.includes(`variable ${prefix}model`), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
for (const [field, code] of [
|
||||
["model", "unknown-model"], ["harness", "harness-off"],
|
||||
["harness", "harness-refused"], ["thinking", "thinking-unsupported"],
|
||||
]) {
|
||||
test(`route check ${prefix}${field} ${code} names its key and hides its value`, () => {
|
||||
const vars = prefix ? {
|
||||
harness: "pi", model: "listed", thinking: "high",
|
||||
"fallback.harness": "pi", "fallback.model": "listed", "fallback.thinking": "high",
|
||||
} : { harness: "pi", model: "listed", thinking: "high" };
|
||||
const key = `${prefix}${field}`;
|
||||
vars[key] = SECRET;
|
||||
const proposed = {
|
||||
roles: { coder: { vars } }, launch: { models: { listed: {} } },
|
||||
plugins: { harness: code === "harness-refused" ? ["pi", SECRET] : ["pi"] },
|
||||
};
|
||||
assert.throws(() => checkRoutes({ proposed, levels: name => name === SECRET ? null : ["high"] }), error => {
|
||||
assert.ok(error instanceof OrganizationError);
|
||||
assert.equal(error.code, code);
|
||||
assert.ok(!String(error).includes(SECRET), "route refusal disclosed a var value");
|
||||
assert.ok(error.message.includes(`variable ${key}`), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,6 @@ test('a refused root file hides the root level only, and says why without a host
|
||||
writeFileSync(join(dir, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: { x: { definition: 'reviewer', vars: { thinking: 'sk-secret-marker-7731' } } } }));
|
||||
const badVar = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
|
||||
assert.equal(badVar.body.root.state, 'invalid');
|
||||
assert.match(badVar.body.root.reason, /variable thinking must be one of/);
|
||||
assert.match(badVar.body.root.reason, /variable thinking has a value it can't take/);
|
||||
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
|
||||
});
|
||||
Reference in new issue
Block a user