Skip to content
Draft
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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,7 @@ graph LR
| Registries — easier search + add-server | Done | P1 | 21/24 (88%) | [070-registry-easy-upstream-add](./specs/070-registry-easy-upstream-add/) | |
| Scanner simplification (deterministic default, opt-in deep scan) | Done | P1 | 38/42 (90%) | [077-scanner-simplification](./specs/077-scanner-simplification/) | |
| Deferred-schema serialization for the direct tools/list surface (spec 102) | Done | P1 | 89/89 (100%) | [102-schema-deferred](./specs/102-schema-deferred/) | #1063 |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | Done | P1 | 94/113 (83%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | Done | P1 | 111/113 (98%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Tray↔core decoupling: socket/REST API only, no config-file reads | Done | P2 | — | | |
| Spec 107 server edition SSO front door hardened for real IdPs | Done | P2 | 126/126 (100%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | |

Expand Down Expand Up @@ -1032,6 +1032,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) |
| [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) |
| [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 94/113 (83%) |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `shipped` | 111/113 (98%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
34 changes: 17 additions & 17 deletions specs/105-agent-scope-hardening/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@

## Phase 1: Setup (shared across PRs)

- [ ] T001 Add `agentCtx(allowed []string, perms []string, pin string) context.Context` and `adminCtx()` helpers with the `["*"]`-means-unrestricted rule documented, in `internal/server/scope_fixture_test.go` (new; consumed by every PR's tests; empty `AllowedServers` = deny-all)
- [ ] T002 [P] Add `startCountingUpstream(t, tools ...toolSpec) (*countingUpstream)` generalising `startCountingTargetTierUpstream` (`internal/server/mcp_call_tool_target_tier_test.go:335-411`) so any test can assert zero upstream calls, in `internal/server/scope_fixture_test.go`
- [ ] T003 [P] Amend spec text per plan §"Spec text amendments" (FR-008 empty-raw-name analogue; FR-003/004 D1 pinned zero-reach decision; SC-007 restated as the gate on FR-005/008/010 + inverted-assertion list; FR-009 unresolved identity refused for every caller per Edge Case `spec.md:107`; FR-002 anonymous callers; SC-005 two new named administrator exceptions — FR-005 profile-scoped admin on shared-index fallback (D3) and FR-001 caller-kind-first (D5)) in `specs/105-agent-scope-hardening/spec.md`
- [x] T001 Add `agentCtx(allowed []string, perms []string, pin string) context.Context` and `adminCtx()` helpers with the `["*"]`-means-unrestricted rule documented, in `internal/server/scope_fixture_test.go` (new; consumed by every PR's tests; empty `AllowedServers` = deny-all)
- [x] T002 [P] Add `startCountingUpstream(t, tools ...toolSpec) (*countingUpstream)` generalising `startCountingTargetTierUpstream` (`internal/server/mcp_call_tool_target_tier_test.go:335-411`) so any test can assert zero upstream calls, in `internal/server/scope_fixture_test.go`
- [x] T003 [P] Amend spec text per plan §"Spec text amendments" (FR-008 empty-raw-name analogue; FR-003/004 D1 pinned zero-reach decision; SC-007 restated as the gate on FR-005/008/010 + inverted-assertion list; FR-009 unresolved identity refused for every caller per Edge Case `spec.md:107`; FR-002 anonymous callers; SC-005 two new named administrator exceptions — FR-005 profile-scoped admin on shared-index fallback (D3) and FR-001 caller-kind-first (D5)) in `specs/105-agent-scope-hardening/spec.md`

---

Expand All @@ -24,26 +24,26 @@

### Failing tests (write first, confirm RED against the merge base)

- [ ] T004 [US2] FR009-G1: `setupQuarantineRuntime` manual trust, baseline `erase` approved, discover `[erase, ns:erase]` → expect `BlockedTools["ns:erase"]` and a pending record keyed `ns:erase`; full-tier `a`-only `call_tool_read a:ns:erase` → `TOOL_QUARANTINED`, zero upstream — `internal/runtime/tool_quarantine_identity_test.go` (new) + `internal/server/mcp_call_tool_target_tier_test.go`
- [ ] T005 [P] [US2] FR009-G2: `applyDifferentialToolUpdate` with `[erase, ns:erase]` → `GetToolsByServer` length 2; `SaveToolApproval(ns:erase, Disabled)` survives a rerun (HEAD deletes it) — `internal/runtime/lifecycle_identity_test.go` (new) + `internal/index/bleve_rawname_test.go` (new: distinct docIDs)
- [ ] T006 [P] [US2] FR009-G3: seed `{a, erase, pending}`, delete `ns:erase` record; full-tier ctx: `makeDirectModeHandler` entry `ns:erase` → IsError + zero upstream; `preflightApprovalReader.ToolApproval` → non-nil pending — `internal/server/mcp_direct_callability_test.go` + `internal/server/preflight_glue_test.go`
- [ ] T007 [P] [US2] FR009-G4: counting upstream `[erase]`; full-tier `a`-only AND admin `call_tool_read a:ghost` → `Permission denied`, zero upstream (D4: every caller; SC-005 named exception); sandbox `call_tool('a','ghost')` → `PERMISSION_DENIED` envelope; unknown-server branch unchanged (control) — `internal/server/mcp_call_tool_target_tier_test.go` + `internal/server/mcp_code_execution_scope_test.go` (new)
- [ ] T008 [P] [US2] FR009-G5: manual-trust server, quarantine on, StateView has `ns:erase`, storage has no record → refused; only `{a, erase, approved}` seeded → `a:ns:erase` refused; `quarantine_enabled=false` → unchanged — `internal/server/tool_gate_test.go`
- [ ] T009 [P] [US2] FR009-G6: generated tables at the spec shape (`spec.md:132`): retrieve **54 cells** = 3 permission sets × 3 target tiers × 3 `call_tool_*` variants × strict-intent on/off, pre-classified allowed / insufficient-permission / intent-mismatch; plus direct (permission set × target tier, driven through the registered handler in A — G's T104a re-drives the same table through `HandleMessage`, D15) and nested (via real sandbox, envelope asserted) tables; paired-name rows (`erase` approved, `ns:erase` config-denied via `disabled_tools` / unapproved); `{read,destructive}` rows; `auth.AdminContext()` control rows; counting oracle on every cell — `internal/server/scope_target_tier_matrix_test.go` (new)
- [ ] T010 [P] [US2] FR009-G7: regression cell `{read,destructive}` × write target refused on all three paths (passes today; pins the exact-match rule) — `internal/server/scope_target_tier_matrix_test.go`
- [x] T004 [US2] FR009-G1: `setupQuarantineRuntime` manual trust, baseline `erase` approved, discover `[erase, ns:erase]` → expect `BlockedTools["ns:erase"]` and a pending record keyed `ns:erase`; full-tier `a`-only `call_tool_read a:ns:erase` → `TOOL_QUARANTINED`, zero upstream — `internal/runtime/tool_quarantine_identity_test.go` (new) + `internal/server/mcp_call_tool_target_tier_test.go`
- [x] T005 [P] [US2] FR009-G2: `applyDifferentialToolUpdate` with `[erase, ns:erase]` → `GetToolsByServer` length 2; `SaveToolApproval(ns:erase, Disabled)` survives a rerun (HEAD deletes it) — `internal/runtime/lifecycle_identity_test.go` (new) + `internal/index/bleve_rawname_test.go` (new: distinct docIDs)
- [x] T006 [P] [US2] FR009-G3: seed `{a, erase, pending}`, delete `ns:erase` record; full-tier ctx: `makeDirectModeHandler` entry `ns:erase` → IsError + zero upstream; `preflightApprovalReader.ToolApproval` → non-nil pending — `internal/server/mcp_direct_callability_test.go` + `internal/server/preflight_glue_test.go`
- [x] T007 [P] [US2] FR009-G4: counting upstream `[erase]`; full-tier `a`-only AND admin `call_tool_read a:ghost` → `Permission denied`, zero upstream (D4: every caller; SC-005 named exception); sandbox `call_tool('a','ghost')` → `PERMISSION_DENIED` envelope; unknown-server branch unchanged (control) — `internal/server/mcp_call_tool_target_tier_test.go` + `internal/server/mcp_code_execution_scope_test.go` (new)
- [x] T008 [P] [US2] FR009-G5: manual-trust server, quarantine on, StateView has `ns:erase`, storage has no record → refused; only `{a, erase, approved}` seeded → `a:ns:erase` refused; `quarantine_enabled=false` → unchanged — `internal/server/tool_gate_test.go`
- [x] T009 [P] [US2] FR009-G6: generated tables at the spec shape (`spec.md:132`): retrieve **54 cells** = 3 permission sets × 3 target tiers × 3 `call_tool_*` variants × strict-intent on/off, pre-classified allowed / insufficient-permission / intent-mismatch; plus direct (permission set × target tier, driven through the registered handler in A — G's T104a re-drives the same table through `HandleMessage`, D15) and nested (via real sandbox, envelope asserted) tables; paired-name rows (`erase` approved, `ns:erase` config-denied via `disabled_tools` / unapproved); `{read,destructive}` rows; `auth.AdminContext()` control rows; counting oracle on every cell — `internal/server/scope_target_tier_matrix_test.go` (new)
- [x] T010 [P] [US2] FR009-G7: regression cell `{read,destructive}` × write target refused on all three paths (passes today; pins the exact-match rule) — `internal/server/scope_target_tier_matrix_test.go`

### Implementation

- [ ] T011 [US2] Add `RawName` to `config.ToolMetadata` with `CanonicalToolName`/`RawToolName` helpers in `internal/config/tool_identity.go` (new) and populate it from upstream tool names in `internal/upstream/core/client.go:394-397`
- [ ] T012 [US2] Producers use `RawName`: `checkToolApprovals` keys records by raw name (`internal/runtime/tool_quarantine.go:443-616,1216-1229`), `applyDifferentialToolUpdate`/`newToolsMap` stop collapsing and stop deleting exact-name records (`internal/runtime/lifecycle.go:673-790,975-980`); legacy collapsed record approves only its own raw name (one-shot, documented in code comment)
- [ ] T013 [US2] Bleve docID = `server:` + raw name (`internal/index/bleve.go:158-183,356-372,433`), `RawName` derived on read from the docID (not the stored `tool_name` / `full_tool_name`, which stay byte-identical scored fields — SC-005). **No index rebuild trigger** (adversarial review, 2026-09-14): the raw-keyed differential update self-heals a pre-upgrade collapsed doc on the first discovery (`TestApplyDifferentialToolUpdate_HealsPreUpgradeCollapsedDoc`), so a global wipe gated on the storage schema counter was pure cost and fired on every start
- [ ] T014 [US2] Reader: `lookupToolApproval` exact-wins + no-record-under-active-gate ⇒ pending (`internal/server/tool_gate.go:96-227`); direct callability and preflight read through it (`internal/server/mcp_direct_callability.go:136,192-201,253`, `internal/server/preflight_glue.go:400-411`); `ClassifyTool` consults the gate before returning Ready on nil approval (`internal/preflight/classify.go:98-100`)
- [ ] T015 [US2] Unresolved identity on a known server refuses **every** caller on retrieve (`internal/server/mcp.go:2283-2323`) and nested (`internal/server/mcp_code_execution.go:1251-1268` → explicit unresolved sentinel in the `ToolAnnotationFunc` contract, `internal/jsruntime/runtime.go:401-409` refuses on it); the unknown-server branch (`mcp_code_execution.go:987-997`) is untouched (D4); seed StateView in fixtures that register an upstream without one (`mcp_call_tool_trim_test.go:57`)
- [ ] T016 [US2] Docs: "Target tool tier" paragraph (exact-match permissions, annotation-less → read, unresolved → refused for scoped callers) replacing the "destructive implies both" claim in `docs/features/agent-tokens.md:140-150`
- [x] T011 [US2] Add `RawName` to `config.ToolMetadata` with `CanonicalToolName`/`RawToolName` helpers in `internal/config/tool_identity.go` (new) and populate it from upstream tool names in `internal/upstream/core/client.go:394-397`
- [x] T012 [US2] Producers use `RawName`: `checkToolApprovals` keys records by raw name (`internal/runtime/tool_quarantine.go:443-616,1216-1229`), `applyDifferentialToolUpdate`/`newToolsMap` stop collapsing and stop deleting exact-name records (`internal/runtime/lifecycle.go:673-790,975-980`); legacy collapsed record approves only its own raw name (one-shot, documented in code comment)
- [x] T013 [US2] Bleve docID = `server:` + raw name (`internal/index/bleve.go:158-183,356-372,433`), `RawName` derived on read from the docID (not the stored `tool_name` / `full_tool_name`, which stay byte-identical scored fields — SC-005). **No index rebuild trigger** (adversarial review, 2026-09-14): the raw-keyed differential update self-heals a pre-upgrade collapsed doc on the first discovery (`TestApplyDifferentialToolUpdate_HealsPreUpgradeCollapsedDoc`), so a global wipe gated on the storage schema counter was pure cost and fired on every start
- [x] T014 [US2] Reader: `lookupToolApproval` exact-wins + no-record-under-active-gate ⇒ pending (`internal/server/tool_gate.go:96-227`); direct callability and preflight read through it (`internal/server/mcp_direct_callability.go:136,192-201,253`, `internal/server/preflight_glue.go:400-411`); `ClassifyTool` consults the gate before returning Ready on nil approval (`internal/preflight/classify.go:98-100`)
- [x] T015 [US2] Unresolved identity on a known server refuses **every** caller on retrieve (`internal/server/mcp.go:2283-2323`) and nested (`internal/server/mcp_code_execution.go:1251-1268` → explicit unresolved sentinel in the `ToolAnnotationFunc` contract, `internal/jsruntime/runtime.go:401-409` refuses on it); the unknown-server branch (`mcp_code_execution.go:987-997`) is untouched (D4); seed StateView in fixtures that register an upstream without one (`mcp_call_tool_trim_test.go:57`)
- [x] T016 [US2] Docs: "Target tool tier" paragraph (exact-match permissions, annotation-less → read, unresolved → refused for scoped callers) replacing the "destructive implies both" claim in `docs/features/agent-tokens.md:140-150`

### Inverted pinned tests (gap-map §7)

- [ ] T017 [US2] Invert `internal/server/mcp_call_tool_target_tier_test.go:210-223,244-253,474` (ghost tool now refused for scoped callers); rebuild `internal/server/mcp_routing_test.go:306-447` direct cells on the real fixture with the counting oracle; resolve `extractToolName` callers in the three test files that still use it
- [x] T017 [US2] Invert `internal/server/mcp_call_tool_target_tier_test.go:210-223,244-253,474` (ghost tool now refused for scoped callers); rebuild `internal/server/mcp_routing_test.go:306-447` direct cells on the real fixture with the counting oracle; resolve `extractToolName` callers in the three test files that still use it

### Verification

Expand Down
Loading