# Workspace Manifests — Declarative Permission Scoping for Agents

> **Status:** Implemented in W-mq07avbk000m5543. Inspired by OpenAI's AGENTS.md / Workspace Manifest primitive (April 2026, [link](https://openai.com/index/the-next-evolution-of-the-agents-sdk/)).

Workspace manifests move per-agent tool / repo / URL / memory permissions from implicit system-prompt text to **machine-readable, runtime-enforced declarations**. Each agent's manifest lives on its config entry and is enforced at dispatch time and spawn time without requiring agents to read or honour prompt language.

## Schema

A manifest lives on each agent definition under `workspace_manifest`. All fields are optional; any missing field is **permissive** (no restriction), so agents without a manifest behave exactly as they did before this feature shipped.

```json
{
  "agents": {
    "ralph": {
      "name": "Ralph",
      "role": "Engineer",
      "skills": ["implementation", "testing"],
      "workspace_manifest": {
        "allowed_tools": ["Edit", "Read", "Bash", "Grep", "Glob"],
        "allowed_repos": ["github:yemi33/minions", "opg-microsoft/minions"],
        "allowed_external_urls": ["github.com", "*.npmjs.com"],
        "memory_scope": "shared"
      }
    }
  }
}
```

| Field | Type | Default | Semantics |
| ----- | ---- | ------- | --------- |
| `allowed_tools` | `string[]` \| `null` | `null` (permissive) | Canonical tool-name whitelist (e.g. `Edit`, `Read`, `Bash`). Empty array = deny all. Case-sensitive. Merged into the runtime adapter's `--allowedTools` flag at spawn time. |
| `allowed_repos` | `string[]` \| `null` | `null` (permissive) | Canonical PR-scope (`github:owner/repo`, `ado:org/proj/repo`) or bare `owner/repo`. Empty array = deny all. Case-insensitive. Enforced at dispatch time — out-of-scope dispatch fails with `failure_class: 'workspace-manifest-repo-forbidden'`. |
| `allowed_external_urls` | `string[]` \| `null` | `null` (permissive) | Host allow-list for `web_fetch` / `web_search`. Bare host (`github.com`) = exact match; `*.example.com` = wildcard subdomain **and** apex. Empty array = deny all. Currently advisory — see "Enforcement points" below. |
| `memory_scope` | `'private'` \| `'shared'` \| `'read-only-shared'` | `'shared'` | What slice of team knowledge the agent can read/write. Currently exposed via `shared.agentMemoryScope(agent)` for callers that want to gate inbox writes; full enforcement at the consolidation layer is future work. |

## Default behaviour & backward compatibility

The defaults are deliberately permissive so the manifest can be rolled out incrementally:

```js
// engine/shared.js
const WORKSPACE_MANIFEST_DEFAULTS = {
  allowed_tools:         null,   // no tool restriction
  allowed_repos:         null,   // no repo restriction
  allowed_external_urls: null,   // no URL restriction
  memory_scope:          'shared',
};
```

A config that never mentions `workspace_manifest` produces the exact same dispatch and spawn behaviour as the pre-manifest engine. Built-in agents (Ripley, Dallas, Lambert, Rebecca, Ralph) ship without manifests by default — manifests are an opt-in per-agent hardening primitive, not a fleet-wide default.

## Enforcement points

| Phase | Location | What it does |
| ----- | -------- | ------------ |
| **Dispatch — repo gate** | `engine.js spawnAgent()` (right after project resolution) | Calls `shared.agentCanUseRepo(agent, project)`. Mismatch → `completeDispatch(... FAILURE_CLASS.WORKSPACE_MANIFEST_REPO, agentRetryable: false)` and `cleanupTempAgent` runs. Non-retryable because the structural answer is "widen the manifest or pick a different agent". |
| **Spawn — tool merge** | `engine.js spawnAgent()` → `_buildAgentSpawnFlags(..., allowedTools)` | `shared.mergeManifestAllowedTools(claudeConfig.allowedTools, manifest.allowed_tools)` produces the intersection of the runtime baseline and the manifest list. Result is passed as `--allowedTools <csv>` to Claude / Copilot / Codex, so the CLI itself enforces the narrowed surface. Empty manifest list (`[]`) = deny-all. Same merge runs on the steering-resume codepath. |
| **Agent context** | Future work (playbook.js) | Manifest can be surfaced into the agent prompt so the agent sees its declared scope. Today, `shared.resolveAgentManifest(agent)` returns the resolved struct any caller can read. |
| **URL fetch** | Advisory today | `shared.agentCanFetchUrl(agent, url)` is available for runtime intercepts. Minions itself does not currently intercept `web_fetch` inside the spawned CLI subprocess; a future runtime adapter hook can wire this. |
| **Memory scope** | Advisory today | `shared.agentMemoryScope(agent)` is available for consolidation / playbook / inbox callers. Semantics: `private` = agent only sees its own `knowledge/agents/<id>.md`; `shared` = full team knowledge (current default); `read-only-shared` = reads shared memory but should not write inbox/notes. |

## Helpers (in `engine/shared.js`)

```js
const { MEMORY_SCOPES, WORKSPACE_MANIFEST_DEFAULTS,
        validateWorkspaceManifest, resolveAgentManifest,
        agentCanUseRepo, agentCanUseTool, agentCanFetchUrl, agentMemoryScope,
        mergeManifestAllowedTools, formatManifestRejection } = require('./engine/shared');
```

- `validateWorkspaceManifest(manifest)` → `{ ok, errors }`. `null`/`undefined` is valid (uses defaults).
- `resolveAgentManifest(agent, config?)` → fresh copy of `WORKSPACE_MANIFEST_DEFAULTS` overlaid with the agent's `workspace_manifest`. Malformed manifest silently falls back to defaults; surface the error via `validateWorkspaceManifest` at config-load time.
- `agentCanUseRepo(agent, projectOrString)` → `bool`. Accepts a project object or a string identifier (`github:owner/repo`, `ado:org/proj/repo`, or bare `owner/repo`). Case-insensitive.
- `agentCanUseTool(agent, toolName)` → `bool`. Case-sensitive.
- `agentCanFetchUrl(agent, url)` → `bool`. Wildcard `*.example.com` matches subdomains **and** apex.
- `agentMemoryScope(agent)` → one of `MEMORY_SCOPES`. Unknown values fall back to `'shared'`.
- `mergeManifestAllowedTools(baselineCsv, manifestArray)` → merged CSV. Intersection semantics: `null` manifest = baseline unchanged; empty array = deny-all; empty baseline + manifest = manifest as ceiling.
- `formatManifestRejection({ agentId, kind, target, allowed })` → structured human-readable rejection string used by the dispatch repo gate.

## Failure classes (`shared.FAILURE_CLASS`)

| Code | Meaning |
| ---- | ------- |
| `workspace-manifest-repo-forbidden` | Dispatch routed an agent to a project not in its `allowed_repos`. Non-retryable. |
| `workspace-manifest-tool-forbidden` | Reserved for future tool-call intercepts. Non-retryable as-is. |
| `workspace-manifest-url-forbidden`  | Reserved for future URL-fetch intercepts. Non-retryable as-is. |

## Audit trail

Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new state file or event topic was added. Per-agent audit of what each minion actually touched can be derived from existing `engine/dispatch.json` records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.

## Rollout guidance

1. Leave defaults (no manifests) until you have at least one agent that should be locked down (e.g. a contract agent that should only ever touch one repo, or an external collaborator's agent).
2. Set `workspace_manifest.allowed_repos` first — it's the highest-impact gate and is fully enforced at dispatch time.
3. Add `workspace_manifest.allowed_tools` for agents whose role doesn't need `Bash` / `WebFetch` / `WebSearch`. The runtime CLI enforces this, so the narrowing is hard-edged.
4. Treat `allowed_external_urls` and `memory_scope` as documentation today; they become hard gates as we add runtime intercepts.

## Related

- `engine/shared.js` — manifest helpers and `FAILURE_CLASS`.
- `engine.js spawnAgent()` — dispatch-time enforcement.
- `test/unit/workspace-manifest.test.js` — full validator + gate + integration tests.
- [`completion-reports.md`](completion-reports.md) — how the dispatch failure surfaces in the standard completion contract.
