Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/memory/feature-flows/self-change-grants.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ People (JWT, their own `user` key) and the system agent are never fenced.
| `schedules.manage` | create / update / delete / enable / disable a schedule; its webhook and webhook secret | the agent's **own** schedules (#2996); `trigger` |
| `instructions.manage` | `CLAUDE.md`, `AGENTS.md`, `.claude/**` except `.claude/skills/**` through the file routes; `git/reset-to-main-preserve-state` | — |
| `agents.manage` | create a durable agent, delete, `deploy-local`, `systems/deploy`, `PUT /model`, and `read-only` / `resources` / `timeout` / `public-channel-model` / `guardrails` | spawning or discarding an **ephemeral** agent (ent#69) |
| `projects.manage` (ent#588) | start a Workspace project for its owner (owner = creator and first member, agent = steward, members-only) and import one of its own folder projects — MCP `create_project` / `import_project`, checked by the projects module before any read | working on a project it is already active on; adding people or changing visibility is never granted |

Bounds on a holder:
- **Reach** (every capability): on every fenced route that names a target —
Expand Down
7 changes: 4 additions & 3 deletions docs/memory/requirements/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,21 +107,22 @@
- **Status**: ✅ Implemented (2026-10-05)
- **GitHub Issues**: trinity-enterprise#164 (re-scoped 2026-10-02: grants, not per-action approval); the Settings toggles are trinity-enterprise#756
- **Problem**: an agent's key resolves to its owner (Invariant #8), so the owner gates let any agent reshape any agent of the same owner — its instructions, schedules, configuration, or the fleet itself. ent#596 closed that for skills with one capability grant; this extends the same seam to the rest of the agent's shape.
- **Rule**: four capabilities in a closed set (`db/capability_grants.CAPABILITIES`). Humans (JWT, `user` key, `system`) are never fenced; an agent principal without the grant is **refused** — no pause, no approval-and-resume. The 403 names the missing permission (`*_management_not_permitted`) and tells the agent to raise an ask (`type: question`) whose title **includes the permission id** (`schedules.manage`); answering that ask does **not** grant anything — an instance admin grants it from a signed-in session, in the agent's Settings → *Permissions to change itself* (ent#756) or `PUT /api/agents/{agent}/capability-grants/{capability}`. An `agents.manage` holder reaches only agents its owner owns, and cannot change its own read-only mode or guardrails. Every refusal is audited (`capability_refused`).
- **Rule**: five capabilities in a closed set (`db/capability_grants.CAPABILITIES`). Humans (JWT, `user` key, `system`) are never fenced; an agent principal without the grant is **refused** — no pause, no approval-and-resume. The 403 names the missing permission (`*_management_not_permitted`) and tells the agent to raise an ask (`type: question`) whose title **includes the permission id** (`schedules.manage`); answering that ask does **not** grant anything — an instance admin grants it from a signed-in session, in the agent's Settings → *Permissions to change itself* (ent#756) or `PUT /api/agents/{agent}/capability-grants/{capability}`. An `agents.manage` holder reaches only agents its owner owns, and cannot change its own read-only mode or guardrails. Every refusal is audited (`capability_refused`).

| Capability | Covers | Exempt |
|---|---|---|
| `skills.manage` (ent#596) | skill writes, skill sets, `.claude/skills/**` via the file routes | — |
| `schedules.manage` | create / update / delete / enable / disable a schedule, and its webhook + webhook secret | the agent's **own** schedules (#2996 — its own use, bounded by autonomy); "trigger now" |
| `instructions.manage` | `CLAUDE.md`, `AGENTS.md` and `.claude/**` except skills, via the file routes (write, create-folder, delete — a delete of an ancestor counts); `git/reset-to-main-preserve-state` | — |
| `agents.manage` | create a durable agent, delete, `deploy-local`, `systems/deploy`, the chat model, and the five reconfigure writes in §2.8 | spawning / discarding an **ephemeral** agent (ent#69 governs ghosts) |
| `projects.manage` (ent#588) | start a Workspace project **on its owner's behalf** — the owner is creator and first member, the agent its steward, members-only — and import one of the agent's **own** folder projects (MCP `create_project` / `import_project`) | working on a project the agent is already active on (tasks, log, health) needs no grant; members and visibility are never grantable |

- **Not grantable**: autonomy, api-key-setting, capabilities, capacity (PERSON, §2.8) and rename (ent#69 Part 2, human-only). A calibrating companion (owner readiness stamp, ent#663) cannot be granted `instructions.manage` (422 `calibrating_agent`); ephemeral and system agents cannot be granted anything.
- **Grant surface**: `GET /api/agents/{name}/capability-grants` (owner-level: all four, held or not, with who/when — an agent may read its own); `PUT /api/agents/{name}/capability-grants/{capability}` `{granted}` — admin **and interactive**, idempotent, audited (`capability_grant` / `capability_revoke`). The ent#596 `PUT /skill-manager` route is kept.
- **Grant surface**: `GET /api/agents/{name}/capability-grants` (owner-level: all five, held or not, with who/when — an agent may read its own); `PUT /api/agents/{name}/capability-grants/{capability}` `{granted}` — admin **and interactive**, idempotent, audited (`capability_grant` / `capability_revoke`). The ent#596 `PUT /skill-manager` route is kept.
- **Upgrade effect**: no agent holds `schedules.manage` / `instructions.manage` / `agents.manage` after deploy. An agent that orchestrates others (creates durable agents, writes another agent's schedules or instructions, reconfigures) is refused until an admin grants it.
- **Known limit**: the fence is on the platform's routes. An agent can still edit files inside its **own** container with its own tools; `instructions.manage` governs the platform write path, not the agent's shell.
- **Tests**: `tests/unit/test_ent164_self_change_grants.py` (fences read off FastAPI's dependant graph, behaviour per class, ghost and own-schedule exemptions, calibrating refusal), `tests/unit/test_2996_owner_config_person_only.py` (real keys: an ungranted agent gets the named refusal, a granted one changes the stored value, the grant does not open the PERSON writes; the grant route), `tests/unit/test_2996_human_only_routes.py` (census class `person_or_grant`).
- **Settings surface (trinity-enterprise#756)**: Agent Settings → **Permissions to change itself** — the four as toggles (skills · schedules · instructions · other agents), each with a plain line of what it allows and what is refused without it, and *granted by · when* (absolute time on hover). Read by the owner (and admins); a non-owner's read 404s and the card does not render. Editable only by an admin — the owner sees the state with *Only an instance admin can change these*; the PUT itself stays the gate (admin **and** interactive). A fresh agent shows all four **off** with a one-line explanation. **Autonomy** is shown beside them as a person's switch, never a grant. The agent's **open permission requests** are listed — pending queue items from this agent whose title contains a permission id (the refusal tells it to put one there) — each linking to Operations. Toggle refusals (calibrating, system, ephemeral) render next to their own toggle in the backend's words. **OSS-core, ungated** — the UI for #3236's grants follows #3236's open-core ruling.
- **Settings surface (trinity-enterprise#756)**: Agent Settings → **Permissions to change itself** — the five as toggles (skills · schedules · instructions · other agents · projects), each with a plain line of what it allows and what is refused without it, and *granted by · when* (absolute time on hover). Read by the owner (and admins); a non-owner's read 404s and the card does not render. Editable only by an admin — the owner sees the state with *Only an instance admin can change these*; the PUT itself stays the gate (admin **and** interactive). A fresh agent shows all five **off** with a one-line explanation. **Autonomy** is shown beside them as a person's switch, never a grant. The agent's **open permission requests** are listed — pending queue items from this agent whose title contains a permission id (the refusal tells it to put one there) — each linking to Operations. Toggle refusals (calibrating, system, ephemeral) render next to their own toggle in the backend's words. **OSS-core, ungated** — the UI for #3236's grants follows #3236's open-core ruling.
- **Flow**: `feature-flows/self-change-grants.md`

---
7 changes: 7 additions & 0 deletions src/backend/db/capability_grants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,19 @@
#: them (resources, timeout, guardrails, read-only, models, rename). Autonomy is
#: NOT in it: raising autonomy stays person-only and is never granted.
CAPABILITY_AGENTS_MANAGE = "agents.manage"
#: trinity-enterprise#588 (ent#661's planned agent right): create a Workspace
#: project ON BEHALF OF the agent's owner — the owner is its creator and first
#: member, the agent its steward — and import one of the agent's OWN folder
#: projects. Members and visibility stay person-only; nothing here adds a person
#: or widens who can see a project.
CAPABILITY_PROJECTS_MANAGE = "projects.manage"

#: The closed set. A grant for anything else is refused at the sink, so a typo
#: can never persist a capability nothing checks.
CAPABILITIES = frozenset({
CAPABILITY_SKILLS_MANAGE, CAPABILITY_SCHEDULES_MANAGE,
CAPABILITY_INSTRUCTIONS_MANAGE, CAPABILITY_AGENTS_MANAGE,
CAPABILITY_PROJECTS_MANAGE,
})


Expand Down
8 changes: 8 additions & 0 deletions src/backend/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -1855,6 +1855,14 @@ def _ask_for_it(capability: str) -> str:
"its owner owns, deletes only agents it spawned, and cannot change its "
"own read-only mode or guardrails." + _ask_for_it("agents.manage"),
),
"projects.manage": (
"project_management_not_permitted",
"This agent does not hold the project-management permission, which creating "
"a Workspace project on its owner's behalf, or importing one of its own "
"folder projects, requires. Working on a project it is already on needs no "
"grant; adding people or changing who can see a project is never granted."
+ _ask_for_it("projects.manage"),
),
}


Expand Down
2 changes: 1 addition & 1 deletion src/backend/routers/agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ async def set_agent_guardrails(

@router.get("/{agent_name}/capability-grants", response_model=AgentCapabilityGrants)
async def list_agent_capability_grants(agent_name: OwnedAgentByName):
"""Which self-change permissions this agent holds — all four, held or not,
"""Which self-change permissions this agent holds — all five, held or not,
with who granted each and when. Visible to the owner (and admins); never
to the agent's other users."""
from db.capability_grants import CAPABILITIES
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<!--
Permissions to change itself (trinity-enterprise#756) — the four grants of
Permissions to change itself (trinity-enterprise#756) — the self-change grants of
trinity-enterprise#164 as admin-only toggles. Visible to the owner (and
admins); anyone else gets a 404 from the read and the card does not render.
Every state shares the card's footprint: skeleton → toggles, or LoadFailed.
Expand All @@ -12,8 +12,8 @@
>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Permissions to change itself</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
What this agent may change about itself and other agents on its own. All four are off until an
instance admin turns one on; people and the system agent never need them.
What this agent may change about itself and other agents on its own. Every one is off until an
instance admin turns it on; people and the system agent never need them.
</p>

<div v-if="view.state === 'loading'" aria-busy="true" data-testid="self-change-loading">
Expand Down
9 changes: 8 additions & 1 deletion src/frontend/src/utils/capabilityGrants.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* The four self-change permissions an admin grants an agent (trinity-enterprise#164),
* The self-change permissions an admin grants an agent (trinity-enterprise#164, and
* `projects.manage` from trinity-enterprise#588),
* as the agent's Settings shows them (trinity-enterprise#756).
*
* Pure: the copy, the order and the permission-request match live here so the
Expand Down Expand Up @@ -32,6 +33,12 @@ export const SELF_CHANGE_CAPABILITIES = [
can: "Create, deploy and delete agents, and change their model, resources, timeout and guardrails — only agents its owner owns.",
without: 'Without it, it can still spawn short-lived helpers. It can never lift its own read-only mode or guardrails.',
},
{
id: 'projects.manage',
label: 'Start projects',
can: "Create a Workspace project on its owner's behalf, and bring in one of its own folder projects.",
without: 'Without it, it still works on projects it is already on. Adding people or changing who can see a project is never granted.',
},
]

/**
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/tests/unit/selfChangePermissionsPanel.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* trinity-enterprise#756 — an agent's self-change permissions as four
* trinity-enterprise#756 — an agent's self-change permissions as
* admin-only toggles in its Settings.
*
* Mounted (#2918): each toggle gates a grant write, so the proof is a click
Expand Down Expand Up @@ -71,7 +71,7 @@ beforeEach(() => {
})

describe('SelfChangePermissionsPanel (ent#756)', () => {
it('a fresh agent shows all four off, with a line saying so — never a blank card', async () => {
it('a fresh agent shows every permission off, with a line saying so — never a blank card', async () => {
route()
const w = await mountAs('admin')
for (const c of CAPS) {
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('SelfChangePermissionsPanel (ent#756)', () => {
expect(byId(w, 'self-change-permissions').exists()).toBe(false)
})

it('shows autonomy beside the four, as a person\'s switch and not a grant', async () => {
it('shows autonomy beside the permissions, as a person\'s switch and not a grant', async () => {
route({ autonomy: { autonomy_enabled: false } })
const w = await mountAs('admin')
expect(byId(w, 'self-change-autonomy').text()).toMatch(/Autonomy\s*·\s*off/)
Expand Down
2 changes: 2 additions & 0 deletions src/mcp-server/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ export const TOOL_ACCESS_POLICY: Readonly<Record<string, ToolAccessPolicy>> = {
link_to_project: { kind: "none", why: "a project id; the backend answers only for a project the calling agent is active on (ent#661)" },
get_steward_digest: { kind: "none", why: "the calling agent's own stewarded projects (ent#661 v3)" },
set_project_health: { kind: "none", why: "a project id; the backend answers only when the calling agent is its active steward (ent#661 v3)" },
create_project: { kind: "none", why: "creates on the calling agent's OWN owner's behalf; the backend gates it on projects.manage (ent#588)" },
import_project: { kind: "none", why: "a path in the calling agent's OWN files; the backend gates it on projects.manage (ent#588)" },
// --- assignments.ts ---
get_agent_assignments: { kind: "in-tool", how: ASSIGNMENTS_SELF },
// --- connector.ts (connector / anonymous tiers) ---
Expand Down
10 changes: 10 additions & 0 deletions src/mcp-server/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3987,6 +3987,16 @@ export class TrinityClient {
return this.projectRequest("POST", this.projectPath(projectId, "/health"), turn, body);
}

/** trinity-enterprise#588: create a project on the agent's OWNER's behalf (projects.manage). */
async createProjectAsAgent(body: Record<string, unknown>, turn?: string): Promise<Record<string, unknown>> {
return this.projectRequest("POST", "/api/enterprise/projects/agent/projects", turn, body);
}

/** trinity-enterprise#588: import one of the agent's OWN folder projects (projects.manage). */
async importProjectAsAgent(path: string, turn?: string): Promise<Record<string, unknown>> {
return this.projectRequest("POST", "/api/enterprise/projects/agent/projects/import", turn, { path });
}

// --- Role assignments (trinity-enterprise#500) ----------------------------

/**
Expand Down
41 changes: 41 additions & 0 deletions src/mcp-server/src/tools/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,44 @@ describe("ent#661 v3 — the steward's tools", () => {
assert.ok(kind.includes("ask"));
});
});

describe("ent#588 — an agent starts or imports a project (projects.manage)", () => {
it("create_project sends name and goal, and the tracker only when given", async () => {
const calls: unknown[][] = [];
const tools = makeTools({
createProjectAsAgent: async (body: Record<string, unknown>) => {
calls.push(["create", body]);
return { ...PROJECT, steward: { kind: "agent", ref: "a1" } };
},
} as Partial<TrinityClient>);
const out = JSON.parse(await tools.create_project.execute({ name: "Q4 launch", goal: "Ship it." }));
assert.equal(out.success, true);
assert.equal(out.project.id, PROJECT.id);
assert.deepEqual(calls, [["create", { name: "Q4 launch", goal: "Ship it." }]]);
});

it("without the grant the named refusal comes back, never a throw", async () => {
const tools = makeTools({
createProjectAsAgent: async () => {
throw apiError(403, { code: "project_management_not_permitted", message: "No project-management permission." });
},
} as Partial<TrinityClient>);
const out = JSON.parse(await tools.create_project.execute({ name: "x", goal: "y" }));
assert.equal(out.success, false);
assert.equal(out.code, "project_management_not_permitted");
});

it("import_project forwards only the path; a second import's 409 is surfaced", async () => {
const calls: unknown[][] = [];
const tools = makeTools({
importProjectAsAgent: async (path: string) => {
calls.push(["import", path]);
throw apiError(409, { code: "already_imported", message: "This folder was already imported." });
},
} as Partial<TrinityClient>);
const out = JSON.parse(await tools.import_project.execute({ path: "project_files/q4" }));
assert.deepEqual(calls, [["import", "project_files/q4"]]);
assert.equal(out.success, false);
assert.equal(out.code, "already_imported");
});
});
Loading
Loading