feat(skills): runtime MCP-tool folding + reference-file disclosure + example seed (PR-6b)#468
Merged
Merged
Conversation
…example seed (PR-6b) Runtime increment after PR-6a (#467). All opt-in via agent_type="skill"; the default "chat" path is unchanged, so zero default behavior change. 1. Fold gateway/external MCP tools behind the meta-tools. PR-6a folded only LOCAL callables; gateway/external tools materialize as *client objects* (one MCPClient exposing many tools), so they were available but not hidden. New mechanism: - mcp_tool_folding.py: a per-client fold set; both FilteredMCPClient and UICapableMCPClient drop folded names from list_tools_sync (the seam Strands uses to build the model tool list). Setting a fold also invalidates the client's _loaded_tools cache so Strands re-lists with the fold applied (external clients are pre-flighted before folds are known). - mcp_binding.py: resolve_mcp_bindings maps each non-local bound id to its concrete MCP tool(s) + owning client (gateway via expand_gateway_tool_ids from the catalog, no session; external by enumerating the live client), wrapping each as a FoldedMCPTool. The client object stays in the agent's tool list (Strands keeps its session alive); skill_executor runs folded tools through MCPClient.call_tool_sync. - SkillAgent._bind_mcp_tools wires it after the existing local binding. 2. Read-reference-file progressive-disclosure level. skill_dispatcher's previously-unused `reference` arg now serves a skill's supporting reference files from the PR-4 S3 SkillResourceStore on demand; the no-arg dispatch response lists available filenames so the model knows what it can read (mirrors how a real SKILL.md body names its files). 3. Bootstrap example-skill seed. seed_example_skills seeds one demonstrable bundle: instructions + a bound local tool (fetch_url_content) + an S3-uploaded reference file, granted to the default role. Mirrors seed_default_tools; the skill-resources bucket name is derived in seed.sh from the project prefix. Spec: docs/specs/admin-skills-rbac-tool-binding.md (§0.5, §8, §12). Full backend suite: 3711 passed, 3 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 10, 2026
philmerrell
added a commit
that referenced
this pull request
Jun 10, 2026
Final increment of the admin-managed Skills feature. Stacked on PR-6b (#468). The server now defaults a user turn to the SkillAgent instead of ChatAgent (routes.DEFAULT_AGENT_TYPE = "skill"). A user with zero accessible skills gets a SkillAgent that degrades to plain ChatAgent behavior, so the flip is a no-op for them; a user whose roles grant a skill gets that skill's bound tools folded behind the two meta-tools (PR-6a/6b). Clients opt out per turn with agent_type="chat". Implementation: - routes.py: DEFAULT_AGENT_TYPE constant; the invocations() handler computes effective_agent_type = input_data.agent_type or DEFAULT_AGENT_TYPE and uses it for skill resolution + the three non-resume get_agent calls. - The resume get_agent call gates accessible_skill_ids on the snapshot's type (snapshot.agent_type == "skill"), so a turn explicitly built as "chat" that pauses on an interrupt rebuilds the SAME cache key on resume (empty skills_hash) instead of orphaning the paused agent. - service.get_agent keeps a conservative "chat" fallback for direct callers; the request-policy default lives in the route. SPA omits agent_type → gets "skill". toolTokens is measured post-deploy via the context-attribution contextBreakdown partition (folded bound tools drop out of the tools partition; the skill catalog moves into the system partition). Full backend suite: 3712 passed, 3 skipped (1 pre-existing flaky PBT test). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
colinmxs
added a commit
that referenced
this pull request
Jul 8, 2026
* Validate role mappings and shorten role-cache invalidation window
Adds two related guards on the role-management surface:
1. apis/shared/rbac/role_constraints.py validates jwt_role_mappings on
both create_role and update_role:
- Every entry must match ^[A-Za-z0-9_-]{2,64}$ (well-shaped IdP group
identifiers only — no whitespace, HTML, control characters, etc.).
- On a protected role (system_admin), entries that would map every
authenticated user — 'default', '*', 'user', 'users', 'everyone',
'anyone', 'authenticated', 'all', 'public', etc. — are rejected.
Violations raise RoleConstraintError (a ValueError subclass) which
the existing route handler converts to HTTP 400.
2. apis/shared/rbac/version.py exposes a process-wide monotonic
roles-version counter. AppRoleAdminService bumps it after every
role mutation, and AppRoleCache.invalidate_all() bumps it too. The
user-profile cache in apis/shared/auth/dependencies tags each entry
with the current version and treats entries from older versions as
stale on read. This eliminates the post-revert window where users
continued to see permissions from a recently-removed role until the
5-minute TTL elapsed.
Tests:
- tests/rbac/test_role_mutation_constraints.py (23 tests) covers
ubiquitous-mapping rejection on protected roles, format validation
including a parametrized set of malformed inputs, and confirms the
rule does not apply to non-protected roles.
- tests/rbac/test_role_version_invalidation.py (6 tests) covers
monotonicity, thread-safety under concurrent bumps, profile-cache
refresh after a bump, and confirms cache.invalidate_all + role
mutations both bump the version.
* Apply static AST policy to user-supplied diagram and analysis code
Both generate_diagram_and_validate and analyze_spreadsheet take a
python_code parameter and forward it to AWS Bedrock Code Interpreter.
The sandbox is isolated from the host, but legitimate use of these
tools is narrow: matplotlib / numpy / pandas / seaborn for charts and
dataframe operations. Anything beyond that surface area — subprocess,
os, sys, socket, eval/exec/compile, dunder attribute walks — is an
abuse signal regardless of intent.
apis/shared/security/python_ast_policy.py adds validate_diagram_code,
which parses the source with the standard ast module and walks the
tree:
- Imports must root in an allowlist of plotting / data-analysis
packages (matplotlib, numpy, pandas, seaborn, scipy, math,
statistics, json, datetime, random, collections, itertools,
functools, re, decimal, fractions, csv, io). Submodules of allowed
roots pass; anything else is rejected.
- Bare names matching the host-process escape set (subprocess, os,
sys, socket, shutil, pickle, ctypes, importlib, urllib, http,
requests, httpx, asyncio, threading, multiprocessing, signal,
pathlib, tempfile, etc.) are rejected wherever they appear, even
with no import statement (defeats __import__('os') and aliasing).
- Dunder attribute access (__class__, __bases__, __subclasses__,
__builtins__, __dict__, ...) is rejected so the standard 'walk the
type hierarchy' obfuscation cannot reach builtins.
- Empty source, syntax errors, and sources over 32 KiB are rejected
generically.
Both tools call the validator at the top of their implementation,
before any sandbox lookup, file resolution, or AWS API call. The check
runs server-side after the LLM emits the tool call, so a user-supplied
system_prompt cannot disable it. Rejections return a generic message
('Diagram code rejected by policy.' / 'Analysis code rejected by
policy.') with no detail; the structural reason is logged at WARN.
Tests:
- 56 unit tests in tests/security/test_python_ast_policy.py covering
every forbidden import, every forbidden bare name, dunder access
via attribute chains, obfuscation patterns (alias assignment,
string concatenation), edge cases (empty / whitespace / oversize /
syntax-error / non-string), and four realistic chart-code samples.
- 5 integration tests for the diagram tool confirm the sandbox is not
invoked when policy fails, and that valid chart code passes the
policy gate.
- 3 integration tests for the spreadsheet tool confirm policy fires
before file resolution, and that legitimate pandas analysis code
passes.
* feat(tools): add MCPGatewayConfig model for Gateway target registration (#419) (#450)
Adds the protocol=mcp ("MCP Gateway (AgentCore)") config layer so an admin can
register an externally deployed MCP server as a target on the centralized
AgentCore Gateway. This is the backend model foundation (PR1 of 5); the live
target lifecycle, infra grants, route orchestration, and form land in later PRs.
- GatewayListingMode (default|dynamic), GatewayCredentialType
(gateway_iam_role|oauth|api_key), and GatewayOAuthGrantType
(authorization_code|client_credentials|token_exchange) enums, mirroring the
bedrock-agentcore-control listingMode / credentialProviderType / grantType
surfaces (uppercased at the AWS boundary by the forthcoming
GatewayTargetService).
- MCPGatewayConfig (mirrors MCPServerConfig) with target_name, endpoint_url,
listing_mode, credential_type, credential_provider_arn, oauth_scopes,
grant_type, custom_parameters, per-tool MCPToolEntry flags, and AWS-assigned
target_id/gateway_arn (the catalog<->AWS link used by update/delete
reconciliation). Reuses MCPToolEntry serialization.
- OAuth grant_type defaults to authorization_code (3LO, on-behalf-of-user — the
flow AWS's June-2026 Gateway auth-code blueprint documents); custom_parameters
are carried because they are part of the AgentCore token-vault key and must
match between target registration and token retrieval.
- Co-gating validator: OAuth requires DEFAULT listing + a provider ARN; API key
requires a provider ARN; gateway IAM role forbids one. Encodes the listing-mode
gotcha (DYNAMIC disables 3LO + semantic search) at the model layer.
- ToolDefinition.mcp_gateway_config + to_dynamo_item/from_dynamo_item round-trip
under the mcpGatewayConfig key.
- MCPGatewayConfigRequest/Response (+ to_model/from_model) wired into
ToolCreateRequest, ToolUpdateRequest, and AdminToolResponse.
- 17 model/serialization/validator tests; existing tool-model consumers green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(infra): publish gateway id SSM param + app-api Gateway target IAM grants (#419) (#452)
PR3 of 5 for admin-managed AgentCore Gateway target registration. Wires the
infrastructure the app-api GatewayTargetService (PR #451) needs at runtime.
- AgentCoreGatewayConstruct publishes /{prefix}/gateway/id as an SSM parameter
(the construct header already claimed this but only emitted CFN outputs).
app-api reads it at runtime via ssm:GetParameter — never at CFN deploy time —
so there is no same-stack publish/consume ordering deadlock.
- app-api-iam-grants adds:
- SsmReadGatewayId: ssm:GetParameter on /{projectPrefix}/gateway/id.
- AgentCoreGatewayTargetAccess: bedrock-agentcore:{Create,Get,Update,Delete,
List}GatewayTarget scoped to the gateway resource type
(arn:...:bedrock-agentcore:region:account:gateway/*). Target operations are
authorized through the parent gateway ARN — there is no gateway-target
resource type.
- Action names verified against the control-plane API model and the IAM
service-authorization reference (2026-06-05). Create/Get/Delete/List are in the
reference; UpdateGatewayTarget is a real control-plane operation following the
Update* IAM precedent in this file (UpdateOauth2CredentialProvider) but was not
yet in the reference — included with a comment + fallback note (delete+recreate)
in case any account rejects the forward-looking name.
- Completes the placeholder gateway-SSM-params construct test.
Gate: tsc --noEmit clean; jest constructs/security-policy/ssm-safety/platform-stack
green (full stack synthesizes with the new statements; no Action:*/Resource:*
violations). Pre-existing, unrelated config.test.ts failures and the bare
`cdk synth` artifacts-cert lookup error reproduce on clean develop.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): Gateway target service + admin route lifecycle (#419) (#453)
Re-lands the GatewayTargetService that was stranded when #451 merged into its
stacked base instead of develop, and adds the admin route + ToolCatalogService
lifecycle for protocol=mcp Gateway target registration (create-AWS-first, update
reconcile, hard/soft delete, 409/502 route mapping). Backend for #419 is now
complete on develop; the admin form (Phase 5) remains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(admin): Gateway target admin form for protocol=mcp (#419) (#455)
Admin UI to register an externally deployed MCP server as an AgentCore Gateway target: protocol=mcp form section (target/endpoint/listing-mode/credential-type + conditional ARN/scopes/grant), per-tool approval rows with Discover-from-server, OAuth co-gating, and 502-vs-400 error handling. Completes the #419 feature on develop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): AGENTCORE_GATEWAY_ID env override for local/CI testing (#419) (#456)
GatewayTargetService resolves the gateway id from an explicit value, then AGENTCORE_GATEWAY_ID, then SSM /{PROJECT_PREFIX}/gateway/id — letting local/CI bypass the SSM lookup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Bind persisted roles to JWT in profile-sync handler (#458)
POST /users/me/sync now derives the persisted roles list exclusively
from current_user.roles (the validated JWT claims). Roles are already
populated server-side by the BFF callback's _sync_user_from_id_token
off the trusted ID-token decode, and per-request RBAC reads them from
the access token's verified claims plus the Users table that the
callback writes — the request body has no role to play in this path.
UserProfileSyncRequest no longer declares a 'roles' field. Pydantic's
extra='allow' setting keeps the endpoint backwards-compatible with
clients that still send extra keys (no 4xx; the extras surface via
model_extra and are dropped when building the persisted profile). The
handler logs a WARNING when it sees a legacy 'roles' key so stale
clients can be tracked down.
Tests:
- test_sync_persists_jwt_roles_not_body_roles: body roles do not
influence the persisted record.
- test_sync_with_no_jwt_roles_persists_empty_list: empty JWT roles
cannot be filled in by the body.
- test_sync_warns_when_legacy_roles_field_present: legacy field emits
the WARN log.
- Plus seven baseline tests covering email normalization, name and
picture pass-through, validation errors, auth requirement, and the
graceful no-op when the Users repository is disabled.
* feat(tools): Gateway MCP targets — self-service registration, agent wiring & health UX (#419) (#457)
* fix(tools): correct Gateway mcpServer credential config (None + IAM service) (#419)
CreateGatewayTarget rejected our GATEWAY_IAM_ROLE targets with
"IamCredentialProvider is required for mcpServer targets using IAM
authentication": unlike OpenAPI/Lambda targets (which accept a bare
GATEWAY_IAM_ROLE), an mcpServer (HTTP endpoint) target must supply an explicit
iamCredentialProvider naming the AWS service to sign for. We were sending
`[{credentialProviderType: GATEWAY_IAM_ROLE}]` with no nested provider.
- New credential type NONE (public endpoint) — omits credentialProviderConfigurations
entirely (the API parameter is optional). This is now the default (least-config
path; the admin opts into IAM/OAuth/API-key).
- GATEWAY_IAM_ROLE now carries aws_service (required) + optional aws_region, sent as
credentialProvider.iamCredentialProvider{service, region?}. Model validator
requires aws_service for IAM and the create/update calls omit the credential
block for NONE.
- Backend: MCPGatewayConfig + request/response models + GatewayTargetService.
- Frontend: 'none' credential option (default), AWS service/region fields shown for
the IAM case, build/restore wiring, and the discover auth mapping unchanged
(gateway_iam_role → aws-iam, else none).
Verified: full backend suite 3466 passed; tool-form gateway spec 6/6 (ng test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agents): point Gateway client at the infra-param gateway + expand #419 catalog tools (#419)
The agent resolved the gateway from a hardcoded `/strands-agent-chatbot/dev/...`
SSM param, while the admin form (#419) registered targets on the CDK gateway
`/{PROJECT_PREFIX}/gateway/id` — two different gateways, so admin-registered
tools never reached the agent.
- New shared `gateway_identity.resolve_gateway_id`/`gateway_url_from_id`: one
source of truth (explicit id → AGENTCORE_GATEWAY_ID override → SSM
`/{PROJECT_PREFIX}/gateway/id`). The agent's `get_gateway_url_from_ssm` and
GatewayTargetService both resolve through it, so they can't diverge.
- Expand a `protocol=mcp` catalog tool (`gateway_class_search`) into the
gateway's runtime per-tool ids (`gateway_<targetName>___<toolName>`) in
base_agent, so the FilteredMCPClient matches them; raw RBAC gateway ids
(already containing `___`) pass through unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): self-service Gateway MCP registration — per-target IAM grant, health, discovery UX (#419)
Make admins able to register any same-account Lambda-URL MCP server through the
form with no infra change, and surface the failures that were previously silent.
Per-target Lambda grant (replaces the gateway role's standing `mcp-*` wildcard):
- app-api grants the gateway role `lambda:InvokeFunctionUrl` on exactly the
registered function at registration (`AddPermission` naming the role as
Principal — a role-specific resource grant authorizes it same-account, no
identity grant) and revokes it on delete. `gateway_lambda_grant.py` +
GatewayTargetService lifecycle; new `MCPGatewayConfig.lambda_function_name`
and a conditional "Lambda function name" form field.
- Cross-account validation: `GetFunctionUrlConfig` confirms same-account + that
the endpoint is under the function URL; otherwise a 400 tells the admin to go
public or use a credential provider.
- CDK: gateway role loses its Lambda-invoke wildcard (logs only); app-api gains
scoped `AddPermission`/`RemovePermission`/`GetFunctionUrlConfig` + `GetGateway`.
Gateway target health: `GET /admin/tools/{id}/gateway-status` (+ statusReasons
on GatewayTargetInfo) and a Ready/Syncing/Failed/Missing badge on the tools
list, so a FAILED target sync is visible instead of only "the agent can't see
the tool".
Discovery + form polish: unwrap the upstream HTTP status from MCP discovery
errors (403 stays 403 with an actionable message; 401→400 to avoid the SPA
logout); auto-derive awsService/awsRegion from the endpoint and recommend IAM
when an AWS host is left on None.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(docs-site): add API Keys + Admin section to nav, flesh out Tools page (#459)
* docs(docs-site): add API Keys to Features and a new Admin section
Adds an API Keys page to the Features menu, and registers a new "Admin"
sidebar section (after Features) whose sub-menu mirrors the SPA admin
console — Cost Analytics, Quotas, Fine-Tuning, Models, Tools, Connectors,
Users, Roles, Auth Providers, User Menu Links, and Conversation Modes,
plus an Admin Overview landing page. Pages follow the existing Draft-stub
convention and are picked up via Starlight directory autogeneration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(docs-site): flesh out Tools page and shorten its menu label
Shortens the Features menu link to "Tools" via sidebar.label while keeping
the full "Tools and Multi-Protocol Architecture" page title. Replaces the
Draft stub with content covering the four tool protocols (Direct, AWS SDK,
MCP+SigV4, A2A), the intentionally-limited default tool set, and the
extension points that make it flexible by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(specs): add admin-managed Skills with RBAC + tool binding design plan (#460)
Design plan for an admin feature to author Skills (instruction bundles that
bind existing catalog tools) and make them available to user roles via RBAC,
leveraging the existing SkillAgent/SkillRegistry progressive-disclosure runtime.
Covers data model, persistence, admin API, RBAC extensions, cross-source tool
binding, cache correctness, frontend, forward-compat for user-shared skills,
and a 6-PR breakdown. Design only — no implementation.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): shared data layer for admin-managed skills (PR-1) (#461)
Foundational, no-behavior-change PR-1 of the admin-managed Skills feature
(docs/specs/admin-skills-rbac-tool-binding.md §4/§5/§11). Adds the
DynamoDB-backed Skill catalog as a parallel of the Tools catalog; nothing
consumes it yet (RBAC, admin API, frontend, runtime wiring are PR-2..PR-6).
- apis/shared/skills/models.py — SkillDefinition (mirrors ToolDefinition):
skill_id (^[a-z][a-z0-9_]{2,49}$), display_name, description, instructions,
bound_tool_ids, compose, status (active/draft/disabled), category,
forward-compat owner_id/visibility (ADMIN-scope in v1), computed
allowed_app_roles, audit fields; snake_case→camelCase to/from_dynamo_item.
- apis/shared/skills/repository.py — SkillCatalogRepository (mirrors
ToolCatalogRepository): reuses the app-roles table (PK=SKILL#{id},
SK=METADATA); get/list/create/update/soft_delete/exists/batch_get.
- apis/shared/skills/freshness.py — 10s-TTL per-skill freshness hash +
all-skill-ids snapshot + invalidate(skill_id).
- apis/shared/skills/__init__.py — public exports.
- infra: add SkillOwnerIndex GSI (GSI4PK=OWNER#{owner_id},
GSI4SK=SKILL#{skill_id}, PAY_PER_REQUEST) to the app-roles table now to
avoid a Phase-2 migration; unused in v1.
- tests: model round-trip + skill_id regex, repository CRUD (moto), freshness
cache; infra tables-detailed asserts the new GSI; conftest roles_table
gains GSI4 + a skill_repository fixture.
Import-boundary clean (apis.shared imports neither app_api nor inference_api).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): RBAC extension — grant skills to roles (PR-2) (#462)
PR-2 of the admin-managed Skills feature
(docs/specs/admin-skills-rbac-tool-binding.md §4.2/§5/§7/§11). Extends the
RBAC engine so skills are granted to AppRoles exactly like tools/models, and
adds the server-side SkillAccessService. No route/frontend/runtime wiring yet
(PR-3..PR-5), so no user-visible behavior change.
Mirrors the tool RBAC path throughout:
- rbac/models.py: EffectivePermissions.skills + AppRole.granted_skills +
UserEffectivePermissions.skills (trailing default); grantedSkills/skills on
the Create/Update/Response DTOs (defaulted → additive, non-breaking).
- rbac/admin_service.py: _compute_effective_permissions unions granted_skills
across inheritance; create_role threads it; new get_roles_granting_skill /
add_skill_to_role / remove_skill_from_role.
- rbac/service.py: _merge_permissions unions skills (with "*"); new
can_access_skill / get_accessible_skills.
- rbac/repository.py: write SKILL_GRANT#{id} reverse-lookup items reusing the
GSI2 keyspace with a disjoint SKILL# partition (no new GSI); get_roles_for_skill.
- rbac/seeder.py + scripts/seed_bootstrap_data.py: system_admin gets the skill
wildcard (granted_skills=["*"] + SKILL_GRANT#*); default stays gated ([]).
- app_api/admin/services/skill_access.py: SkillAccessService mirroring
ToolAccessService (no gateway_* passthrough — skills have no dynamic form).
Tests: skill resolution/wildcard/inheritance/reverse-lookup, access-service
filtering, repo round-trip + tool↔skill partition non-collision + stale-grant
cleanup, bootstrap SKILL_GRANT#* assertions, make_app_role factory extended.
Full suite: 3571 passed, 3 skipped. Import-boundary clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): admin API for skill authoring + role grants (PR-3) (#463)
PR-3 of the admin-managed Skills feature
(docs/specs/admin-skills-rbac-tool-binding.md §6/§11). Adds the /admin/skills
REST surface + SkillCatalogService, mirroring app_api/admin/tools/routes.py and
app_api/tools/service.py. Net-new admin-only routes; no frontend (PR-4) or
runtime (PR-5) consumer yet, so no behavior change to existing surfaces.
- apis/shared/skills/models.py: admin request/response DTOs mirroring the tool
equivalents (SkillCreate/UpdateRequest, SkillRoleAssignment, SkillRolesResponse,
Set/AddRemoveSkillRolesRequest, AdminSkillResponse + from_skill_definition,
AdminSkillListResponse).
- apis/shared/skills/repository.py: add hard delete_skill (PR-1 had only
soft_delete_skill; the ?hard= flag needs it).
- apis/app_api/skills/service.py (new): SkillCatalogService — CRUD,
_validate_bound_tools (batch-get vs the tool catalog; reject unknown/non-active),
allowedAppRoles hydration, bidirectional role sync onto granted_skills.
- apis/app_api/admin/skills/routes.py (new): /admin/skills CRUD + /roles
endpoints; ValueError->400, missing->404, freshness invalidation on writes.
- apis/app_api/admin/routes.py: register the subrouter under /admin.
Tests (moto-backed): service CRUD + bound-tool validation + role sync +
hydration; TestClient route tests for status codes, camelCase shape, bound-tool
400, and a /roles round-trip. One moto app-roles table backs SKILL#/TOOL#/ROLE#.
Full suite: 3606 passed, 3 skipped. Import-boundary clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(specs): re-scope admin Skills to reference material; defer scripts (#464)
Adds a §0 revision (2026-06-09) to the admin-managed Skills plan capturing the
direction agreed with the product owner:
- Three drivers, reordered: tool-hiding UX (primary) > token efficiency >
ecosystem-compatible authored expertise. Skills and assistants are distinct
primitives (skill = bound tools for an outcome; assistant = a knowledge-
grounded "Project"), not redundant.
- New skill bundle shape: SKILL.md instructions + supporting reference files
(read-only, progressive disclosure) + bound catalog tools.
- Scripts + code execution DEFERRED (out of scope) — removes the code-exec
risk; imports bring knowledge faithfully, behavior is re-expressed via bound
catalog tools.
- Import-prefill of a SKILL.md/folder/zip is now in scope (asset ingestion,
not live discovery; tool bindings stay manual).
- Re-sequenced remaining phasing: PR-4 reference-file data layer (S3-backed
resource store) -> PR-5 frontend incl. import -> PR-6 runtime incl. a
read-reference-file disclosure level + cross-source bind resolver ->
PR-7 default flip.
- Inline updates to non-goals, data model (resources manifest), persistence
(S3 skill-resources bucket), phasing, and risks. PR-1..3 marked merged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): S3-backed reference-file data layer for admin Skills (PR-4) (#465)
Adds the data + API layer for a skill's supporting reference files
(read-only markdown/resources for deep progressive disclosure). Bytes
live in a new content-addressed S3 bucket; the DynamoDB SkillDefinition
row carries only a lightweight `resources` manifest (400 KB item limit).
No runtime or frontend consumes them yet — zero behavior change.
- models: SkillResourceRef + resources manifest on SkillDefinition,
round-tripped through to/from_dynamo_item (camelCase, backward-compatible)
- shared: SkillResourceStore (content-hash keyed, dedupe, graceful
degradation), mirroring the artifacts/MCP-apps persistence patterns
- service: upload/list/read/delete resource CRUD with filename/size/count
validation, atomic manifest update, orphaned-object GC
- api: /admin/skills/{id}/resources endpoints (require_admin)
- cdk: SkillResourcesConstruct bucket, threaded via PlatformComputeRefs;
app-api read/write + inference-api read-only (PR-6 forward-compat)
- tests: model round-trip, moto store, TestClient endpoints, infra jest
Spec: docs/specs/admin-skills-rbac-tool-binding.md §0.5 (PR-4)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): admin frontend for authoring skills + reference files (PR-5) (#466)
Adds the admin/skills/ Angular feature, mirroring admin/tools/: list page,
create/edit form, bound-tool picker, role-grant dialog, and management of a
skill's S3-backed reference files (PR-4 data layer). Import-prefill ingests a
SKILL.md (frontmatter -> id/name/description, body -> instructions); tool
bindings are picked manually and scripts are out of scope.
- models: admin-skill.model.ts (AdminSkill, SkillResourceRef, request DTOs,
SKILL_STATUSES/CATEGORIES) + skill-import.util.ts (parse SKILL.md + slugify)
- service: AdminSkillService (resource()+signals CRUD, role sync, reference-file
list/upload/read/delete)
- pages: skill-list (filters, expandable rows, tool/ref-file/role badges,
role dialog, soft delete) + skill-form (reactive form, tool picker,
reference-file upload/view/delete + inline authoring, SKILL.md import)
- components: skill-role-dialog (copy of tool-role-dialog) + tool-picker-dialog
(multi-select active catalog tools)
- routes: skills / skills/new / skills/edit/:skillId; "Skills" admin nav link
- tests: skill-import.util.spec + admin-skill.service.spec (ng test)
No backend changes. Spec: docs/specs/admin-skills-rbac-tool-binding.md §0.5 (PR-5)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): runtime — DB-backed load + RBAC + skills_hash + local binding (PR-6a) (#467)
Opt-in (agent_type="skill"); the default "chat" path is unchanged.
First slice of the skills runtime wiring: admin-authored skills now load for
the right users, fold their local tools behind the meta-tools, and cache
correctly. Gateway/external MCP tool folding + the read-reference-file level
+ an example seed are deferred to PR-6b; the default flip to PR-7.
- registry: load_records() DB source (inline instructions + bound_tool_ids +
compose + resources), DB-aware load_instructions, all_bound_tool_ids,
bind_catalog_tools (resolve bound catalog ids -> live objects). File scan
unchanged.
- skill_tools: make_skill_tools(registry) — per-agent (dispatcher, executor)
closures, fixing a latent concurrency bug (module-global registry would
cross-pollute concurrent per-user skills). Module-level fns kept for dev.
- skill_agent: DB discovery + enabled_tools augmentation (skill-as-grant, all
sources) before super().__init__; bind/fold local bound tools; degrade to
ChatAgent at 0 skills; restore original enabled_tools in the construction
snapshot for resume cache parity.
- chat: route resolves accessible skills via shared AppRoleService
(get_accessible_skills, "*" -> get_all_skill_ids), threads them into every
get_agent call; get_agent adds skills_hash to _create_cache_key (digest of
accessible ids + their updated_at). Empty for chat -> key unchanged.
- tests: registry DB source/binding, make_skill_tools isolation, skills_hash
cache separation + chat path unaffected, _fetch_skill_records filtering.
Spec: docs/specs/admin-skills-rbac-tool-binding.md §0.5 (PR-6), §8
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): runtime MCP-tool folding + reference-file disclosure + example seed (PR-6b) (#468)
Runtime increment after PR-6a (#467). All opt-in via agent_type="skill";
the default "chat" path is unchanged, so zero default behavior change.
1. Fold gateway/external MCP tools behind the meta-tools.
PR-6a folded only LOCAL callables; gateway/external tools materialize as
*client objects* (one MCPClient exposing many tools), so they were
available but not hidden. New mechanism:
- mcp_tool_folding.py: a per-client fold set; both FilteredMCPClient and
UICapableMCPClient drop folded names from list_tools_sync (the seam
Strands uses to build the model tool list). Setting a fold also
invalidates the client's _loaded_tools cache so Strands re-lists with
the fold applied (external clients are pre-flighted before folds are
known).
- mcp_binding.py: resolve_mcp_bindings maps each non-local bound id to its
concrete MCP tool(s) + owning client (gateway via expand_gateway_tool_ids
from the catalog, no session; external by enumerating the live client),
wrapping each as a FoldedMCPTool. The client object stays in the agent's
tool list (Strands keeps its session alive); skill_executor runs folded
tools through MCPClient.call_tool_sync.
- SkillAgent._bind_mcp_tools wires it after the existing local binding.
2. Read-reference-file progressive-disclosure level.
skill_dispatcher's previously-unused `reference` arg now serves a skill's
supporting reference files from the PR-4 S3 SkillResourceStore on demand;
the no-arg dispatch response lists available filenames so the model knows
what it can read (mirrors how a real SKILL.md body names its files).
3. Bootstrap example-skill seed.
seed_example_skills seeds one demonstrable bundle: instructions + a bound
local tool (fetch_url_content) + an S3-uploaded reference file, granted to
the default role. Mirrors seed_default_tools; the skill-resources bucket
name is derived in seed.sh from the project prefix.
Spec: docs/specs/admin-skills-rbac-tool-binding.md (§0.5, §8, §12).
Full backend suite: 3711 passed, 3 skipped.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: per-tool MCP enablement (skills binding + model settings) (#469)
* feat(tools): scoped tool-id helpers + agent runtime resolution
A bare catalog tool id still means the whole MCP server; a scoped `toolId::name` selects one tool of it. Adds apis/shared/tools/scoped_ids.py as the single source of truth and resolves scoped ids at the runtime seam: the tool-filter classifies by base id, expand_gateway_tool_ids emits only the chosen gateway_<target>___<name>, and external MCP clients pass the SDK-native tool_filters allow-list (matched on the raw mcp tool name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): validate and persist per-tool selections
Skill bound_tool_ids and user tool preferences accept scoped ids: the base catalog tool must exist + be active, and (when the server has a curated list) the named tool must be one it exposes. GET /tools/ now surfaces each MCP server's tools via UserToolAccess.serverTools, each with its effective enabled state (scoped pref -> server-level pref -> catalog default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): live discover-tools endpoints for saved MCP servers
Adds POST /admin/tools/{id}/discover (admin, skills picker) and POST /tools/{id}/discover (session auth + RBAC, model settings) so per-tool selection works for servers whose tools aren't enumerated in the catalog. External servers are listed live (OAuth-3LO falls back to the curated list); gateway targets return the tools recorded at registration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(admin): per-tool selection in the skills tool picker
The Bind Tools dialog now expands an MCP server into its individual tools with per-tool checkboxes (parent shows all/partial/none), emitting scoped ids in boundToolIds; servers with no curated list get a live Discover tools action. Bound-tool chips render as "Server · tool". Adds the frontend scoped-tool-id helper mirroring the backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): per-tool toggles in model settings
An MCP server row in model settings expands into per-tool switches; toggling the server toggles all, toggling a tool switches the server to a subset. enabledToolIds emits scoped ids for a partial server and the bare id when all tools are on. Servers with no enumerated tools offer a live Discover tools action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): flip default agent_type to "skill" (PR-7) (#470)
Final increment of the admin-managed Skills feature. Stacked on PR-6b (#468).
The server now defaults a user turn to the SkillAgent instead of ChatAgent
(routes.DEFAULT_AGENT_TYPE = "skill"). A user with zero accessible skills gets
a SkillAgent that degrades to plain ChatAgent behavior, so the flip is a no-op
for them; a user whose roles grant a skill gets that skill's bound tools folded
behind the two meta-tools (PR-6a/6b). Clients opt out per turn with
agent_type="chat".
Implementation:
- routes.py: DEFAULT_AGENT_TYPE constant; the invocations() handler computes
effective_agent_type = input_data.agent_type or DEFAULT_AGENT_TYPE and uses it
for skill resolution + the three non-resume get_agent calls.
- The resume get_agent call gates accessible_skill_ids on the snapshot's type
(snapshot.agent_type == "skill"), so a turn explicitly built as "chat" that
pauses on an interrupt rebuilds the SAME cache key on resume (empty
skills_hash) instead of orphaning the paused agent.
- service.get_agent keeps a conservative "chat" fallback for direct callers; the
request-policy default lives in the route. SPA omits agent_type → gets "skill".
toolTokens is measured post-deploy via the context-attribution contextBreakdown
partition (folded bound tools drop out of the tools partition; the skill catalog
moves into the system partition).
Full backend suite: 3712 passed, 3 skipped (1 pre-existing flaky PBT test).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agent): re-enable Strands Bedrock auto prompt caching (#471)
Uncomment the cache_config block in ModelConfig.to_bedrock_config() so
caching_enabled=True now emits CacheConfig(strategy="auto"). Strands
places cache points per-model and no-ops with a warning for models that
don't support automatic caching, so this is safe to set unconditionally
on the Bedrock path.
The prior deferral was for the strands PR #1438 blocker (cachePoint
blocks colliding with non-PDF document attachments), resolved in
strands-agents 1.39.0 — we pin 1.40.0. Update the test that asserted the
key was omitted to assert CacheConfig(strategy="auto") is present.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(env): document missing backend env vars in .env.example (#472)
Cross-referenced the env vars set by the CDK constructs (per
tests/supply_chain/test_env_var_contract.py) against what the Python
code reads, and added the gap to backend/src/.env.example with
placeholder values and explanatory comments matching the existing style.
Added (17 vars):
- Core wiring: PROJECT_PREFIX, AGENTCORE_RUNTIME_WORKLOAD_NAME,
INFERENCE_API_URL, LOG_LEVEL
- AgentCore: MEMORY_ARN, AGENTCORE_MEMORY_TYPE, BROWSER_ID,
AGENTCORE_LOCAL_OAUTH_CALLBACK_URL
- DynamoDB: DYNAMODB_API_KEYS_TABLE_NAME,
DYNAMODB_USER_MENU_LINKS_TABLE_NAME
- New "Admin-managed Skills" section: S3_SKILL_RESOURCES_BUCKET_NAME
- New "Fine-tuning" section: FINE_TUNING_ENABLED,
DYNAMODB_FINE_TUNING_ACCESS_TABLE_NAME,
DYNAMODB_FINE_TUNING_JOBS_TABLE_NAME, S3_FINE_TUNING_BUCKET_NAME,
FINE_TUNING_DEFAULT_QUOTA_HOURS, SAGEMAKER_EXECUTION_ROLE_ARN,
SAGEMAKER_SECURITY_GROUP_ID, SAGEMAKER_SUBNET_IDS
Intentionally excluded: render-Lambda-internal aliases (ARTIFACTS_BUCKET,
ARTIFACTS_TABLE, RENDER_TOKEN_SECRET_ARN, CSP_SCRIPT_SRC,
FRAME_ANCESTOR_ORIGIN), the AWS-injected AWS_DEFAULT_REGION, and legacy
local-only cruft that nothing reads.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): platform chat-mode settings foundation (skills-mode PR-1) (#473)
Adds the admin-managed chat-mode policy that the skills-mode feature
(docs/specs/skills-mode.md) builds on: which agent mode (skill/chat) new
conversations default to, and whether users may toggle between modes.
- New apis/shared/platform_settings/ domain — ChatModeSettings stored as
a SYSTEM_SETTINGS#chat-mode sentinel item in the auth-providers table
(the existing first-boot convention; zero CDK changes since both
app-api and the inference runtime already have the table env + IAM),
with a 60s TTL-cached service for the per-turn inference read.
- GET/PUT /admin/settings/chat (require_admin) with audit stamping.
- GET /system/chat-settings — SPA-facing policy read (session auth).
- Defaults reproduce current behavior (skill default, toggling allowed),
so an unconfigured environment sees no change. Nothing consumes the
policy yet — enforcement lands in PR-2.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* add time info to user messages on hover
* feat(skills): user skills surface + chat-mode policy enforcement (skills-mode PR-2) (#474)
Backend half of skills mode (docs/specs/skills-mode.md): users get a
listable, per-skill-toggleable view of their RBAC-granted skills, and the
admin chat-mode policy from PR-1 (#473) is now enforced on /invocations.
- New GET /skills/ + PUT /skills/preferences (session auth) — ACTIVE
RBAC-accessible skills with per-user prefs merged, mirroring the tools
endpoints. Prefs stored as USER#{id}/SKILL_PREFERENCES in the skills
table (UserSkillPreference, mirrors UserToolPreference).
- Shared resolver apis/shared/skills/access.py — single source of truth
for "which skills does this user get" used by both app-api and the
inference path (the routes helper stays as a thin test seam).
- InvocationRequest.enabled_skills — None = all accessible (back-compat);
a list is intersected server-side with the RBAC set (narrow, never
grant); the effective set feeds SkillAgent AND the skills_hash cache
key, so toggle changes can't cross-pollute cached agents.
- Chat-mode policy enforcement: effective agent_type now resolves through
the admin settings (TTL-cached). When toggling is disabled the client's
skill/chat choice is overridden server-side; voice and other internal
types bypass the policy. DEFAULT_AGENT_TYPE is now aliased to the
policy model's compiled-in default so they can't drift.
- PausedTurnSnapshot.enabled_skills — paused skill turns persist their
effective skill set so resume rebuilds the same cache key even if the
user toggles skills mid-pause; legacy snapshots fall back to
request-time resolution.
Full backend suite: 3808 passed, 3 skipped.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(kaizen): weekly research scan 2026-06-12 (#475)
Generated by the kaizen-research skill. Top 5 ideas appended to
docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(spa): skills mode UX — mode toggle, skills picker, request wiring (skills-mode PR-3) (#476)
* feat(spa): skills mode UX — mode toggle, skills picker, request wiring (skills-mode PR-3)
The user-facing half of skills mode (docs/specs/skills-mode.md), on top of
the #474 backend:
- New SkillService (mirrors ToolService): loads GET /skills/, per-skill
optimistic toggles persisted via PUT /skills/preferences.
- New ChatModeService: admin policy from GET /system/chat-settings
(compiled-in default on failure), effective mode precedence =
policy lock > local selection > user preferred mode > admin default.
Toggling persists preferredAgentMode (user settings) and the session's
agentType so a conversation reopens in the mode it was using.
- Model-settings panel: Skills/Tools segmented control (hidden when the
admin disallows toggling), Skills toggle section (visual twin of the
Tools section, with empty state) in skills mode, Tools section in
tools mode.
- Chat request: sends agent_type each turn; skills mode sends
enabled_skills and enabled_tools=[] (capabilities come from skills);
assistant turns are excluded and keep pre-skills-mode behavior.
- Session page hydrates the stored mode from session preferences,
mirroring the lastModel pattern.
- Backend (additive): SessionPreferences.agent_type (validated to
skill|chat, merge-safe in the metadata PUT) and user settings
preferredAgentMode.
Frontend: 1215 tests green (ng test) + production build clean.
Backend: full suite green (two pre-existing PBT tests flaked under
load in an unrelated area — session-manager truncation — and pass
clean in isolation and on file re-run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(spa): silence the error dialog for the mode toggle's best-effort persist
Toggling skills/tools mode persists preferredAgentMode via
PUT /users/me/settings, which fails loud (503, #161 behavior) when
user-settings storage isn't configured. The toggle already applied
in-memory, so the background persist now opts out of the global error
toast via the existing SUPPRESS_ERROR_TOAST context token; explicit
settings-page saves stay loud. Failure still logs to console.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): raise OAuth consent interrupt for skill-folded external MCP tools (#477)
In skills mode a skill-bound external MCP tool executes through the
skill_executor meta-tool, so OAuthConsentHook's provider_lookup (keyed on
the selected tool being an MCPAgentTool) resolved nothing and the consent
gate silently never fired: the tool ran tokenless, returned an auth error
as text, and the model apologized instead of the turn pausing with an
oauth_required interrupt.
- OAuthConsentHook: optional tool_use_provider_lookup second-chance
resolver (gate + 401-retry handler) consulted when provider_lookup
returns None
- mcp_binding: make_folded_tool_provider_lookup maps the executor's
tool_use input (skill_name/tool_name) -> bound FoldedMCPTool -> owning
client -> provider id; gateway clients resolve to None (SigV4, not
user OAuth)
- FoldedMCPTool.invoke now returns a ToolResult-shaped error on failure
so the error status survives the fold and the consent hook's 401-retry
heuristic (gated on status == "error") can fire for folded tools
- SkillAgent wires the resolver over its registry via the new
BaseAgent._build_tool_use_provider_lookup hook point (None for chat)
The resume path is unchanged: the interrupt is raised on the
skill_executor call itself, so consent -> interrupt_responses -> cache
warm -> the client's lazy token provider picks up the token.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): gate skill-folded external MCP tools behind the approval prompt (#478)
An admin's needs_approval=True flag was silently bypassed in skills mode:
the flagged tool runs behind the skill_executor meta-tool, so the approval
hook's selected_tool lookup resolved nothing and the tool_use name never
matched. Mirror the OAuth consent fold fix — an optional tool_use-based
second-chance lookup resolves the folded target via the SkillRegistry,
checks it against the owning client's needs_approval set, and raises the
same tool_approval_required interrupt with the inner tool's name + args so
the dialog describes the real tool, not the executor.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: add Amazon Bedrock Mantle as a model provider (#479)
* feat(models): add Bedrock Mantle as a model provider
Mantle is AWS's OpenAI-compatible inference surface for Bedrock-hosted
open-weight models (qwen, gpt-oss, gemma, deepseek, ...). It is a distinct
provider from `bedrock` because it speaks the OpenAI wire protocol with a
short-term bearer token rather than the Converse API with SigV4.
What this adds:
- apis/shared/bedrock/bearer_token.py: SigV4-presigned `CallWithBearerToken`
bearer token (ported inline from aws-bedrock-token-generator, no new dep)
plus region+path base-URL helper.
- GET /admin/mantle/models: browse the live regional Mantle roster via the
OpenAI SDK (seeds the escape-hatch form's model-id suggestions).
- ModelProvider.MANTLE end to end: model_config translation, agent_factory
builds a Strands OpenAIModel against the Mantle base URL, BaseAgent +
inference chat pipeline thread the value through.
- mantle_endpoint_path on the managed-model shape, persisted and resolved
server-authoritatively in _resolve_model_settings. Mantle serves different
models on different paths (/v1 vs /openai/v1) and exposes NO API to discover
which, so the path is recorded per model (from the model card) rather than
probed or mapped. Carried through the paused-turn snapshot so a resumed
Mantle turn rebuilds the same base URL.
Caching is intentionally left off for Mantle: prompt caching on Bedrock is
model-bound to Anthropic Claude + a few Amazon Nova models, none of which run
through the Mantle provider.
Requires `bedrock-mantle:*` IAM (separate infra commit) and Mantle being
enabled for the account/region.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(infra): grant bedrock-mantle IAM for Mantle browse + inference
Bedrock Mantle has its own IAM service namespace — `bedrock-mantle:*`, NOT
`bedrock:*`. Mirror the AWS-managed AmazonBedrockMantleInferenceAccess policy:
- AgentCore runtime role: `bedrock-mantle:CreateInference` + `Get*`/`List*`
on `project/*`, plus `bedrock-mantle:CallWithBearerToken` (mantle-provider
inference).
- App-API task role: read-only `Get*`/`List*` + `CallWithBearerToken` for the
GET /admin/mantle/models browse endpoint.
The token signer is authorized against this namespace; without it inference
returns an IAM denial even when Mantle is enabled for the account. Integration
test asserts both roles carry CallWithBearerToken and the runtime carries
CreateInference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(spa): curated Bedrock Mantle catalog + endpoint-path form
Add a "Bedrock Mantle" tab to the admin model catalog, mirroring the Bedrock
tab: curated cards with vetted, human-reviewed settings (pricing, modalities,
context, and the all-important endpoint path baked in). No probing, no magic.
- CURATED_MANTLE_MODELS seeded with Qwen3 Coder 30B (/v1) and Gemma 4 31B
(/openai/v1). Pricing verified against the AWS Bedrock pricing page (2026-06);
modalities/capabilities/context/path verified against each model card
(Gemma 4: text+image+video in, text out, reasoning + tool use + vision).
- Model shape gains `mantleEndpointPath`; the model form becomes Mantle-aware:
a /v1 vs /openai/v1 selector (with model-card guidance), caching controls
hidden (Mantle open-weight models don't cache), and a model-id datalist
seeded from the live GET /admin/mantle/models roster for off-catalog adds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Bind persisted email to the validated session in profile-sync handler
POST /users/me/sync now derives the persisted email from
current_user.email (the JWT-bound value populated by the BFF callback
at login time) instead of the request body. Display fields (name,
picture) still come from the body; the field set on
UserProfileSyncRequest is reduced to those plus provider_sub.
Pydantic's extra='allow' setting keeps the endpoint backwards-
compatible with clients that still send legacy keys ('roles', 'email')
— they surface via model_extra and are dropped when building the
persisted profile. The handler logs a single WARNING listing every
legacy field it observed so stale clients can be tracked down.
A session whose JWT has no email claim now returns 422 rather than
persisting an empty row.
Tests:
- test_sync_persists_jwt_email_not_body_email: a body-supplied email
does not influence the persisted record.
- test_sync_email_domain_derived_from_jwt: the derived email_domain
also tracks the JWT, not the body.
- test_sync_jwt_email_normalized_to_lowercase: persisted email is
lowercased regardless of the JWT casing.
- test_sync_missing_jwt_email_returns_422: missing JWT email refuses
the upsert.
- test_sync_warns_when_legacy_fields_present: combined warn covers
both 'roles' and 'email' in body.
- test_share_access_email_match (7 tests): locks in that
ShareService._check_access compares the share's allowed_emails list
against requester.email only — case-insensitive, with owner override
and access_level=public/specific semantics covered.
* Wrap user-supplied system prompts with a platform safety floor
SystemPromptBuilder.from_user_prompt now assembles every prompt as:
PLATFORM_SAFETY_FLOOR
<user_instructions>
<user-supplied text>
</user_instructions>
The floor states that text inside the user_instructions tag is advisory,
that tool-input policies are enforced server-side and cannot be coerced,
that the code-execution surface accepts only chart / dataframe code, and
that identity claims come from the validated session rather than the
prompt. Embedded user_instructions tags in the user portion are stripped
before assembly so a caller cannot close the wrapper and write text below
the floor.
User-supplied prompts are bounded:
- SystemPromptBuilder truncates the user portion to MAX_USER_PROMPT_LENGTH
(8 KiB) before assembly.
- InvocationRequest.system_prompt rejects payloads larger than
MAX_USER_SYSTEM_PROMPT_CHARS (also 8 KiB) at the API boundary so
oversized inputs surface as a 4xx instead of being silently truncated.
The diagram-tool input policy added in apis/shared/security/python_ast_policy.py
remains the authoritative enforcement against arbitrary code execution;
this branch is structural defense-in-depth at the prompt boundary.
Tests:
- test_system_prompt_safety_floor.py (10 new tests) covers floor
precedence over user portion, behaviour for empty/None user prompts,
resistance to wrapper-tag injection (closing or opening), length
truncation, exact-limit pass-through, and the API request boundary's
oversize rejection / valid-size acceptance / None-allowed contracts.
- test_system_prompt_builder.py existing TestFromUserPrompt updated
from 'user prompt becomes the entire base prompt unchanged' to
'assembled prompt starts with the floor and contains the user text
inside the wrapper tags'.
* Route fetch_url_content through the shared URL validator
The fetch_url_content tool now runs every URL through the
apis.shared.security.url_validator helper before opening any HTTP
connection. The validator rejects loopback, link-local, RFC1918, ULA,
multicast, reserved, unspecified, CGNAT, and cloud metadata-service
addresses, and resolves every DNS answer to defeat host-name
rebinding to a private result.
Redirect handling is now manual. The httpx client is constructed with
follow_redirects=False; the tool walks the redirect chain itself,
applying the same validator to each Location target before re-fetching.
Up to three redirects are followed; a 3xx without a Location header
falls through to the standard response path. Redirect targets that
fail the validator surface a generic 'Redirect target is not
permitted.' error to the caller.
Tests:
- 8-case parametrized rejection sweep covering 169.254.169.254,
fd00:ec2::254, 127.0.0.1, RFC1918 ranges, [::1], and 0.0.0.0,
asserting that AsyncClient is never constructed when validation
fails (ergo no network I/O).
- DNS-resolution rebinding: a host name whose only A record is private
is rejected with no client construction.
- Disallowed scheme (file://) rejected without network I/O.
- Generic error contract: rejection message contains no resolved
address or 'metadata' label.
- Public URL passes the validator and reaches the network layer.
- Redirect handling: the constructed client must use
follow_redirects=False, and a 302 to 169.254.169.254 results in
exactly one outbound request (the original) — the tool never
follows to the metadata IP.
* Reject session-metadata PUT when session id is owned by another user
The PUT /sessions/{session_id}/metadata handler now consults a new
session_exists_for_other_user() helper before taking the
'session-not-found-for-this-user → create new' branch. The helper
queries the SessionLookupIndex GSI directly (without filtering on
userId) and reports whether a row exists for the given session id
under any user. When that returns True for a non-owner, the PUT
returns 404 — matching GET's behaviour and avoiding an enumeration
oracle.
The existing per-user fetch (get_session_metadata) already returns
None for non-owners because of how its DynamoDB query is partitioned;
the new helper closes the gap that None has two distinct meanings:
genuinely empty vs. taken-by-someone-else.
Tests:
- TestUpdateSessionMetadataOwnership (4 tests):
- 404 when the session id is taken under a different user, and the
write helper is never called.
- The existence-check verdict alone is enough to block the write
even when the per-user fetch returned None.
- Genuinely fresh session ids still create normally.
- Owner updates skip the existence check entirely (the per-user
fetch already returned the record).
- Two existing 'create-new' tests updated to mock the new helper.
* Scope SigV4 signing on outbound MCP requests to recognized AWS endpoints
detect_aws_service_from_url now returns Optional[str]: a recognized
service name for Lambda Function URLs, API Gateway, and AgentCore
Gateway hosts, and None for everything else. The previous fallback to
'lambda' for unrecognized hosts meant the SigV4 signer would attach
task IAM credentials (Authorization: AWS4-HMAC-SHA256 ... +
X-Amz-Security-Token) to outbound requests destined for arbitrary
hosts.
create_external_mcp_client treats the None return as a refusal: when
auth_type=aws-iam and the server URL doesn't match a known AWS
service, the function returns None instead of constructing a
SigV4-wired client. The /admin/tools/discover route already maps a
None client to a 400 with a generic message, so the discovery
endpoint's contract for arbitrary-URL admin discovery still holds for
non-signing auth modes (auth_type=none / oauth2 / etc.) — only the
credential-bearing AWS IAM path is scoped down.
Tests:
- test_mcp_sigv4_scope.py (14 tests):
- 4 service-detection cases for legitimate AWS hostnames
(Lambda Function URL, API Gateway, AgentCore Gateway).
- 7 None-return cases for non-AWS hosts including hostname
look-alikes (amazonaws.com.attacker.com, lambda-url.fake.com,
lambda-url-lookalike.example.com).
- aws-iam against a non-AWS URL refuses client construction.
- aws-iam against an AWS URL constructs normally.
- Non-signing auth modes (auth_type=none) against arbitrary URLs
still construct, since no credentials are at stake.
- Existing test_defaults_to_lambda_for_unknown_url replaced with
test_returns_none_for_unknown_url to reflect the new contract.
* fix(backup/restore): include S3 Vectors index in the snapshot loop (#481)
The assistants RAG knowledge base is backed by an AWS::S3Vectors::Index
('rag-vector-index-v1') that lives in a separate AWS service from
regular S3 — the s3vectors boto3 client / AWS::S3Vectors::* CFN types.
'aws s3 sync' cannot reach it, list_objects_v2 doesn't see it, and
nothing in backup.py or restore.py was touching it. The result: after
teardown -> redeploy -> restore, the new vector index was empty, every
assistant's knowledge base appeared connected (DDB document metadata
restored, S3 originals restored) but every retrieval call returned
zero hits because no vectors existed for any assistant_id.
This change closes that gap with the same 'snapshot and replay' model
the rest of the restore tool uses.
backup.py
- new VECTOR_INDEXES list (parallel to S3_BUCKETS)
- new backup_vector_index() that paginates s3vectors.list_vectors
with returnData=True + returnMetadata=True and streams every
record to vectors/{logical}.jsonl.gz in the backup bucket. The
line format ({key, data, metadata}) is byte-compatible with
s3vectors.put_vectors on restore.
- run() iterates VECTOR_INDEXES after the regular S3 buckets pass
restore.py
- new VECTOR_INDEXES constant mirroring backup.py
- new restore_vector_index() that reads the gzipped JSONL, batches
records 50-at-a-time (matching bedrock_embeddings.store_embeddings
_in_s3 BATCH_SIZE), and calls s3vectors.put_vectors. Idempotent
on re-run (put_vectors with same key is upsert), skips cleanly on
older backups that pre-date the vectors snapshot, and skips
cleanly on target prefixes where RAG is disabled.
- run_restore() runs the vector restore step after the S3 buckets
pass and before the AgentCore Memory replay
tests
- tests/supply_chain/test_backup_coverage.py adds
TestBackupCoversVectorIndexes — 5 assertions that scan the CDK
constructs for AWS::S3Vectors::Index resources and verify backup.py
declares them, calls list_vectors with returnData/returnMetadata,
and restore.py both defines AND wires in restore_vector_index.
Same canary pattern that already covers DynamoDB tables and
regular S3 buckets.
- tests/supply_chain/test_backup_coverage.py: extract_backup_tables
is now section-scoped so its 'logical' regex stops slurping
entries from S3_BUCKETS or the new VECTOR_INDEXES. Drive-by fix.
- scripts/restore-data/test_restore.py adds 7 behavioral tests for
restore_vector_index covering: 1:1 round-trip with batch-of-50
flushing, missing backup file skip, missing target SSM skip,
dry-run, idempotent re-run, unknown logical name skip, and the
rag-vectors logical-name pin.
All 48/48 restore-data tests pass; all 5/5 new vector coverage tests
pass. The 3 pre-existing supply-chain failures (system-prompts table
and skill-resources bucket from prior feature PRs) are untouched and
unrelated.
NOTE: the existing affected forker still has an empty vector index
post-restore. This PR does not include a one-off recovery script —
that's intentionally scoped as a separate task per the diagnosis
discussion.
Co-authored-by: Colin <colin@boisestate.edu>
* feat(docs): add full-screen maintenance page (#482)
Adds a standalone, directly-navigable maintenance splash at
/maintenance for use during migration downtime. It lives in
src/pages/ (outside Starlight's content collection), so it never
appears in the sidebar/nav and renders without Starlight chrome —
reachable only by navigating to the URL directly.
Self-contained markup + styles (works even if nothing else loads)
matching the docs/SPA house style: Boise blue, frosted glass,
lava-lamp blob field, graph-paper grid, Bricolage display type, a
pulsing Bronco-orange status orb, and an indeterminate progress
shimmer. Responsive, honors prefers-reduced-motion, and carries
noindex/nofollow so crawlers skip it.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(docs): remove action buttons from maintenance page (#483)
The maintenance splash (added in #482) is purely informational, so
the "Try again" reload button and "Status & updates" link add no
real value on a static page. Drop both buttons along with the now-
unused click handler script and the .actions/.btn CSS, leaving the
status pill, headline, message, progress shimmer, and sign-off.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Tighten admin-route input handling and data-layer ownership edges (#484)
A handful of related cleanups across admin and data-edge routes that
share a common shape: each one either accepted ill-formed input that
went on to crash a downstream call, leaked upstream error detail in
500 bodies, or didn't verify ownership before mutating shared records.
Admin/Bedrock model listing
GET /admin/bedrock/models filter parameters now use Pydantic
Literal/regex types so out-of-shape values are refused at the
request boundary with a generic 422 (no input echo, no enum-set
reflection). The route's bespoke ClientError/BotoCoreError/Exception
handlers are removed in favour of the app-wide
register_aws_client_error_handler installed at startup, which maps
ValidationException-class codes to a generic 400 and other
ClientErrors to a generic 502. A new register_validation_error_handler
replaces FastAPI's default 422 body with the same generic body.
Memory-record delete
DELETE /memory/{record_id} now fetches the record's metadata via
GetMemoryRecord and verifies the calling user appears in the
/actors/{user_id} segment of the record's namespace before issuing
batch_delete_memory_records. Non-owners (and missing records) get a
generic 404. Two new helpers — get_memory_record_owner and
record_namespace_owner — encapsulate the lookup and namespace parse.
OIDC discovery + auth-provider connectivity tests
POST /admin/auth-providers/discover now runs the supplied issuer URL
through the SSRF validator (https-only, no loopback / link-local /
private / metadata addresses) before fetching .well-known/openid-
configuration. POST /admin/auth-providers/{id}/test now applies the
same validator to each stored URL — jwks_uri, token_endpoint, and
the issuer-derived discovery URL — independently; a URL that fails
validation is reported unreachable in the per-endpoint result map
and is not contacted.
Files pagination cursor
GET /files now refuses cursors whose embedded PK doesn't match the
caller's USER#{user_id} partition. The repository raises a new
InvalidCursorError; the route maps that to a generic 400 instead of
the previous 500 the cross-partition DynamoDB query produced.
Tests:
- test_admin_bedrock_models.py (16) — Pydantic rejection of bad
enum/regex filters short-circuits before any boto3 call; AWS
ValidationException maps to 400 with no message reflection; other
ClientError maps to 502 generic; legitimate filters reach AWS;
unhandled exception → 500 with no exception detail in body.
- test_memory_delete_ownership.py (4) — owner can delete; non-owner
gets 404 and no AWS call; record without namespaces treated as not
owned; missing record → 404 with no AWS call.
- test_auth_providers_ssrf.py (12) — discover refuses 8 disallowed
issuer shapes (loopback, link-local, RFC1918, IPv6 loopback, http://,
file://, javascript:); accepts a legitimate https issuer; test
endpoint skips disallowed jwks_uri / token_endpoint / issuer URLs
individually, marking them unreachable without contacting them.
- test_files_cursor_validation.py (5) — cursor with another user's
PK rejected at repository, garbage cursor rejected, cursor without
PK rejected, owner's own cursor still works, route maps the error
to 400.
- Two existing auth_providers tests updated to mock DNS through the
validator's getaddrinfo so their fake hostnames continue to work.
* ci(artifacts): plumb CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS through deploy workflows (#485)
The artifacts iframe CSP frame-ancestors list is built from the deployed
SPA origin plus config.artifacts.extraFrameAncestors (fed by
CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS), and is enforced on both the
artifacts CloudFront response-headers-policy and the render Lambda's
FRAME_ANCESTOR_ORIGIN. But neither platform.yml nor
nightly-deploy-pipeline.yml passed that var through, so there was no way
to allow a local SPA (http://localhost:4200) to embed artifacts served by
a deployed dev distribution — the iframe fails with "refused to connect".
Mirror the existing CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS passthrough.
Unset by default (config falls back to []), so prod is a no-op.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Sanitize admin error paths and pin viewer-facing TLS to 1.2+ baseline
Three small, independent cleanups:
Fine-tuning jobs listing — tolerant deserialization
GET /admin/fine-tuning/jobs now serializes records one at a time;
any record that fails JobResponse validation is dropped with a WARN
log naming the offending job_id, and the listing returns 200 with
the well-formed records. Repository-level failures (the table
unreachable, etc.) still surface as 500 — only per-record
validation drops are tolerated.
External model listing — generic 503 on missing credentials
GET /admin/gemini/models and GET /admin/openai/models now respond
with 503 'External model provider not configured.' when the
required API key environment variable is unset. The specific env
var name (GOOGLE_API_KEY / GOOGLE_GEMINI_API_KEY / OPENAI_API_KEY)
is logged server-side at ERROR for operator diagnostics, never
echoed to the response body.
Transport security baseline
CloudFront SPA distribution now pins MinimumProtocolVersion to
TLSv1.2_2021 when serving a custom domain — drops TLS 1.0/1.1
entirely and prunes the cipher set to AEAD-only.
ALB HTTPS listener now pins SslP…
colinmxs
added a commit
that referenced
this pull request
Jul 9, 2026
* feat(skills): runtime MCP-tool folding + reference-file disclosure + example seed (PR-6b) (#468)
Runtime increment after PR-6a (#467). All opt-in via agent_type="skill";
the default "chat" path is unchanged, so zero default behavior change.
1. Fold gateway/external MCP tools behind the meta-tools.
PR-6a folded only LOCAL callables; gateway/external tools materialize as
*client objects* (one MCPClient exposing many tools), so they were
available but not hidden. New mechanism:
- mcp_tool_folding.py: a per-client fold set; both FilteredMCPClient and
UICapableMCPClient drop folded names from list_tools_sync (the seam
Strands uses to build the model tool list). Setting a fold also
invalidates the client's _loaded_tools cache so Strands re-lists with
the fold applied (external clients are pre-flighted before folds are
known).
- mcp_binding.py: resolve_mcp_bindings maps each non-local bound id to its
concrete MCP tool(s) + owning client (gateway via expand_gateway_tool_ids
from the catalog, no session; external by enumerating the live client),
wrapping each as a FoldedMCPTool. The client object stays in the agent's
tool list (Strands keeps its session alive); skill_executor runs folded
tools through MCPClient.call_tool_sync.
- SkillAgent._bind_mcp_tools wires it after the existing local binding.
2. Read-reference-file progressive-disclosure level.
skill_dispatcher's previously-unused `reference` arg now serves a skill's
supporting reference files from the PR-4 S3 SkillResourceStore on demand;
the no-arg dispatch response lists available filenames so the model knows
what it can read (mirrors how a real SKILL.md body names its files).
3. Bootstrap example-skill seed.
seed_example_skills seeds one demonstrable bundle: instructions + a bound
local tool (fetch_url_content) + an S3-uploaded reference file, granted to
the default role. Mirrors seed_default_tools; the skill-resources bucket
name is derived in seed.sh from the project prefix.
Spec: docs/specs/admin-skills-rbac-tool-binding.md (§0.5, §8, §12).
Full backend suite: 3711 passed, 3 skipped.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: per-tool MCP enablement (skills binding + model settings) (#469)
* feat(tools): scoped tool-id helpers + agent runtime resolution
A bare catalog tool id still means the whole MCP server; a scoped `toolId::name` selects one tool of it. Adds apis/shared/tools/scoped_ids.py as the single source of truth and resolves scoped ids at the runtime seam: the tool-filter classifies by base id, expand_gateway_tool_ids emits only the chosen gateway_<target>___<name>, and external MCP clients pass the SDK-native tool_filters allow-list (matched on the raw mcp tool name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): validate and persist per-tool selections
Skill bound_tool_ids and user tool preferences accept scoped ids: the base catalog tool must exist + be active, and (when the server has a curated list) the named tool must be one it exposes. GET /tools/ now surfaces each MCP server's tools via UserToolAccess.serverTools, each with its effective enabled state (scoped pref -> server-level pref -> catalog default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tools): live discover-tools endpoints for saved MCP servers
Adds POST /admin/tools/{id}/discover (admin, skills picker) and POST /tools/{id}/discover (session auth + RBAC, model settings) so per-tool selection works for servers whose tools aren't enumerated in the catalog. External servers are listed live (OAuth-3LO falls back to the curated list); gateway targets return the tools recorded at registration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(admin): per-tool selection in the skills tool picker
The Bind Tools dialog now expands an MCP server into its individual tools with per-tool checkboxes (parent shows all/partial/none), emitting scoped ids in boundToolIds; servers with no curated list get a live Discover tools action. Bound-tool chips render as "Server · tool". Adds the frontend scoped-tool-id helper mirroring the backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): per-tool toggles in model settings
An MCP server row in model settings expands into per-tool switches; toggling the server toggles all, toggling a tool switches the server to a subset. enabledToolIds emits scoped ids for a partial server and the bare id when all tools are on. Servers with no enumerated tools offer a live Discover tools action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): flip default agent_type to "skill" (PR-7) (#470)
Final increment of the admin-managed Skills feature. Stacked on PR-6b (#468).
The server now defaults a user turn to the SkillAgent instead of ChatAgent
(routes.DEFAULT_AGENT_TYPE = "skill"). A user with zero accessible skills gets
a SkillAgent that degrades to plain ChatAgent behavior, so the flip is a no-op
for them; a user whose roles grant a skill gets that skill's bound tools folded
behind the two meta-tools (PR-6a/6b). Clients opt out per turn with
agent_type="chat".
Implementation:
- routes.py: DEFAULT_AGENT_TYPE constant; the invocations() handler computes
effective_agent_type = input_data.agent_type or DEFAULT_AGENT_TYPE and uses it
for skill resolution + the three non-resume get_agent calls.
- The resume get_agent call gates accessible_skill_ids on the snapshot's type
(snapshot.agent_type == "skill"), so a turn explicitly built as "chat" that
pauses on an interrupt rebuilds the SAME cache key on resume (empty
skills_hash) instead of orphaning the paused agent.
- service.get_agent keeps a conservative "chat" fallback for direct callers; the
request-policy default lives in the route. SPA omits agent_type → gets "skill".
toolTokens is measured post-deploy via the context-attribution contextBreakdown
partition (folded bound tools drop out of the tools partition; the skill catalog
moves into the system partition).
Full backend suite: 3712 passed, 3 skipped (1 pre-existing flaky PBT test).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agent): re-enable Strands Bedrock auto prompt caching (#471)
Uncomment the cache_config block in ModelConfig.to_bedrock_config() so
caching_enabled=True now emits CacheConfig(strategy="auto"). Strands
places cache points per-model and no-ops with a warning for models that
don't support automatic caching, so this is safe to set unconditionally
on the Bedrock path.
The prior deferral was for the strands PR #1438 blocker (cachePoint
blocks colliding with non-PDF document attachments), resolved in
strands-agents 1.39.0 — we pin 1.40.0. Update the test that asserted the
key was omitted to assert CacheConfig(strategy="auto") is present.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(env): document missing backend env vars in .env.example (#472)
Cross-referenced the env vars set by the CDK constructs (per
tests/supply_chain/test_env_var_contract.py) against what the Python
code reads, and added the gap to backend/src/.env.example with
placeholder values and explanatory comments matching the existing style.
Added (17 vars):
- Core wiring: PROJECT_PREFIX, AGENTCORE_RUNTIME_WORKLOAD_NAME,
INFERENCE_API_URL, LOG_LEVEL
- AgentCore: MEMORY_ARN, AGENTCORE_MEMORY_TYPE, BROWSER_ID,
AGENTCORE_LOCAL_OAUTH_CALLBACK_URL
- DynamoDB: DYNAMODB_API_KEYS_TABLE_NAME,
DYNAMODB_USER_MENU_LINKS_TABLE_NAME
- New "Admin-managed Skills" section: S3_SKILL_RESOURCES_BUCKET_NAME
- New "Fine-tuning" section: FINE_TUNING_ENABLED,
DYNAMODB_FINE_TUNING_ACCESS_TABLE_NAME,
DYNAMODB_FINE_TUNING_JOBS_TABLE_NAME, S3_FINE_TUNING_BUCKET_NAME,
FINE_TUNING_DEFAULT_QUOTA_HOURS, SAGEMAKER_EXECUTION_ROLE_ARN,
SAGEMAKER_SECURITY_GROUP_ID, SAGEMAKER_SUBNET_IDS
Intentionally excluded: render-Lambda-internal aliases (ARTIFACTS_BUCKET,
ARTIFACTS_TABLE, RENDER_TOKEN_SECRET_ARN, CSP_SCRIPT_SRC,
FRAME_ANCESTOR_ORIGIN), the AWS-injected AWS_DEFAULT_REGION, and legacy
local-only cruft that nothing reads.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): platform chat-mode settings foundation (skills-mode PR-1) (#473)
Adds the admin-managed chat-mode policy that the skills-mode feature
(docs/specs/skills-mode.md) builds on: which agent mode (skill/chat) new
conversations default to, and whether users may toggle between modes.
- New apis/shared/platform_settings/ domain — ChatModeSettings stored as
a SYSTEM_SETTINGS#chat-mode sentinel item in the auth-providers table
(the existing first-boot convention; zero CDK changes since both
app-api and the inference runtime already have the table env + IAM),
with a 60s TTL-cached service for the per-turn inference read.
- GET/PUT /admin/settings/chat (require_admin) with audit stamping.
- GET /system/chat-settings — SPA-facing policy read (session auth).
- Defaults reproduce current behavior (skill default, toggling allowed),
so an unconfigured environment sees no change. Nothing consumes the
policy yet — enforcement lands in PR-2.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* add time info to user messages on hover
* feat(skills): user skills surface + chat-mode policy enforcement (skills-mode PR-2) (#474)
Backend half of skills mode (docs/specs/skills-mode.md): users get a
listable, per-skill-toggleable view of their RBAC-granted skills, and the
admin chat-mode policy from PR-1 (#473) is now enforced on /invocations.
- New GET /skills/ + PUT /skills/preferences (session auth) — ACTIVE
RBAC-accessible skills with per-user prefs merged, mirroring the tools
endpoints. Prefs stored as USER#{id}/SKILL_PREFERENCES in the skills
table (UserSkillPreference, mirrors UserToolPreference).
- Shared resolver apis/shared/skills/access.py — single source of truth
for "which skills does this user get" used by both app-api and the
inference path (the routes helper stays as a thin test seam).
- InvocationRequest.enabled_skills — None = all accessible (back-compat);
a list is intersected server-side with the RBAC set (narrow, never
grant); the effective set feeds SkillAgent AND the skills_hash cache
key, so toggle changes can't cross-pollute cached agents.
- Chat-mode policy enforcement: effective agent_type now resolves through
the admin settings (TTL-cached). When toggling is disabled the client's
skill/chat choice is overridden server-side; voice and other internal
types bypass the policy. DEFAULT_AGENT_TYPE is now aliased to the
policy model's compiled-in default so they can't drift.
- PausedTurnSnapshot.enabled_skills — paused skill turns persist their
effective skill set so resume rebuilds the same cache key even if the
user toggles skills mid-pause; legacy snapshots fall back to
request-time resolution.
Full backend suite: 3808 passed, 3 skipped.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(kaizen): weekly research scan 2026-06-12 (#475)
Generated by the kaizen-research skill. Top 5 ideas appended to
docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(spa): skills mode UX — mode toggle, skills picker, request wiring (skills-mode PR-3) (#476)
* feat(spa): skills mode UX — mode toggle, skills picker, request wiring (skills-mode PR-3)
The user-facing half of skills mode (docs/specs/skills-mode.md), on top of
the #474 backend:
- New SkillService (mirrors ToolService): loads GET /skills/, per-skill
optimistic toggles persisted via PUT /skills/preferences.
- New ChatModeService: admin policy from GET /system/chat-settings
(compiled-in default on failure), effective mode precedence =
policy lock > local selection > user preferred mode > admin default.
Toggling persists preferredAgentMode (user settings) and the session's
agentType so a conversation reopens in the mode it was using.
- Model-settings panel: Skills/Tools segmented control (hidden when the
admin disallows toggling), Skills toggle section (visual twin of the
Tools section, with empty state) in skills mode, Tools section in
tools mode.
- Chat request: sends agent_type each turn; skills mode sends
enabled_skills and enabled_tools=[] (capabilities come from skills);
assistant turns are excluded and keep pre-skills-mode behavior.
- Session page hydrates the stored mode from session preferences,
mirroring the lastModel pattern.
- Backend (additive): SessionPreferences.agent_type (validated to
skill|chat, merge-safe in the metadata PUT) and user settings
preferredAgentMode.
Frontend: 1215 tests green (ng test) + production build clean.
Backend: full suite green (two pre-existing PBT tests flaked under
load in an unrelated area — session-manager truncation — and pass
clean in isolation and on file re-run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(spa): silence the error dialog for the mode toggle's best-effort persist
Toggling skills/tools mode persists preferredAgentMode via
PUT /users/me/settings, which fails loud (503, #161 behavior) when
user-settings storage isn't configured. The toggle already applied
in-memory, so the background persist now opts out of the global error
toast via the existing SUPPRESS_ERROR_TOAST context token; explicit
settings-page saves stay loud. Failure still logs to console.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): raise OAuth consent interrupt for skill-folded external MCP tools (#477)
In skills mode a skill-bound external MCP tool executes through the
skill_executor meta-tool, so OAuthConsentHook's provider_lookup (keyed on
the selected tool being an MCPAgentTool) resolved nothing and the consent
gate silently never fired: the tool ran tokenless, returned an auth error
as text, and the model apologized instead of the turn pausing with an
oauth_required interrupt.
- OAuthConsentHook: optional tool_use_provider_lookup second-chance
resolver (gate + 401-retry handler) consulted when provider_lookup
returns None
- mcp_binding: make_folded_tool_provider_lookup maps the executor's
tool_use input (skill_name/tool_name) -> bound FoldedMCPTool -> owning
client -> provider id; gateway clients resolve to None (SigV4, not
user OAuth)
- FoldedMCPTool.invoke now returns a ToolResult-shaped error on failure
so the error status survives the fold and the consent hook's 401-retry
heuristic (gated on status == "error") can fire for folded tools
- SkillAgent wires the resolver over its registry via the new
BaseAgent._build_tool_use_provider_lookup hook point (None for chat)
The resume path is unchanged: the interrupt is raised on the
skill_executor call itself, so consent -> interrupt_responses -> cache
warm -> the client's lazy token provider picks up the token.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agents): gate skill-folded external MCP tools behind the approval prompt (#478)
An admin's needs_approval=True flag was silently bypassed in skills mode:
the flagged tool runs behind the skill_executor meta-tool, so the approval
hook's selected_tool lookup resolved nothing and the tool_use name never
matched. Mirror the OAuth consent fold fix — an optional tool_use-based
second-chance lookup resolves the folded target via the SkillRegistry,
checks it against the owning client's needs_approval set, and raises the
same tool_approval_required interrupt with the inner tool's name + args so
the dialog describes the real tool, not the executor.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: add Amazon Bedrock Mantle as a model provider (#479)
* feat(models): add Bedrock Mantle as a model provider
Mantle is AWS's OpenAI-compatible inference surface for Bedrock-hosted
open-weight models (qwen, gpt-oss, gemma, deepseek, ...). It is a distinct
provider from `bedrock` because it speaks the OpenAI wire protocol with a
short-term bearer token rather than the Converse API with SigV4.
What this adds:
- apis/shared/bedrock/bearer_token.py: SigV4-presigned `CallWithBearerToken`
bearer token (ported inline from aws-bedrock-token-generator, no new dep)
plus region+path base-URL helper.
- GET /admin/mantle/models: browse the live regional Mantle roster via the
OpenAI SDK (seeds the escape-hatch form's model-id suggestions).
- ModelProvider.MANTLE end to end: model_config translation, agent_factory
builds a Strands OpenAIModel against the Mantle base URL, BaseAgent +
inference chat pipeline thread the value through.
- mantle_endpoint_path on the managed-model shape, persisted and resolved
server-authoritatively in _resolve_model_settings. Mantle serves different
models on different paths (/v1 vs /openai/v1) and exposes NO API to discover
which, so the path is recorded per model (from the model card) rather than
probed or mapped. Carried through the paused-turn snapshot so a resumed
Mantle turn rebuilds the same base URL.
Caching is intentionally left off for Mantle: prompt caching on Bedrock is
model-bound to Anthropic Claude + a few Amazon Nova models, none of which run
through the Mantle provider.
Requires `bedrock-mantle:*` IAM (separate infra commit) and Mantle being
enabled for the account/region.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(infra): grant bedrock-mantle IAM for Mantle browse + inference
Bedrock Mantle has its own IAM service namespace — `bedrock-mantle:*`, NOT
`bedrock:*`. Mirror the AWS-managed AmazonBedrockMantleInferenceAccess policy:
- AgentCore runtime role: `bedrock-mantle:CreateInference` + `Get*`/`List*`
on `project/*`, plus `bedrock-mantle:CallWithBearerToken` (mantle-provider
inference).
- App-API task role: read-only `Get*`/`List*` + `CallWithBearerToken` for the
GET /admin/mantle/models browse endpoint.
The token signer is authorized against this namespace; without it inference
returns an IAM denial even when Mantle is enabled for the account. Integration
test asserts both roles carry CallWithBearerToken and the runtime carries
CreateInference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(spa): curated Bedrock Mantle catalog + endpoint-path form
Add a "Bedrock Mantle" tab to the admin model catalog, mirroring the Bedrock
tab: curated cards with vetted, human-reviewed settings (pricing, modalities,
context, and the all-important endpoint path baked in). No probing, no magic.
- CURATED_MANTLE_MODELS seeded with Qwen3 Coder 30B (/v1) and Gemma 4 31B
(/openai/v1). Pricing verified against the AWS Bedrock pricing page (2026-06);
modalities/capabilities/context/path verified against each model card
(Gemma 4: text+image+video in, text out, reasoning + tool use + vision).
- Model shape gains `mantleEndpointPath`; the model form becomes Mantle-aware:
a /v1 vs /openai/v1 selector (with model-card guidance), caching controls
hidden (Mantle open-weight models don't cache), and a model-id datalist
seeded from the live GET /admin/mantle/models roster for off-catalog adds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Bind persisted email to the validated session in profile-sync handler
POST /users/me/sync now derives the persisted email from
current_user.email (the JWT-bound value populated by the BFF callback
at login time) instead of the request body. Display fields (name,
picture) still come from the body; the field set on
UserProfileSyncRequest is reduced to those plus provider_sub.
Pydantic's extra='allow' setting keeps the endpoint backwards-
compatible with clients that still send legacy keys ('roles', 'email')
— they surface via model_extra and are dropped when building the
persisted profile. The handler logs a single WARNING listing every
legacy field it observed so stale clients can be tracked down.
A session whose JWT has no email claim now returns 422 rather than
persisting an empty row.
Tests:
- test_sync_persists_jwt_email_not_body_email: a body-supplied email
does not influence the persisted record.
- test_sync_email_domain_derived_from_jwt: the derived email_domain
also tracks the JWT, not the body.
- test_sync_jwt_email_normalized_to_lowercase: persisted email is
lowercased regardless of the JWT casing.
- test_sync_missing_jwt_email_returns_422: missing JWT email refuses
the upsert.
- test_sync_warns_when_legacy_fields_present: combined warn covers
both 'roles' and 'email' in body.
- test_share_access_email_match (7 tests): locks in that
ShareService._check_access compares the share's allowed_emails list
against requester.email only — case-insensitive, with owner override
and access_level=public/specific semantics covered.
* Wrap user-supplied system prompts with a platform safety floor
SystemPromptBuilder.from_user_prompt now assembles every prompt as:
PLATFORM_SAFETY_FLOOR
<user_instructions>
<user-supplied text>
</user_instructions>
The floor states that text inside the user_instructions tag is advisory,
that tool-input policies are enforced server-side and cannot be coerced,
that the code-execution surface accepts only chart / dataframe code, and
that identity claims come from the validated session rather than the
prompt. Embedded user_instructions tags in the user portion are stripped
before assembly so a caller cannot close the wrapper and write text below
the floor.
User-supplied prompts are bounded:
- SystemPromptBuilder truncates the user portion to MAX_USER_PROMPT_LENGTH
(8 KiB) before assembly.
- InvocationRequest.system_prompt rejects payloads larger than
MAX_USER_SYSTEM_PROMPT_CHARS (also 8 KiB) at the API boundary so
oversized inputs surface as a 4xx instead of being silently truncated.
The diagram-tool input policy added in apis/shared/security/python_ast_policy.py
remains the authoritative enforcement against arbitrary code execution;
this branch is structural defense-in-depth at the prompt boundary.
Tests:
- test_system_prompt_safety_floor.py (10 new tests) covers floor
precedence over user portion, behaviour for empty/None user prompts,
resistance to wrapper-tag injection (closing or opening), length
truncation, exact-limit pass-through, and the API request boundary's
oversize rejection / valid-size acceptance / None-allowed contracts.
- test_system_prompt_builder.py existing TestFromUserPrompt updated
from 'user prompt becomes the entire base prompt unchanged' to
'assembled prompt starts with the floor and contains the user text
inside the wrapper tags'.
* Route fetch_url_content through the shared URL validator
The fetch_url_content tool now runs every URL through the
apis.shared.security.url_validator helper before opening any HTTP
connection. The validator rejects loopback, link-local, RFC1918, ULA,
multicast, reserved, unspecified, CGNAT, and cloud metadata-service
addresses, and resolves every DNS answer to defeat host-name
rebinding to a private result.
Redirect handling is now manual. The httpx client is constructed with
follow_redirects=False; the tool walks the redirect chain itself,
applying the same validator to each Location target before re-fetching.
Up to three redirects are followed; a 3xx without a Location header
falls through to the standard response path. Redirect targets that
fail the validator surface a generic 'Redirect target is not
permitted.' error to the caller.
Tests:
- 8-case parametrized rejection sweep covering 169.254.169.254,
fd00:ec2::254, 127.0.0.1, RFC1918 ranges, [::1], and 0.0.0.0,
asserting that AsyncClient is never constructed when validation
fails (ergo no network I/O).
- DNS-resolution rebinding: a host name whose only A record is private
is rejected with no client construction.
- Disallowed scheme (file://) rejected without network I/O.
- Generic error contract: rejection message contains no resolved
address or 'metadata' label.
- Public URL passes the validator and reaches the network layer.
- Redirect handling: the constructed client must use
follow_redirects=False, and a 302 to 169.254.169.254 results in
exactly one outbound request (the original) — the tool never
follows to the metadata IP.
* Reject session-metadata PUT when session id is owned by another user
The PUT /sessions/{session_id}/metadata handler now consults a new
session_exists_for_other_user() helper before taking the
'session-not-found-for-this-user → create new' branch. The helper
queries the SessionLookupIndex GSI directly (without filtering on
userId) and reports whether a row exists for the given session id
under any user. When that returns True for a non-owner, the PUT
returns 404 — matching GET's behaviour and avoiding an enumeration
oracle.
The existing per-user fetch (get_session_metadata) already returns
None for non-owners because of how its DynamoDB query is partitioned;
the new helper closes the gap that None has two distinct meanings:
genuinely empty vs. taken-by-someone-else.
Tests:
- TestUpdateSessionMetadataOwnership (4 tests):
- 404 when the session id is taken under a different user, and the
write helper is never called.
- The existence-check verdict alone is enough to block the write
even when the per-user fetch returned None.
- Genuinely fresh session ids still create normally.
- Owner updates skip the existence check entirely (the per-user
fetch already returned the record).
- Two existing 'create-new' tests updated to mock the new helper.
* Scope SigV4 signing on outbound MCP requests to recognized AWS endpoints
detect_aws_service_from_url now returns Optional[str]: a recognized
service name for Lambda Function URLs, API Gateway, and AgentCore
Gateway hosts, and None for everything else. The previous fallback to
'lambda' for unrecognized hosts meant the SigV4 signer would attach
task IAM credentials (Authorization: AWS4-HMAC-SHA256 ... +
X-Amz-Security-Token) to outbound requests destined for arbitrary
hosts.
create_external_mcp_client treats the None return as a refusal: when
auth_type=aws-iam and the server URL doesn't match a known AWS
service, the function returns None instead of constructing a
SigV4-wired client. The /admin/tools/discover route already maps a
None client to a 400 with a generic message, so the discovery
endpoint's contract for arbitrary-URL admin discovery still holds for
non-signing auth modes (auth_type=none / oauth2 / etc.) — only the
credential-bearing AWS IAM path is scoped down.
Tests:
- test_mcp_sigv4_scope.py (14 tests):
- 4 service-detection cases for legitimate AWS hostnames
(Lambda Function URL, API Gateway, AgentCore Gateway).
- 7 None-return cases for non-AWS hosts including hostname
look-alikes (amazonaws.com.attacker.com, lambda-url.fake.com,
lambda-url-lookalike.example.com).
- aws-iam against a non-AWS URL refuses client construction.
- aws-iam against an AWS URL constructs normally.
- Non-signing auth modes (auth_type=none) against arbitrary URLs
still construct, since no credentials are at stake.
- Existing test_defaults_to_lambda_for_unknown_url replaced with
test_returns_none_for_unknown_url to reflect the new contract.
* fix(backup/restore): include S3 Vectors index in the snapshot loop (#481)
The assistants RAG knowledge base is backed by an AWS::S3Vectors::Index
('rag-vector-index-v1') that lives in a separate AWS service from
regular S3 — the s3vectors boto3 client / AWS::S3Vectors::* CFN types.
'aws s3 sync' cannot reach it, list_objects_v2 doesn't see it, and
nothing in backup.py or restore.py was touching it. The result: after
teardown -> redeploy -> restore, the new vector index was empty, every
assistant's knowledge base appeared connected (DDB document metadata
restored, S3 originals restored) but every retrieval call returned
zero hits because no vectors existed for any assistant_id.
This change closes that gap with the same 'snapshot and replay' model
the rest of the restore tool uses.
backup.py
- new VECTOR_INDEXES list (parallel to S3_BUCKETS)
- new backup_vector_index() that paginates s3vectors.list_vectors
with returnData=True + returnMetadata=True and streams every
record to vectors/{logical}.jsonl.gz in the backup bucket. The
line format ({key, data, metadata}) is byte-compatible with
s3vectors.put_vectors on restore.
- run() iterates VECTOR_INDEXES after the regular S3 buckets pass
restore.py
- new VECTOR_INDEXES constant mirroring backup.py
- new restore_vector_index() that reads the gzipped JSONL, batches
records 50-at-a-time (matching bedrock_embeddings.store_embeddings
_in_s3 BATCH_SIZE), and calls s3vectors.put_vectors. Idempotent
on re-run (put_vectors with same key is upsert), skips cleanly on
older backups that pre-date the vectors snapshot, and skips
cleanly on target prefixes where RAG is disabled.
- run_restore() runs the vector restore step after the S3 buckets
pass and before the AgentCore Memory replay
tests
- tests/supply_chain/test_backup_coverage.py adds
TestBackupCoversVectorIndexes — 5 assertions that scan the CDK
constructs for AWS::S3Vectors::Index resources and verify backup.py
declares them, calls list_vectors with returnData/returnMetadata,
and restore.py both defines AND wires in restore_vector_index.
Same canary pattern that already covers DynamoDB tables and
regular S3 buckets.
- tests/supply_chain/test_backup_coverage.py: extract_backup_tables
is now section-scoped so its 'logical' regex stops slurping
entries from S3_BUCKETS or the new VECTOR_INDEXES. Drive-by fix.
- scripts/restore-data/test_restore.py adds 7 behavioral tests for
restore_vector_index covering: 1:1 round-trip with batch-of-50
flushing, missing backup file skip, missing target SSM skip,
dry-run, idempotent re-run, unknown logical name skip, and the
rag-vectors logical-name pin.
All 48/48 restore-data tests pass; all 5/5 new vector coverage tests
pass. The 3 pre-existing supply-chain failures (system-prompts table
and skill-resources bucket from prior feature PRs) are untouched and
unrelated.
NOTE: the existing affected forker still has an empty vector index
post-restore. This PR does not include a one-off recovery script —
that's intentionally scoped as a separate task per the diagnosis
discussion.
Co-authored-by: Colin <colin@boisestate.edu>
* feat(docs): add full-screen maintenance page (#482)
Adds a standalone, directly-navigable maintenance splash at
/maintenance for use during migration downtime. It lives in
src/pages/ (outside Starlight's content collection), so it never
appears in the sidebar/nav and renders without Starlight chrome —
reachable only by navigating to the URL directly.
Self-contained markup + styles (works even if nothing else loads)
matching the docs/SPA house style: Boise blue, frosted glass,
lava-lamp blob field, graph-paper grid, Bricolage display type, a
pulsing Bronco-orange status orb, and an indeterminate progress
shimmer. Responsive, honors prefers-reduced-motion, and carries
noindex/nofollow so crawlers skip it.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(docs): remove action buttons from maintenance page (#483)
The maintenance splash (added in #482) is purely informational, so
the "Try again" reload button and "Status & updates" link add no
real value on a static page. Drop both buttons along with the now-
unused click handler script and the .actions/.btn CSS, leaving the
status pill, headline, message, progress shimmer, and sign-off.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Tighten admin-route input handling and data-layer ownership edges (#484)
A handful of related cleanups across admin and data-edge routes that
share a common shape: each one either accepted ill-formed input that
went on to crash a downstream call, leaked upstream error detail in
500 bodies, or didn't verify ownership before mutating shared records.
Admin/Bedrock model listing
GET /admin/bedrock/models filter parameters now use Pydantic
Literal/regex types so out-of-shape values are refused at the
request boundary with a generic 422 (no input echo, no enum-set
reflection). The route's bespoke ClientError/BotoCoreError/Exception
handlers are removed in favour of the app-wide
register_aws_client_error_handler installed at startup, which maps
ValidationException-class codes to a generic 400 and other
ClientErrors to a generic 502. A new register_validation_error_handler
replaces FastAPI's default 422 body with the same generic body.
Memory-record delete
DELETE /memory/{record_id} now fetches the record's metadata via
GetMemoryRecord and verifies the calling user appears in the
/actors/{user_id} segment of the record's namespace before issuing
batch_delete_memory_records. Non-owners (and missing records) get a
generic 404. Two new helpers — get_memory_record_owner and
record_namespace_owner — encapsulate the lookup and namespace parse.
OIDC discovery + auth-provider connectivity tests
POST /admin/auth-providers/discover now runs the supplied issuer URL
through the SSRF validator (https-only, no loopback / link-local /
private / metadata addresses) before fetching .well-known/openid-
configuration. POST /admin/auth-providers/{id}/test now applies the
same validator to each stored URL — jwks_uri, token_endpoint, and
the issuer-derived discovery URL — independently; a URL that fails
validation is reported unreachable in the per-endpoint result map
and is not contacted.
Files pagination cursor
GET /files now refuses cursors whose embedded PK doesn't match the
caller's USER#{user_id} partition. The repository raises a new
InvalidCursorError; the route maps that to a generic 400 instead of
the previous 500 the cross-partition DynamoDB query produced.
Tests:
- test_admin_bedrock_models.py (16) — Pydantic rejection of bad
enum/regex filters short-circuits before any boto3 call; AWS
ValidationException maps to 400 with no message reflection; other
ClientError maps to 502 generic; legitimate filters reach AWS;
unhandled exception → 500 with no exception detail in body.
- test_memory_delete_ownership.py (4) — owner can delete; non-owner
gets 404 and no AWS call; record without namespaces treated as not
owned; missing record → 404 with no AWS call.
- test_auth_providers_ssrf.py (12) — discover refuses 8 disallowed
issuer shapes (loopback, link-local, RFC1918, IPv6 loopback, http://,
file://, javascript:); accepts a legitimate https issuer; test
endpoint skips disallowed jwks_uri / token_endpoint / issuer URLs
individually, marking them unreachable without contacting them.
- test_files_cursor_validation.py (5) — cursor with another user's
PK rejected at repository, garbage cursor rejected, cursor without
PK rejected, owner's own cursor still works, route maps the error
to 400.
- Two existing auth_providers tests updated to mock DNS through the
validator's getaddrinfo so their fake hostnames continue to work.
* ci(artifacts): plumb CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS through deploy workflows (#485)
The artifacts iframe CSP frame-ancestors list is built from the deployed
SPA origin plus config.artifacts.extraFrameAncestors (fed by
CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS), and is enforced on both the
artifacts CloudFront response-headers-policy and the render Lambda's
FRAME_ANCESTOR_ORIGIN. But neither platform.yml nor
nightly-deploy-pipeline.yml passed that var through, so there was no way
to allow a local SPA (http://localhost:4200) to embed artifacts served by
a deployed dev distribution — the iframe fails with "refused to connect".
Mirror the existing CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS passthrough.
Unset by default (config falls back to []), so prod is a no-op.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Sanitize admin error paths and pin viewer-facing TLS to 1.2+ baseline
Three small, independent cleanups:
Fine-tuning jobs listing — tolerant deserialization
GET /admin/fine-tuning/jobs now serializes records one at a time;
any record that fails JobResponse validation is dropped with a WARN
log naming the offending job_id, and the listing returns 200 with
the well-formed records. Repository-level failures (the table
unreachable, etc.) still surface as 500 — only per-record
validation drops are tolerated.
External model listing — generic 503 on missing credentials
GET /admin/gemini/models and GET /admin/openai/models now respond
with 503 'External model provider not configured.' when the
required API key environment variable is unset. The specific env
var name (GOOGLE_API_KEY / GOOGLE_GEMINI_API_KEY / OPENAI_API_KEY)
is logged server-side at ERROR for operator diagnostics, never
echoed to the response body.
Transport security baseline
CloudFront SPA distribution now pins MinimumProtocolVersion to
TLSv1.2_2021 when serving a custom domain — drops TLS 1.0/1.1
entirely and prunes the cipher set to AEAD-only.
ALB HTTPS listener now pins SslPolicy to ELBSecurityPolicy-TLS13-1-
2-Res-2021-06 — TLS 1.2 minimum, modern ciphers only, no CBC.
Tests:
- test_admin_lows.py (8) — fine-tuning listing skips malformed
records and returns 200; well-formed-only and all-malformed cases;
repository failure still surfaces as 500; gemini and openai
unconfigured each return 503 with no env-var-name reflection in
the body.
- transport-security.test.ts (2) — CDK synth assertions that the
CloudFront distribution sets MinimumProtocolVersion=TLSv1.2_2021
and the ALB HTTPS listener sets a 2021-vintage SslPolicy.
* fix(skills): resolve and persist subset-scoped external MCP tool bindings (#486)
* fix(skills): resolve subset-scoped external MCP bindings at fold time
A skill binding a *subset* of an external MCP server (scoped ids like
`canvas::courses`) resolved to no client at fold time, so the skill
folded zero tools and the model reported the server "not connected" —
the OAuth consent gate never fired because no FoldedMCPTool existed.
- base_agent: register/look up external tools by their *base* catalog id
(the catalog and tool filter both key on the base), deduping scoped ids
for the same server so a per-tool binding is classified and loaded.
- ExternalMCPIntegration.get_client: collapse a scoped lookup id to its
base and match the cached client, tolerating the `|allow:<names>`
subset cache-key suffix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(skills): reset stale tool folds when rebuilding skill agents
MCP clients are process-global and reused across agent builds, and the
fold set persists on them (set_folded_tool_names only ever adds).
resolve_mcp_bindings enumerates an external server through that same
fold-filtered list_tools_sync, so a stale fold from a prior build makes
a re-bind see zero tools — the bound tool "works once, then disappears"
on the next turn.
- mcp_tool_folding: add reset_folded_tool_names to clear a client's fold
set and cached tool list.
- skill_agent: reset every client this build will (re)bind before
resolving, then let the build recompute and re-apply the fold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(deps): remediate all 22 HIGH Dependabot findings (#487)
Bumps vulnerable dependencies across backend, scripts, and frontend to
patched versions. All 22 open HIGH-severity Dependabot alerts addressed.
Backend (pyproject.toml + uv.lock):
- cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf)
- starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283)
- python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539)
- pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526)
- urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432)
Scripts (backup-data, restore-data):
- add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf)
Frontend (package.json + package-lock.json):
- @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268)
(cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages)
- hono override -> 4.12.26 (CVE-2026-54290)
- piscina override -> 5.2.0 (CVE-2026-55388)
- undici override -> 7.28.0 (CVE-2026-9697)
- vite override -> 8.0.16 (CVE-2026-53571)
Verified: backend pytest 3935 passed/3 skipped; frontend build OK;
frontend unit tests 1216 passed. All locked versions >= patched thresholds.
* Fix/dependabot quick fixes (#488)
* fix(deps): remediate all 22 HIGH Dependabot findings
Bumps vulnerable dependencies across backend, scripts, and frontend to
patched versions. All 22 open HIGH-severity Dependabot alerts addressed.
Backend (pyproject.toml + uv.lock):
- cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf)
- starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283)
- python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539)
- pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526)
- urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432)
Scripts (backup-data, restore-data):
- add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf)
Frontend (package.json + package-lock.json):
- @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268)
(cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages)
- hono override -> 4.12.26 (CVE-2026-54290)
- piscina override -> 5.2.0 (CVE-2026-55388)
- undici override -> 7.28.0 (CVE-2026-9697)
- vite override -> 8.0.16 (CVE-2026-53571)
Verified: backend pytest 3935 passed/3 skipped; frontend build OK;
frontend unit tests 1216 passed. All locked versions >= patched thresholds.
* fix(deps): remediate easy MEDIUM/LOW Dependabot findings
Straightforward in-range/direct dependency bumps for remaining alerts.
Risky or blocked findings deliberately left out (see below).
Backend (pyproject.toml + uv.lock):
- aiohttp 3.13.5 -> 3.14.1
- authlib 1.7.0 -> 1.7.1
- python-multipart 0.0.30 -> 0.0.31
- idna pinned 3.15 (transitive)
Scripts (backup-data, restore-data):
- pytest 8.4.2 -> 9.0.3 (dev)
Frontend (package.json + package-lock.json):
- mermaid 11.14.0 -> 11.15.0 (direct)
- @babel/core override ">=7.29.6 <8.0.0" -> 7.29.7
(bounded to 7.x; open-ended pulled babel 8 which broke the Angular build)
Infrastructure (package-lock.json, lock-only):
- @babel/core -> 7.29.7 (within range)
Deliberately NOT included (not "easy"):
- esbuild 0.28.1: pinned by vite 8; override risks build breakage (low sev)
- js-yaml 4.2.0: major bump (consumers pin ^3)
- dompurify: one alert has no patched version published
- infra fast-uri (HIGH) / brace-expansion / js-yaml: bundled inside
aws-cdk-lib@2.251.0/node_modules; npm overrides can't reach them.
Requires an aws-cdk-lib upgrade -- separate, deliberate change.
Verified: backend pytest 3935 passed/3 skipped; frontend build OK +
1216 unit tests passed; infra tsc build OK + 396 jest tests passed.
* Fix/dependabot quick fixes (#489)
* fix(deps): remediate all 22 HIGH Dependabot findings
Bumps vulnerable dependencies across backend, scripts, and frontend to
patched versions. All 22 open HIGH-severity Dependabot alerts addressed.
Backend (pyproject.toml + uv.lock):
- cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf)
- starlette 1.0.0 -> 1.3.1 (CVE-2026-48818, CVE-2026-54283)
- python-multipart 0.0.27 -> 0.0.30 (CVE-2026-53539)
- pyjwt[crypto] 2.12.1 -> 2.13.0 (CVE-2026-48526)
- urllib3 pinned 2.7.0 (CVE-2026-44431, CVE-2026-44432)
Scripts (backup-data, restore-data):
- add uv constraint cryptography>=48.0.1 -> 49.0.0 (GHSA-537c-gmf6-5ccf)
Frontend (package.json + package-lock.json):
- @angular/* framework 21.2.11 -> 21.2.17 (CVE-2026-50170/50171/54266/54267/54268)
(cdk 21.2.14, build/cli 21.2.16 -- latest existing patch for those packages)
- hono override -> 4.12.26 (CVE-2026-54290)
- piscina override -> 5.2.0 (CVE-2026-55388)
- undici override -> 7.28.0 (CVE-2026-9697)
- vite override -> 8.0.16 (CVE-2026-53571)
Verified: backend pytest 3935 passed/3 skipped; frontend build OK;
frontend unit tests 1216 passed. All locked versions >= patched thresholds.
* fix(deps): remediate easy MEDIUM/LOW Dependabot findings
Straightforward in-range/direct dependency bumps for remaining alerts.
Risky or blocked findings deliberately left out (see below).
Backend (pyproject.toml + uv.lock):
- aiohttp 3.13.5 -> 3.14.1
- authlib 1.7.0 -> 1.7.1
- python-multipart 0.0.30 -> 0.0.31
- idna pinned 3.15 (transitive)
Scripts (backup-data, restore-data):
- pytest 8.4.2 -> 9.0.3 (dev)
Frontend (package.json + package-lock.json):
- mermaid 11.14.0 -> 11.15.0 (direct)
- @babel/core override ">=7.29.6 <8.0.0" -> 7.29.7
(bounded to 7.x; open-ended pulled babel 8 which broke the Angular build)
Infrastructure (package-lock.json, lock-only):
- @babel/core -> 7.29.7 (within range)
Deliberately NOT included (not "easy"):
- esbuild 0.28.1: pinned by vite 8; override risks build breakage (low sev)
- js-yaml 4.2.0: major bump (consumers pin ^3)
- dompurify: one alert has no patched version published
- infra fast-uri (HIGH) / brace-expansion / js-yaml: bundled inside
aws-cdk-lib@2.251.0/node_modules; npm overrides can't reach them.
Requires an aws-cdk-lib upgrade -- separate, deliberate change.
Verified: backend pytest 3935 passed/3 skipped; frontend build OK +
1216 unit tests passed; infra tsc build OK + 396 jest tests passed.
* fix(deps): upgrade aws-cdk-lib 2.251.0 -> 2.260.0 to clear bundled CVEs
The remaining infra Dependabot HIGH/MEDIUM alerts were in deps bundled
inside aws-cdk-lib's own node_modules (unreachable by npm overrides):
- fast-uri 3.1.0 -> 3.1.2 (HIGH, 2 advisories) — bundled via ajv
- brace-expansion 5.0.5 -> 5.0.6 (MEDIUM) — bundled via minimatch
Bumping aws-cdk-lib to 2.260.0 (latest 2.x) re-bundles both at patched
versions. constructs 10.6.0 already satisfies the ^10.5.0 peer (unchanged).
Verified: infra tsc build clean; jest 396 passed/18 suites (full
PlatformStack construction + Template.fromStack assertions, no template
regressions). cdk-lib v2 minor bump, backward compatible.
* ci: add pull_request test gate (backend/frontend/infra) (#490)
The deploy workflows (backend.yml, platform.yml, frontend-deploy.yml) only
run on push to develop/main and run their test jobs as a pre-deploy gate,
so unit tests never executed on the PR itself. PRs into develop ran only
skip-auth-guard.
Adds .github/workflows/ci.yml triggered on pull_request -> [develop, main]
with three parallel test jobs reusing the existing commands:
- test-backend: uv sync + uv run pytest tests/
- test-frontend: npm ci + npm run test:ci (vitest)
- test-infra: npm ci + npx jest
No build/deploy/AWS steps — deploys stay push-only. Actions are SHA-pinned
with the shared checkout SHA, runners pinned to ubuntu-24.04, and
cancel-in-progress: true (safe; no CDK deploy). Conforms to all
tests/supply_chain checks (31 passed).
* fix(infra): shared CloudFront cert + consistent domain/cert handling across edge origins (#491)
Collapse the three-separate-cert first-deploy footgun into one shared
wildcard, and make cert handling consistent and fail-loud across all
CloudFront origins.
- config.ts: add CDK_CLOUDFRONT_CERTIFICATE_ARN (top-level
cloudfrontCertificateArn). frontend/artifacts/mcpSandbox certs fall
back to it when their section-specific ARN is unset; section-specific
wins. ALB cert stays separate (region-specific). One us-east-1 wildcard
({domain}+*.{domain}) now satisfies all three origins.
- artifacts-distribution-construct: add domain-set-but-cert-missing guard
mirroring mcp-sandbox (replaces the false 'config.ts already enforced'
comment + opaque fromCertificateArn(undefined) crash), and add a
domain-less fallback to the CloudFront default domain so domain-less
synth no longer crashes with 'reading startsWith'.
- load-env.sh: forward cloudfrontCertificateArn + mcpSandbox.certificateArn
context params (were missing, breaking the cdk.context.json path).
- workflows: wire CDK_CLOUDFRONT_CERTIFICATE_ARN job-level env in
platform / nightly / teardown.
- docs: step-02/step-03/ACTIONS-REFERENCE recommend the single shared
cert and reframe per-origin vars as optional overrides; troubleshooting
entry for the synth cert-guard failure.
- tests: CloudFront cert resolution (config), artifacts cert guard +
domain-less fallback, and end-to-end shared-cert PlatformStack synth.
Full infra suite: 20 suites / 406 tests green.
* Fix/cdk cli version and deploy node pin (#492)
* fix(infra): bump aws-cdk CLI 2.1120.0 -> 2.1128.0 to match aws-cdk-lib 2.260.0
aws-cdk-lib 2.260.0 emits cloud-assembly schema 54.0.0, but the pinned
aws-cdk CLI (2.1120.0) only reads up to schema 53 — so synth/deploy
failed with 'CDK CLI is not compatible with the CDK library ... Maximum
schema version supported is 53.x.x, but found 54.0.0. You need at least
CLI version 2.1128.0'. The library was bumped without bumping the CLI.
Scripts invoke 'npx cdk', which resolves the local aws-cdk devDependency
on the CI runner, so bumping the pin + regenerating the lockfile is the
fix. Also bump the devcontainer global CDK pin (Dockerfile) and the
version tables (README, dev-environment steering) that are documented to
track package.json, so the interactive 'cdk' in the container doesn't
drift and reproduce the same error locally.
Verified in the devcontainer: npx cdk --version -> 2.1128.0; a synth of
an aws-cdk-lib 2.260.0 assembly (manifest schema 54.0.0) is read by the
2.1128.0 CLI with exit 0; full infra suite 20 suites / 406 tests green.
* ci(platform): pin Node 22 in the PlatformStack deploy jobs
The deploy jobs in platform.yml and nightly-deploy-pipeline.yml were the
only jobs without actions/setup-node — they ran scripts/platform/deploy.sh
(which does npm ci + cdk via deploy.sh -> scripts/cdk/install.sh) on the
runner's ambient Node instead of the Node 22 every other job and the
devcontainer pin. Add setup-node (node 22 + npm cache) so the deploy
toolchain is pinned and reproducible.
Note: deps were already being installed (deploy.sh calls install.sh ->
npm ci); the recent schema-mismatch failure was the stale aws-cdk pin
(2.1120.0), fixed in 4339f261 by bumping to 2.1128.0. This change is the
toolchain-pinning gap the #396 refactor left in the deploy jobs.
* Fix/deploy fixes (#494)
* fix(infra): auto-generate IAM role names to avoid first-deploy collisions
Drop explicit roleName from the AgentCore memory/code-interpreter/browser/
gateway/runtime execution roles and the SageMaker execution role. Fixed
physical names collide with orphaned roles left by a rolled-back/partial
deploy. Every consumer references these roles by .roleArn (or resolves them
at runtime via GetGateway / SAGEMAKER_EXECUTION_ROLE_ARN), so auto-generated
names are safe.
* chore(infra): replace deprecated pointInTimeRecovery with pointInTimeRecoverySpecification
Silences the aws_dynamodb.TableOptions#pointInTimeRecovery deprecation
warnings across all DynamoDB table constructs. Synthesized CloudFormation
output is unchanged (still PointInTimeRecoveryEnabled: true).
* fix(deploy): re-seed image-tag SSM param when the referenced ECR image is missing
The seed guard skipped any URI-shaped value, trusting the build pipeline.
But image-tag params are not CFN-managed and survive teardown, so a stale
project-repo URI could outlive its ECR repo and break the AgentCore Runtime
/ ECS task def with 'repository does not exist'. Verify the image actually
exists (ecr describe-images) before skipping; otherwise overwrite with the
bootstrap URI.
* fix(infra): grant Cognito group + delete actions for first-boot admin setup
Add cognito-idp:CreateGroup and AdminAddUserToGroup (the first-boot flow
creates the system_admin group and adds the initial admin) plus
AdminDeleteUser (so the rollback path doesn't orphan a Cognito user and
block retry with UsernameExistsException) to the app-api task role.
* fix(infra): restore stable IAM role names for AgentCore execution roles (#495)
Reverts the roleName removal from 7107cf98 for the AgentCore memory/
code-interpreter/browser/gateway/runtime and SageMaker execution roles.
Auto-generating these names is unsafe on an already-deployed stack: the
role ARN feeds create-only properties on the AgentCore resources
(BrowserCustom/CodeInterpreterCustom executionRoleArn, Memory
memoryExecutionRoleArn, Gateway/Runtime roleArn). Renaming the role
replaces it (new ARN) -> forces replacement of the dependent AgentCore
resource -> CFN re-creates it with the same create-only Name -> 'already
exists' collision -> UPDATE_ROLLBACK. Confirmed via ai-sbmt-api-PlatformStack
events (BrowserCustom/CodeInterpreterCustom DELETE_COMPLETE with empty
PhysicalResourceId during rollback).
Orphaned fixed-name roles on a *fresh* deploy are handled by deleting the
orphans before deploying, not by renaming. Added comments on each role to
prevent re-introducing the auto-name change.
* fix(ci): build arm64 images on native ARM runners (#496)
The RAG ingestion Lambda and AgentCore Runtime are arm64, but the
post-refactor backend.yml ran their build jobs on amd64 (ubuntu-24.04).
inference-api compensated with QEMU emulation (platform: linux/arm64);
rag-ingestion had neither the platform input nor a build-one.sh PLATFORM,
so it produced an amd64 image -> the arm64 Lambda failed every invoke with
Runtime.InvalidEntrypoint (file uploads stuck 'uploading', no embeddings).
Restore the pre-refactor (main) approach: build both arm images on native
ubuntu-24.04-arm runners instead of emulating on amd64.
- backend.yml: build-inference-api and build-rag-ingestion -> runs-on
ubuntu-24.04-arm; drop the QEMU-triggering platform input from
inference-api (native build needs no emulation).
- build-one.sh: rag-ingestion PLATFORM=linux/arm64 (explicit native build).
Deploy jobs stay on ubuntu-24.04 (API-only, no docker build). Note: the
stale amd64 rag-ingestion ECR image must be deleted once so the content-hash
build doesn't skip the corrected arm64 build.
* feat(skills): gate skills feature behind SKILLS_ENABLED flag (default off) (#497)
* feat(skills): gate skills feature behind SKILLS_ENABLED flag (default off)
Defer the skills feature (user picker, admin catalog, skills mode) for a
release without removing any merged code. A new
apis/shared/feature_flags.py::skills_enabled() reads SKILLS_ENABLED
(default false), mirroring the FINE_TUNING_ENABLED precedent. Deployed
environments go dark automatically because the env var is absent; set
SKILLS_ENABLED=true on both app-api and inference-api to re-enable.
Gated surfaces (code and data left intact):
- app-api main.py: user-facing skills router mounted only when enabled.
- app-api admin/routes.py: admin skills + chat-mode-policy routers gated.
- app-api system/routes.py: GET /system/chat-settings reports chat/no-toggle/
skillsEnabled:false when off (new skills_enabled field on the response).
- inference-api chat/routes.py: force skill -> chat when off (voice/other
agent types untouched).
- SPA: ChatModeService.skillsEnabled drives admin nav hide; mode toggle and
skills section auto-hide via allowModeToggle:false; SkillService eager load
gated by an effect so a disabled env never fires the now-404 GET /skills/.
Tests default off; skills-mode tests opt in via SKILLS_ENABLED=true and new
off-behavior tests cover the forced-chat path and admin mount gating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(model-settings): mock Skill/ChatMode services to stop teardown rejection
model-settings.spec instantiated the real SkillService and ChatModeService,
which fire /skills/ (now via an effect) and /system/chat-settings on
construction. With no httpMock those requests fail asynchronously, and the
SKILLS_ENABLED change shifted the timing so a console.error landed during
worker teardown — surfacing as an unhandled EnvironmentTeardownError that
failed the vitest run with exit 1 even though all 1218 tests passed.
Provide minimal mocks for both services so the spec fires no stray async
work. Full suite exits 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(tools): forward admin OIDC token on MCP tool discovery (#498)
The admin "discover from server" button signs its request as the app-api
task role (SigV4, service=lambda), but that role has no
lambda:InvokeFunctionUrl — only inference-api does. Against an
AuthType=AWS_IAM Lambda Function URL the signed request is rejected with
403, which surfaces during MCP client init as an anyio TaskGroup
ExceptionGroup and falls through to a generic 502.
For same-team MCP servers that validate a forwarded user JWT (Lambda URL
AuthType=NONE), discovery should mirror the runtime forward_auth_token
path and sign with the admin's own OIDC token instead of SigV4. Add a
forward_auth_token flag to MCPDiscoverRequest; when set, the discover
route forwards admin.raw_token as the bearer (400 if unavailable) and
skips SigV4. Provider-gated OAuth (3LO) discovery is still rejected — the
admin session can't supply an end-user provider token.
Wire the flag through the admin tool form's discover call so the existing
"Forward app authentication token" checkbox governs discovery too.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(nightly): auto-teardown ephemeral nightly deploys; fix teardown for single-stack (#499)
Reconcile the nightly pipeline and teardown scripts with the single
PlatformStack, API-driven-deploy architecture.
nightly-deploy-pipeline.yml: restore the workflow_call input contract the
orchestrator still passes (ref, project-prefix, alb-subdomain, skip-teardown,
label, source-project-prefix, run-e2e). Every job now checks out inputs.ref and
deploys to the ephemeral inputs.project-prefix (never the shared environment).
Add an always() teardown job (needs all deploy/test jobs, gated on
skip-teardown) so every nightly stack is destroyed even on partial/failed
deploys -- no paying for idle resources. Ephemeral env runs with no custom
domain and an unset CDK_COGNITO_DOMAIN_PREFIX (defaults to the unique prefix).
scripts/nightly/teardown.sh: delete <prefix>-PlatformStack via cloudformation
delete-stack + wait (was a dead cdk-destroy loop over removed per-stack names).
scripts/teardown/destroy.sh: add PlatformStack to the foundation phase while
keeping legacy InfrastructureStack/app-stack handling, so the manual teardown
works for both single-stack and legacy deployments.
* fix(app-api): grant secretsmanager:PutSecretValue on auth-provider secret (#501)
The admin auth-providers endpoints (POST/DELETE /admin/auth-providers) write
the provider client-secret bag back to the auth-provider-secrets secret via
PutSecretValue (apis/shared/auth_providers/repository.py), but the App API
task role was only granted GetSecretValue. Configuring/removing an auth
provider failed with AccessDeniedException on secretsmanager:PutSecretValue.
Add a least-privilege PutSecretValue statement scoped to just the
auth-provider-secrets secret (trailing wildcard matches the random ARN
suffix). No other runtime-written secret needs this.
* Fix/nightly (#500)
* fix(nightly): auto-teardown ephemeral nightly deploys; fix teardown for single-stack
Reconcile the nightly pipeline and teardown scripts with the single
PlatformStack, API-driven-deploy architecture.
nightly-deploy-pipeline.yml: restore the workflow_call input contract the
orchestrator still passes (ref, project-prefix, alb-subdomain, skip-teardown,
label, source-project-prefix, run-e2e). Every job now checks out inputs.ref and
deploys to the ephemeral inputs.project-prefix (never the shared environment).
Add an always() teardown job (needs all deploy/test jobs, gated on
skip-teardown) so every nightly stack is destroyed even on partial/failed
deploys -- no paying for idle resources. Ephemeral env runs with no custom
domain and an unset CDK_COGNITO_DOMAIN_PREFIX (defaults to the unique prefix).
scripts/nightly/teardown.sh: delete <prefix>-PlatformStack via cloudformation
delete-stack + wait (was a dead cdk-destroy loop over removed per-stack names).
scripts/teardown/destroy.sh: add PlatformStack to the foundation phase while
keeping legacy InfrastructureStack/app-stack handling, so the manual teardown
works for both single-stack and legacy deployments.
* fix(nightly): restore test-infra/backend/frontend gates in deploy pipeline
The pipeline rewrite for ephemeral auto-teardown dropped the per-pipeline
test gates, breaking infrastructure/test/repo-shape.test.ts which requires:
- build-*/code-deploy-* jobs gate on test-backend
- deploy-frontend gates on test-frontend
- deploy-platform gates on test-infra
Re-add the three test jobs (checking out inputs.ref) and the needs edges,
keeping the input-driven ephemeral deploy + always() teardown intact. Also
add the test jobs to teardown's needs so teardown waits for the whole graph.
Verified: npx jest repo-shape passes (49/49); both nightly workflow YAMLs
parse and all orchestrator calls pass only declared inputs.
* docs(infra): correct stale SSM comment on mcp-sandbox origin wiring (#502)
The McpSandboxDistributionConstruct doc comment claimed it publishes the
proxy origin to SSM at `/{prefix}/mcp-sandbox/origin`. That was true of the
pre-#396 standalone McpSandboxStack, but the single-stack consolidation
dropped the SSM publication: the origin is now exposed as `proxyOrigin` and
threaded through PlatformComputeRefs straight into inference-api's
`AGENTCORE_MCP_APPS_SANDBOX_ORIGIN` env var. The stale comment misleads
anyone debugging the sandbox (a missing SSM param looks like a broken
deploy when it is expected). Update the comment to match the code.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(kaizen): weekly research scan 2026-06-19 (#493)
Generated by the kaizen-research skill. Top 5 ideas appended to
docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp-apps): accept spec-array content in ui/message (#503)
The ui/message bridge handler read `params.content` as a single block
({type,text}), but per SEP-1865 / the ext-apps SDK the View sends an ARRAY
of content blocks (content: [{type:'text',text}]). Every spec-compliant
widget message was therefore rejected with -32000 "Invalid ui/message
params" (e.g. an MCP App's app.sendMessage()).
Read `content` as an array and concatenate its text blocks (mirrors the
ui/update-model-context handler). Update MessageParams to the array shape,
fix the two bridge specs that sent single objects, and add a multi-block
concatenation regression test.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mcp-apps): retry transient TLS/connect failures on MCP client start (#504)
A single TLS handshake blip (e.g. SSLV3_ALERT_HANDSHAKE_FAILURE from a
TLS-inspecting middlebox) or connection reset when starting an external
MCP client otherwise fails the whole agent build: Strands' start() raises
MCPClientInitializationError, the tool fails to load, and agent creation
errors out for the user.
UICapableMCPClient.start() now retries transient transport failures —
ConnectError/SSLError/timeouts, detected by walking the
MCPClientInitializationError -> ExceptionGroup -> httpx.ConnectError chain
— up to 3 attempts with exponential backoff. Non-transient errors (bad
URL, auth, protocol) are re-raised on the first attempt. Strands resets
its init future + background thread on failure (stop()), so re-invoking
start() is safe. Covers both external and gateway clients.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mcp-apps): give widget ui/message turns the loading + scroll affordances (#505)
A user turn initiated by an MCP App widget (ui/message → submitChatRequest)
appeared in the conversation but skipped the two affordances the composer
path triggers: the loading indicator (the page sets chatLoading before
submitting) and the scroll-to-top of the new user message (chat-container's
post-submit setTimeout). The widget delegate called the service directly,
bypassing the chat-input → chat-container → page chain.
- ChatStateService: add a `scrollToLastUserTick` signal + `requestScrollToLastUser()`.
- ChatContainer: react to the tick by scrolling the last user message to the
top (mirrors onMessageSubmitted; skips the initial 0 so it doesn't scroll on
mount).
- mcp-app-frame widget delegate: set chatLoading(true) and request the scroll
around submitChatRequest (the user message is added synchronously inside it).
The composer path is untouched (it keeps its own setTimeout scroll), so no
existing behavior changes.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(connectors): scaffold export-target adapters for saving conversations to connected apps (#507)
Extends th…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR-6b — runtime increment after PR-6a
Third piece of the admin-managed Skills runtime, following #467 (PR-6a). Spec:
docs/specs/admin-skills-rbac-tool-binding.md— §0.5 (phasing), §8 (runtime integration), §12 (risks).Everything is opt-in via
agent_type="skill". The default"chat"path is untouched, so this is a zero default-behavior-change increment. The default flip +toolTokensmeasurement remain PR-7.1. Fold gateway/external MCP tools behind the meta-tools (the hard part)
PR-6a could only fold local callables. Gateway/external tools materialize as client objects (one
MCPClientexposing many tools), not individual callables — so they were available but not hidden. This PR closes the gap without disturbing client lifecycle:integrations/mcp_tool_folding.py(new) — a per-client fold set. BothFilteredMCPClient(gateway) andUICapableMCPClient(external) drop folded tool names fromlist_tools_sync()— the exact seam Strands uses to build the model's tool list, so a folded tool's schema never rides in context. Setting a fold also invalidates the client's_loaded_toolscache so Strands re-lists with the fold applied (external clients are pre-flighted before folds are known).skills/mcp_binding.py(new) —resolve_mcp_bindingsmaps each non-localbound_tool_id→ its concrete MCP tool(s) + owning client: gateway viaexpand_gateway_tool_ids(cataloggateway_<id>→ runtimegateway_<target>___<tool>, prefix stripped — resolved from the catalog, no session needed); external by enumerating the live client (its session is active post-load_tools). Each becomes aFoldedMCPTooladapter the registry stores soskill_dispatchershows its schema andskill_executorruns it throughMCPClient.call_tool_sync. The client object stays in the agent's tool list (Strands keeps its session alive); only the bound tool's schema is folded out.SkillAgent._bind_mcp_toolswires it after the existing local binding;bind_catalog_toolsnow accepts a list per catalog id (one id can expand to several runtime tools).2. Read-reference-file progressive-disclosure level
skill_dispatcher's previously-unusedreferencearg is now wired: it serves a skill's supporting reference files from the PR-4 S3SkillResourceStoreon demand (the runtime already has bucket read access, granted in PR-6a). The no-arg dispatch response listsavailable_referencesso the model knows which files it can read — mirroring how a realSKILL.mdbody names its files (see validation walk below).3. Bootstrap example-skill seed
seed_example_skills(mirrorsseed_default_tools) seeds one demonstrable bundle —web_research: instructions + a bound local tool (fetch_url_content) + an S3-uploaded reference file (extraction_tips.md) — and grants it to thedefaultrole (RBAC reads precomputedeffectivePermissions.skills, so both that array and the reverseSKILL_GRANT#item are written). The skill-resources bucket name is derived inseed.shfrom the project prefix; reference upload is best-effort (skill still seeds without bytes if the bucket is absent).§0.6 validation walk
Walked Anthropic's real
pdfskill bundle end-to-end:SKILL.md(frontmatter + body) +reference.md/forms.md+scripts/*.py(deferred per §0.3) +LICENSE.txt. Key finding that shaped piece 2: the SKILL.md body names its reference files ("see reference.md", "read forms.md and follow its instructions"), so the dispatcher must surface available filenames and serve them on request. The seed example reproduces this shape.Testing
3711 passed, 3 skipped(the one pre-existing flaky PBT testtest_pbt_auth_sweeppassed this run).test_mcp_binding.py,test_mcp_tool_folding.py, plus reference-file + folded-executor cases intest_skill_registry.py/test_skill_tools.py, andseed_example_skillscases (moto S3 + DynamoDB).tests/architecture/test_import_boundaries.py) green — the runtime reachesSkillResourceStoreonly throughapis.shared.Acceptance
🤖 Generated with Claude Code