Compare commits

..
9 changed files with 179 additions and 504 deletions

No files matched your search

@@ -1,451 +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 12 that touch role or rights policy land only
with Jason's approval.
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 `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.
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
A rail is a rule no role, template, plugin, approval or setting can change
while the system runs. Only a reviewed commit changes one. Each row says where
the code enforces it 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 and binding entries use the same tables (A4). |
| R3 | Fail closed: an unknown action, tool, right, plugin kind or role field 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. |
| 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`). | Added: the brief's list did not name it, and "any capacity" must not dissolve it. P1 keeps it for every composition. |
| R5 | Human-only acts: approving a hire, fire or role change (`org.hire`), binding a credential, changing personality after hire, installing or enabling a plugin, changing a rail, and resolving a merge or policy decision. No agent approves its own request, and no agent approves a request that changes authority. | No agent resolves its own arbitrated raise: it goes to the human (`broker.mjs:7075`). Plugin enabling needs a human session (`broker.mjs:6008`). Hard waits always go to the human (R4). | Corrected. The brief's "no agent approves another agent's request" is false today. A cross-role raise goes to the domain arbiter, the chief by default, and an agent of that role resolves it (`broker.mjs:7074,7212-7215`, `packages/organization/src/org-template.json:5`). PRD 1.5 REQ-DEC-2 put `git.merge.protected` and `policy.change` on that path (`vocabulary.mjs:69-70,83,93`), and merge-gate holds merge cross-role (`roles/merge-gate.json:10`, `broker.mjs:5198-5201`). This design keeps agent arbitration only for one-off routed work that grants no lasting authority: task, review, record and message actions. A4 routes every decision whose action is `role.hire`, `role.fire`, `credential.mint`, `git.merge.protected` or `policy.change` to the human, whatever its class. That reverses part of REQ-DEC-2, so it needs Jason's approval. The dev tester may enable some plugin kinds (`broker.mjs:6013`); that stays dev-only. Personality has no field yet (T1). |
| 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`). | Corrected: 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 through a flagged line in a human-approved decision. | 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`). | Added 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. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | Added: 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. |
Dropped: none. Two items changed meaning. R5 now says "no agent approves its
own request" and lists what always goes to a human, because the code routes
cross-role work to an agent arbiter by design. R6 now says "core or a plugin"
declares a tool, because core declares all of them today.
## 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. allows `git.merge.protected` and `policy.change` only in `crossRole`, so
each use is its own decision that a human resolves (R5);
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, approve
a human decision, 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`). The decision always routes to the human, whatever its
class, so the arbiter path at `broker.mjs:7074` never applies. A hire needs no
approval count and has no cap (ruling 3). Spend stays bounded by the
organization's limits.
**Approve.** A human whose membership in that organization holds `org.hire`
approves in the Console, against the current digest. Two limits in the MVP:
- 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.
**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. Usability and security
Read this first. It says who can change what.
Everything in the first two columns is done by a person. No agent can do any
of it.
| A person changes it directly | It needs an approved decision | Only a reviewed code change |
|---|---|---|
| Organizations and projects (owner) | Hiring, firing or changing an agent's role (`org.hire`) | The rails in section 3 |
| Drafting a role copy, or inventing a role from known parts. A draft does nothing until a decision uses it (`org.hire`) | Any agent power above the chief's own, shown flagged (owner approves) | The core list of tools and actions |
| An agent's model, limits and route (`org.hire`) | Giving an agent a password, key or token (owner approves) | The shipped role templates |
| An agent's personality and traits after hire | Applying a template update to your copy | The rights table, including `org.hire` |
| Enabling a plugin in an organization (owner) | Each merge or policy change an agent asks for | Plugin kinds |
| Installing a plugin on the instance (instance owner, at the host) | Adding an agent at the root level (instance owner) | System config, which only bootstrap writes |
What never moves, whoever asks:
- 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 hire. Only the chief asks, and a person says yes.
- An agent cannot merge into a protected branch or change policy unless a
person approves that one merge or change.
- 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. 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 approves another agent's merge or policy change | A4 routes those decisions to the human whatever their class (R5). | Other routed work, such as task scope, still has an agent arbiter by design. |
| 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. |
## 11. 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 a human with `org.hire` approves, which lists
> every grant above the chief's own as a flagged line. 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".
## 12. Slices
Phase A after A3, in order. A6 and A7 stay as agent-levels section 9 has
them.
**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; `git.merge.protected` or `policy.change` in `withinRole` refuses;
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, human routing for
`role.hire`, `role.fire`, `credential.mint`, `git.merge.protected` and
`policy.change` decisions (R5), and removal of the direct path and
`#hireable`. 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,
and reverses part of PRD 1.5 REQ-DEC-2, 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` or
`policy.change` decision routes to the human and an arbiter's resolve refuses
`not-resolver`; a hire a person starts in the Console leaves a decision record
with its flagged lines.
**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.
+3 -31
View File
@@ -41,34 +41,6 @@ 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);
@@ -94,7 +66,7 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
agents[name] = {
definition: entry.definition,
title: definition.title,
vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
};
}
@@ -149,7 +121,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: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`) };
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(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)`);
@@ -157,7 +129,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: checkVarsNamingKeys(entry.vars, "project", `${at}.vars`) };
out[name] = { form: "narrow", vars: checkVars(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} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
throw new OrganizationError("unknown-model", `${instance} variable ${model} 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} ${harness} ${name} isn't switched on in plugins.harness`);
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} 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", `harness module ${name} didn't load, so its thinking levels are unknown`);
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 (!applies.includes(vars[thinking])) {
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; 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} model isn't a launch.models key`);
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}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;
@@ -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} selects unknown model ${role.vars.model}`);
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`);
// 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
+27 -13
View File
@@ -16,7 +16,7 @@
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
import { isPlainObject, 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(", ")} (got ${JSON.stringify(v)})`);
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")}`);
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 (got ${JSON.stringify(v)})`);
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name`);
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} (got ${JSON.stringify(v)})`);
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min}`);
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 (got ${JSON.stringify(v)})`);
refuse(`variable ${key} must be an http or https URL`);
}
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,21 +70,31 @@ 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 (got ${JSON.stringify(v)})`);
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback`);
return v;
}
function branch(v, key) {
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name`);
return v;
}
function list(check, { nonEmpty = true } = {}) {
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
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];
};
}
function boolean(v, key) {
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false`);
return v;
}
@@ -125,7 +135,10 @@ export const REGISTRY = Object.freeze({
"bus.tester": def(["business"], boolean, { default: false }),
// Project.
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
"tracker.project": def(["project"], (v, key) => {
if (!Number.isSafeInteger(v) || v < 1) refuse(`variable ${key} must be a positive integer`);
return v;
}),
// 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),
@@ -161,16 +174,17 @@ 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: ${JSON.stringify(v)}`);
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool`);
}, { 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: ${JSON.stringify(v)}`);
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action`);
}, { 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.
// 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.
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`);
@@ -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} has a value it can't take`) && !error.message.includes(secret));
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key}`) && !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"; }), /unknown model constructor/);
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
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);
@@ -0,0 +1,140 @@
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 has a value it can't take/);
assert.match(badVar.body.root.reason, /variable thinking must be one of/);
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
});