mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
feature(skills): keep allowed-tools as compatibility metadata (#6935)
* refactor(skills): keep allowed-tools as compatibility metadata * docs(skills): align current guides with agent tool authority * test(skills): simplify authority regressions and sync Korean docs
This commit is contained in:
parent
61e3513c66
commit
0d73798beb
@ -1002,7 +1002,7 @@ After a restart, the configured refresh token must still be valid.
|
||||
|
||||
Notification launch and failed Agent-run deliveries use capped exponential backoff with a visible attempt count and stop after five failed attempts. When a bounded ordinary release exceeds its drain deadline, the service retains ownership until it settles. A permanently rejected target such as a deleted chat is dead-lettered immediately instead of retried forever or recreated. Cancellation endpoints return after durably recording the request; the background service owns the potentially slow remote MCP call and its retry schedule.
|
||||
|
||||
Notification runs keep their trusted delivery instruction separate from the framed, untrusted remote event payload. The process-started task runtime—not a hot config read—controls whether the task-management tools are exposed, so changing `mcp_tasks` requires a Gateway restart. When a skill's `allowed-tools` policy is active, `list_background_tasks` and `cancel_background_task` must be declared explicitly like other business tools.
|
||||
Notification runs keep their trusted delivery instruction separate from the framed, untrusted remote event payload. The process-started task runtime—not a hot config read—controls whether the task-management tools are exposed, so changing `mcp_tasks` requires a Gateway restart. `list_background_tasks` and `cancel_background_task` are ordinary business tools governed by the agent's own authorization, like every other tool.
|
||||
See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.
|
||||
|
||||
Security: pass per-request MCP credentials only through `config.context.secrets`;
|
||||
@ -1545,9 +1545,9 @@ The **data-analysis** skill preserves each CSV file or Excel sheet when names co
|
||||
|
||||
After an answer loads skills, its toolbar includes **Skills used**. Hover over or click the icon to see the skills and their sources; hovering a skill name underlines it. Select a skill to inspect its `SKILL.md` in the resizable side panel (a drawer on mobile). The view uses snapshots captured during successful configured read-tool loads or explicit slash activation, so later edits or removal of a skill do not rewrite its history. Repeated loads appear once per run, in first-load order. Range reads and bounded snapshots are labeled as partial; older conversations without captured evidence do not show this menu. Copy returns the captured Markdown, including YAML frontmatter. Package-relative links and images remain readable references rather than navigating away from the conversation. Skill loads performed through other tools, such as shell commands, are not inferred from answer text.
|
||||
|
||||
An enabled skill's `allowed-tools` policy applies only after that skill is explicitly slash-activated or captured in the agent's active skill context after a `read_file` load. Merely enabling, advertising, or listing a skill in a custom agent or subagent `skills` allowlist does not reduce that agent's normal toolset; subagents use the same progressive discovery and activation policy as the lead agent. During a slash-activated run, that explicit skill's policy is authoritative: reading another `SKILL.md` may provide instructions but cannot widen the slash skill's tools. Without slash activation, policies from skills actually loaded into active context retain their union semantics. Once active, the policy filters both model-visible tool schemas and tool execution. Framework discovery tools (`tool_search` and `describe_skill`) remain available so an allowed deferred tool or installed skill can still be discovered, but discovery and promotion never grant permission to execute a business tool omitted from `allowed-tools`. `task` is not framework-exempt; a restrictive skill must list it explicitly to delegate to a subagent. Per-step policy decisions are internal runtime context and are removed from observable or persisted context copies. Registry failures and an active set with no remaining valid skill fail closed to framework-safe tools; individual stale paths are ignored only when another valid active skill remains. This is best-effort behavioral scoping, not a hard security boundary: loading skill instructions through another tool is not captured, and active-skill entries can be evicted from bounded context.
|
||||
A skill's `allowed-tools` frontmatter is compatibility metadata carried for interop with other agent frameworks: DeerFlow parses, validates, exports, and describes it, but it never filters model-visible tool schemas, refuses tool calls, or rewrites tool-search promotions. Merely enabling, advertising, or listing a skill in a custom agent or subagent `skills` allowlist never changes an agent's toolset either. Real tool authority lives in Agent configuration and authorization: assembly-time provider filtering, runtime Guardrail enforcement, guardrail allowlists, and the deferred-promotion catalog own the permission ceiling. Skill visibility and activation remain independently enforced: slash activation honors disabled skills and custom-agent whitelists, and the `skill:activate` authorization decision still gates both explicit slash activation and autonomous `SKILL.md` loads.
|
||||
|
||||
When you install `.skill` archives through the Gateway, DeerFlow accepts standard space-separated `allowed-tools`, optional frontmatter metadata, and the Claude-compatible `argument-hint` field instead of rejecting otherwise valid external skills. YAML lists remain supported for `allowed-tools` and preserve exact runtime names. Exact portable spellings such as `WebFetch`, `WebSearch`, `Glob`, `Grep`, and `Read` map to DeerFlow's `web_fetch`, `web_search`, `glob`, `grep`, and `read_file` tools; lowercase or otherwise unknown scalar names remain unchanged so custom and MCP tools keep their exact runtime spelling. Parenthesized entries such as `Bash(tvly *)` are tokenized as one literal entry, including spaces, quoted text, and escaped parentheses, but remain inactive because DeerFlow does not inspect tool arguments; declare `bash` only when the skill may use the full Bash tool.
|
||||
When you install `.skill` archives through the Gateway, DeerFlow accepts standard space-separated `allowed-tools`, optional frontmatter metadata, and the Claude-compatible `argument-hint` field instead of rejecting otherwise valid external skills. YAML lists remain supported for `allowed-tools` and preserve exact runtime names. Exact portable spellings such as `WebFetch`, `WebSearch`, `Glob`, `Grep`, and `Read` map to DeerFlow's `web_fetch`, `web_search`, `glob`, `grep`, and `read_file` tools; lowercase or otherwise unknown scalar names remain unchanged so custom and MCP tools keep their exact runtime spelling. Parenthesized entries such as `Bash(tvly *)` are tokenized as one literal entry, including spaces, quoted text, and escaped parentheses; such scoped declarations remain literal compatibility metadata and do not authorize or restrict commands — DeerFlow's Agent configuration and authorization, not tool arguments, decide what may run.
|
||||
|
||||
Disabling a skill also removes it from the sandbox filesystem view, so shell commands and structured file tools follow the same enabled state. Local, Docker/AIO, hostPath provisioner, and newly created E2B sandboxes source `/mnt/skills` from enabled-only projections that update when public, custom, legacy, or managed integration skills are toggled, edited, created, deleted, or installed. Structured `read_file` calls (including line ranges and read-before-write checks) use the sandbox provider's mount mapping, so the user identity captured when the sandbox was acquired remains authoritative. Managed integration packages remain shared, while their projected filesystem visibility follows each user's enabled state. Multi-worker Gateways re-read on-disk enable state while rebuilding user projections, so a toggle handled by one worker is honored by another worker's next sandbox acquire. Existing E2B sandboxes retain their creation-time snapshot until they are recreated. PVC-backed provisioner skills keep their configured PVC snapshot/layout for now; dynamic PVC materialization is tracked separately.
|
||||
|
||||
|
||||
@ -657,7 +657,7 @@ Gateway는 MCP 서버의 일반 `submit` / `status` / `cancel` 도구를 영속
|
||||
|
||||
알림 실행 시작과 실패한 에이전트 실행 전달은 시도 횟수가 표시되는 상한 있는 지수 백오프를 사용하며, 5번 실패하면 중단합니다. 제한이 있는 일반 해제가 드레인 기한을 넘기면, 서비스는 처리가 끝날 때까지 소유권을 유지합니다. 삭제된 채팅처럼 영구적으로 거부된 대상은 끝없이 재시도하거나 다시 생성하지 않고 즉시 데드 레터로 처리됩니다. 취소 엔드포인트는 요청을 영속적으로 기록한 뒤 반환되며, 시간이 오래 걸릴 수 있는 원격 MCP 호출과 그 재시도 일정은 백그라운드 서비스가 맡습니다.
|
||||
|
||||
알림 실행은 신뢰할 수 있는 전달 지시를, 별도로 감싼 신뢰할 수 없는 원격 이벤트 페이로드와 분리해 유지합니다. 작업 관리 도구의 노출 여부는 실행 중에 다시 읽은 설정이 아니라 프로세스와 함께 시작된 작업 런타임이 결정하므로, `mcp_tasks`를 변경하면 Gateway를 재시작해야 합니다. 스킬의 `allowed-tools` 정책이 활성화되어 있으면 `list_background_tasks`와 `cancel_background_task`도 다른 업무 도구처럼 명시적으로 선언해야 합니다.
|
||||
알림 실행은 신뢰할 수 있는 전달 지시를, 별도로 감싼 신뢰할 수 없는 원격 이벤트 페이로드와 분리해 유지합니다. 작업 관리 도구의 노출 여부는 실행 중에 다시 읽은 설정이 아니라 프로세스와 함께 시작된 작업 런타임이 결정하므로, `mcp_tasks`를 변경하면 Gateway를 재시작해야 합니다. `list_background_tasks`와 `cancel_background_task`는 다른 모든 도구와 마찬가지로 에이전트 자체의 권한 부여가 관리하는 일반 업무 도구입니다.
|
||||
자세한 내용은 [MCP 서버 가이드](backend/docs/MCP_SERVER.md)를 참고하세요.
|
||||
|
||||
보안: 요청별 MCP 자격 증명은 반드시 `config.context.secrets`를 통해서만 전달하세요. 자격 증명은 두 실행 메타데이터 위치(`metadata.auth_token` 또는 `config.metadata.auth_token`) 중 어디에도 절대 넣어서는 안 됩니다. 레거시 메타데이터 자격 증명에서 마이그레이션할 때 지원되는 인터셉터 흐름과, 필요한 자격 증명 교체 및 남아 있는 사본 정리 방법은 [MCP 자격 증명 마이그레이션 및 정리](backend/docs/MCP_SERVER.md#migrating-legacy-mcp-credentials)를 참고하세요.
|
||||
@ -1055,9 +1055,9 @@ DeerFlow 2.0은 더 이상 직접 엮어서 조립하는 프레임워크가 아
|
||||
|
||||
답변이 스킬을 로드하면 해당 답변의 툴바에 **Skills used**가 표시됩니다. 아이콘에 마우스를 올리거나 클릭하면 사용된 스킬과 그 출처를 볼 수 있으며, 스킬 이름에 마우스를 올리면 밑줄이 표시됩니다. 스킬을 선택하면 크기를 조절할 수 있는 사이드 패널(모바일에서는 드로어)에서 해당 `SKILL.md`를 확인할 수 있습니다. 이 보기는 설정된 읽기 도구를 통한 성공적인 로드나 명시적 슬래시 활성화 중에 캡처된 스냅샷을 사용하므로, 이후 스킬을 수정하거나 제거해도 그 기록은 다시 쓰이지 않습니다. 반복된 로드는 실행마다 한 번씩, 처음 로드된 순서대로 표시됩니다. 범위 읽기와 크기 상한이 있는 스냅샷은 부분(partial)으로 표시되며, 캡처된 근거가 없는 이전 대화에는 이 메뉴가 표시되지 않습니다. 복사하면 YAML frontmatter를 포함한 캡처된 Markdown이 반환됩니다. 패키지 기준 상대 링크와 이미지는 대화 밖으로 이동하지 않고 읽을 수 있는 참조로 남습니다. 셸 명령처럼 다른 도구를 통해 수행된 스킬 로드는 답변 텍스트로부터 추론하지 않습니다.
|
||||
|
||||
활성화된 스킬의 `allowed-tools` 정책은 해당 스킬이 명시적으로 슬래시 활성화되었거나, `read_file` 로드 후 에이전트의 활성 스킬 컨텍스트에 캡처된 뒤에만 적용됩니다. 스킬을 단순히 활성화하거나, 알리거나, 커스텀 에이전트 또는 서브에이전트의 `skills` 허용 목록에 나열하는 것만으로는 그 에이전트의 일반 도구 세트가 줄어들지 않습니다. 서브에이전트도 리드 에이전트와 동일한 점진적 탐색 및 활성화 정책을 사용합니다. 슬래시 활성화된 실행 중에는 그 명시적 스킬의 정책이 최종 기준입니다. 다른 `SKILL.md`를 읽으면 지침을 얻을 수는 있지만 슬래시 스킬의 도구 범위를 넓힐 수는 없습니다. 슬래시 활성화가 없으면, 실제로 활성 컨텍스트에 로드된 스킬들의 정책은 합집합 의미를 유지합니다. 정책이 활성화되면 모델에 보이는 도구 스키마와 도구 실행을 모두 필터링합니다. 프레임워크 탐색 도구(`tool_search`와 `describe_skill`)는 계속 사용할 수 있으므로 허용된 지연 도구나 설치된 스킬을 여전히 찾을 수 있지만, 탐색과 승격이 `allowed-tools`에서 빠진 업무 도구를 실행할 권한을 부여하는 일은 결코 없습니다. `task`는 프레임워크 예외 대상이 아닙니다. 제한적인 스킬이 서브에이전트에 위임하려면 이를 명시적으로 나열해야 합니다. 단계별 정책 결정은 내부 런타임 컨텍스트이며, 관찰 가능하거나 영속 저장되는 컨텍스트 사본에서는 제거됩니다. 레지스트리 실패나 유효한 스킬이 하나도 남지 않은 활성 집합은 프레임워크 안전 도구로 실패 시 차단(fail closed)됩니다. 개별 오래된 경로는 다른 유효한 활성 스킬이 남아 있을 때에만 무시됩니다. 이는 최선 노력(best-effort) 방식의 동작 범위 제한일 뿐, 엄격한 보안 경계가 아닙니다. 다른 도구를 통해 스킬 지침을 로드하는 경우는 캡처되지 않으며, 활성 스킬 항목은 크기 상한이 있는 컨텍스트에서 축출될 수 있습니다.
|
||||
스킬의 `allowed-tools` frontmatter는 다른 에이전트 프레임워크와의 상호 운영을 위해 담아 두는 호환성 메타데이터입니다. DeerFlow는 이를 파싱하고, 검증하고, 내보내고, 설명하지만, 모델에 보이는 도구 스키마를 필터링하거나, 도구 호출을 거부하거나, `tool_search` 승격을 다시 쓰는 일은 결코 없습니다. 스킬을 단순히 활성화하거나, 알리거나, 커스텀 에이전트 또는 서브에이전트의 `skills` 허용 목록에 나열하는 것만으로 에이전트의 도구 세트가 달라지지도 않습니다. 실제 도구 권한은 Agent 구성과 권한 부여에 있습니다. 에이전트 조립 시점의 프로바이더 필터링, 런타임 가드레일 적용, 가드레일 허용 목록, 지연 승격 카탈로그가 권한 상한을 정합니다. 스킬 가시성과 활성화 통제는 여전히 별도로 강제됩니다. 슬래시 활성화는 비활성화된 스킬과 커스텀 에이전트 허용 목록을 존중하며, `skill:activate` 권한 부여 결정은 명시적 슬래시 활성화와 자율적인 `SKILL.md` 로드를 모두 계속 제어합니다.
|
||||
|
||||
Gateway를 통해 `.skill` 아카이브를 설치하면, DeerFlow는 표준 공백 구분 `allowed-tools`, 선택적 frontmatter 메타데이터, Claude 호환 `argument-hint` 필드를 받아들이므로 그 외에는 유효한 외부 스킬을 거부하지 않습니다. `allowed-tools`에 YAML 리스트도 계속 사용할 수 있으며, 정확한 런타임 이름이 보존됩니다. `WebFetch`, `WebSearch`, `Glob`, `Grep`, `Read` 같은 정확한 이식용 표기는 DeerFlow의 `web_fetch`, `web_search`, `glob`, `grep`, `read_file` 도구에 매핑됩니다. 소문자이거나 그 밖에 알 수 없는 스칼라 이름은 변경되지 않으므로 커스텀 도구와 MCP 도구는 정확한 런타임 표기를 유지합니다. `Bash(tvly *)`처럼 괄호가 있는 항목은 공백, 따옴표로 감싼 텍스트, 이스케이프된 괄호까지 포함해 하나의 리터럴 항목으로 토큰화되지만, DeerFlow는 도구 인수를 검사하지 않으므로 효력이 없습니다. 스킬이 전체 Bash 도구를 사용할 수 있는 경우에만 `bash`를 선언하세요.
|
||||
Gateway를 통해 `.skill` 아카이브를 설치하면, DeerFlow는 표준 공백 구분 `allowed-tools`, 선택적 frontmatter 메타데이터, Claude 호환 `argument-hint` 필드를 받아들이므로 그 외에는 유효한 외부 스킬을 거부하지 않습니다. `allowed-tools`에 YAML 리스트도 계속 사용할 수 있으며, 정확한 런타임 이름이 보존됩니다. `WebFetch`, `WebSearch`, `Glob`, `Grep`, `Read` 같은 정확한 이식용 표기는 DeerFlow의 `web_fetch`, `web_search`, `glob`, `grep`, `read_file` 도구에 매핑됩니다. 소문자이거나 그 밖에 알 수 없는 스칼라 이름은 변경되지 않으므로 커스텀 도구와 MCP 도구는 정확한 런타임 표기를 유지합니다. `Bash(tvly *)`처럼 괄호가 있는 항목은 공백, 따옴표로 감싼 텍스트, 이스케이프된 괄호까지 포함해 하나의 리터럴 항목으로 토큰화됩니다. 이런 범위 지정 선언은 문자 그대로의 호환성 메타데이터로 남아 어떤 명령의 실행 권한도 부여하거나 제한하지 않으며, 무엇을 실행할 수 있는지는 도구 인수가 아니라 DeerFlow의 Agent 구성과 권한 부여가 결정합니다.
|
||||
|
||||
스킬을 비활성화하면 샌드박스 파일 시스템 보기에서도 제거되므로, 셸 명령과 구조화된 파일 도구가 동일한 활성화 상태를 따릅니다. Local, Docker/AIO, hostPath provisioner, 그리고 새로 생성된 E2B 샌드박스는 활성화된 스킬만 담은 투영(projection)에서 `/mnt/skills`를 가져오며, 이 투영은 public, custom, legacy 또는 관리형 통합 스킬이 켜고 꺼지거나, 수정·생성·삭제·설치될 때 갱신됩니다. 구조화된 `read_file` 호출(줄 범위 읽기와 쓰기 전 읽기 검사 포함)은 샌드박스 프로바이더의 마운트 매핑을 사용하므로, 샌드박스를 획득할 때 캡처된 사용자 식별 정보가 계속 기준이 됩니다. 관리형 통합 패키지는 공유된 상태로 유지되지만, 투영된 파일 시스템에서의 가시성은 각 사용자의 활성화 상태를 따릅니다. 멀티 워커 Gateway는 사용자 투영을 다시 만들 때 디스크의 활성화 상태를 다시 읽으므로, 한 워커가 처리한 토글이 다른 워커의 다음 샌드박스 획득에도 반영됩니다. 기존 E2B 샌드박스는 다시 생성될 때까지 생성 시점의 스냅샷을 유지합니다. PVC 기반 provisioner 스킬은 당분간 설정된 PVC 스냅샷/레이아웃을 유지하며, 동적 PVC 구체화는 별도로 추적됩니다.
|
||||
|
||||
|
||||
@ -565,7 +565,7 @@ O Gateway consegue adaptar as ferramentas comuns `submit` / `status` / `cancel`
|
||||
|
||||
O disparo de notificações e as entregas de runs do Agent que falharam usam backoff exponencial com teto e contagem de tentativas visível, e param depois de cinco tentativas malsucedidas. Quando uma liberação comum com limite ultrapassa o prazo de drenagem, o serviço mantém a posse até ela se resolver. Um destino rejeitado de forma permanente, como um chat excluído, vai imediatamente para dead-letter em vez de ser tentado para sempre ou recriado. Os endpoints de cancelamento retornam depois de registrar o pedido de forma durável; o serviço em background é dono da chamada MCP remota, que pode ser lenta, e do cronograma de novas tentativas.
|
||||
|
||||
Os runs de notificação mantêm a instrução de entrega confiável separada do payload de evento remoto, que é não confiável e vem emoldurado. É o runtime de tasks iniciado com o processo, e não uma leitura a quente da configuração, que controla se as ferramentas de gerenciamento de tasks ficam expostas, então mudar `mcp_tasks` exige reiniciar o Gateway. Quando a política `allowed-tools` de uma skill está ativa, `list_background_tasks` e `cancel_background_task` precisam ser declaradas explicitamente, como as outras ferramentas de negócio.
|
||||
Os runs de notificação mantêm a instrução de entrega confiável separada do payload de evento remoto, que é não confiável e vem emoldurado. É o runtime de tasks iniciado com o processo, e não uma leitura a quente da configuração, que controla se as ferramentas de gerenciamento de tasks ficam expostas, então mudar `mcp_tasks` exige reiniciar o Gateway. Um `allowed-tools` de skill é apenas metadado de compatibilidade e nunca restringe ou concede ferramentas.
|
||||
Veja o [Guia do servidor MCP](backend/docs/MCP_SERVER.md) para instruções detalhadas.
|
||||
|
||||
Segurança: passe credenciais MCP por requisição apenas por `config.context.secrets`; as credenciais nunca devem ser colocadas em nenhuma das superfícies de metadados do run (`metadata.auth_token` ou `config.metadata.auth_token`). Veja em [MCP credential migration and cleanup](backend/docs/MCP_SERVER.md#migrating-legacy-mcp-credentials) o fluxo de interceptor suportado e a rotação e limpeza de cópias retidas exigidas ao migrar de credenciais legadas em metadados.
|
||||
@ -928,9 +928,9 @@ O usuário pode ativar explicitamente uma skill habilitada por um único turno c
|
||||
|
||||
Depois que uma resposta carrega skills, a barra de ferramentas dela passa a incluir **Skills used**. Passe o mouse ou clique no ícone para ver as skills e suas origens; passar o mouse sobre o nome de uma skill o sublinha. Selecione uma skill para inspecionar o `SKILL.md` no painel lateral redimensionável (uma gaveta no celular). A visão usa snapshots capturados durante carregamentos bem-sucedidos pela ferramenta de leitura configurada ou durante a ativação explícita por barra, então edições posteriores ou a remoção de uma skill não reescrevem esse histórico. Carregamentos repetidos aparecem uma vez por run, na ordem do primeiro carregamento. Leituras por intervalo e snapshots limitados são rotulados como parciais; conversas antigas sem evidência capturada não mostram esse menu. Copiar devolve o Markdown capturado, incluindo o frontmatter YAML. Links e imagens relativos ao pacote continuam como referências legíveis, em vez de navegar para fora da conversa. Carregamentos de skill feitos por outras ferramentas, como comandos de shell, não são inferidos a partir do texto da resposta.
|
||||
|
||||
A política `allowed-tools` de uma skill habilitada só se aplica depois que essa skill é ativada explicitamente por barra ou capturada no contexto de skills ativas do agente após um carregamento via `read_file`. Apenas habilitar, anunciar ou listar uma skill na allowlist `skills` de um agente personalizado ou subagent não reduz o conjunto normal de ferramentas desse agente; os subagents usam a mesma política de descoberta e ativação progressivas do lead agent. Durante um run ativado por barra, a política dessa skill explícita é a que manda: ler outro `SKILL.md` pode fornecer instruções, mas não amplia as ferramentas da skill da barra. Sem ativação por barra, as políticas das skills realmente carregadas no contexto ativo mantêm a semântica de união. Uma vez ativa, a política filtra tanto os schemas de ferramentas visíveis ao modelo quanto a execução de ferramentas. As ferramentas de descoberta do framework (`tool_search` e `describe_skill`) continuam disponíveis para que uma ferramenta adiada permitida ou uma skill instalada ainda possam ser descobertas, mas descoberta e promoção nunca concedem permissão para executar uma ferramenta de negócio que não esteja em `allowed-tools`. `task` não é isenta pelo framework; uma skill restritiva precisa listá-la explicitamente para delegar a um subagent. As decisões de política por passo são contexto interno do runtime e são removidas das cópias de contexto observáveis ou persistidas. Falhas do registro e um conjunto ativo sem nenhuma skill válida restante fecham em modo seguro, restrito às ferramentas seguras do framework; caminhos obsoletos individuais só são ignorados quando resta outra skill ativa válida. Trata-se de um escopo comportamental best-effort, não de uma fronteira de segurança rígida: carregar instruções de skill por outra ferramenta não é capturado, e entradas de skills ativas podem ser removidas de um contexto limitado.
|
||||
O `allowed-tools` no frontmatter de uma skill é metadado de compatibilidade mantido para interoperabilidade com outros frameworks de agentes: o DeerFlow o analisa, valida, exporta e descreve, mas ele nunca filtra os schemas de ferramentas visíveis ao modelo, recusa chamadas de ferramentas nem reescreve promoções do tool-search. Meramente habilitar, anunciar ou listar uma skill na allowlist `skills` de um agente personalizado ou subagent também nunca altera o conjunto de ferramentas desse agente. A autoridade real sobre ferramentas vive na configuração do Agent e na autorização: a filtragem de provedores no momento da montagem, a imposição do Guardrail em runtime, as allowlists de guardrail e o catálogo de promoção adiada definem o teto de permissões. A visibilidade e a ativação de skills continuam impostas de forma independente: a ativação por barra respeita skills desabilitadas e whitelists de agentes personalizados, e a decisão de autorização `skill:activate` continua controlando tanto a ativação explícita por barra quanto os carregamentos autônomos de `SKILL.md`.
|
||||
|
||||
Ao instalar arquivos `.skill` pelo Gateway, o DeerFlow aceita o `allowed-tools` padrão separado por espaços, metadados opcionais de frontmatter e o campo `argument-hint` compatível com o Claude, em vez de rejeitar skills externas que de resto são válidas. Listas YAML continuam suportadas em `allowed-tools` e preservam os nomes exatos de runtime. Grafias portáteis exatas como `WebFetch`, `WebSearch`, `Glob`, `Grep` e `Read` são mapeadas para as ferramentas `web_fetch`, `web_search`, `glob`, `grep` e `read_file` do DeerFlow; nomes escalares em minúsculas ou desconhecidos ficam como estão, para que ferramentas personalizadas e MCP mantenham a grafia exata de runtime. Entradas entre parênteses como `Bash(tvly *)` são tokenizadas como uma única entrada literal, incluindo espaços, texto entre aspas e parênteses escapados, mas ficam inativas porque o DeerFlow não inspeciona argumentos de ferramentas; declare `bash` apenas quando a skill puder usar a ferramenta Bash completa.
|
||||
Ao instalar arquivos `.skill` pelo Gateway, o DeerFlow aceita o `allowed-tools` padrão separado por espaços, metadados opcionais de frontmatter e o campo `argument-hint` compatível com o Claude, em vez de rejeitar skills externas que de resto são válidas. Listas YAML continuam suportadas em `allowed-tools` e preservam os nomes exatos de runtime. Grafias portáteis exatas como `WebFetch`, `WebSearch`, `Glob`, `Grep` e `Read` são mapeadas para as ferramentas `web_fetch`, `web_search`, `glob`, `grep` e `read_file` do DeerFlow; nomes escalares em minúsculas ou desconhecidos ficam como estão, para que ferramentas personalizadas e MCP mantenham a grafia exata de runtime. Entradas entre parênteses como `Bash(tvly *)` são tokenizadas como uma única entrada literal, incluindo espaços, texto entre aspas e parênteses escapados; essas declarações com escopo permanecem metadados literais de compatibilidade e não autorizam nem restringem comandos — a configuração do Agent e a autorização do DeerFlow, não os argumentos de ferramentas, decidem o que pode rodar.
|
||||
|
||||
Desabilitar uma skill também a remove da visão de sistema de arquivos do sandbox, de modo que comandos de shell e ferramentas estruturadas de arquivo seguem o mesmo estado de habilitação. Os sandboxes Local, Docker/AIO, provisioner com hostPath e os E2B recém-criados montam `/mnt/skills` a partir de projeções que contêm só as skills habilitadas, atualizadas quando skills públicas, personalizadas, legadas ou de integração gerenciada são alternadas, editadas, criadas, excluídas ou instaladas. As chamadas estruturadas de `read_file` (incluindo intervalos de linhas e verificações de leitura antes da escrita) usam o mapeamento de mounts do provedor de sandbox, então a identidade de usuário capturada na aquisição do sandbox continua sendo a que vale. Os pacotes de integração gerenciados continuam compartilhados, enquanto a visibilidade projetada deles no sistema de arquivos segue o estado de habilitação de cada usuário. Gateways com vários workers releem o estado de habilitação em disco ao reconstruir as projeções do usuário, então um toggle tratado por um worker é respeitado na próxima aquisição de sandbox por outro worker. Sandboxes E2B existentes mantêm o snapshot do momento da criação até serem recriados. As skills de provisioner baseadas em PVC mantêm por enquanto o snapshot/layout de PVC configurado; a materialização dinâmica de PVC é acompanhada à parte.
|
||||
|
||||
|
||||
@ -1829,9 +1829,9 @@ Custom agents can restrict which skills they discover and activate by defining a
|
||||
- **`[]` (empty list)**: Disables all skills for this specific agent.
|
||||
- **`["skill-name"]`**: Makes only the explicitly specified skills available.
|
||||
|
||||
This field is a discovery and activation allowlist; it does not activate every listed skill's `allowed-tools` policy when the agent is constructed. Use `tool_groups` to define the agent's baseline tools. A listed skill's policy applies only after slash activation or an actual `SKILL.md` load.
|
||||
This field is a discovery and activation allowlist; a listed skill's `allowed-tools` frontmatter is compatibility metadata only and never filters the agent's tools. Use `tool_groups` to define the agent's baseline tools.
|
||||
|
||||
The same semantics apply to `subagents.agents.<name>.skills` and `subagents.custom_agents.<name>.skills`: omitted or `null` exposes all enabled skills, `[]` exposes none, and a list limits discovery and activation. A passive subagent skill never removes baseline tools; its `allowed-tools` declaration becomes active only after slash activation or a completed `SKILL.md` read.
|
||||
The same semantics apply to `subagents.agents.<name>.skills` and `subagents.custom_agents.<name>.skills`: omitted or `null` exposes all enabled skills, `[]` exposes none, and a list limits discovery and activation. A skill's `allowed-tools` declaration never removes baseline tools; it is compatibility metadata for other agent frameworks.
|
||||
|
||||
`LocalSandboxProvider` enforces this filesystem view through its managed virtual
|
||||
path mappings only. Explicit per-Agent skill policies therefore fail closed when
|
||||
|
||||
@ -126,10 +126,10 @@ fail-open and do not change guard behavior. These events are emitted only when
|
||||
the active catalog. `changes.source` distinguishes automatic `routing_hint`
|
||||
promotion from an explicit `tool_search`; the remaining fields contain sorted
|
||||
new tool names, their count, `is_subagent`, and the optional subagent
|
||||
`agent_id`. Explicit-search events observe the final `Command` after the active
|
||||
skill policy has removed denied schemas. Automatic promotion records the graph
|
||||
state transition, but promotion never bypasses the independent skill or
|
||||
authorization execution policies. Repeated model passes or searches that add
|
||||
`agent_id`. Explicit-search events observe the final `Command` returned by the tool middleware chain.
|
||||
Automatic promotion records the graph state transition; promotion is scoped to
|
||||
the agent's admitted deferred catalog and never bypasses the independent
|
||||
assembly or execution authorization. Repeated model passes or searches that add
|
||||
no names emit no event. Queries, routing keywords, catalog hashes, tool schemas
|
||||
and descriptions, arguments, and results are not copied into this middleware
|
||||
event. Other event types retain their existing payload contracts.
|
||||
|
||||
@ -16,9 +16,7 @@ same resulting prompt. Empty overlays preserve bytes.
|
||||
runtime overrides, rendered prompt hash, authorized tools and middleware order.
|
||||
Consumers must unwrap via `runtime/runs/worker.py::_agent_graph` to also accept
|
||||
third-party factories returning bare graphs.
|
||||
The hashed skill catalog preserves `allowed_tools=None` (legacy allow-all),
|
||||
`()` (explicit allow-none for business tools), and non-empty allowlists as
|
||||
distinct policy states; allowlist ordering does not affect the fingerprint.
|
||||
The hashed skill catalog preserves `allowed_tools=None` (field omitted), `()` (declared empty), and non-empty allowlists as distinct compatibility-metadata states; allowlist ordering does not affect the fingerprint. The field is exported metadata only and never filters tools.
|
||||
- Dynamic model selection via `create_chat_model()` with thinking/vision support
|
||||
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
|
||||
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
|
||||
|
||||
@ -594,31 +594,14 @@ def build_middlewares(
|
||||
)
|
||||
)
|
||||
|
||||
# Observe the final tool_search Command after every inner policy/result
|
||||
# Observe the final tool_search Command after every inner result
|
||||
# transformer has run. Tool wrappers are first-in-list outermost, so this
|
||||
# must be registered before SkillToolPolicyMiddleware.
|
||||
# must be registered before the deferred filter below.
|
||||
if deferred_setup is not None and deferred_setup.deferred_names:
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
|
||||
middlewares.append(DeferredToolPromotionAuditMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
|
||||
|
||||
# Enabled skills are only discoverable metadata. Apply allowed-tools at
|
||||
# runtime after explicit slash activation or an actual skill-file load.
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
middlewares.append(
|
||||
SkillToolPolicyMiddleware(
|
||||
available_skills=available_skills,
|
||||
app_config=resolved_app_config,
|
||||
user_id=user_id,
|
||||
slash_source_owner_token=slash_source_owner_token,
|
||||
# Persisted skill_context entries are re-authorized against the
|
||||
# skill:activate decision before their allowed-tools apply (the
|
||||
# policy may have changed since the entry was stamped).
|
||||
skill_authorization=skill_authorization,
|
||||
)
|
||||
)
|
||||
|
||||
# Capture completed task delegations and loaded skill files before
|
||||
# summarization can compact them, then inject durable context channels
|
||||
# (summary + ledger + skills) into model calls.
|
||||
@ -706,8 +689,8 @@ def build_middlewares(
|
||||
|
||||
# Hide deferred tool schemas from model binding until tool_search promotes them.
|
||||
# The lead deferred set + catalog hash come from the full build-time MCP
|
||||
# catalog; SkillToolPolicyMiddleware separately filters model visibility,
|
||||
# tool_search results, and execution for the active skill at runtime.
|
||||
# catalog. Skill `allowed-tools` is compatibility metadata only; agent
|
||||
# assembly/execution authorization owns the permission ceiling.
|
||||
if deferred_setup is not None and deferred_setup.deferred_names:
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
|
||||
@ -941,6 +941,8 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
||||
- Follow the activated skill before choosing a general workflow.
|
||||
- The runtime injects the activated skill content for explicit slash activations; do not call `read_file` for that SKILL.md again unless the injected skill references supporting resources you need.
|
||||
|
||||
**Allowed tools:** A skill's `allowed-tools` frontmatter is compatibility metadata carried for other agent frameworks. It never restricts or grants tools here; your tools are controlled by DeerFlow's Agent configuration and authorization.
|
||||
|
||||
**Skills are located at:** {container_base_path}
|
||||
{skill_evolution_section}
|
||||
{skills_list}
|
||||
|
||||
@ -110,41 +110,40 @@ Before changing a later authorization phase, read the [authorization RFC](../../
|
||||
**Lead-only middlewares** (`build_middlewares`, appended after the base):
|
||||
|
||||
17. **DynamicContextMiddleware** - Injects date and optional memory outside the static prompt. Opt-out removes its frozen server memory but retains date/user messages. Date follows server-local time unless `DEER_FLOW_DATE_TIMEZONE` names an IANA zone (invalid values fall back locally). `context.client` adds one framed, request-only `<client_context>` message (untrusted; never checkpointed).
|
||||
18. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event; a retried activation call replays the recorded reminder without re-auditing
|
||||
19. **SkillToolPolicyMiddleware** - Applies `allowed-tools` only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses `skill_context` as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured `read_file` loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. `tool_search` and `describe_skill` remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by `runtime.secret_context` and included in `REDACTED_CONTEXT_KEYS` for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as `bash cat` are not captured, and bounded autonomous `skill_context` can evict old entries. `task` is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after `SkillActivationMiddleware` (which publishes the slash source through `runtime.secret_context`'s public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before `DurableContextMiddleware`; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking.
|
||||
20. **DurableContextMiddleware** - Before compaction, captures task dispatch/result summaries in `ThreadState.delegations` and loaded skill name/path/description (never bodies) in `ThreadState.skill_context`; projects them per request. Static authority uses `SystemMessage`; untrusted `summary_text`, delegation results and skill descriptions use a separate hidden `HumanMessage` data block, never persisted as messages or promoted to system instructions. Subagents attach it immediately before summarization: summary precedes preserved AI/tool history for strict providers. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope.
|
||||
21. **SummarizationMiddleware** - *(optional)* Compacts near token limits. Fraction trigger/keep profiles belong to the active run model; an explicit summary model only generates. Memory flush/manual compaction follow runtime/checkpoint agent policy. Preserve the latest user request and DynamicContext reminders; a backward cutoff may retain old AI/tool turns or make first-turn compaction a no-op.
|
||||
22. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly. A raising model call requeues its reminder, uncounted, for the retry.
|
||||
23. **TokenUsageMiddleware** - *(optional, if `token_usage.enabled`)* Records token usage metrics; subagent usage is read from terminal `ToolMessage.additional_kwargs` in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with `subagent_token_usage_attributed=true`, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable.
|
||||
18. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event; a retried activation call replays the recorded reminder without re-auditing. Skill `allowed-tools` is compatibility metadata only: activation never filters, grants, or denies tools — Agent assembly/execution authorization owns the permission ceiling.
|
||||
19. **DurableContextMiddleware** - Before compaction, captures task dispatch/result summaries in `ThreadState.delegations` and loaded skill name/path/description (never bodies) in `ThreadState.skill_context`; projects them per request. Static authority uses `SystemMessage`; untrusted `summary_text`, delegation results and skill descriptions use a separate hidden `HumanMessage` data block, never persisted as messages or promoted to system instructions. Subagents attach it immediately before summarization: summary precedes preserved AI/tool history for strict providers. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope.
|
||||
20. **SummarizationMiddleware** - *(optional)* Compacts near token limits. Fraction trigger/keep profiles belong to the active run model; an explicit summary model only generates. Memory flush/manual compaction follow runtime/checkpoint agent policy. Preserve the latest user request and DynamicContext reminders; a backward cutoff may retain old AI/tool turns or make first-turn compaction a no-op.
|
||||
21. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly. A raising model call requeues its reminder, uncounted, for the retry.
|
||||
22. **TokenUsageMiddleware** - *(optional, if `token_usage.enabled`)* Records token usage metrics; subagent usage is read from terminal `ToolMessage.additional_kwargs` in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with `subagent_token_usage_attributed=true`, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable.
|
||||
TODO attribution skips non-string statuses before membership; preserve args/TODO state for normal tool validation.
|
||||
24. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint.
|
||||
25. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses); captures the runtime-resolved user so standalone LangGraph Server reads and writes stay in the same bucket
|
||||
26. **ViewImageMiddleware** - *(vision models only)* Enforces the shared vision cap on remote rereads (see [download contract](../../sandbox/AGENTS.md)). Rebuilds hidden base64 context in `ModelRequest.messages`, keeping lightweight `viewed_images` in checkpoints. Caps each request at four recent images and 28 MiB base64; constructor limits may tighten caps. Skips over-budget reads. Re-views refresh reducer recency. Validated blob refs precede compatibility paths. `_image_injection_plan` removes stale server-marked context, including old `before_model`/`after_model` payloads, then shares eligibility for sync/async injection. Removal requires both the reserved ID prefix and marker, preserving user and unmarked legacy messages. Both paths recheck `sandbox:execute` before I/O. Host copies require `actual_path` to match the current user/thread's canonical virtual path, even for old checkpoints or replacement sandboxes; absent/mismatched identity skips reads. Only the async wrapper passes `authorization_checked=True` after its gate to avoid duplicate checks in its worker.
|
||||
27. **McpRoutingMiddleware** - Auto-promotes deferred schemas matching the latest real user message before `DeferredToolFilterMiddleware`, without executing tools. `message_utils` owns turn IDs and legacy resume detection. Routing preserves same-turn search names during migration. New names emit `middleware:tool_promotion` with `source=routing_hint`; repeat passes emit nothing
|
||||
28. **DeferredToolPromotionAuditMiddleware** - Observes final `tool_search` `Command`s; keep it outer of `SkillToolPolicyMiddleware` so denied names are excluded. It atomically claims new names per lead run or subagent execution to dedupe parallel searches, derives subagent attribution from the server-installed recorder, returns the original `Command`, and omits private payloads
|
||||
29. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides MCP schemas until search/routing promotes them. Promotions union within the catalog/user-message scope and reset next turn. Legacy state without `turn_id` survives only when an AI response follows the latest user message (in-flight resume).
|
||||
30. **SystemMessageCoalescingMiddleware** - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest `dynamic_context_reminder` SystemMessage survives. The subagent builder places its date-only context middleware immediately before this coalescer, so the built-in subagent prompt and hidden date reminder still reach providers as one leading system block
|
||||
31. **SubagentLimitMiddleware** - `subagent_enabled`: caps ordinary `task` calls per response (`max_concurrent_subagents`, resolved before construction against startup `subagent_runtime.max_running` and 1-64) and per run (`max_total_subagents` override or `subagents.max_total_per_run`, default 6, clamped 1-50). Count current-run durable ledger entries, tagged with `run_id` on capture: repeated planning shares the cap; later user runs get a fresh budget. Durable `batch_task` has separate persisted total/live/running limits and no ordinary ledger entries. When exhausted, strip remaining `task` calls, force `finish_reason="stop"`, and append a visible limit note so the agent synthesizes existing results.
|
||||
23. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint.
|
||||
24. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses); captures the runtime-resolved user so standalone LangGraph Server reads and writes stay in the same bucket
|
||||
25. **ViewImageMiddleware** - *(vision models only)* Enforces the shared vision cap on remote rereads (see [download contract](../../sandbox/AGENTS.md)). Rebuilds hidden base64 context in `ModelRequest.messages`, keeping lightweight `viewed_images` in checkpoints. Caps each request at four recent images and 28 MiB base64; constructor limits may tighten caps. Skips over-budget reads. Re-views refresh reducer recency. Validated blob refs precede compatibility paths. `_image_injection_plan` removes stale server-marked context, including old `before_model`/`after_model` payloads, then shares eligibility for sync/async injection. Removal requires both the reserved ID prefix and marker, preserving user and unmarked legacy messages. Both paths recheck `sandbox:execute` before I/O. Host copies require `actual_path` to match the current user/thread's canonical virtual path, even for old checkpoints or replacement sandboxes; absent/mismatched identity skips reads. Only the async wrapper passes `authorization_checked=True` after its gate to avoid duplicate checks in its worker.
|
||||
26. **McpRoutingMiddleware** - Auto-promotes deferred schemas matching the latest real user message before `DeferredToolFilterMiddleware`, without executing tools. `message_utils` owns turn IDs and legacy resume detection. Routing preserves same-turn search names during migration. New names emit `middleware:tool_promotion` with `source=routing_hint`; repeat passes emit nothing
|
||||
27. **DeferredToolPromotionAuditMiddleware** - Observes final `tool_search` `Command`s. It atomically claims new names per lead run or subagent execution to dedupe parallel searches, derives subagent attribution from the server-installed recorder, returns the original `Command`, and omits private payloads
|
||||
28. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides MCP schemas until search/routing promotes them. Promotions union within the catalog/user-message scope and reset next turn. Legacy state without `turn_id` survives only when an AI response follows the latest user message (in-flight resume).
|
||||
29. **SystemMessageCoalescingMiddleware** - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest `dynamic_context_reminder` SystemMessage survives. The subagent builder places its date-only context middleware immediately before this coalescer, so the built-in subagent prompt and hidden date reminder still reach providers as one leading system block
|
||||
30. **SubagentLimitMiddleware** - `subagent_enabled`: caps ordinary `task` calls per response (`max_concurrent_subagents`, resolved before construction against startup `subagent_runtime.max_running` and 1-64) and per run (`max_total_subagents` override or `subagents.max_total_per_run`, default 6, clamped 1-50). Count current-run durable ledger entries, tagged with `run_id` on capture: repeated planning shares the cap; later user runs get a fresh budget. Durable `batch_task` has separate persisted total/live/running limits and no ordinary ledger entries. When exhausted, strip remaining `task` calls, force `finish_reason="stop"`, and append a visible limit note so the agent synthesizes existing results.
|
||||
Capture/retain delegations by `(run_id, tool_call_id)`. Later-run replies cannot close older orphans; missing run IDs match conservatively.
|
||||
32. **LoopDetectionMiddleware** - `loop_detection.enabled`: detects repeated tool-call loops. Hard stops clear structured/raw/content-block calls, force a final text answer, and stamp `loop_capped` via `consume_stop_reason` (#3875 Phase 2, matching `TokenBudgetMiddleware`). Persist first warned-state transitions per call hash/tool-frequency burst and hard stops as `middleware:loop_detection`, with `is_subagent`/optional `agent_id`; omit arguments, message content, results and argument-derived hashes. Ordinary task subagents use dedicated recorder keys via a parent-loop proxy; never pass `RunJournal` into their isolated loop. Durable batch subagents have no parent journal and persist no transitions.
|
||||
31. **LoopDetectionMiddleware** - `loop_detection.enabled`: detects repeated tool-call loops. Hard stops clear structured/raw/content-block calls, force a final text answer, and stamp `loop_capped` via `consume_stop_reason` (#3875 Phase 2, matching `TokenBudgetMiddleware`). Persist first warned-state transitions per call hash/tool-frequency burst and hard stops as `middleware:loop_detection`, with `is_subagent`/optional `agent_id`; omit arguments, message content, results and argument-derived hashes. Ordinary task subagents use dedicated recorder keys via a parent-loop proxy; never pass `RunJournal` into their isolated loop. Durable batch subagents have no parent journal and persist no transitions.
|
||||
New user runs reset budgets/history; same-run goal continuations share it.
|
||||
Warnings allow a final save and one read-before-write retry through non-looping tools; otherwise report unsaved results. Hard stops never permit saves.
|
||||
Isolate sibling warnings and keep lifecycle hooks topology-stable. Before changing this guard, read
|
||||
[Loop detection lifecycle](../../../../../docs/LOOP_DETECTION.md) for
|
||||
fallback identity, cleanup/LRU/reset, severity ordering, and test invariants.
|
||||
33. **TokenBudgetMiddleware** - `token_budget.enabled`: run-ID budgets survive continuations; missing/invalid IDs reset invocation state. With token usage tracking, place CompletedSubagentUsageMiddleware after the guard: reversed `after_model` hooks backfill terminal child usage before enforcement. Keep TokenUsageMiddleware earlier so current-step attribution reflects calls retained by all guards.
|
||||
34. **ModelContentCompatibilityMiddleware** - Read-time sanitizer (`wrap_model_call`/`awrap_model_call`, request view only; never writes state): rewrites URL-sourced `file` blocks (any role, gated on the `url` key; an http(s) URL is preserved when the selected model's serializer accepts it — OpenAI Responses API or Anthropic) and non-fetchable-scheme (non-`http`/`https`/`data`) `image` blocks into text placeholders whose MIME/location metadata passes the shared `neutralize_untrusted_tags` guard, so pre-MCP-fix checkpoints heal on the next turn with no migration. Uses the conversion layer's `resource_placeholder_text`/`model_visible_location` for model-visible text; artifact capture scans state messages, so healing adds neither `resource_links` nor handles. See the MCP guide for conversion and the module docstring for healing; malformed URLs degrade to location-less placeholders instead of raising. Registered after DurableContext/summarization, before the date/coalescer tail.
|
||||
35. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
|
||||
36. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Subagents share the list before their safety tail; separate lead/subagent lists are unsupported. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review.
|
||||
37. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
|
||||
38. **ModelLengthFinishReasonMiddleware** - Responses: same-container `status=incomplete` + `incomplete_details.reason=max_output_tokens`. Suppress calls via shared cloning after clearing invalid calls; keep non-call blocks/metadata. Stamp cap markers; add notice.
|
||||
39. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
40. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []` — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.
|
||||
32. **TokenBudgetMiddleware** - `token_budget.enabled`: run-ID budgets survive continuations; missing/invalid IDs reset invocation state. With token usage tracking, place CompletedSubagentUsageMiddleware after the guard: reversed `after_model` hooks backfill terminal child usage before enforcement. Keep TokenUsageMiddleware earlier so current-step attribution reflects calls retained by all guards.
|
||||
33. **ModelContentCompatibilityMiddleware** - Read-time sanitizer (`wrap_model_call`/`awrap_model_call`, request view only; never writes state): rewrites URL-sourced `file` blocks (any role, gated on the `url` key; an http(s) URL is preserved when the selected model's serializer accepts it — OpenAI Responses API or Anthropic) and non-fetchable-scheme (non-`http`/`https`/`data`) `image` blocks into text placeholders whose MIME/location metadata passes the shared `neutralize_untrusted_tags` guard, so pre-MCP-fix checkpoints heal on the next turn with no migration. Uses the conversion layer's `resource_placeholder_text`/`model_visible_location` for model-visible text; artifact capture scans state messages, so healing adds neither `resource_links` nor handles. See the MCP guide for conversion and the module docstring for healing; malformed URLs degrade to location-less placeholders instead of raising. Registered after DurableContext/summarization, before the date/coalescer tail.
|
||||
34. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
|
||||
35. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Subagents share the list before their safety tail; separate lead/subagent lists are unsupported. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review.
|
||||
36. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success
|
||||
37. **ModelLengthFinishReasonMiddleware** - Responses: same-container `status=incomplete` + `incomplete_details.reason=max_output_tokens`. Suppress calls via shared cloning after clearing invalid calls; keep non-call blocks/metadata. Stamp cap markers; add notice.
|
||||
38. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
39. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []` — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.
|
||||
|
||||
Inline `skill_references` accepts 1–16 names; empty lists retain slash/plain flow.
|
||||
Resolve the whole batch through user storage, enabled state and agent allowlist
|
||||
before activation; reject any invalid entry. Keep bodies in escaped HumanMessage
|
||||
context with task text once. Authenticated paths feed secrets/tool policy; record usage per skill.
|
||||
context with task text once. Authed paths supply secret binding and authorized Skill context; record usage per skill.
|
||||
Legacy slash syntax is unchanged.
|
||||
|
||||
`model_response.py` holds the shared response-classification helpers (`last_ai_message`,
|
||||
|
||||
@ -54,7 +54,8 @@ _SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
|
||||
# misses — resolved per the carried fail-closed/fail-open policy with a
|
||||
# WARNING, never a silent synchronous fallback.
|
||||
# Entries bind nothing for that call instead (fail closed by construction).
|
||||
# Same marker pattern as SkillToolPolicyMiddleware._REGISTRY_LOAD_FAILED.
|
||||
# Same marker pattern as the registry-load marker used by the secret-binding
|
||||
# resolution below.
|
||||
_REGISTRY_LOAD_FAILED = object()
|
||||
|
||||
# _SECRETS_BINDING_AUDIT_KEY: last audited binding (skill and secret names only,
|
||||
@ -845,9 +846,8 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
not keep binding secrets after the provider starts denying activation.
|
||||
|
||||
*exclude_names* carries the slash-activated skill names: explicit slash
|
||||
activation dominates for the rest of the run (the same rule the
|
||||
tool-policy middleware applies), and the slash source is resolved from
|
||||
a fresher registry than the entry snapshot — letting the same skill
|
||||
activation dominates for the rest of the run, and the slash source is resolved
|
||||
from a fresher registry than the entry snapshot — letting the same skill
|
||||
contribute both sources would bind two eras of its declarations when
|
||||
they change across the prepass→activation window. *exclude_paths*
|
||||
carries the authenticated slash identity's container path — the stable
|
||||
|
||||
@ -23,7 +23,7 @@ SKILL_CONTEXT_ENTRY_KEY = "skill_context_entry"
|
||||
# Stamped instead of SKILL_CONTEXT_ENTRY_KEY when the read SKILL.md belongs to
|
||||
# a skill whose action-scoped ``skill:activate`` decision is denied: the file
|
||||
# content was served (Layer 1 visibility allows it) but the skill must not
|
||||
# activate — no durable skill_context entry, tool policy, or secret binding.
|
||||
# activate — no durable skill_context entry or secret binding.
|
||||
# extract_skills uses it to skip the entry without the "missing skill read
|
||||
# metadata" warning that an unstamped read would otherwise produce.
|
||||
SKILL_CONTEXT_DENIED_KEY = "skill_context_denied"
|
||||
|
||||
@ -1,529 +0,0 @@
|
||||
"""Apply skill ``allowed-tools`` only to skills active in lead-agent context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.authz.activation_decisions import ActivationDecisions
|
||||
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, read_slash_skill_source_paths
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
|
||||
from deerflow.skills.tool_policy import ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES, allowed_tool_names_for_skills
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLICY_DECISION_VERSION = 2
|
||||
_POLICY_SOURCE_PASSIVE = "passive"
|
||||
_POLICY_SOURCE_SLASH = "slash"
|
||||
_POLICY_SOURCE_SKILL_CONTEXT = "skill_context"
|
||||
_POLICY_SOURCES = frozenset({_POLICY_SOURCE_PASSIVE, _POLICY_SOURCE_SLASH, _POLICY_SOURCE_SKILL_CONTEXT})
|
||||
_MISSING_POLICY_DECISION = object()
|
||||
# Async-prepass registry load already attempted and failed: the async hook must
|
||||
# not let the worker-side filter silently retry storage — a successful retry
|
||||
# would resolve skills whose names are absent from the (empty) decision batch —
|
||||
# a policy-resolved miss (per the carried fail-closed/fail-open policy, with a
|
||||
# WARNING), never a silent fallback. Tri-state marker
|
||||
# in the style of _MISSING_POLICY_DECISION.
|
||||
# Registry argument handed down the policy-resolution call chain: a loaded
|
||||
# snapshot (dict), the load-failure marker, or None ("load it here" — the
|
||||
# sync-path behavior).
|
||||
type _RegistryArg = dict[str, Skill] | object | None
|
||||
|
||||
_REGISTRY_LOAD_FAILED = object()
|
||||
_TOOL_SEARCH_NAME = "tool_search"
|
||||
|
||||
type _PolicySignature = tuple[str, tuple[str, ...]]
|
||||
|
||||
|
||||
class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Restrict agent tools to declarations from slash/in-context skills.
|
||||
|
||||
Merely enabling a skill makes it discoverable; it does not activate its
|
||||
authority policy. A skill becomes policy-active when the user slash-activates
|
||||
it for the run or after the model loads it into ``skill_context``. Explicit
|
||||
slash activation dominates for the rest of that run: passively reading a
|
||||
second skill cannot widen the slash skill's authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
user_id: str | None = None,
|
||||
slash_source_owner_token: str,
|
||||
skill_authorization=None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if not isinstance(slash_source_owner_token, str) or not slash_source_owner_token:
|
||||
raise ValueError("slash_source_owner_token must be a non-empty string")
|
||||
self._available_skills = set(available_skills) if available_skills is not None else None
|
||||
self._app_config = app_config
|
||||
self._user_id = user_id
|
||||
self._slash_source_owner_token = slash_source_owner_token
|
||||
self._skill_authorization = skill_authorization
|
||||
self._decision_owner_token = secrets.token_urlsafe(24)
|
||||
|
||||
def _activation_allowed(self, skill_name: str, *, activation_decisions: ActivationDecisions | None = None) -> bool:
|
||||
"""Action-scoped ``skill:activate`` decision for one skill name.
|
||||
|
||||
Persisted ``skill_context`` entries were authorized when they were
|
||||
stamped, but the policy may have changed since — every entry is
|
||||
re-authorized before its allowed-tools declaration is applied.
|
||||
*activation_decisions* carries the per-step
|
||||
:class:`~deerflow.authz.activation_decisions.ActivationDecisions`
|
||||
batch precomputed on the event loop via ``aauthorize()`` (async
|
||||
hooks): a covered name returns the batched decision; a miss inside
|
||||
the batch resolves per the carried provider-error policy with a loud
|
||||
WARNING and never touches a provider. ``None`` (the sync hooks, or
|
||||
authorization disabled) is the only case that takes the synchronous
|
||||
check below — the correct API there.
|
||||
"""
|
||||
if self._skill_authorization is None:
|
||||
return True
|
||||
if activation_decisions is not None:
|
||||
# Construction-enforced: a miss inside an async batch resolves per
|
||||
# the provider-error policy with a loud log — it never falls back
|
||||
# to the synchronous authorize() from a worker thread.
|
||||
return activation_decisions.decision_for(skill_name)
|
||||
from deerflow.authz.skill_filter import skill_activation_allowed
|
||||
|
||||
return skill_activation_allowed(self._skill_authorization, skill_name)
|
||||
|
||||
async def _collect_activation_decisions(self, names) -> ActivationDecisions | None:
|
||||
"""Precompute ``skill:activate`` decisions on the event loop (async hooks).
|
||||
|
||||
The policy resolution itself runs in a worker thread (storage reads),
|
||||
but the provider decision belongs on the loop with ``aauthorize()``.
|
||||
*names* must already be canonical (``Skill.name``) — the caller
|
||||
resolves the policy paths through the live registry off-loop first,
|
||||
because the decision consumers (``_active_skills_for_paths``) check
|
||||
the registry skill's declared name. Authorizing a path-derived name
|
||||
here would surface as a batch miss at the consumer, which resolves
|
||||
per the carried fail-closed/fail-open policy with a WARNING (denied
|
||||
under the production default; allowed without any provider consult
|
||||
under fail-open) — visible, but still a wiring bug to fix.
|
||||
"""
|
||||
if self._skill_authorization is None:
|
||||
return None
|
||||
candidates = {name for name in names if isinstance(name, str) and name}
|
||||
from deerflow.authz.skill_filter import skill_activation_allowed_async
|
||||
|
||||
decisions: dict[str, bool] = {}
|
||||
for name in sorted(candidates):
|
||||
decisions[name] = await skill_activation_allowed_async(self._skill_authorization, name)
|
||||
# An empty batch is still a batch — consumers resolving an uncovered
|
||||
# name must fail per policy, not silently take the sync path.
|
||||
return ActivationDecisions(decisions, fail_closed=self._skill_authorization.fail_closed)
|
||||
|
||||
def _resolve_policy_registry(self, paths: tuple[str, ...]) -> tuple[dict[str, Skill] | object, list[str]]:
|
||||
"""Load the path-keyed registry and canonicalize the policy paths.
|
||||
|
||||
Blocking (skill-tree read): worker thread only. Returns the registry,
|
||||
the canonical ``Skill.name`` for every resolvable path, or — when the
|
||||
load fails — the ``_REGISTRY_LOAD_FAILED`` marker with no names. The
|
||||
marker preserves the failure: the async caller hands it down so
|
||||
``_active_skills_for_paths`` applies its fail-closed treatment instead
|
||||
of retrying storage (a transient-failure retry that succeeds would
|
||||
resolve skills whose names are absent from the decision batch — a
|
||||
policy-resolved miss: denied under the production fail-closed
|
||||
default, allowed without any provider consult under fail-open, and
|
||||
logged loudly either way). Unresolvable paths yield no name:
|
||||
``_active_skills_for_paths`` skips them before the activation check,
|
||||
so no decision is needed for them.
|
||||
"""
|
||||
try:
|
||||
from deerflow.skills.container_registry import build_container_path_registry
|
||||
|
||||
registry = build_container_path_registry(self._storage())
|
||||
except Exception:
|
||||
logger.exception("Failed to load active skills for allowed-tools policy")
|
||||
return _REGISTRY_LOAD_FAILED, []
|
||||
canonical: list[str] = []
|
||||
for path in paths:
|
||||
skill = registry.get(posixpath.normpath(path))
|
||||
if skill is not None:
|
||||
canonical.append(skill.name)
|
||||
return registry, canonical
|
||||
|
||||
def _storage(self) -> SkillStorage:
|
||||
if self._user_id is not None:
|
||||
return get_or_new_user_skill_storage(self._user_id, app_config=self._app_config)
|
||||
if self._app_config is not None:
|
||||
return get_or_new_skill_storage(app_config=self._app_config)
|
||||
return get_or_new_skill_storage()
|
||||
|
||||
@staticmethod
|
||||
def _skill_context_entries(request: ModelRequest | ToolCallRequest) -> list:
|
||||
"""Persisted ``skill_context`` entries from agent state (possibly empty)."""
|
||||
state = getattr(request, "state", None)
|
||||
if state is None:
|
||||
state = {}
|
||||
if isinstance(state, Mapping):
|
||||
entries = state.get("skill_context") or []
|
||||
elif hasattr(state, "skill_context"):
|
||||
entries = getattr(state, "skill_context") or []
|
||||
else:
|
||||
logger.warning("Unsupported agent state shape for skill tool policy: %s", type(state).__name__)
|
||||
entries = []
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
logger.warning("Invalid skill_context shape for skill tool policy: %s", type(entries).__name__)
|
||||
entries = []
|
||||
return list(entries)
|
||||
|
||||
def _active_policy(self, request: ModelRequest | ToolCallRequest) -> _PolicySignature:
|
||||
context = getattr(getattr(request, "runtime", None), "context", None)
|
||||
slash_paths = read_slash_skill_source_paths(context, owner_token=self._slash_source_owner_token)
|
||||
if slash_paths:
|
||||
return _POLICY_SOURCE_SLASH, slash_paths
|
||||
|
||||
paths: list[str] = []
|
||||
for entry in self._skill_context_entries(request):
|
||||
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
|
||||
paths.append(entry["path"])
|
||||
if paths:
|
||||
return _POLICY_SOURCE_SKILL_CONTEXT, tuple(paths)
|
||||
return _POLICY_SOURCE_PASSIVE, ()
|
||||
|
||||
def _active_skills_for_paths(
|
||||
self,
|
||||
paths: tuple[str, ...],
|
||||
*,
|
||||
activation_decisions: ActivationDecisions | None = None,
|
||||
registry: _RegistryArg = None,
|
||||
) -> tuple[list[Skill], bool]:
|
||||
if not paths:
|
||||
return [], False
|
||||
|
||||
if registry is _REGISTRY_LOAD_FAILED:
|
||||
# The async prepass already attempted the load and failed. Do not
|
||||
# retry here: a successful retry would resolve skills absent from
|
||||
# the prepass-built decision map and silently fall back to the
|
||||
# synchronous provider API from this worker thread. Preserve the
|
||||
# failure with the same treatment as a first-load failure below.
|
||||
return [], True
|
||||
if registry is None:
|
||||
try:
|
||||
from deerflow.skills.container_registry import build_container_path_registry
|
||||
|
||||
registry = build_container_path_registry(self._storage())
|
||||
except Exception:
|
||||
logger.exception("Failed to load active skills for allowed-tools policy")
|
||||
# A real active reference exists but cannot be authorized. Signal a
|
||||
# policy failure so callers retain only framework-safe tools.
|
||||
return [], True
|
||||
|
||||
active: list[Skill] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
skill = registry.get(posixpath.normpath(path))
|
||||
if skill is None:
|
||||
logger.warning("Active skill path could not be resolved for allowed-tools policy: %s", path)
|
||||
continue
|
||||
if not skill.enabled:
|
||||
logger.warning("Active skill is disabled for allowed-tools policy: %s", path)
|
||||
continue
|
||||
if self._available_skills is not None and skill.name not in self._available_skills:
|
||||
logger.warning("Active skill is outside the agent allowlist for allowed-tools policy: %s", path)
|
||||
continue
|
||||
# Phase 3: skill_context persists across runs — an entry stamped
|
||||
# under an earlier policy must not keep applying its allowed-tools
|
||||
# declaration after the provider starts denying activation (same
|
||||
# skip-and-continue treatment as a disabled or unallowlisted skill).
|
||||
if not self._activation_allowed(skill.name, activation_decisions=activation_decisions):
|
||||
logger.info("Active skill is denied by the skill:activate policy for allowed-tools policy: %s", path)
|
||||
continue
|
||||
if skill.name in seen:
|
||||
continue
|
||||
seen.add(skill.name)
|
||||
active.append(skill)
|
||||
if not active:
|
||||
logger.warning("No active skill references could be authorized for allowed-tools policy; failing closed")
|
||||
return [], True
|
||||
return active, False
|
||||
|
||||
def _allowed_names_for_paths(
|
||||
self,
|
||||
paths: tuple[str, ...],
|
||||
*,
|
||||
activation_decisions: ActivationDecisions | None = None,
|
||||
registry: _RegistryArg = None,
|
||||
) -> set[str] | None:
|
||||
active_skills, policy_failed = self._active_skills_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
|
||||
if policy_failed:
|
||||
return set(ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES)
|
||||
allowed = allowed_tool_names_for_skills(active_skills)
|
||||
if allowed is None:
|
||||
return None
|
||||
return allowed | set(ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES)
|
||||
|
||||
@staticmethod
|
||||
def _runtime_context(request: ModelRequest | ToolCallRequest) -> dict | None:
|
||||
context = getattr(getattr(request, "runtime", None), "context", None)
|
||||
return context if isinstance(context, dict) else None
|
||||
|
||||
def _store_policy_decision(self, request: ModelRequest, policy: _PolicySignature, allowed: set[str] | None) -> None:
|
||||
context = self._runtime_context(request)
|
||||
if context is not None:
|
||||
source, paths = policy
|
||||
context[SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY] = {
|
||||
"version": _POLICY_DECISION_VERSION,
|
||||
"owner_token": self._decision_owner_token,
|
||||
"source": source,
|
||||
"active_paths": list(paths),
|
||||
"allowed_names": None if allowed is None else sorted(allowed),
|
||||
}
|
||||
|
||||
def _read_policy_decision(self, context: dict | None, policy: _PolicySignature) -> set[str] | None | object:
|
||||
if context is None:
|
||||
return _MISSING_POLICY_DECISION
|
||||
decision = context.get(SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY)
|
||||
if not isinstance(decision, dict):
|
||||
return _MISSING_POLICY_DECISION
|
||||
if type(decision.get("version")) is not int or decision["version"] != _POLICY_DECISION_VERSION:
|
||||
return _MISSING_POLICY_DECISION
|
||||
if not isinstance(decision.get("owner_token"), str) or decision["owner_token"] != self._decision_owner_token:
|
||||
return _MISSING_POLICY_DECISION
|
||||
source, paths = policy
|
||||
stored_source = decision.get("source")
|
||||
if not isinstance(stored_source, str) or stored_source not in _POLICY_SOURCES or stored_source != source:
|
||||
return _MISSING_POLICY_DECISION
|
||||
stored_paths = decision.get("active_paths")
|
||||
if not isinstance(stored_paths, list) or not all(isinstance(path, str) for path in stored_paths) or tuple(stored_paths) != paths:
|
||||
return _MISSING_POLICY_DECISION
|
||||
allowed = decision.get("allowed_names")
|
||||
if allowed is None:
|
||||
return None
|
||||
if not isinstance(allowed, list) or not all(isinstance(name, str) for name in allowed):
|
||||
return _MISSING_POLICY_DECISION
|
||||
return set(allowed)
|
||||
|
||||
def _allowed_names(
|
||||
self,
|
||||
request: ModelRequest | ToolCallRequest,
|
||||
*,
|
||||
policy: _PolicySignature | None = None,
|
||||
activation_decisions: ActivationDecisions | None = None,
|
||||
registry: _RegistryArg = None,
|
||||
) -> set[str] | None:
|
||||
resolved_policy = self._active_policy(request) if policy is None else policy
|
||||
_, paths = resolved_policy
|
||||
context = self._runtime_context(request)
|
||||
decision = self._read_policy_decision(context, resolved_policy)
|
||||
if decision is not _MISSING_POLICY_DECISION:
|
||||
return decision
|
||||
return self._allowed_names_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
|
||||
|
||||
def _filter_model_request(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
*,
|
||||
policy: _PolicySignature | None = None,
|
||||
refresh_decision: bool = False,
|
||||
activation_decisions: ActivationDecisions | None = None,
|
||||
registry: _RegistryArg = None,
|
||||
) -> ModelRequest:
|
||||
resolved_policy = self._active_policy(request) if policy is None else policy
|
||||
_, paths = resolved_policy
|
||||
if refresh_decision:
|
||||
allowed = self._allowed_names_for_paths(paths, activation_decisions=activation_decisions, registry=registry)
|
||||
else:
|
||||
allowed = self._allowed_names(request, policy=resolved_policy, activation_decisions=activation_decisions, registry=registry)
|
||||
if refresh_decision:
|
||||
self._store_policy_decision(request, resolved_policy, allowed)
|
||||
if allowed is None:
|
||||
return request
|
||||
tools = [tool for tool in request.tools if getattr(tool, "name", None) in allowed]
|
||||
if len(tools) < len(request.tools):
|
||||
logger.debug("Skill policy filtered %d lead tool schema(s)", len(request.tools) - len(tools))
|
||||
return request.override(tools=tools)
|
||||
|
||||
def _blocked_tool_message(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
*,
|
||||
allowed: set[str] | None,
|
||||
) -> ToolMessage | None:
|
||||
name = str(request.tool_call.get("name") or "")
|
||||
if allowed is None or not name or name in allowed:
|
||||
return None
|
||||
return ToolMessage(
|
||||
content=f"Error: Tool '{name}' is not allowed by the active skill policy.",
|
||||
tool_call_id=str(request.tool_call.get("id") or "missing_tool_call_id"),
|
||||
name=name,
|
||||
status="error",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_search_policy_error(request: ToolCallRequest) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="Error: tool_search returned a result that could not be validated against the active skill policy.",
|
||||
tool_call_id=str(request.tool_call.get("id") or "missing_tool_call_id"),
|
||||
name=_TOOL_SEARCH_NAME,
|
||||
status="error",
|
||||
)
|
||||
|
||||
def _filter_tool_search_result(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
result: ToolMessage | Command,
|
||||
*,
|
||||
allowed: set[str] | None,
|
||||
) -> ToolMessage | Command:
|
||||
"""Remove denied schemas and promotions from tool_search output.
|
||||
|
||||
Keeping tool_search available is safe only if it cannot return a full
|
||||
schema for a tool removed by the active policy. Deferred filtering still
|
||||
controls when an allowed schema becomes model-visible; this method keeps
|
||||
the discovery result itself within the same authorization boundary.
|
||||
"""
|
||||
name = str(request.tool_call.get("name") or "")
|
||||
if name != _TOOL_SEARCH_NAME or allowed is None:
|
||||
return result
|
||||
if not isinstance(result, Command) or not isinstance(result.update, dict):
|
||||
logger.warning("Active-policy tool_search returned an unsupported result shape")
|
||||
return self._tool_search_policy_error(request)
|
||||
|
||||
promoted = result.update.get("promoted")
|
||||
messages = result.update.get("messages")
|
||||
if not isinstance(promoted, dict) or not isinstance(messages, list) or len(messages) != 1:
|
||||
logger.warning("Active-policy tool_search command omitted promoted/messages updates")
|
||||
return self._tool_search_policy_error(request)
|
||||
raw_names = promoted.get("names")
|
||||
if not isinstance(raw_names, list) or not all(isinstance(item, str) for item in raw_names):
|
||||
logger.warning("Active-policy tool_search returned malformed promoted names")
|
||||
return self._tool_search_policy_error(request)
|
||||
|
||||
permitted_names = [item for item in raw_names if item in allowed]
|
||||
sanitized_messages: list[ToolMessage] = []
|
||||
for message in messages:
|
||||
if not isinstance(message, ToolMessage) or message.name != _TOOL_SEARCH_NAME:
|
||||
logger.warning("Active-policy tool_search returned an unexpected message shape")
|
||||
return self._tool_search_policy_error(request)
|
||||
content = message.content
|
||||
if raw_names:
|
||||
try:
|
||||
schemas = json.loads(content) if isinstance(content, str) else None
|
||||
except json.JSONDecodeError:
|
||||
schemas = None
|
||||
if not isinstance(schemas, list):
|
||||
logger.warning("Active-policy tool_search returned schemas that could not be filtered")
|
||||
return self._tool_search_policy_error(request)
|
||||
filtered_schemas = [schema for schema in schemas if isinstance(schema, dict) and (schema.get("name") in permitted_names or (isinstance(schema.get("function"), dict) and schema["function"].get("name") in permitted_names))]
|
||||
content = json.dumps(filtered_schemas, indent=2, ensure_ascii=False) if filtered_schemas else "No tools found matching the active skill policy."
|
||||
sanitized_messages.append(message.model_copy(update={"content": content}))
|
||||
|
||||
sanitized_update = dict(result.update)
|
||||
sanitized_update["promoted"] = {**promoted, "names": permitted_names}
|
||||
sanitized_update["messages"] = sanitized_messages
|
||||
return Command(
|
||||
graph=result.graph,
|
||||
update=sanitized_update,
|
||||
resume=result.resume,
|
||||
goto=result.goto,
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
policy = self._active_policy(request)
|
||||
return handler(self._filter_model_request(request, policy=policy, refresh_decision=True))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
policy = self._active_policy(request)
|
||||
_, paths = policy
|
||||
if not paths:
|
||||
self._store_policy_decision(request, policy, None)
|
||||
return await handler(request)
|
||||
# Three phases, each on the right executor: the registry lookup
|
||||
# (skill-tree read) and the policy filter run in a worker thread,
|
||||
# while the skill:activate re-authorization decisions are awaited on
|
||||
# the event loop (aauthorize) for the *canonical* skill names the
|
||||
# registry resolved — keying the map by path-derived names would miss
|
||||
# and fall back to the sync provider API from the thread (wrong API
|
||||
# for loop-affine providers). The loaded registry is reused by the
|
||||
# filter so the whole hook costs one skill-tree scan.
|
||||
registry, canonical_names = await asyncio.to_thread(self._resolve_policy_registry, paths)
|
||||
activation_decisions = await self._collect_activation_decisions(canonical_names)
|
||||
filtered = await asyncio.to_thread(
|
||||
self._filter_model_request,
|
||||
request,
|
||||
policy=policy,
|
||||
refresh_decision=True,
|
||||
activation_decisions=activation_decisions,
|
||||
registry=registry,
|
||||
)
|
||||
return await handler(filtered)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
policy = self._active_policy(request)
|
||||
if not policy[1]:
|
||||
return handler(request)
|
||||
allowed = self._allowed_names(request, policy=policy)
|
||||
blocked = self._blocked_tool_message(request, allowed=allowed)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return self._filter_tool_search_result(request, handler(request), allowed=allowed)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
policy = self._active_policy(request)
|
||||
if not policy[1]:
|
||||
return await handler(request)
|
||||
# Reuse the current model step's cached decision when present — only a
|
||||
# cache miss pays the three-phase resolution below (mirrors the sync
|
||||
# wrap_tool_call, which reads the same cache inside _allowed_names).
|
||||
cached = self._read_policy_decision(self._runtime_context(request), policy)
|
||||
if cached is not _MISSING_POLICY_DECISION:
|
||||
allowed = cached
|
||||
else:
|
||||
# Same three-phase shape as awrap_model_call: canonical names
|
||||
# resolved off-loop, decisions awaited on the loop, blocking
|
||||
# policy resolution in the thread with the registry reused.
|
||||
registry, canonical_names = await asyncio.to_thread(self._resolve_policy_registry, policy[1])
|
||||
activation_decisions = await self._collect_activation_decisions(canonical_names)
|
||||
allowed = await asyncio.to_thread(
|
||||
self._allowed_names,
|
||||
request,
|
||||
policy=policy,
|
||||
activation_decisions=activation_decisions,
|
||||
registry=registry,
|
||||
)
|
||||
blocked = self._blocked_tool_message(request, allowed=allowed)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return self._filter_tool_search_result(request, await handler(request), allowed=allowed)
|
||||
@ -75,7 +75,8 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
self._skills_root = app_config.skills.container_path
|
||||
|
||||
def _storage(self):
|
||||
# Same resolution order as SkillActivationMiddleware/SkillToolPolicyMiddleware:
|
||||
# Same resolution order as SkillActivationMiddleware: the canonical
|
||||
# path comes from the same user-scoped registry.
|
||||
# the registry must be the user-scoped one when a user context exists,
|
||||
# or per-user custom skills would never resolve to their Skill.name.
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
|
||||
@ -165,7 +166,7 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
if entry is None:
|
||||
return message
|
||||
# Phase 3: a completed SKILL.md read only *activates* a skill (durable
|
||||
# skill_context entry, allowed-tools policy, autonomous secret binding)
|
||||
# skill_context entry, autonomous secret binding)
|
||||
# when the action-scoped ``skill:activate`` decision allows it. The
|
||||
# catalog/visibility layer (Layer 1) may expose the file itself — the
|
||||
# action decision is what gates the autonomous activation path the
|
||||
@ -602,12 +603,11 @@ def build_subagent_runtime_middlewares(
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Enabled/configured skills are discoverable metadata, not automatically
|
||||
# active authority. Mirror the lead agent's activation + policy pair so a
|
||||
# subagent keeps its ordinary tool set until a slash command or a completed
|
||||
# SKILL.md read activates the corresponding allowed-tools declaration.
|
||||
# Enabled/configured skills are discoverable metadata, not authority over
|
||||
# the tool set. Slash activation or a completed SKILL.md read loads the
|
||||
# skill body (and binds its required-secrets) without narrowing tools;
|
||||
# agent assembly/execution authorization owns the permission ceiling.
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
slash_source_owner_token = secrets.token_urlsafe(24)
|
||||
middlewares.append(
|
||||
@ -623,17 +623,6 @@ def build_subagent_runtime_middlewares(
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
|
||||
middlewares.append(DeferredToolPromotionAuditMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
|
||||
middlewares.append(
|
||||
SkillToolPolicyMiddleware(
|
||||
available_skills=available_skills,
|
||||
app_config=app_config,
|
||||
user_id=user_id,
|
||||
slash_source_owner_token=slash_source_owner_token,
|
||||
# Persisted skill_context entries are re-authorized against the
|
||||
# skill:activate decision before their allowed-tools apply.
|
||||
skill_authorization=skill_authorization,
|
||||
)
|
||||
)
|
||||
|
||||
if model_name is None and app_config.models:
|
||||
model_name = app_config.models[0].name
|
||||
@ -649,8 +638,8 @@ def build_subagent_runtime_middlewares(
|
||||
|
||||
# Hide deferred (MCP) tool schemas from the subagent's model binding until
|
||||
# tool_search promotes them. This is the same wiring the lead agent gets. The deferred
|
||||
# set + catalog hash come from the build-time setup (assembled after
|
||||
# tool-policy filtering); promotion is read from graph state. Empty/None
|
||||
# set + catalog hash come from the build-time setup (assembled from the
|
||||
# subagent's authorized tool list); promotion is read from graph state. Empty/None
|
||||
# setup (deferral disabled or no MCP tool survived) is a pure no-op.
|
||||
if deferred_setup is not None and deferred_setup.deferred_names:
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
@ -70,12 +70,11 @@ def record_tool_promotion(
|
||||
|
||||
|
||||
class DeferredToolPromotionAuditMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Observe final ``tool_search`` Commands after policy filtering.
|
||||
"""Observe final ``tool_search`` Commands.
|
||||
|
||||
This wrapper must remain outer of ``SkillToolPolicyMiddleware``. Tool-call
|
||||
wrappers unwind in reverse registration order, so observing the handler's
|
||||
final return value is what prevents denied schemas from being reported as
|
||||
effective promotions.
|
||||
Tool-call wrappers unwind in reverse registration order, so observing the
|
||||
handler's final return value is what keeps the audit aligned with the
|
||||
Command the deferred filter actually applies.
|
||||
"""
|
||||
|
||||
def __init__(self, deferred_names: frozenset[str], catalog_hash: str | None) -> None:
|
||||
|
||||
@ -176,7 +176,7 @@ Tests: `tests/test_secret_file.py`, `tests/test_credentials_key.py`.
|
||||
|
||||
**`extensions_config.json`**:
|
||||
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`, `session_init_timeout`). `routing.mode="prefer"` emits `<mcp_routing_hints>` prompt guidance; if `tool_search` defers the hinted tool, `McpRoutingMiddleware` can also auto-promote matching deferred schemas before the model call. It does not hard-disable other tools. `session_init_timeout` (default `DEFAULT_MCP_SESSION_INIT_TIMEOUT` = 60s, `null` to disable) bounds server bring-up: tool discovery and persistent stdio session initialization, so a hung server cannot block agent construction indefinitely; durable HTTP/SSE task calls use it for their ephemeral session initialization too. `tool_call_timeout` bounds individual stdio calls and durable-task calls on every transport; other HTTP/SSE tools use transport-level timeouts.
|
||||
- `tool_search.auto_promote_top_k` - Global MCP routing auto-promote breadth. Default `3`, clamped to `1..5`; applies only when `tool_search.enabled=true` and only to deferred MCP tools with `routing.mode="prefer"` and non-empty keywords. For lead agents the deferred catalog is built from the full configured MCP set; auto-promotion never grants authority because an active skill's runtime policy still filters model-visible schemas, `tool_search` results, and execution.
|
||||
- `tool_search.auto_promote_top_k` - Global MCP routing auto-promote breadth. Default `3`, clamped to `1..5`; applies only when `tool_search.enabled=true` and only to deferred MCP tools with `routing.mode="prefer"` and non-empty keywords. For lead agents the deferred catalog is built from the full configured MCP set; auto-promotion never grants authority — it only raises visibility for tools already admitted to the agent's catalog, while assembly and execution authorization still apply independently.
|
||||
- `skills` - Map of skill name → state (enabled)
|
||||
- `middlewares` - `AgentMiddleware` entries for lead and subagent runtime extension: class-path strings or `{class, kwargs}` objects. `kwargs` values must be JSON types; YAML dates and timestamps are coerced to ISO strings so they match JSON. `config.yaml -> extensions` can override these fields after validation; overrides are replace-per-field, not list concatenation.
|
||||
|
||||
|
||||
@ -213,7 +213,8 @@ class AgentConfig(BaseModel):
|
||||
model: str | None = None
|
||||
tool_groups: list[str] | None = None
|
||||
# skills controls which skills are discoverable and may be activated by the
|
||||
# agent. It does not activate their allowed-tools policies at construction:
|
||||
# agent. allowed-tools is compatibility metadata only and never restricts
|
||||
# tools:
|
||||
# - None (or omitted): load all enabled skills (default fallback behavior)
|
||||
# - [] (explicit empty list): disable all skills
|
||||
# - ["skill1", "skill2"]: load only the specified skills
|
||||
|
||||
@ -79,19 +79,12 @@ def core_ordering_constraints() -> tuple[OrderingConstraint, ...]:
|
||||
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
|
||||
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
||||
from deerflow.agents.middlewares.sandbox_audit_middleware import SandboxAuditMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||
from deerflow.agents.middlewares.tool_progress_middleware import ToolProgressMiddleware
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
|
||||
from deerflow.guardrails.middleware import GuardrailMiddleware
|
||||
|
||||
return (
|
||||
OrderingConstraint(
|
||||
outer=DeferredToolPromotionAuditMiddleware,
|
||||
inner=SkillToolPolicyMiddleware,
|
||||
reason=("DeferredToolPromotionAuditMiddleware must observe the policy-filtered tool_search Command so denied schemas are never reported as effective promotions"),
|
||||
),
|
||||
OrderingConstraint(
|
||||
outer=ToolProgressMiddleware,
|
||||
inner=ToolErrorHandlingMiddleware,
|
||||
|
||||
@ -55,10 +55,10 @@ def _anchors() -> dict[Placement, PlacementAnchor]:
|
||||
),
|
||||
Placement.TOOL_VISIBLE: outermost(),
|
||||
# As close to the tool callable as the chain allows. Deliberately NOT
|
||||
# inner_of(ToolErrorHandlingMiddleware): SkillToolPolicyMiddleware and
|
||||
# ClarificationMiddleware are appended later and also wrap tool calls,
|
||||
# so anchoring there left two wrappers inner of "raw" and the placement
|
||||
# silently stopped meaning what it says.
|
||||
# inner_of(ToolErrorHandlingMiddleware): later-appended middlewares such
|
||||
# as ClarificationMiddleware also wrap tool calls, so anchoring there
|
||||
# left wrappers inner of "raw" and the placement silently stopped
|
||||
# meaning what it says.
|
||||
#
|
||||
# ClarificationMiddleware remains the one carve-out, the same shape as
|
||||
# MODEL_PHYSICAL's above: it must stay last (it short-circuits the tool
|
||||
|
||||
@ -29,10 +29,11 @@ SECRETS_CONTEXT_KEY = "secrets"
|
||||
# tool. Both reserved keys are stripped from trace payloads (see tracing redactor).
|
||||
ACTIVE_SECRETS_CONTEXT_KEY = "__active_skill_secrets"
|
||||
|
||||
# Reserved sub-key holding the active skill tool-policy decision for one model
|
||||
# step. The decision includes a middleware-instance owner token that prevents a
|
||||
# caller from forging an allow-all decision in its mergeable run context, so the
|
||||
# entire value must be stripped from every observable serialization surface.
|
||||
# Reserved HISTORICAL sub-key that once held the active skill tool-policy
|
||||
# decision for one model step. The policy was removed, but the key stays
|
||||
# reserved so existing payloads containing it remain redacted (see tracing
|
||||
# redactor); its value must stay stripped from every observable
|
||||
# serialization surface.
|
||||
SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY = "__skill_tool_policy_decision"
|
||||
|
||||
LEGACY_AUTH_TOKEN_METADATA_KEY = "auth_token"
|
||||
|
||||
@ -14,11 +14,11 @@
|
||||
|
||||
- **Integration package boundaries**: recurse through provider and namespace directories, but stop at each package's `SKILL.md`. Nested fixtures must neither become catalog entries nor shadow real public skills; regression coverage lives in `tests/test_user_scoped_skill_storage.py`.
|
||||
- **Location**: global public skills live under `deer-flow/skills/public/`; user-authored custom skills live under `{DEER_FLOW_HOME}/users/{user_id}/skills/custom/`; globally managed integration skills live under `{DEER_FLOW_HOME}/integrations/skills/{provider}/`; per-user integration credentials remain under `{DEER_FLOW_HOME}/users/{user_id}/integrations/{provider}/{config,data}`
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools as a spec-compatible string or YAML list, argument-hint, required-secrets). Exact portable spellings such as `Bash`, `WebFetch`, `WebSearch`, `Glob`, `Grep`, `Read`, `Write`, and `Edit` map to `bash`, `web_fetch`, `web_search`, `glob`, `grep`, `read_file`, `write_file`, and `str_replace`; lowercase or otherwise unknown scalar names and YAML-list entries preserve their exact runtime spelling. Argument-scoped entries remain literal and inactive because the tool policy does not inspect arguments; the scalar tokenizer keeps spaces, quotes, and escaped parentheses inside patterns intact.
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools as a spec-compatible string or YAML list, argument-hint, required-secrets). Exact portable spellings such as `Bash`, `WebFetch`, `WebSearch`, `Glob`, `Grep`, `Read`, `Write`, and `Edit` map to `bash`, `web_fetch`, `web_search`, `glob`, `grep`, `read_file`, `write_file`, and `str_replace`; lowercase or otherwise unknown scalar names and YAML-list entries preserve their exact runtime spelling. Argument-scoped entries remain literal compatibility metadata; they neither authorize nor restrict commands, because tool authority lives in Agent configuration and authorization rather than tool arguments. The scalar tokenizer keeps spaces, quotes, and escaped parentheses inside patterns intact.
|
||||
- **Loading**: `load_skills()` recursively scans public, per-user custom, global integration, and legacy custom locations for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json plus per-user skill state for non-public categories; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. A custom skill directory may be a one-level symlink to an external directory for compatibility with operator-managed skill trees; activation still rejects a symlinked `SKILL.md` or deeper path escape. Deleting such a skill unlinks the entry and leaves the external tree untouched. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. Ancestor-link cycles are pruned; independent aliases remain discoverable.
|
||||
- **External reload**: `POST /api/skills/reload` is an admin-only invalidation hook for trusted MinIO/NFS/CSI writes. `SkillStorage` instances do not cache a catalog — `load_skills()` scans on every call — so the route clears all `(app_config, user_id)` entries and the rendered prompt-section LRU, waits up to the shared refresh timeout for the existing off-loop single-flight refresh, then publishes the shared reset marker (below). Each invalidation receives a generation-bound result handle; a successful scan atomically replaces the global enabled-skills cache, while a loader-level failure propagates to the HTTP waiter and preserves the last-known-good global cache. Per-user/config scans capture the refresh version and cannot repopulate shared caches if invalidation occurs while they are loading. A timed-out HTTP wait fails generically while the daemon refresh worker continues. Subsequent runs rescan after a successful reload; active runs keep their existing snapshot. Direct mount writes bypass install/edit validation, SkillScan, and history, so mounted roots are an operator-controlled trust boundary.
|
||||
- **Shared cache reset**: the prompt-layer caches in `agents/lead_agent/prompt.py` (global enabled-skills list, per-`(app_config, user_id)` entries, rendered `<skill_system>` LRU) are the only skill state that can diverge between Gateway processes sharing one home volume. Every mutation in `routers/skills.py` (install/upload, edit, delete, rollback, enable/disable, reload) and the agent `skill_manage` tool refreshes its own process, then calls `publish_skills_cache_reset(user_id=...)`, which atomically replaces `.<extensions config name>.skills-cache-reset.json` beside the shared config under the extensions-config locks (`deerflow.config.shared_reset_marker`). Every cache lookup polls that marker at most once per second and, on a new signature, calls `_invalidate_enabled_skills_cache()` or, for a marker that names one `user_id` as the only publication since the last poll, `invalidate_user_skill_cache(user_id)`; the publishing process skips its own generation. `/skills/reload` runs refresh + publish through the same `await_drained(_drain_skill_mutation(...))` tail as the mutations, so a cancelled caller cannot skip the marker; it answers `scope: shared_config` when the marker was written and `scope: process` when no extensions config path resolves. Publication failures raise `SkillCacheResetPublishError` (a `RuntimeError`, never an `OSError`), so the handlers' `FileNotFoundError -> 404` / `ValueError -> 400` mappings cannot claim an applied change was not found; every handler reports it as a generic 500, and a 200 never means only one process was refreshed. `skill_manage` drains write, history, refresh and publish as one unit too; its publish failure says the change was applied. The marker reaches processes mounting that directory, not replicas with independent filesystems. Tests: `tests/test_skills_cache_shared_reset.py`.
|
||||
- **Tool policy**: Agent `allowed-tools` declarations apply dynamically only to slash-activated skills and skills captured in `ThreadState.skill_context` through configured `read_file` loads; passive enabled skills and skill allowlists do not clamp the baseline tool set. A lead custom Agent's explicit `skills` list is additionally enforced at the sandbox filesystem layer (see Sandbox projection); subagent skill lists still scope discovery and activation only because concurrently delegated subagents share the lead thread sandbox. Subagents render only skill discovery metadata at startup and reuse the same adjacent `SkillActivationMiddleware` + `SkillToolPolicyMiddleware` pair as the lead; their configured `skills` field limits discovery and activation instead of eagerly loading bodies or unioning policies. Slash policy is dominant for its run, preventing subsequently read skills from widening explicit authority; autonomous captured skills use the existing union only when no slash source exists. `tool_search` and `describe_skill` stay available as framework discovery infrastructure, while every discovered or promoted business tool still requires active-policy permission for schema visibility and execution; `task`, `list_background_tasks`, and `cancel_background_task` likewise require explicit declarations. Each active model call intentionally reloads the full live registry so enable/disable changes, frontmatter edits, and custom/public name-shadow winners take effect without a stale TTL or unsafe direct-path cache; all tool calls produced by that model step reuse the resulting source-and-path-signed decision. Registry failures and all-invalid active sets fail closed, while stale individual paths are skipped when another valid skill remains. The dynamic `allowed-tools` policy remains best-effort behavioral scoping: alternate loading paths are not captured and bounded autonomous context may evict entries.
|
||||
- **Tool policy (compatibility metadata)**: `allowed-tools` declarations are parsed, validated, exported, and described for interop with other agent frameworks, but they never filter model-visible schemas, refuse calls, narrow a subagent, or rewrite `tool_search` promotions. Real tool authority lives in Agent configuration and authorization: assembly-time provider filtering, runtime Guardrail enforcement, guardrail allowlists, and the deferred-promotion catalog. A lead custom Agent's explicit `skills` list is still enforced at the sandbox filesystem layer (see Sandbox projection); subagent skill lists scope discovery and activation only because concurrently delegated subagents share the lead thread sandbox. Subagents render skill discovery metadata at startup and reuse the same `SkillActivationMiddleware` as the lead; their configured `skills` field limits discovery and activation, not tools.
|
||||
- **Sandbox projection**: `skills/projection.py` materializes enabled-only shared trees at `{base_dir}/skills_view/public` and `{base_dir}/users/{user_id}/skills_view/{custom,legacy,integrations}`. A lead Agent with an explicit `skills` allowlist (including `[]`) gets the intersection of enabled public/user-visible skills and that allowlist at `{base_dir}/users/{user_id}/threads/{thread_id}/skills_view/{public,custom,legacy,integrations}`. `skills=None` keeps the shared zero-copy mount until a thread has used an explicit policy; later unrestricted runs repopulate the same stable thread root with all enabled skills (with host bash enabled, `LocalSandboxProvider` mounts the shared views instead). Rebuilds sign source state, view state, and normalized policy in a manifest, revoke every old category before adding the new policy, stage copies in temporary directories, and atomically replace files. Category root inodes stay stable for live bind mounts; concurrent readers can briefly see fewer skills during a policy change, never a skill revoked by the new policy. Policy-scoped copies reject absolute symlinks and relative symlinks that resolve outside their own skill package, preventing a permitted package from linking back to an omitted source. It copies files into the view (`_copy_into_view`) so a sandbox write cannot mutate the canonical skill inode; the operational trade-off is an O(total bytes) I/O and per-user/thread storage multiplier across rebuilds, prioritized for write isolation over zero-copy hardlinks. Steady-state freshness checks combine source and view metadata tree digests, so in-sandbox view tampering is detected and repaired on the next acquire. Storage writes, archive installs, deletes, and toggles rebuild shared scopes under a cross-process lock; Gateway boot ensures only the shared public view, user views are repaired lazily on acquire, and Agent thread views are recomputed before sandbox reuse. Managed integration packages are global, but their projected category is per-user because enabled state is isolated. User projection rebuilds re-read global enable state from disk instead of the process singleton, so a toggle handled by another Gateway worker is reflected on the next acquire. Gateway public-skill toggles take the public projection lock before the shared `extensions_config_write_lock`, re-read an existing config raw from disk, change only that skill's entry, and rebuild before responding; keep this as one worker-owned critical section so MCP writes cannot interleave and request cancellation cannot release either lock while the worker still runs. The shared public steady-state signature check runs without the global projection lock; stale/error paths take the lock and re-check before rebuilding or clearing. User and thread scope checks are serialized per scope. Projection failures clear the affected view before raising.
|
||||
- **Projection manifest failures**: `_write_manifest` keeps UTF-8 JSON, private `mkstemp` permissions and same-directory replacement. On ordinary errors or interruptions, reuse `utils.atomic_write.cleanup_failed_atomic_write`; transfer descriptor ownership only after `fdopen` succeeds. Descriptor close, sibling unlink and warning logs must not mask the original error. Coverage: `tests/test_skill_projection_manifest_cleanup.py`.
|
||||
- **Injection (legacy / default)**: Enabled skills are listed in the agent system prompt with full metadata and container paths (`<available_skills>` block). Controlled by `skills.deferred_discovery: false` (default).
|
||||
@ -31,7 +31,7 @@
|
||||
- **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. The moderation adapter must normalize both plain-text responses and LangChain Responses API text blocks before parsing the required JSON decision. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. `skills/package_files.py` is the single definition of code files (`scripts/` members, code suffixes, extensionless `#!` files) and executable magic bytes; the installer, export guard, and SkillScan all import it, so do not re-derive either rule locally. Files that fail NUL-free UTF-8 decoding are treated as binaries unless they are code files: interpreters still run those, so they raise `package-undecodable-script` (HIGH) and are analyzed over a lossy decode, keeping one stray byte from hiding a file or downgrading a `CRITICAL` match; one with executable magic skips the text rules, which only misread its string tables. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`.
|
||||
- **SkillScan HTTP hosts**: recognize HTTP/HTTPS schemes case-insensitively in both URL regexes and prefix checks, without changing source evidence. Use URL authority parsing for lowercase hosts and bracketed IPv6 loopback; userinfo must never hide an external host. Malformed URLs remain outbound for exfiltration checks. Cleartext HTTP detection covers IPv6 literals; cloud-metadata hostname matching is case-insensitive. Coverage: `tests/test_skillscan_native.py`.
|
||||
- **Python secret mappings**: `secret-env-assignment` scans literal container values and conditional branches under secret-like bindings. Dictionary keys are structural labels and contribute candidates only when their folded literal matches the shared `_SECRET_TOKEN_PATTERNS`; they must not inherit credential evidence from the enclosing variable name. Opaque keys without a recognized token format stay unreported. Regressions in `tests/test_skillscan_native.py` cover runtime-loaded mappings, nested labels, credential-value locations, and literal/concatenated token keys; `tests/test_skill_review_core.py` pins the CI CLI gate.
|
||||
- **Skill Review Core**: `packages/harness/deerflow/skills/review/` provides read-only package snapshots, deterministic facts, resource/eval analysis, report rendering, and the CLI (`python -m deerflow.skills.review.cli`). It reuses the shared frontmatter helper and SkillScan; it must not import `app.*`, execute target scripts, install dependencies, or call networks. JSON contracts live in `contracts/skill_review/`. The analyzer hands SkillScan a byte-faithful copy of every non-symlink snapshot file (binary entries carry `content_base64`), so package rules such as `package-executable-binary` cover binaries; only eval-fixture `SKILL.md` samples are withheld, while scripts and other files under `evals/fixtures/` are scanned. Materialization uses exclusive create, so a duplicate archive member or a case-folded name fails the scan closed (`not_assessed: skillscan`) instead of overwriting an earlier file, as does an entry without bytes in an untruncated snapshot. The `review_skill_package` built-in tool labels results with `review_subject_entry` and never `skill_context_entry`, so reviewing a target does not activate it, bind its `required-secrets`, or apply its `allowed-tools`. Its model-visible `ToolMessage.content` is a compact JSON payload with untrusted control tags neutralized; the full raw review payload, including Markdown renders, stays in `ToolMessage.artifact`. Local path targets are host paths, so the tool confines them to the configured skills root and the caller's own `{base_dir}/users/{user_id}/`; never re-add the Gateway cwd or `/tmp`, because `DEER_FLOW_HOME` sits under the cwd and those roots expose other users' skills. A skills root that contains `{base_dir}/users/` is dropped with a warning. CI should run the CLI with `--fail-on error --fail-on-incomplete` so blocker/error findings and truncated/not-assessed packages fail the gate. The public `skills/public/skill-reviewer` skill owns semantic readiness review and suggestions only; mutation and runtime experiments remain owned by `skill-creator`.
|
||||
- **Skill Review Core**: `packages/harness/deerflow/skills/review/` provides read-only package snapshots, deterministic facts, resource/eval analysis, report rendering, and the CLI (`python -m deerflow.skills.review.cli`). It reuses the shared frontmatter helper and SkillScan; it must not import `app.*`, execute target scripts, install dependencies, or call networks. JSON contracts live in `contracts/skill_review/`. The analyzer hands SkillScan a byte-faithful copy of every non-symlink snapshot file (binary entries carry `content_base64`), so package rules such as `package-executable-binary` cover binaries; only eval-fixture `SKILL.md` samples are withheld, while scripts and other files under `evals/fixtures/` are scanned. Materialization uses exclusive create, so a duplicate archive member or a case-folded name fails the scan closed (`not_assessed: skillscan`) instead of overwriting an earlier file, as does an entry without bytes in an untruncated snapshot. The `review_skill_package` built-in tool labels results with `review_subject_entry` and never `skill_context_entry`, so reviewing a target does not activate it or bind its `required-secrets`. Its model-visible `ToolMessage.content` is a compact JSON payload with untrusted control tags neutralized; the full raw review payload, including Markdown renders, stays in `ToolMessage.artifact`. Local path targets are host paths, so the tool confines them to the configured skills root and the caller's own `{base_dir}/users/{user_id}/`; never re-add the Gateway cwd or `/tmp`, because `DEER_FLOW_HOME` sits under the cwd and those roots expose other users' skills. A skills root that contains `{base_dir}/users/` is dropped with a warning. CI should run the CLI with `--fail-on error --fail-on-incomplete` so blocker/error findings and truncated/not-assessed packages fail the gate. The public `skills/public/skill-reviewer` skill owns semantic readiness review and suggestions only; mutation and runtime experiments remain owned by `skill-creator`.
|
||||
|
||||
#### Request-Scoped Secrets (`required-secrets`)
|
||||
|
||||
|
||||
@ -190,13 +190,12 @@ def _render_skill_metadata(skills: list, container_base_path: str) -> str:
|
||||
blocks: list[str] = []
|
||||
for s in skills:
|
||||
mutability = "[custom, editable]" if s.category == SkillCategory.CUSTOM else "[built-in]"
|
||||
# `()` is an explicit empty allowlist — the policy middleware strips every
|
||||
# business tool for it — so only an omitted field (`None`) means unrestricted.
|
||||
# `(all)` describes this skill's own frontmatter, not the enforced union: once
|
||||
# any loaded skill declares allowed-tools,
|
||||
# ``allowed_tool_names_for_skills`` (tool_policy.py) gives a `None` skill no
|
||||
# tools, so a mixed set can render `(all)` while the middleware restricts it.
|
||||
tools_line = "(all)" if s.allowed_tools is None else (", ".join(s.allowed_tools) or "(none)")
|
||||
# allowed-tools is compatibility metadata copied from other agent
|
||||
# frameworks; it never filters, grants, or denies tools in DeerFlow.
|
||||
# "(not declared)" only means the field was omitted; "(empty
|
||||
# declaration)" only means it was declared empty. Agent
|
||||
# assembly/execution authorization owns the actual permission ceiling.
|
||||
tools_line = "(not declared)" if s.allowed_tools is None else (", ".join(s.allowed_tools) or "(empty declaration)")
|
||||
location = s.get_container_file_path(container_base_path)
|
||||
# name/description/allowed-tools come from untrusted ``.skill`` frontmatter;
|
||||
# escape so a value cannot forge a framework tag in the describe_skill output.
|
||||
@ -204,7 +203,7 @@ def _render_skill_metadata(skills: list, container_base_path: str) -> str:
|
||||
description = html.escape(s.description, quote=False)
|
||||
tools = html.escape(tools_line, quote=False)
|
||||
loc = html.escape(location, quote=False)
|
||||
blocks.append(f"## Skill: {name}\n- Description: {description} {mutability}\n- Allowed tools: {tools}\n- Location: {loc}")
|
||||
blocks.append(f"## Skill: {name}\n- Description: {description} {mutability}\n- Allowed tools (compatibility metadata, not enforced): {tools}\n- Location: {loc}")
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
@ -242,6 +241,8 @@ You have access to skills that provide optimized workflows for specific tasks.
|
||||
**Explicit Slash Skill Activation:**
|
||||
- If the user starts a request with `/<skill-name>`, that skill was explicitly requested.
|
||||
- The runtime injects the activated skill content; do not call `read_file` for that SKILL.md again unless the injected skill references supporting resources you need.
|
||||
|
||||
**Allowed tools:** A skill's `allowed-tools` metadata is compatibility information for other agent frameworks. It never restricts or grants tools here; your tools are controlled by DeerFlow's Agent configuration and authorization.
|
||||
{evolution}
|
||||
<skill_index>
|
||||
{names}
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NamedTool(Protocol):
|
||||
name: str
|
||||
|
||||
|
||||
# Framework built-ins that remain available even when an active skill declares
|
||||
# allowed-tools. They support controlled file/review/discovery workflows rather
|
||||
# than extending the reviewed/activated skill's own business-tool authority.
|
||||
# In particular, promotion through tool_search does not restore a tool removed
|
||||
# by SkillToolPolicyMiddleware, and describe_skill only returns catalog metadata.
|
||||
ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"describe_skill",
|
||||
"read_file",
|
||||
"review_skill_package",
|
||||
"tool_search",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def allowed_tool_names_for_skills(skills: list[Skill]) -> set[str] | None:
|
||||
"""Return the union of explicit skill allowed-tools declarations.
|
||||
|
||||
None means legacy allow-all behavior. It is returned only when no loaded
|
||||
skill declares allowed-tools. Once any skill declares the field, legacy
|
||||
skills without the field contribute no tools instead of disabling the
|
||||
explicit restrictions from other skills.
|
||||
"""
|
||||
if not skills:
|
||||
return None
|
||||
|
||||
allowed: set[str] = set()
|
||||
has_explicit_declaration = False
|
||||
for skill in skills:
|
||||
if skill.allowed_tools is None:
|
||||
continue
|
||||
has_explicit_declaration = True
|
||||
if not skill.allowed_tools:
|
||||
logger.info("Skill %s declared empty allowed-tools", skill.name)
|
||||
allowed.update(skill.allowed_tools)
|
||||
|
||||
if not has_explicit_declaration:
|
||||
return None
|
||||
return allowed
|
||||
|
||||
|
||||
def filter_tools_by_skill_allowed_tools[ToolT: NamedTool](
|
||||
tools: list[ToolT],
|
||||
skills: list[Skill],
|
||||
*,
|
||||
always_allowed_tool_names: set[str] | frozenset[str] = frozenset(),
|
||||
) -> list[ToolT]:
|
||||
allowed = allowed_tool_names_for_skills(skills)
|
||||
if allowed is None:
|
||||
return tools
|
||||
|
||||
allowed_with_framework_tools = allowed | set(always_allowed_tool_names)
|
||||
return [tool for tool in tools if tool.name in allowed_with_framework_tools]
|
||||
@ -156,7 +156,7 @@ after_seq)`: filter `metadata["task_id"]` in SQL with
|
||||
using the existing run index. This pages one task without a run-wide limit
|
||||
truncating its tail. Keep `step_events.py` pure and unit-tested.
|
||||
|
||||
**Deferred MCP tools** (if `tool_search.enabled`): `SubagentExecutor._build_initial_state` applies the subagent name allow/deny list and assembly-time authorization before calling the shared `assemble_deferred_tools`, appends the `tool_search` tool, injects the `<available-deferred-tools>` section into the subagent's `SystemMessage`, and threads the setup to `_create_agent`, which attaches `McpRoutingMiddleware` (when PR1 routing metadata matches deferred tools) before `DeferredToolFilterMiddleware` through `build_subagent_runtime_middlewares(...)`. Runtime skill policy is intentionally later and dynamic: `tool_search` may disclose/promote catalog metadata, but `SkillToolPolicyMiddleware` still removes or blocks any promoted business tool omitted by the active skill. Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh `ThreadState` so promotion is isolated per run
|
||||
**Deferred MCP tools** (if `tool_search.enabled`): `SubagentExecutor._build_initial_state` applies the subagent name allow/deny list and assembly-time authorization before calling the shared `assemble_deferred_tools`, appends the `tool_search` tool, injects the `<available-deferred-tools>` section into the subagent's `SystemMessage`, and threads the setup to `_create_agent`, which attaches `McpRoutingMiddleware` (when PR1 routing metadata matches deferred tools) before `DeferredToolFilterMiddleware` through `build_subagent_runtime_middlewares(...)`. Skill `allowed-tools` is compatibility metadata and never removes or blocks a promoted business tool; the authorization Guardrail on the same chain still denies genuinely unauthorized tools. Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh `ThreadState` so promotion is isolated per run
|
||||
**Checkpointer isolation**: Subagent graphs are compiled with `checkpointer=False` to avoid inheriting the parent run's checkpointer, since subagents are one-shot and never resume.
|
||||
**Checkpoint lineage / stream isolation**: `_aexecute` deliberately omits checkpoint-coordinate keys (`thread_id`, `checkpoint_ns`, `checkpoint_id`, `checkpoint_map`) from the child `RunnableConfig`. LangGraph must inherit those coordinates from the copied parent ContextVar so the delegated graph retains a non-root subgraph namespace; explicitly re-supplying even the same parent `thread_id` starts a new root lineage on LangGraph 1.2.6+ and can route child AI/tool frames into the parent `messages` stream. DeerFlow business components still receive the parent `thread_id` through `runtime.context`, which is the preferred lookup path for sandbox, middleware, and attribution code. Regression coverage in `tests/test_subagent_executor.py::TestSubagentCheckpointLineage` keeps the invocation-contract assertion active on every supported version and version-gates the production-shaped parent-stream test to LangGraph 1.2.6+, where the leak exists.
|
||||
|
||||
|
||||
@ -21,8 +21,9 @@ class SubagentConfig:
|
||||
disallowed_tools: Optional list of tool names to deny.
|
||||
skills: Optional list of skill names to make discoverable and activatable.
|
||||
If None, all enabled skills are available. If empty, skills are
|
||||
disabled for this subagent. Skill bodies and their allowed-tools
|
||||
policies take effect only after activation/loading at runtime.
|
||||
disabled for this subagent. Skill bodies load lazily at runtime;
|
||||
allowed-tools is compatibility metadata only and never restricts
|
||||
this subagent's tools.
|
||||
model: Model to use - 'inherit' uses parent's model.
|
||||
max_turns: Maximum agent turns — model call plus the tools it runs —
|
||||
before stopping. Built-in agents use the value set here
|
||||
|
||||
@ -1034,7 +1034,7 @@ class SubagentExecutor:
|
||||
)
|
||||
self.tools = self._base_tools
|
||||
# Populated from the same per-user, config-filtered registry used to
|
||||
# build the prompt. Runtime skill activation/policy middleware receives
|
||||
# build the prompt. Runtime skill activation middleware receives
|
||||
# this exact set so a subagent cannot activate an undisclosed skill.
|
||||
self._available_skill_names: set[str] = set()
|
||||
# Guard middlewares that expose ``consume_stop_reason`` (currently
|
||||
@ -1465,8 +1465,9 @@ class SubagentExecutor:
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_deferred_tools_prompt_section, get_mcp_routing_hints_prompt_section
|
||||
|
||||
# Skills are discoverable metadata until explicitly slash-activated or
|
||||
# loaded through read_file. Their allowed-tools declarations are applied
|
||||
# dynamically by SkillToolPolicyMiddleware, not eagerly here.
|
||||
# loaded through read_file. Their allowed-tools declarations are
|
||||
# compatibility metadata only, not tool authority; loading them here
|
||||
# does not filter any tool.
|
||||
skills = await self._load_skills()
|
||||
self._assembled_skills = list(skills)
|
||||
self._available_skill_names = {skill.name for skill in skills}
|
||||
@ -1517,9 +1518,10 @@ class SubagentExecutor:
|
||||
# binding full MCP schemas.
|
||||
# The generated tool_search helper is intentionally not subject to the
|
||||
# subagent's name-level allow/deny (config.tools / disallowed_tools):
|
||||
# its catalog is built from that already-filtered list. Active skill
|
||||
# policy is applied later by middleware to both schema visibility and
|
||||
# execution, so promotion cannot widen an active skill's authority.
|
||||
# its catalog is built from that already-filtered list. The catalog remains
|
||||
# scoped to the subagent admitted tool list, and runtime authorization
|
||||
# still checks execution, so promotion cannot widen
|
||||
# the subagent's admitted tool authority.
|
||||
final_tools, deferred_setup = assemble_deferred_tools(
|
||||
configured_tools,
|
||||
enabled=resolved_app_config.tool_search.enabled,
|
||||
|
||||
@ -86,7 +86,7 @@ Scheduled-task runtime note:
|
||||
coded results as REST (`contracts/scheduled_task_errors_contract.json`).
|
||||
- Scheduled background runs resolve to the `scheduled` interaction policy through trusted `context.non_interactive=true` and therefore exclude `ask_clarification` from the lead-agent tool list. The legacy `context.non_interactive=true` key remains accepted only for internally authenticated scheduler calls during migration; arbitrary HTTP/IM clients cannot set it.
|
||||
|
||||
Durable MCP task-management tools are added only while the process-local task submitter is installed. They expose bounded local task fields, including whether cancellation was requested, but never the remote handle. Cancellation records that request durably and returns immediately; the background service owns the remote call and retries. These remain ordinary business tools under an active skill's `allowed-tools` policy and must be declared explicitly.
|
||||
Durable MCP task-management tools are added only while the process-local task submitter is installed. They expose bounded local task fields, including whether cancellation was requested, but never the remote handle. Cancellation records that request durably and returns immediately; the background service owns the remote call and retries. They are ordinary business tools under the agent's own authorization, like every other tool.
|
||||
|
||||
`list_background_tasks` accepts an optional `TaskStatus` filter and intersects it with `active_only`. Omitting it or passing `None` preserves existing calls. The tool returns the 20 most recent matching records; `count` is the number returned. The tool schema validates statuses, and internal callers must not silently ignore invalid values. Scope comes from the runtime, never model-supplied user, thread, or incarnation values.
|
||||
|
||||
|
||||
@ -278,12 +278,11 @@ def build_tool_search_tool(catalog: DeferredToolCatalog) -> BaseTool:
|
||||
def build_deferred_tool_setup(candidate_tools: list[BaseTool], *, enabled: bool) -> DeferredToolSetup:
|
||||
"""Build deferred-tool setup from one agent build's candidate tools.
|
||||
|
||||
Lead agents pass their full configured tool list; ``SkillToolPolicyMiddleware``
|
||||
later filters model-visible schemas, execution, and ``tool_search`` results
|
||||
for the active skill while keeping the discovery tool itself available.
|
||||
Subagents may pass a statically policy-filtered list because their configured
|
||||
skills are loaded at startup. The downstream deferred-schema middleware still
|
||||
hides unpromoted MCP schemas in either case.
|
||||
Lead agents and subagents pass their authorized tool list. Skill
|
||||
`allowed-tools` declarations are compatibility metadata and never filter
|
||||
this assembly; agent assembly/execution authorization owns the ceiling.
|
||||
The downstream deferred-schema middleware still hides unpromoted MCP
|
||||
schemas in either case.
|
||||
|
||||
Returns an empty setup (see :class:`DeferredToolSetup`) in two distinct
|
||||
cases: deferral is disabled, or it is enabled but no MCP tool survived
|
||||
@ -305,9 +304,9 @@ def assemble_deferred_tools(candidate_tools: list[BaseTool], *, enabled: bool) -
|
||||
|
||||
Fail closed on deferral assembly itself: if tool_search is enabled and MCP
|
||||
candidates exist but no deferred set was recovered, raise rather than silently
|
||||
binding their full schemas to the model. Lead-agent authorization is enforced
|
||||
separately at runtime by ``SkillToolPolicyMiddleware``; subagents may already
|
||||
have applied their static skill policy to ``candidate_tools``.
|
||||
binding their full schemas to the model. Agent authorization (provider
|
||||
filtering plus runtime Guardrails) owns the permission ceiling; skill
|
||||
`allowed-tools` is compatibility metadata and does not participate.
|
||||
|
||||
Shared by every agent-build path (lead, embedded client, subagent) so they
|
||||
all get the same fail-closed guarantee from one place.
|
||||
@ -388,8 +387,8 @@ def get_deferred_tools_prompt_section(*, deferred_names: frozenset[str] = frozen
|
||||
Lists only names so the agent knows what exists and can use tool_search to
|
||||
load them. Returns empty string when there are no deferred tools. The set is
|
||||
computed at agent build time and passed in. Lead-agent sets contain the full
|
||||
configured MCP catalog because active skill policy is applied at runtime;
|
||||
subagent sets may already have been filtered by their startup skill policy.
|
||||
configured MCP catalog; subagent sets may already have been narrowed by the
|
||||
subagent's tool configuration and authorization.
|
||||
|
||||
Lives here, next to the assembly that produces ``deferred_names``, so every
|
||||
agent-build path (lead, embedded client, subagent) renders the section the
|
||||
|
||||
@ -267,9 +267,9 @@ def get_available_tools(
|
||||
|
||||
# Tag MCP-sourced tools so deferred-tool assembly at each
|
||||
# agent construction site can identify them. Lead agents
|
||||
# assemble their full configured MCP catalog and apply active
|
||||
# skill policy at runtime; subagents may pass an already
|
||||
# policy-filtered list because their skills load at startup.
|
||||
# assemble their full configured MCP catalog; subagents
|
||||
# may pass an already filtered list reflecting their own
|
||||
# tool configuration and authorization.
|
||||
for t in mcp_tools:
|
||||
tag_mcp_tool(t)
|
||||
else:
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
@ -20,8 +19,6 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.tools import tool as as_tool
|
||||
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.skills.tool_policy import filter_tools_by_skill_allowed_tools
|
||||
from deerflow.skills.types import Skill
|
||||
from deerflow.tools.builtins.tool_search import DeferredToolSetup, assemble_deferred_tools, build_deferred_tool_setup
|
||||
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
||||
|
||||
@ -82,10 +79,11 @@ def test_deferred_hidden_when_built_and_run_in_different_contexts():
|
||||
assert not any("mcp_secret" in names for names in _BOUND), f"deferred MCP tool leaked into bind_tools: {_BOUND}"
|
||||
|
||||
|
||||
def test_policy_excluded_mcp_tool_not_in_catalog():
|
||||
"""Finding 1: a tool removed by policy is not searchable/exposed."""
|
||||
filtered_after_policy = [active_tool] # mcp_secret denied by skill allowed-tools
|
||||
setup = build_deferred_tool_setup(filtered_after_policy, enabled=True)
|
||||
def test_assembly_filtered_mcp_tool_not_in_catalog():
|
||||
"""A tool removed by assembly-time authorization is not searchable/exposed
|
||||
(skill allowed-tools no longer participates in that filtering)."""
|
||||
assembled_after_authorization = [active_tool] # mcp_secret denied by the agent ceiling
|
||||
setup = build_deferred_tool_setup(assembled_after_authorization, enabled=True)
|
||||
assert setup.deferred_names == frozenset()
|
||||
assert setup.tool_search_tool is None
|
||||
|
||||
@ -134,47 +132,11 @@ def test_subagent_reentry_does_not_touch_lead_state():
|
||||
assert {t.name for t in out.tools} == {"active_tool", "mcp_secret"} # promotion intact
|
||||
|
||||
|
||||
def _make_skill(allowed_tools):
|
||||
"""Skill carrying an explicit allowed-tools allowlist (None = legacy allow-all)."""
|
||||
return Skill(
|
||||
name="s",
|
||||
description="d",
|
||||
license="MIT",
|
||||
skill_dir=Path("/tmp/s"),
|
||||
skill_file=Path("/tmp/s/SKILL.md"),
|
||||
relative_path=Path("s"),
|
||||
category="public",
|
||||
allowed_tools=tuple(allowed_tools) if allowed_tools is not None else None,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def test_policy_denied_mcp_yields_no_tool_search_end_to_end():
|
||||
"""An allowlist that denies the MCP tool gates it end-to-end: after the real
|
||||
policy filter no MCP tool survives, so ``assemble_deferred_tools`` adds no
|
||||
tool_search (and does not fail-closed, because no MCP tool leaked through)."""
|
||||
filtered = filter_tools_by_skill_allowed_tools([active_tool, tag_mcp_tool(mcp_secret)], [_make_skill(["active_tool"])])
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=True)
|
||||
|
||||
assert [t.name for t in final_tools] == ["active_tool"]
|
||||
assert "tool_search" not in {t.name for t in final_tools}
|
||||
assert setup.deferred_names == frozenset()
|
||||
|
||||
|
||||
def test_tool_search_appended_after_policy_but_never_exposes_denied_tool():
|
||||
"""Intentional behavior change vs. upstream (Copilot review on PR #3342).
|
||||
|
||||
``tool_search`` is appended AFTER skill-allowlist filtering, so an allowlist
|
||||
can no longer deny ``tool_search`` by name. This is safe by construction: the
|
||||
tool only appears when allowed MCP tools survive the filter, and its catalog
|
||||
is derived from the already policy-filtered list — so it can never expose a
|
||||
tool the allowlist denied. Locks that contract so the ordering cannot regress.
|
||||
"""
|
||||
allowed = ["active_tool", "mcp_secret"] # permits the MCP tool, does NOT list tool_search
|
||||
filtered = filter_tools_by_skill_allowed_tools([active_tool, tag_mcp_tool(mcp_secret)], [_make_skill(allowed)])
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=True)
|
||||
def test_tool_search_appended_to_the_authorized_tool_list():
|
||||
"""Deferred assembly appends ``tool_search`` after the authorized MCP
|
||||
candidates, so the model can discover and promote catalog tools."""
|
||||
final_tools, setup = assemble_deferred_tools([active_tool, tag_mcp_tool(mcp_secret)], enabled=True)
|
||||
|
||||
names = {t.name for t in final_tools}
|
||||
assert "tool_search" in names # appended despite not being in the allowlist
|
||||
assert "tool_search" in names
|
||||
assert setup.deferred_names == frozenset({"mcp_secret"})
|
||||
assert set(setup.deferred_names) <= set(allowed) # catalog never exceeds the allowlist
|
||||
|
||||
@ -91,16 +91,13 @@ def test_every_duplicate_participant_must_satisfy_the_constraint():
|
||||
|
||||
|
||||
def test_core_constraints_are_declared():
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||
from deerflow.agents.middlewares.tool_progress_middleware import ToolProgressMiddleware
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
from deerflow.extensions.ordering import core_ordering_constraints
|
||||
|
||||
core_ordering_constraints.cache_clear() # Prior wiring tests temporarily replace middleware modules.
|
||||
pairs = {(c.outer, c.inner) for c in core_ordering_constraints()}
|
||||
assert (ToolProgressMiddleware, ToolErrorHandlingMiddleware) in pairs
|
||||
assert (DeferredToolPromotionAuditMiddleware, SkillToolPolicyMiddleware) in pairs
|
||||
|
||||
from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware
|
||||
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
||||
|
||||
@ -4,18 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, create_autospec
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.tools import tool
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deerflow.agents.lead_agent import agent as lead_agent_module
|
||||
from deerflow.agents.middlewares import summarization_middleware as summarization_middleware_module
|
||||
@ -33,60 +27,6 @@ from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.config.subagents_config import SubagentsAppConfig
|
||||
from deerflow.config.summarization_config import SummarizationConfig
|
||||
from deerflow.runtime.checkpoint_mode import INTERNAL_CHECKPOINT_MODE_KEY
|
||||
from deerflow.runtime.secret_context import write_slash_skill_source_path
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
|
||||
_POLICY_INTEGRATION_TOOL_CALLS: list[str] = []
|
||||
|
||||
|
||||
@tool
|
||||
def policy_integration_dangerous_tool() -> str:
|
||||
"""Record an invocation of a tool that the active skill does not allow."""
|
||||
_POLICY_INTEGRATION_TOOL_CALLS.append("executed")
|
||||
return "executed"
|
||||
|
||||
|
||||
class _PolicyBypassModel(BaseChatModel):
|
||||
"""Emit a forbidden call even when the bound schema omits it."""
|
||||
|
||||
call_count: int = 0
|
||||
bound_tool_names: list[list[str]] = []
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "policy-bypass-test"
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any):
|
||||
self.bound_tool_names.append([tool.name for tool in tools])
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
self.call_count += 1
|
||||
if self.call_count == 1:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "forbidden-call",
|
||||
"name": policy_integration_dangerous_tool.name,
|
||||
"args": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
else:
|
||||
message = AIMessage(content="done")
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
|
||||
class _PolicyStorageStub:
|
||||
def __init__(self, skills: list[Skill]):
|
||||
self._skills = skills
|
||||
|
||||
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
|
||||
return [skill for skill in self._skills if skill.enabled or not enabled_only]
|
||||
|
||||
def get_container_root(self) -> str:
|
||||
return "/mnt/skills"
|
||||
|
||||
|
||||
def _make_app_config(models: list[ModelConfig], loop_detection: LoopDetectionConfig | None = None) -> AppConfig:
|
||||
@ -911,10 +851,9 @@ def test_build_middlewares_passes_run_model_name_to_summarization(monkeypatch):
|
||||
assert captured["run_model_name"] == "custom-agent-model"
|
||||
|
||||
|
||||
def test_build_middlewares_orders_skill_activation_before_policy_and_durable_context(monkeypatch):
|
||||
def test_build_middlewares_orders_skill_activation_before_durable_context(monkeypatch):
|
||||
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
|
||||
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
|
||||
@ -928,88 +867,13 @@ def test_build_middlewares_orders_skill_activation_before_policy_and_durable_con
|
||||
)
|
||||
|
||||
activation_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, SkillActivationMiddleware))
|
||||
policy_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, SkillToolPolicyMiddleware))
|
||||
durable_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, DurableContextMiddleware))
|
||||
assert policy_idx == activation_idx + 1
|
||||
assert durable_idx == policy_idx + 1
|
||||
assert middlewares[activation_idx]._slash_source_owner_token == middlewares[policy_idx]._slash_source_owner_token
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_stale_path", [False, True], ids=["restrictive-skill", "stale-active-path"])
|
||||
def test_compiled_skill_policy_chain_filters_schema_and_blocks_execution(monkeypatch, use_stale_path):
|
||||
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
app_config = _make_app_config(
|
||||
[_make_model("safe-model", supports_thinking=False)],
|
||||
loop_detection=LoopDetectionConfig(enabled=False),
|
||||
)
|
||||
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
|
||||
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
||||
|
||||
middlewares = lead_agent_module.build_middlewares(
|
||||
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
||||
model_name="safe-model",
|
||||
app_config=app_config,
|
||||
)
|
||||
activation_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, SkillActivationMiddleware))
|
||||
durable_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, DurableContextMiddleware))
|
||||
compiled_slice = middlewares[activation_idx : durable_idx + 1]
|
||||
assert [type(middleware) for middleware in compiled_slice] == [SkillActivationMiddleware, SkillToolPolicyMiddleware, DurableContextMiddleware]
|
||||
|
||||
skill_dir = Path("/tmp/skills/public/restricted")
|
||||
restricted = Skill(
|
||||
name="restricted",
|
||||
description="Restrictive integration skill",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_dir / "SKILL.md",
|
||||
relative_path=Path("restricted"),
|
||||
category=SkillCategory.PUBLIC,
|
||||
allowed_tools=("read_file",),
|
||||
enabled=True,
|
||||
)
|
||||
policy = compiled_slice[1]
|
||||
policy._storage = lambda: _PolicyStorageStub([] if use_stale_path else [restricted])
|
||||
|
||||
context: dict[str, object] = {}
|
||||
active_path = "/mnt/skills/public/missing/SKILL.md" if use_stale_path else restricted.get_container_file_path()
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
active_path,
|
||||
owner_token=policy._slash_source_owner_token,
|
||||
)
|
||||
model = _PolicyBypassModel()
|
||||
_POLICY_INTEGRATION_TOOL_CALLS.clear()
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[policy_integration_dangerous_tool],
|
||||
middleware=compiled_slice,
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="continue under the active skill")]},
|
||||
context=context,
|
||||
)
|
||||
|
||||
# LangChain skips ``bind_tools`` entirely when middleware filters the
|
||||
# request to zero schemas. If the forbidden schema survived, this list
|
||||
# would contain a binding with ``policy_integration_dangerous_tool``.
|
||||
assert model.bound_tool_names == []
|
||||
assert _POLICY_INTEGRATION_TOOL_CALLS == []
|
||||
blocked = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "forbidden-call"]
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0].status == "error"
|
||||
assert "not allowed by the active skill policy" in blocked[0].content
|
||||
assert durable_idx == activation_idx + 1
|
||||
|
||||
|
||||
def test_build_middlewares_places_mcp_routing_before_deferred_filter(monkeypatch):
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
from deerflow.tools.builtins.tool_search import DeferredToolSetup
|
||||
|
||||
@ -1033,9 +897,8 @@ def test_build_middlewares_places_mcp_routing_before_deferred_filter(monkeypatch
|
||||
routing_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, McpRoutingMiddleware))
|
||||
filter_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, DeferredToolFilterMiddleware))
|
||||
audit_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, DeferredToolPromotionAuditMiddleware))
|
||||
policy_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, SkillToolPolicyMiddleware))
|
||||
assert audit_idx < policy_idx
|
||||
assert routing_idx < filter_idx
|
||||
assert audit_idx < filter_idx
|
||||
|
||||
|
||||
def test_build_middlewares_uses_loop_detection_config(monkeypatch):
|
||||
|
||||
@ -337,17 +337,6 @@ def test_make_lead_agent_custom_skill_allowlist_does_not_activate_tool_policy(mo
|
||||
assert "describe_skill" in tool_names
|
||||
|
||||
|
||||
def test_skill_allowed_tools_default_does_not_preserve_read_file_for_subagents():
|
||||
from deerflow.skills.tool_policy import filter_tools_by_skill_allowed_tools
|
||||
|
||||
tools = [NamedTool("read_file"), NamedTool("dataagent_query"), NamedTool("bash")]
|
||||
skills = [_make_skill("data-query", ["dataagent_query"])]
|
||||
|
||||
filtered = filter_tools_by_skill_allowed_tools(tools, skills)
|
||||
|
||||
assert [tool.name for tool in filtered] == ["dataagent_query"]
|
||||
|
||||
|
||||
def test_make_lead_agent_all_legacy_skills_preserve_all_tools(monkeypatch):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@ -107,14 +107,14 @@ def test_middleware_order_includes_configured_extension_tail(path: Path) -> None
|
||||
content = " ".join((REPO_ROOT / path).read_text(encoding="utf-8").split())
|
||||
markers = (
|
||||
(
|
||||
"`SkillToolPolicyMiddleware`",
|
||||
"`SkillActivationMiddleware`",
|
||||
"Configured extension middlewares (if any)",
|
||||
"`TerminalResponseMiddleware`",
|
||||
"`ModelLengthFinishReasonMiddleware`",
|
||||
)
|
||||
if "/en/" in path.as_posix()
|
||||
else (
|
||||
"`SkillToolPolicyMiddleware`",
|
||||
"`SkillActivationMiddleware`",
|
||||
"配置的扩展中间件(如有)",
|
||||
"`TerminalResponseMiddleware`",
|
||||
"`ModelLengthFinishReasonMiddleware`",
|
||||
|
||||
@ -11,7 +11,6 @@ from deerflow.skills.describe import (
|
||||
build_skill_search_setup,
|
||||
get_skill_index_prompt_section,
|
||||
)
|
||||
from deerflow.skills.tool_policy import allowed_tool_names_for_skills
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
@ -59,7 +58,7 @@ def test_render_metadata_format(sample_skills: list[Skill]):
|
||||
assert "## Skill: data-analysis" in rendered
|
||||
assert "Description: Analyze data with Python" in rendered
|
||||
assert "[built-in]" in rendered
|
||||
assert "Allowed tools: execute_code, read_file" in rendered
|
||||
assert "Allowed tools (compatibility metadata, not enforced): execute_code, read_file" in rendered
|
||||
assert "Location: /mnt/skills/public/data-analysis/SKILL.md" in rendered
|
||||
|
||||
|
||||
@ -69,11 +68,11 @@ def test_render_custom_skill_mutability(sample_skills: list[Skill]):
|
||||
assert "[custom, editable]" in rendered
|
||||
|
||||
|
||||
def test_render_no_allowed_tools_shows_all(sample_skills: list[Skill]):
|
||||
"""Skills without allowed_tools should show '(all)'."""
|
||||
def test_render_no_allowed_tools_shows_not_declared(sample_skills: list[Skill]):
|
||||
"""Skills without allowed_tools should show '(not declared)'."""
|
||||
no_tools = [s for s in sample_skills if s.allowed_tools is None]
|
||||
rendered = _render_skill_metadata(no_tools[:1], "/mnt/skills")
|
||||
assert "Allowed tools: (all)" in rendered
|
||||
assert "Allowed tools (compatibility metadata, not enforced): (not declared)" in rendered
|
||||
|
||||
|
||||
def test_render_multiple_skills(sample_skills: list[Skill]):
|
||||
@ -290,16 +289,21 @@ def test_describe_tool_select_uncapped():
|
||||
def test_render_explicit_empty_allowed_tools_does_not_claim_all():
|
||||
restricted = _make_skill("locked-down", allowed_tools=())
|
||||
rendered = _render_skill_metadata([restricted], "/mnt/skills")
|
||||
assert "Allowed tools: (all)" not in rendered
|
||||
assert "Allowed tools: (none)" in rendered
|
||||
assert "(all)" not in rendered
|
||||
assert "(not declared)" not in rendered
|
||||
assert "Allowed tools (compatibility metadata, not enforced): (empty declaration)" in rendered
|
||||
|
||||
|
||||
def test_rendered_allowed_tools_agree_with_skill_tool_policy():
|
||||
"""`(all)` must mean unrestricted, which is the one state that renders it."""
|
||||
def test_rendered_allowed_tools_are_labeled_compatibility_metadata():
|
||||
"""allowed-tools is exported for other agent frameworks but never enforced:
|
||||
`(not declared)` means only "field omitted"; `(empty declaration)` means
|
||||
only "declared empty"."""
|
||||
omitted = _make_skill("legacy")
|
||||
assert allowed_tool_names_for_skills([omitted]) is None
|
||||
assert "Allowed tools: (all)" in _render_skill_metadata([omitted], "/mnt/skills")
|
||||
rendered = _render_skill_metadata([omitted], "/mnt/skills")
|
||||
assert "Allowed tools (compatibility metadata, not enforced): (not declared)" in rendered
|
||||
|
||||
restricted = _make_skill("locked-down", allowed_tools=())
|
||||
assert allowed_tool_names_for_skills([restricted]) == set()
|
||||
assert "Allowed tools: (all)" not in _render_skill_metadata([restricted], "/mnt/skills")
|
||||
restricted = _make_skill("locked-down", allowed_tools=("read_file",))
|
||||
rendered = _render_skill_metadata([restricted], "/mnt/skills")
|
||||
assert "Allowed tools (compatibility metadata, not enforced): read_file" in rendered
|
||||
# The label makes clear the declaration does not restrict or grant tools.
|
||||
assert "not enforced" in rendered
|
||||
|
||||
@ -1,3 +1,15 @@
|
||||
"""Deferred tool/skill discovery stays catalog/hash scoped.
|
||||
|
||||
With the Skill tool policy removed, `allowed-tools` never filters
|
||||
model-visible schemas, execution, or tool_search promotions. These tests pin
|
||||
the still-supported deferred behaviors: the deferred filter scopes promotion by
|
||||
catalog hash and the genuine user turn, promoted names come only from the
|
||||
build-time catalog, the
|
||||
promotion audit records the final Command, and skill discovery metadata
|
||||
(describe_skill) stays catalog scoped. Genuine authorization denials are
|
||||
covered by the authorization suites.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
@ -9,11 +21,9 @@ from langchain_core.tools import tool
|
||||
from pydantic import Field
|
||||
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, write_slash_skill_source_path
|
||||
from deerflow.runtime.serialization import serialize
|
||||
from deerflow.skills.describe import build_skill_search_setup
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
@ -22,16 +32,19 @@ from deerflow.tools.mcp_metadata import tag_mcp_tool
|
||||
|
||||
_SLASH_SOURCE_OWNER_TOKEN = "test-slash-source-owner"
|
||||
_CALC_CALLS: list[str] = []
|
||||
_DENIED_CALLS: list[str] = []
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self):
|
||||
self.calls: list[dict] = []
|
||||
self.skill_usages: list[dict] = []
|
||||
|
||||
def record_middleware(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
def record_skill_usage(self, *args, **kwargs):
|
||||
self.skill_usages.append(args[0] if args else kwargs)
|
||||
|
||||
|
||||
@tool
|
||||
def calc(expression: str) -> str:
|
||||
@ -41,10 +54,9 @@ def calc(expression: str) -> str:
|
||||
|
||||
|
||||
@tool
|
||||
def denied_lookup(query: str) -> str:
|
||||
"""Run a lookup the active skill does not authorize."""
|
||||
_DENIED_CALLS.append(query)
|
||||
return "denied data"
|
||||
def other_lookup(query: str) -> str:
|
||||
"""Another catalog tool."""
|
||||
return "other data"
|
||||
|
||||
|
||||
class _StorageStub:
|
||||
@ -71,7 +83,7 @@ class _RecordingModel(GenericFakeChatModel):
|
||||
return self
|
||||
|
||||
|
||||
def _skill(name: str, allowed_tools: list[str]) -> Skill:
|
||||
def _skill(name: str, allowed_tools: list[str] | None) -> Skill:
|
||||
skill_dir = Path(f"/tmp/skills/public/{name}")
|
||||
return Skill(
|
||||
name=name,
|
||||
@ -81,35 +93,87 @@ def _skill(name: str, allowed_tools: list[str]) -> Skill:
|
||||
skill_file=skill_dir / "SKILL.md",
|
||||
relative_path=Path(name),
|
||||
category=SkillCategory.PUBLIC,
|
||||
allowed_tools=tuple(allowed_tools),
|
||||
allowed_tools=None if allowed_tools is None else tuple(allowed_tools),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def _active_policy(skill: Skill) -> tuple[SkillToolPolicyMiddleware, dict[str, object]]:
|
||||
middleware = SkillToolPolicyMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
middleware._storage = lambda: _StorageStub([skill])
|
||||
context: dict[str, object] = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
return middleware, context
|
||||
|
||||
|
||||
def _deferred_setup():
|
||||
return build_deferred_tool_setup(
|
||||
[tag_mcp_tool(calc), tag_mcp_tool(denied_lookup)],
|
||||
[tag_mcp_tool(calc), tag_mcp_tool(other_lookup)],
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def test_passive_empty_skill_policy_preserves_deferred_mcp_discovery_and_calling():
|
||||
passive_fixture = _skill("example-safe-skill", [])
|
||||
storage = _StorageStub([passive_fixture])
|
||||
policy = SkillToolPolicyMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
policy._storage = lambda: storage
|
||||
class _ActivationStorageStub:
|
||||
"""Temp-backed storage stub: load_skills plus the path guards the real
|
||||
SkillActivationMiddleware needs to read SKILL.md from disk."""
|
||||
|
||||
def __init__(self, root: Path, skills: list[Skill]):
|
||||
self._root = root
|
||||
self._skills = skills
|
||||
|
||||
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
|
||||
return [skill for skill in self._skills if skill.enabled or not enabled_only]
|
||||
|
||||
def get_container_root(self) -> str:
|
||||
return "/mnt/skills"
|
||||
|
||||
def get_skills_root_path(self) -> Path:
|
||||
return self._root
|
||||
|
||||
def validate_skill_file_path(self, skill_file: Path) -> Path:
|
||||
resolved = skill_file.resolve()
|
||||
root = self._root.resolve()
|
||||
resolved.relative_to(root) # raises ValueError outside the root
|
||||
return resolved
|
||||
|
||||
|
||||
def _write_activated_skill(tmp_path: Path) -> Skill:
|
||||
"""Real SKILL.md with a declared-but-empty allowed-tools sequence."""
|
||||
skill_dir = tmp_path / "example-safe-skill"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"---",
|
||||
"name: example-safe-skill",
|
||||
"description: Uses the configured MCP calculator",
|
||||
"license: MIT",
|
||||
"allowed-tools: []",
|
||||
"---",
|
||||
"",
|
||||
"# Calculator",
|
||||
"",
|
||||
"Use the deferred MCP calculator.",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return Skill(
|
||||
name="example-safe-skill",
|
||||
description="Uses the configured MCP calculator",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_file,
|
||||
relative_path=Path("example-safe-skill"),
|
||||
category=SkillCategory.CUSTOM,
|
||||
allowed_tools=(),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def test_enabled_skills_preserve_deferred_mcp_discovery_and_calling(tmp_path):
|
||||
"""A real slash activation of a skill whose SKILL.md declares an empty
|
||||
allowed-tools list still leaves deferred discovery and promotion intact:
|
||||
tool_search stays bound, `calc` is promoted and executes, and only the
|
||||
promoted tool becomes visible (allowed-tools is compatibility metadata,
|
||||
never a filter)."""
|
||||
skill = _write_activated_skill(tmp_path)
|
||||
storage = _ActivationStorageStub(tmp_path, [skill])
|
||||
activation = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
activation._storage = lambda: storage
|
||||
setup = _deferred_setup()
|
||||
model = _RecordingModel(
|
||||
[
|
||||
@ -119,69 +183,6 @@ def test_passive_empty_skill_policy_preserves_deferred_mcp_discovery_and_calling
|
||||
{
|
||||
"name": "tool_search",
|
||||
"args": {"query": "select:calc"},
|
||||
"id": "passive-search-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "calc",
|
||||
"args": {"expression": "2 + 2"},
|
||||
"id": "passive-calc-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
_CALC_CALLS.clear()
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[calc, denied_lookup, setup.tool_search_tool],
|
||||
middleware=[
|
||||
policy,
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="use the configured MCP calculator", id="user-passive")]},
|
||||
context={},
|
||||
)
|
||||
|
||||
assert model.bound_tool_names[0] == ["tool_search"]
|
||||
assert "calc" in model.bound_tool_names[1]
|
||||
# This tool remains hidden because it was not promoted, not because the passive
|
||||
# skill applied a policy restriction.
|
||||
assert "denied_lookup" not in model.bound_tool_names[1]
|
||||
assert _CALC_CALLS == ["2 + 2"]
|
||||
assert result["promoted"] == {
|
||||
"catalog_hash": setup.catalog_hash,
|
||||
"names": ["calc"],
|
||||
"turn_id": "user-passive",
|
||||
}
|
||||
assert storage.load_calls == 0
|
||||
|
||||
|
||||
def test_active_skill_can_search_promote_and_call_allowed_deferred_tool():
|
||||
restricted = _skill("restricted", ["calc"])
|
||||
policy, context = _active_policy(restricted)
|
||||
recorder = _Recorder()
|
||||
context["__run_journal"] = recorder
|
||||
setup = _deferred_setup()
|
||||
model = _RecordingModel(
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "tool_search",
|
||||
"args": {"query": "select:calc,denied_lookup"},
|
||||
"id": "search-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
@ -204,42 +205,38 @@ def test_active_skill_can_search_promote_and_call_allowed_deferred_tool():
|
||||
_CALC_CALLS.clear()
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[calc, denied_lookup, setup.tool_search_tool],
|
||||
tools=[calc, other_lookup, setup.tool_search_tool],
|
||||
middleware=[
|
||||
DeferredToolPromotionAuditMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
policy,
|
||||
activation,
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="use the allowed calculator", id="user-active")]},
|
||||
context=context,
|
||||
)
|
||||
recorder = _Recorder()
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="/example-safe-skill use the configured MCP calculator", id="msg-1")], "promoted": {"catalog_hash": setup.catalog_hash, "names": []}},
|
||||
context={"__run_journal": recorder},
|
||||
config={"recursion_limit": 20},
|
||||
)
|
||||
|
||||
# The slash activation really ran: usage was recorded for the skill.
|
||||
assert [usage.get("name") for usage in recorder.skill_usages if isinstance(usage, dict)] == ["example-safe-skill"]
|
||||
assert model.bound_tool_names[0] == ["tool_search"]
|
||||
assert "calc" in model.bound_tool_names[1]
|
||||
assert "denied_lookup" not in model.bound_tool_names[1]
|
||||
# Still hidden because it was not promoted — not by any skill declaration.
|
||||
assert "other_lookup" not in model.bound_tool_names[1]
|
||||
assert _CALC_CALLS == ["2 + 2"]
|
||||
assert result["promoted"] == {
|
||||
"catalog_hash": setup.catalog_hash,
|
||||
"names": ["calc"],
|
||||
"turn_id": "user-active",
|
||||
"turn_id": "msg-1",
|
||||
}
|
||||
search_result = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "search-call"]
|
||||
assert len(search_result) == 1
|
||||
assert '"name": "calc"' in search_result[0].content
|
||||
assert "denied_lookup" not in search_result[0].content
|
||||
assert recorder.calls[0]["changes"]["tool_names"] == ["calc"]
|
||||
assert "denied_lookup" not in recorder.calls[0]["changes"]["tool_names"]
|
||||
|
||||
|
||||
def test_tool_search_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
||||
restricted = _skill("restricted", ["calc"])
|
||||
policy, context = _active_policy(restricted)
|
||||
def test_promotion_is_scoped_to_the_build_time_catalog():
|
||||
"""tool_search can only promote names present in the deferred catalog; a
|
||||
query naming a non-deferred tool promotes nothing."""
|
||||
setup = _deferred_setup()
|
||||
model = _RecordingModel(
|
||||
[
|
||||
@ -248,19 +245,8 @@ def test_tool_search_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "tool_search",
|
||||
"args": {"query": "select:denied_lookup"},
|
||||
"id": "search-denied",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "denied_lookup",
|
||||
"args": {"query": "secret"},
|
||||
"id": "denied-call",
|
||||
"args": {"query": "select:read_file"},
|
||||
"id": "search-miss",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
@ -268,56 +254,42 @@ def test_tool_search_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
_DENIED_CALLS.clear()
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[calc, denied_lookup, setup.tool_search_tool],
|
||||
tools=[calc, other_lookup, setup.tool_search_tool],
|
||||
middleware=[
|
||||
policy,
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="try the denied lookup", id="user-denied")]},
|
||||
context=context,
|
||||
{"messages": [HumanMessage(content="search for a non-deferred tool", id="user-catalog-miss")]},
|
||||
context={},
|
||||
)
|
||||
|
||||
assert all("denied_lookup" not in names for names in model.bound_tool_names)
|
||||
assert _DENIED_CALLS == []
|
||||
assert result["promoted"] == {
|
||||
"catalog_hash": setup.catalog_hash,
|
||||
"names": [],
|
||||
"turn_id": "user-denied",
|
||||
"turn_id": "user-catalog-miss",
|
||||
}
|
||||
search_result = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "search-denied"]
|
||||
assert len(search_result) == 1
|
||||
assert "denied_lookup" not in search_result[0].content
|
||||
blocked = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "denied-call"]
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0].status == "error"
|
||||
assert "not allowed by the active skill policy" in blocked[0].content
|
||||
assert all("calc" not in names for names in model.bound_tool_names[1:])
|
||||
|
||||
|
||||
def test_auto_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
||||
restricted = _skill("restricted", ["calc"])
|
||||
policy, context = _active_policy(restricted)
|
||||
def test_promotion_audit_records_final_command_names():
|
||||
"""The audit middleware observes the final tool_search Command and records
|
||||
the promoted names exactly once per search."""
|
||||
setup = _deferred_setup()
|
||||
routing = McpRoutingMiddleware(
|
||||
{"denied_lookup": {"priority": 100, "keywords": ["denied lookup"]}},
|
||||
setup.catalog_hash,
|
||||
3,
|
||||
)
|
||||
recorder = _Recorder()
|
||||
model = _RecordingModel(
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "denied_lookup",
|
||||
"args": {"query": "secret"},
|
||||
"id": "auto-denied-call",
|
||||
"name": "tool_search",
|
||||
"args": {"query": "select:calc,other_lookup"},
|
||||
"id": "search-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
@ -325,44 +297,34 @@ def test_auto_promotion_cannot_expose_or_execute_denied_deferred_tool():
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
_DENIED_CALLS.clear()
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[calc, denied_lookup, setup.tool_search_tool],
|
||||
tools=[calc, other_lookup, setup.tool_search_tool],
|
||||
middleware=[
|
||||
policy,
|
||||
routing,
|
||||
DeferredToolPromotionAuditMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="run the denied lookup", id="user-auto-denied")]},
|
||||
context=context,
|
||||
asyncio.run(
|
||||
graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="promote both tools")]},
|
||||
context={"__run_journal": recorder},
|
||||
)
|
||||
)
|
||||
|
||||
assert all("denied_lookup" not in names for names in model.bound_tool_names)
|
||||
assert _DENIED_CALLS == []
|
||||
assert result["promoted"] == {
|
||||
"catalog_hash": setup.catalog_hash,
|
||||
"names": ["denied_lookup"],
|
||||
"turn_id": "user-auto-denied",
|
||||
}
|
||||
blocked = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "auto-denied-call"]
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0].status == "error"
|
||||
assert "not allowed by the active skill policy" in blocked[0].content
|
||||
assert recorder.calls[0]["changes"]["tool_names"] == ["calc", "other_lookup"]
|
||||
|
||||
|
||||
def test_restrictive_skill_keeps_deferred_skill_discovery_available():
|
||||
restricted = _skill("restricted", [])
|
||||
hidden = _skill("hidden", ["write_file"])
|
||||
def test_skill_discovery_metadata_stays_catalog_scoped():
|
||||
"""describe_skill answers only from its build-time catalog, independent of
|
||||
any skill's allowed-tools declaration."""
|
||||
listed = _skill("listed", [])
|
||||
skill_setup = build_skill_search_setup(
|
||||
[restricted],
|
||||
[listed],
|
||||
enabled=True,
|
||||
)
|
||||
policy, context = _active_policy(restricted)
|
||||
model = _RecordingModel(
|
||||
[
|
||||
AIMessage(
|
||||
@ -370,7 +332,7 @@ def test_restrictive_skill_keeps_deferred_skill_discovery_available():
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "describe_skill",
|
||||
"args": {"name": f"select:{restricted.name},{hidden.name}"},
|
||||
"args": {"name": f"select:{listed.name},unknown"},
|
||||
"id": "describe-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
@ -382,37 +344,40 @@ def test_restrictive_skill_keeps_deferred_skill_discovery_available():
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[skill_setup.describe_skill_tool],
|
||||
middleware=[policy],
|
||||
middleware=[],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="describe available skills")]},
|
||||
context=context,
|
||||
context={},
|
||||
)
|
||||
|
||||
assert model.bound_tool_names[0] == ["describe_skill"]
|
||||
described = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "describe-call"]
|
||||
assert len(described) == 1
|
||||
assert "Skill: restricted" in described[0].content
|
||||
assert "Skill: hidden" not in described[0].content
|
||||
assert "Skill: listed" in described[0].content
|
||||
assert "Skill: unknown" not in described[0].content
|
||||
|
||||
|
||||
def test_policy_tokens_do_not_appear_in_debug_or_checkpoint_streams():
|
||||
restricted = _skill("restricted", [])
|
||||
policy, context = _active_policy(restricted)
|
||||
def test_activation_tokens_do_not_appear_in_debug_or_checkpoint_streams(tmp_path):
|
||||
"""The slash-source owner token stays out of observable streams even though
|
||||
the (historical) policy decision key is no longer published."""
|
||||
skill = _skill("restricted", [])
|
||||
activation = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
activation._storage = lambda: _StorageStub([skill])
|
||||
model = _RecordingModel([AIMessage(content="done")])
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[],
|
||||
middleware=[policy],
|
||||
middleware=[activation],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
events = list(
|
||||
graph.stream(
|
||||
{"messages": [HumanMessage(content="finish")]},
|
||||
context=context,
|
||||
context={},
|
||||
stream_mode=["debug", "checkpoints"],
|
||||
)
|
||||
)
|
||||
@ -421,6 +386,5 @@ def test_policy_tokens_do_not_appear_in_debug_or_checkpoint_streams():
|
||||
|
||||
assert events
|
||||
assert _SLASH_SOURCE_OWNER_TOKEN not in payload
|
||||
assert policy._decision_owner_token not in payload
|
||||
assert "__slash_skill_secret_source" not in payload
|
||||
assert SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY not in payload
|
||||
assert "__skill_tool_policy_decision" not in payload
|
||||
|
||||
327
backend/tests/test_skill_tool_authority.py
Normal file
327
backend/tests/test_skill_tool_authority.py
Normal file
@ -0,0 +1,327 @@
|
||||
"""Skill allowed-tools is compatibility metadata, not a tool authority.
|
||||
|
||||
`allowed-tools` stays parsed, exported, and described, but it must never filter
|
||||
model-visible schemas, refuse calls, or rewrite tool_search promotions. Agent
|
||||
assembly/execution authorization (provider filtering + Guardrails) owns the
|
||||
permission ceiling.
|
||||
|
||||
These regressions drive the real `build_middlewares` (lead) and
|
||||
`build_subagent_runtime_middlewares` chains through `create_agent`, activate a
|
||||
synthetic skill via a real slash command, and assert both model binding and
|
||||
actual ToolNode execution on sync and async paths. The synthetic skill lives in
|
||||
a real on-disk skills tree served through the configured storage provider, and
|
||||
the block under test is taken structurally between the live
|
||||
`SkillActivationMiddleware` and `DurableContextMiddleware` anchors, so
|
||||
whatever middleware the builder actually installs between those anchors
|
||||
reaches `create_agent`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
from pydantic import Field
|
||||
|
||||
from deerflow.agents.lead_agent import agent as lead_agent_module
|
||||
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
||||
from deerflow.config.loop_detection_config import LoopDetectionConfig
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
from deerflow.config.safety_finish_reason_config import SafetyFinishReasonConfig
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.config.skills_config import SkillsConfig
|
||||
from deerflow.config.subagents_config import SubagentsAppConfig
|
||||
from deerflow.config.token_budget_config import TokenBudgetConfig
|
||||
|
||||
_PARSE_CALLS: list[str] = []
|
||||
_WRITE_CALLS: list[str] = []
|
||||
|
||||
|
||||
@tool
|
||||
def parse_document(path: str) -> str:
|
||||
"""Parse a document and return its text."""
|
||||
_PARSE_CALLS.append(path)
|
||||
return "parsed"
|
||||
|
||||
|
||||
@tool
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Write content to a file."""
|
||||
_WRITE_CALLS.append(path)
|
||||
return "written"
|
||||
|
||||
|
||||
@tool
|
||||
def read_file(path: str) -> str:
|
||||
"""Read a file from the skills container."""
|
||||
return "file body"
|
||||
|
||||
|
||||
class _RecordingModel(GenericFakeChatModel):
|
||||
bound_tool_names: list[list[str]] = Field(default_factory=list)
|
||||
|
||||
def __init__(self, responses: list[AIMessage]):
|
||||
super().__init__(messages=iter(responses))
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any):
|
||||
self.bound_tool_names.append([getattr(candidate, "name", "") for candidate in tools])
|
||||
return self
|
||||
|
||||
|
||||
class _NoopMiddleware:
|
||||
def __repr__(self) -> str: # pragma: no cover - debug aid only
|
||||
return "<noop-test-middleware>"
|
||||
|
||||
|
||||
def _make_app_config(skills_root: Path) -> AppConfig:
|
||||
return AppConfig(
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="safe-model",
|
||||
display_name="safe-model",
|
||||
description=None,
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
model="safe-model",
|
||||
supports_thinking=False,
|
||||
supports_vision=False,
|
||||
)
|
||||
],
|
||||
sandbox=SandboxConfig(use="test"),
|
||||
loop_detection=LoopDetectionConfig(enabled=False),
|
||||
# The synthetic skills tree is served through the real configured
|
||||
# storage provider, so every middleware in the block under test
|
||||
# resolves skills through the common production seam.
|
||||
skills=SkillsConfig(path=str(skills_root)),
|
||||
# Keep the activation→durable-context block free of unrelated guards.
|
||||
safety_finish_reason=SafetyFinishReasonConfig(enabled=False),
|
||||
subagents=SubagentsAppConfig(token_budget=TokenBudgetConfig(enabled=False)),
|
||||
)
|
||||
|
||||
|
||||
def _make_skill(skills_root: Path, name: str, allowed_tools: list[str] | None) -> None:
|
||||
"""Write one real custom skill into the per-test skills tree."""
|
||||
skill_dir = skills_root / "custom" / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"---",
|
||||
f"name: {name}",
|
||||
"description: Demo skill for tool authority regressions",
|
||||
"license: MIT",
|
||||
]
|
||||
if allowed_tools is None:
|
||||
pass
|
||||
elif not allowed_tools:
|
||||
# An actual empty YAML sequence, not a null `allowed-tools:` key.
|
||||
lines.append("allowed-tools: []")
|
||||
else:
|
||||
lines.append("allowed-tools:")
|
||||
lines.extend(f" - {entry}" for entry in allowed_tools)
|
||||
lines.extend(["---", "", "# Demo", "", "Use the parser."])
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def _lead_middlewares(monkeypatch, app_config: AppConfig) -> list:
|
||||
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
|
||||
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
||||
monkeypatch.setattr(lead_agent_module, "TitleMiddleware", lambda *, app_config, extensions: _NoopMiddleware())
|
||||
monkeypatch.setattr(lead_agent_module, "MemoryMiddleware", lambda *args, **kwargs: _NoopMiddleware())
|
||||
return lead_agent_module.build_middlewares(
|
||||
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
||||
model_name="safe-model",
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
def _subagent_middlewares(app_config: AppConfig) -> list:
|
||||
return build_subagent_runtime_middlewares(app_config=app_config, model_name="safe-model")
|
||||
|
||||
|
||||
def _skill_authority_slice(middlewares: list) -> list:
|
||||
"""The contiguous skill block the builder installs, taken structurally.
|
||||
|
||||
The block starts at the live ``SkillActivationMiddleware`` anchor and ends
|
||||
just before the live ``DurableContextMiddleware`` anchor — both types
|
||||
exist in every real builder output. Whatever middleware the builder
|
||||
actually places between those anchors is included positionally, so the
|
||||
block stays meaningful against any assembly without recognizing class
|
||||
names that a later revision may delete. Adjacent placement on the current
|
||||
chain is pinned separately by
|
||||
``test_build_middlewares_orders_skill_activation_before_durable_context``.
|
||||
"""
|
||||
start = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, SkillActivationMiddleware))
|
||||
end = next(i for i in range(start + 1, len(middlewares)) if isinstance(middlewares[i], DurableContextMiddleware))
|
||||
return middlewares[start:end]
|
||||
|
||||
|
||||
def _scripted_model() -> _RecordingModel:
|
||||
return _RecordingModel(
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "parse_document",
|
||||
"args": {"path": "report.pdf"},
|
||||
"id": "parse-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _Journal:
|
||||
def __init__(self) -> None:
|
||||
self.middleware_calls: list[dict] = []
|
||||
self.skill_usages: list[Any] = []
|
||||
|
||||
def record_middleware(self, *args, **kwargs):
|
||||
self.middleware_calls.append(kwargs)
|
||||
|
||||
def record_skill_usage(self, *args, **kwargs):
|
||||
self.skill_usages.append(args[0] if args else kwargs)
|
||||
|
||||
|
||||
def _invoke(graph, *, asynchronous: bool) -> tuple[dict, _Journal]:
|
||||
journal = _Journal()
|
||||
graph_input = {"messages": [HumanMessage(content="/demo-skill parse the report", id="msg-1")]}
|
||||
context = {"__run_journal": journal}
|
||||
config = {"recursion_limit": 20}
|
||||
if asynchronous:
|
||||
result = asyncio.run(graph.ainvoke(graph_input, context=context, config=config))
|
||||
else:
|
||||
result = graph.invoke(graph_input, context=context, config=config)
|
||||
return result, journal
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builder", ["lead", "subagent"])
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_tools",
|
||||
[["write_file"], [], None],
|
||||
ids=["restrictive", "empty", "omitted"],
|
||||
)
|
||||
def test_active_skill_allowed_tools_does_not_remove_authorized_tools(monkeypatch, tmp_path, builder, asynchronous, allowed_tools):
|
||||
"""A slash-activated skill keeps every already-authorized tool bound and executable.
|
||||
|
||||
allowed-tools is compatibility metadata: restrictive, empty, or omitted
|
||||
declarations must not narrow the Agent's tool set on the lead or Subagent
|
||||
assembly, sync or async. The restrictive case lists only `write_file` while
|
||||
the scripted model executes `parse_document`: an Agent-authorized tool
|
||||
OMITTED by the restrictive metadata must still run.
|
||||
"""
|
||||
_make_skill(tmp_path, "demo-skill", allowed_tools)
|
||||
app_config = _make_app_config(tmp_path)
|
||||
if builder == "lead":
|
||||
middlewares = _lead_middlewares(monkeypatch, app_config)
|
||||
else:
|
||||
middlewares = _subagent_middlewares(app_config)
|
||||
slice_ = _skill_authority_slice(middlewares)
|
||||
assert slice_, "builder must install SkillActivationMiddleware"
|
||||
|
||||
model = _scripted_model()
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[read_file, parse_document, write_file],
|
||||
middleware=slice_,
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
_PARSE_CALLS.clear()
|
||||
result, journal = _invoke(graph, asynchronous=asynchronous)
|
||||
|
||||
assert {"read_file", "parse_document", "write_file"} <= set(model.bound_tool_names[0])
|
||||
assert _PARSE_CALLS == ["report.pdf"]
|
||||
assert _WRITE_CALLS == []
|
||||
assert result["messages"][-1].content == "done"
|
||||
# The real slash activation still ran: usage was recorded for the skill.
|
||||
assert [usage.get("name") for usage in journal.skill_usages if isinstance(usage, dict)] == ["demo-skill"]
|
||||
|
||||
|
||||
def test_allowed_tools_metadata_cannot_grant_rbac_denied_tool(monkeypatch, tmp_path):
|
||||
"""Declaring a tool in allowed-tools never bypasses genuine Agent authorization.
|
||||
|
||||
Control test: RBAC denies `write_file` for the guest role. A skill whose
|
||||
allowed-tools lists `write_file` must not make it executable; the
|
||||
Guardrail-backed authorization middleware still refuses the call.
|
||||
"""
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||
|
||||
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["read_file", "parse_document"]}}})
|
||||
_make_skill(tmp_path, "demo-skill", ["write_file"])
|
||||
app_config = _make_app_config(tmp_path)
|
||||
app_config.authorization = AuthorizationConfig(
|
||||
enabled=True,
|
||||
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
||||
monkeypatch.setattr(lead_agent_module, "TitleMiddleware", lambda *, app_config, extensions: _NoopMiddleware())
|
||||
monkeypatch.setattr(lead_agent_module, "MemoryMiddleware", lambda *args, **kwargs: _NoopMiddleware())
|
||||
|
||||
runtime_middlewares = build_lead_runtime_middlewares(
|
||||
app_config=app_config,
|
||||
authorization_provider=provider,
|
||||
)
|
||||
full_chain = _lead_middlewares(monkeypatch, app_config)
|
||||
slice_ = _skill_authority_slice(full_chain)
|
||||
|
||||
model = _RecordingModel(
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "write_file",
|
||||
"args": {"path": "out.txt", "content": "x"},
|
||||
"id": "denied-call",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
),
|
||||
AIMessage(content="done"),
|
||||
]
|
||||
)
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[read_file, parse_document, write_file],
|
||||
middleware=[*runtime_middlewares, *slice_],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
_WRITE_CALLS.clear()
|
||||
journal = SimpleNamespace(
|
||||
record_middleware=lambda *args, **kwargs: None,
|
||||
record_skill_usage=lambda *args, **kwargs: None,
|
||||
)
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="/demo-skill write the report", id="msg-1")]},
|
||||
context={"__run_journal": journal, "user_role": "guest", "thread_id": "t-authority-test"},
|
||||
config={"recursion_limit": 20, "configurable": {"thread_id": "t-authority-test"}},
|
||||
)
|
||||
|
||||
assert _WRITE_CALLS == []
|
||||
blocked = [message for message in result["messages"] if isinstance(message, ToolMessage) and message.tool_call_id == "denied-call"]
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0].status == "error"
|
||||
# The denial must be visible in the ToolMessage content itself, not only
|
||||
# via the status error flag.
|
||||
assert "authz.denied" in blocked[0].content
|
||||
@ -1,946 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain.agents.middleware.types import ModelRequest
|
||||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, write_slash_skill_source_path
|
||||
from deerflow.skills.parser import parse_skill_file
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
|
||||
_SLASH_SOURCE_OWNER_TOKEN = "test-slash-source-owner"
|
||||
|
||||
|
||||
class NamedTool:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
|
||||
class ModelRequestStub:
|
||||
def __init__(self, tools, *, state=None, context=None, messages=None):
|
||||
self.tools = tools
|
||||
self.state = state or {}
|
||||
self.runtime = SimpleNamespace(context={} if context is None else context)
|
||||
self.messages = messages or []
|
||||
|
||||
def override(self, **updates):
|
||||
return ModelRequestStub(
|
||||
updates.get("tools", self.tools),
|
||||
state=updates.get("state", self.state),
|
||||
context=self.runtime.context,
|
||||
messages=updates.get("messages", self.messages),
|
||||
)
|
||||
|
||||
|
||||
class ToolRequestStub:
|
||||
def __init__(self, name: str, *, state=None, context=None):
|
||||
self.tool_call = {"name": name, "id": "call-1", "args": {}}
|
||||
self.state = state or {}
|
||||
self.runtime = SimpleNamespace(context={} if context is None else context)
|
||||
|
||||
|
||||
class StorageStub:
|
||||
def __init__(self, skills):
|
||||
self._skills = skills
|
||||
self.load_calls = 0
|
||||
|
||||
def load_skills(self, *, enabled_only=False):
|
||||
self.load_calls += 1
|
||||
return [skill for skill in self._skills if skill.enabled or not enabled_only]
|
||||
|
||||
def get_container_root(self):
|
||||
return "/mnt/skills"
|
||||
|
||||
|
||||
def _skill(name: str, allowed_tools, *, enabled=True):
|
||||
skill_dir = Path(f"/tmp/skills/public/{name}")
|
||||
return Skill(
|
||||
name=name,
|
||||
description=f"Description for {name}",
|
||||
license="MIT",
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_dir / "SKILL.md",
|
||||
relative_path=Path(name),
|
||||
category=SkillCategory.PUBLIC,
|
||||
allowed_tools=None if allowed_tools is None else tuple(allowed_tools),
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
def _middleware(skills, *, available_skills=None):
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
middleware = SkillToolPolicyMiddleware(
|
||||
available_skills=available_skills,
|
||||
slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware._storage = lambda: StorageStub(skills)
|
||||
return middleware
|
||||
|
||||
|
||||
def _tool_names(request):
|
||||
return [tool.name for tool in request.tools]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"middleware_class_path",
|
||||
[
|
||||
"deerflow.agents.middlewares.skill_activation_middleware.SkillActivationMiddleware",
|
||||
"deerflow.agents.middlewares.skill_tool_policy_middleware.SkillToolPolicyMiddleware",
|
||||
],
|
||||
)
|
||||
def test_skill_policy_middlewares_require_shared_slash_source_token(middleware_class_path):
|
||||
module_name, class_name = middleware_class_path.rsplit(".", 1)
|
||||
module = __import__(module_name, fromlist=[class_name])
|
||||
middleware_class = getattr(module, class_name)
|
||||
|
||||
with pytest.raises(TypeError, match="slash_source_owner_token"):
|
||||
middleware_class()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_token", [None, "", 7])
|
||||
def test_skill_policy_middlewares_reject_invalid_slash_source_tokens(invalid_token):
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
for middleware_class in (SkillActivationMiddleware, SkillToolPolicyMiddleware):
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
middleware_class(slash_source_owner_token=invalid_token)
|
||||
|
||||
|
||||
def test_passive_enabled_skill_does_not_filter_lead_tools():
|
||||
middleware = _middleware([_skill("reviewer", ["review_skill_package"])])
|
||||
request = ModelRequestStub([NamedTool("task"), NamedTool("web_search"), NamedTool("review_skill_package")])
|
||||
|
||||
filtered = middleware._filter_model_request(request)
|
||||
|
||||
assert _tool_names(filtered) == ["task", "web_search", "review_skill_package"]
|
||||
|
||||
|
||||
def test_sync_passive_model_call_skips_storage():
|
||||
middleware = _middleware([])
|
||||
|
||||
def fail_storage():
|
||||
raise AssertionError("passive model calls must not load skill storage")
|
||||
|
||||
middleware._storage = fail_storage
|
||||
request = ModelRequestStub([NamedTool("task")])
|
||||
|
||||
assert middleware.wrap_model_call(request, lambda model_request: model_request) is request
|
||||
|
||||
|
||||
def test_async_passive_model_call_skips_storage_and_thread_offload():
|
||||
middleware = _middleware([])
|
||||
|
||||
def fail_storage():
|
||||
raise AssertionError("passive model calls must not load skill storage")
|
||||
|
||||
middleware._storage = fail_storage
|
||||
request = ModelRequestStub([NamedTool("task")])
|
||||
|
||||
async def handler(model_request):
|
||||
return model_request
|
||||
|
||||
assert asyncio.run(middleware.awrap_model_call(request, handler)) is request
|
||||
|
||||
|
||||
def test_slash_activated_skill_filters_first_model_call_and_task():
|
||||
skill = _skill("reviewer", ["review_skill_package"])
|
||||
context = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("read_file"), NamedTool("review_skill_package")],
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware._filter_model_request(request)
|
||||
|
||||
assert _tool_names(filtered) == ["read_file", "review_skill_package"]
|
||||
|
||||
|
||||
def test_slash_activation_normalizes_unscoped_portable_tools_but_not_command_patterns(tmp_path):
|
||||
skill_dir = tmp_path / "portable"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: portable\ndescription: Portable tools\nallowed-tools: WebFetch Bash(git add *)\n---\nBody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
skill = parse_skill_file(skill_file, category=SkillCategory.CUSTOM)
|
||||
assert skill is not None
|
||||
context = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("bash"), NamedTool("web_fetch"), NamedTool("web_search"), NamedTool("add")],
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
|
||||
|
||||
assert _tool_names(filtered) == ["web_fetch"]
|
||||
assert (
|
||||
middleware.wrap_tool_call(
|
||||
ToolRequestStub("web_fetch", context=context),
|
||||
lambda _: "executed",
|
||||
)
|
||||
== "executed"
|
||||
)
|
||||
blocked = middleware.wrap_tool_call(
|
||||
ToolRequestStub("bash", context=context),
|
||||
lambda _: "executed",
|
||||
)
|
||||
assert blocked.status == "error"
|
||||
blocked_fragment = middleware.wrap_tool_call(
|
||||
ToolRequestStub("add", context=context),
|
||||
lambda _: "executed",
|
||||
)
|
||||
assert blocked_fragment.status == "error"
|
||||
|
||||
|
||||
def test_slash_activation_preserves_scalar_custom_tool_names(tmp_path):
|
||||
skill_dir = tmp_path / "custom-tool"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: custom-tool\ndescription: Custom tool\nallowed-tools: mcp__arxiv__SearchPapers\n---\nBody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
skill = parse_skill_file(skill_file, category=SkillCategory.CUSTOM)
|
||||
assert skill is not None
|
||||
context = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("mcp__arxiv__SearchPapers"), NamedTool("mcp__arxiv__search_papers")],
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
|
||||
|
||||
assert _tool_names(filtered) == ["mcp__arxiv__SearchPapers"]
|
||||
|
||||
|
||||
def test_slash_activation_preserves_lowercase_exact_tool_authority(tmp_path):
|
||||
skill_dir = tmp_path / "lowercase-tool"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: lowercase-tool\ndescription: Lowercase tool\nallowed-tools: write\n---\nBody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
skill = parse_skill_file(skill_file, category=SkillCategory.CUSTOM)
|
||||
assert skill is not None
|
||||
context = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("write"), NamedTool("write_file")],
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
|
||||
|
||||
assert _tool_names(filtered) == ["write"]
|
||||
|
||||
|
||||
def test_slash_activation_normalizes_glob_and_grep_aliases(tmp_path):
|
||||
skill_dir = tmp_path / "search-tools"
|
||||
skill_dir.mkdir()
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text(
|
||||
"---\nname: search-tools\ndescription: Search tools\nallowed-tools: Glob Grep\n---\nBody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
skill = parse_skill_file(skill_file, category=SkillCategory.CUSTOM)
|
||||
assert skill is not None
|
||||
context = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("glob"), NamedTool("grep"), NamedTool("Glob"), NamedTool("Grep")],
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
|
||||
|
||||
assert _tool_names(filtered) == ["glob", "grep"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("active_source", ["slash", "skill_context"])
|
||||
def test_restrictive_skill_explicitly_allows_task_schema_and_execution(active_source):
|
||||
skill = _skill("delegating", ["task"])
|
||||
context = {}
|
||||
state = {}
|
||||
if active_source == "slash":
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
else:
|
||||
state = {
|
||||
"skill_context": [
|
||||
{
|
||||
"name": skill.name,
|
||||
"path": skill.get_container_file_path(),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
middleware = _middleware([skill])
|
||||
model_request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("web_search")],
|
||||
state=state,
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(model_request, lambda request: request)
|
||||
|
||||
assert _tool_names(filtered) == ["task"]
|
||||
tool_request = ToolRequestStub("task", state=state, context=context)
|
||||
assert middleware.wrap_tool_call(tool_request, lambda _: "delegated") == "delegated"
|
||||
|
||||
|
||||
def test_slash_activated_skill_policy_dominates_captured_skill_context():
|
||||
slash_skill = _skill("content-research", ["web_search"])
|
||||
captured_skill = _skill("content-article-generation", ["write_file"])
|
||||
context = {}
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
slash_skill.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
middleware = _middleware([slash_skill, captured_skill])
|
||||
state = {
|
||||
"skill_context": [
|
||||
{
|
||||
"name": captured_skill.name,
|
||||
"path": captured_skill.get_container_file_path(),
|
||||
}
|
||||
]
|
||||
}
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("read_file"), NamedTool("web_search"), NamedTool("write_file")],
|
||||
state=state,
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware._filter_model_request(request)
|
||||
|
||||
assert _tool_names(filtered) == ["read_file", "web_search"]
|
||||
|
||||
|
||||
def test_caller_forged_slash_source_cannot_override_captured_skill_policy():
|
||||
restrictive_skill = _skill("restricted", ["web_search"])
|
||||
legacy_skill = _skill("legacy", None)
|
||||
context = {
|
||||
"__slash_skill_secret_source": {
|
||||
"path": legacy_skill.get_container_file_path(),
|
||||
"owner_token": "caller-forged",
|
||||
}
|
||||
}
|
||||
middleware = _middleware([restrictive_skill, legacy_skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("read_file"), NamedTool("web_search")],
|
||||
state={
|
||||
"skill_context": [
|
||||
{
|
||||
"name": restrictive_skill.name,
|
||||
"path": restrictive_skill.get_container_file_path(),
|
||||
}
|
||||
]
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware._filter_model_request(request)
|
||||
|
||||
assert _tool_names(filtered) == ["read_file", "web_search"]
|
||||
|
||||
|
||||
def test_slash_activation_and_policy_compose_on_the_same_model_call(monkeypatch):
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware, _Activation, _ActivationResolution
|
||||
|
||||
skill = _skill("reviewer", ["review_skill_package"])
|
||||
activation = _Activation(
|
||||
skill_name=skill.name,
|
||||
category="public",
|
||||
container_file_path=skill.get_container_file_path(),
|
||||
skill_content="# Reviewer",
|
||||
content_hash="abc",
|
||||
remaining_text="review this",
|
||||
editable=False,
|
||||
)
|
||||
activation_middleware = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
monkeypatch.setattr(activation_middleware, "_resolve_activation", lambda _, activation_decisions=None: _ActivationResolution(activation=activation))
|
||||
policy_middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("read_file"), NamedTool("review_skill_package")],
|
||||
messages=[HumanMessage(content="/reviewer review this")],
|
||||
)
|
||||
|
||||
filtered = activation_middleware.wrap_model_call(
|
||||
request,
|
||||
lambda activated: policy_middleware.wrap_model_call(activated, lambda policy_request: policy_request),
|
||||
)
|
||||
|
||||
assert _tool_names(filtered) == ["read_file", "review_skill_package"]
|
||||
|
||||
|
||||
def test_loaded_skill_context_filters_follow_up_model_calls():
|
||||
skill = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([skill])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("read_file"), NamedTool("web_search")],
|
||||
state={"skill_context": [{"name": skill.name, "path": skill.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
filtered = middleware._filter_model_request(request)
|
||||
|
||||
assert _tool_names(filtered) == ["read_file", "web_search"]
|
||||
|
||||
|
||||
def test_active_skill_union_and_legacy_semantics_are_preserved():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
second = _skill("second", ["bash"])
|
||||
legacy = _skill("legacy", None)
|
||||
middleware = _middleware([restricted, second, legacy])
|
||||
state = {
|
||||
"skill_context": [
|
||||
{"path": restricted.get_container_file_path()},
|
||||
{"path": second.get_container_file_path()},
|
||||
{"path": legacy.get_container_file_path()},
|
||||
]
|
||||
}
|
||||
request = ModelRequestStub([NamedTool("task"), NamedTool("bash"), NamedTool("web_search")], state=state)
|
||||
|
||||
filtered = middleware._filter_model_request(request)
|
||||
|
||||
assert _tool_names(filtered) == ["bash", "web_search"]
|
||||
|
||||
|
||||
def test_only_legacy_active_skill_preserves_all_tools():
|
||||
legacy = _skill("legacy", None)
|
||||
middleware = _middleware([legacy])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("bash")],
|
||||
state={"skill_context": [{"path": legacy.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == ["task", "bash"]
|
||||
|
||||
|
||||
def test_explicit_empty_allowed_tools_keeps_only_framework_tools():
|
||||
restricted = _skill("restricted", [])
|
||||
middleware = _middleware([restricted])
|
||||
request = ModelRequestStub(
|
||||
[
|
||||
NamedTool("task"),
|
||||
NamedTool("list_background_tasks"),
|
||||
NamedTool("cancel_background_task"),
|
||||
NamedTool("read_file"),
|
||||
NamedTool("review_skill_package"),
|
||||
NamedTool("tool_search"),
|
||||
NamedTool("describe_skill"),
|
||||
],
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == [
|
||||
"read_file",
|
||||
"review_skill_package",
|
||||
"tool_search",
|
||||
"describe_skill",
|
||||
]
|
||||
|
||||
|
||||
def test_active_skill_must_declare_background_task_business_tools():
|
||||
restricted = _skill("task-reader", ["list_background_tasks"])
|
||||
middleware = _middleware([restricted])
|
||||
request = ModelRequestStub(
|
||||
[
|
||||
NamedTool("list_background_tasks"),
|
||||
NamedTool("cancel_background_task"),
|
||||
],
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == [
|
||||
"list_background_tasks",
|
||||
]
|
||||
|
||||
|
||||
def test_active_skill_keeps_framework_discovery_tools():
|
||||
restricted = _skill("restricted", ["calc"])
|
||||
middleware = _middleware([restricted])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("calc"), NamedTool("tool_search"), NamedTool("describe_skill")],
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == ["calc", "tool_search", "describe_skill"]
|
||||
|
||||
|
||||
def test_custom_agent_allowlist_rejects_all_out_of_scope_active_skills():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([restricted], available_skills={"other"})
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("web_search")],
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == []
|
||||
|
||||
|
||||
def test_unauthorized_tool_execution_is_blocked():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([restricted])
|
||||
request = ToolRequestStub(
|
||||
"task",
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
result = middleware.wrap_tool_call(request, lambda _: "executed")
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.name == "task"
|
||||
assert "not allowed" in result.content
|
||||
|
||||
|
||||
def test_allowed_tool_execution_reaches_handler():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([restricted])
|
||||
request = ToolRequestStub(
|
||||
"web_search",
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert middleware.wrap_tool_call(request, lambda _: "executed") == "executed"
|
||||
|
||||
|
||||
def test_async_unauthorized_tool_execution_is_blocked():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([restricted])
|
||||
request = ToolRequestStub(
|
||||
"task",
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
async def handler(_):
|
||||
return "executed"
|
||||
|
||||
result = asyncio.run(middleware.awrap_tool_call(request, handler))
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.name == "task"
|
||||
|
||||
|
||||
def test_unknown_skill_context_path_is_skipped_while_resolvable_skills_apply():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([restricted])
|
||||
request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("read_file"), NamedTool("web_search")],
|
||||
state={
|
||||
"skill_context": [
|
||||
{"path": "/mnt/skills/public/missing/SKILL.md"},
|
||||
{"path": restricted.get_container_file_path()},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == ["read_file", "web_search"]
|
||||
|
||||
|
||||
def test_all_unknown_active_paths_fail_closed_to_framework_tools():
|
||||
middleware = _middleware([])
|
||||
request = ModelRequestStub(
|
||||
[
|
||||
NamedTool("task"),
|
||||
NamedTool("read_file"),
|
||||
NamedTool("review_skill_package"),
|
||||
NamedTool("tool_search"),
|
||||
NamedTool("describe_skill"),
|
||||
],
|
||||
state={"skill_context": [{"path": "/mnt/skills/public/missing/SKILL.md"}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == [
|
||||
"read_file",
|
||||
"review_skill_package",
|
||||
"tool_search",
|
||||
"describe_skill",
|
||||
]
|
||||
|
||||
|
||||
def test_all_disabled_active_paths_fail_closed_to_framework_tools():
|
||||
disabled = _skill("disabled", ["task"], enabled=False)
|
||||
middleware = _middleware([disabled])
|
||||
request = ModelRequestStub(
|
||||
[
|
||||
NamedTool("task"),
|
||||
NamedTool("read_file"),
|
||||
NamedTool("review_skill_package"),
|
||||
NamedTool("tool_search"),
|
||||
NamedTool("describe_skill"),
|
||||
],
|
||||
state={"skill_context": [{"path": disabled.get_container_file_path()}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == [
|
||||
"read_file",
|
||||
"review_skill_package",
|
||||
"tool_search",
|
||||
"describe_skill",
|
||||
]
|
||||
|
||||
|
||||
def test_async_passive_tool_call_skips_storage_and_thread_offload():
|
||||
middleware = _middleware([])
|
||||
|
||||
def fail_storage():
|
||||
raise AssertionError("passive tool calls must not load skill storage")
|
||||
|
||||
middleware._storage = fail_storage
|
||||
request = ToolRequestStub("task")
|
||||
|
||||
async def handler(_):
|
||||
return "executed"
|
||||
|
||||
assert asyncio.run(middleware.awrap_tool_call(request, handler)) == "executed"
|
||||
|
||||
|
||||
def test_sync_passive_tool_call_skips_policy_resolution():
|
||||
middleware = _middleware([])
|
||||
request = ToolRequestStub("task")
|
||||
middleware._blocked_tool_message = MagicMock(side_effect=AssertionError("passive tool calls must bypass policy resolution"))
|
||||
|
||||
assert middleware.wrap_tool_call(request, lambda _: "executed") == "executed"
|
||||
middleware._blocked_tool_message.assert_not_called()
|
||||
|
||||
|
||||
def test_tool_calls_reuse_the_current_model_step_policy_decision():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
context = {}
|
||||
state = {"skill_context": [{"path": restricted.get_container_file_path()}]}
|
||||
model_request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("web_search")],
|
||||
state=state,
|
||||
context=context,
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(model_request, lambda request: request)
|
||||
assert _tool_names(filtered) == ["web_search"]
|
||||
|
||||
for _ in range(3):
|
||||
tool_request = ToolRequestStub("web_search", state=state, context=context)
|
||||
assert middleware.wrap_tool_call(tool_request, lambda _: "executed") == "executed"
|
||||
|
||||
assert storage.load_calls == 1
|
||||
|
||||
|
||||
def test_async_tool_calls_reuse_the_current_model_step_policy_decision():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
context = {}
|
||||
state = {"skill_context": [{"path": restricted.get_container_file_path()}]}
|
||||
model_request = ModelRequestStub(
|
||||
[NamedTool("task"), NamedTool("web_search")],
|
||||
state=state,
|
||||
context=context,
|
||||
)
|
||||
|
||||
async def go():
|
||||
filtered = await middleware.awrap_model_call(model_request, lambda request: asyncio.sleep(0, result=request))
|
||||
assert _tool_names(filtered) == ["web_search"]
|
||||
|
||||
async def execute(_):
|
||||
return "executed"
|
||||
|
||||
results = await asyncio.gather(*(middleware.awrap_tool_call(ToolRequestStub("web_search", state=state, context=context), execute) for _ in range(3)))
|
||||
assert results == ["executed", "executed", "executed"]
|
||||
|
||||
asyncio.run(go())
|
||||
assert storage.load_calls == 1
|
||||
|
||||
|
||||
def test_real_model_and_tool_requests_share_the_model_step_policy_decision():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
context = {}
|
||||
state = {
|
||||
"messages": [],
|
||||
"skill_context": [{"path": restricted.get_container_file_path()}],
|
||||
}
|
||||
model_request = ModelRequest(
|
||||
model=MagicMock(),
|
||||
messages=[],
|
||||
tools=[NamedTool("task"), NamedTool("web_search")],
|
||||
state=state,
|
||||
runtime=Runtime(context=context),
|
||||
)
|
||||
|
||||
filtered = middleware.wrap_model_call(model_request, lambda request: request)
|
||||
assert _tool_names(filtered) == ["web_search"]
|
||||
|
||||
tool_runtime = ToolRuntime(
|
||||
state=state,
|
||||
context=context,
|
||||
config={},
|
||||
stream_writer=lambda _: None,
|
||||
tools=[],
|
||||
tool_call_id="call-1",
|
||||
store=None,
|
||||
)
|
||||
tool_request = ToolCallRequest(
|
||||
tool_call={"name": "web_search", "args": {}, "id": "call-1", "type": "tool_call"},
|
||||
tool=None,
|
||||
state=state,
|
||||
runtime=tool_runtime,
|
||||
)
|
||||
|
||||
assert middleware.wrap_tool_call(tool_request, lambda _: "executed") == "executed"
|
||||
assert storage.load_calls == 1
|
||||
|
||||
|
||||
def test_policy_decision_is_json_safe_and_survives_round_trip():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
state = {"skill_context": [{"path": restricted.get_container_file_path()}]}
|
||||
context = {}
|
||||
model_request = ModelRequestStub([NamedTool("web_search")], state=state, context=context)
|
||||
|
||||
middleware.wrap_model_call(model_request, lambda request: request)
|
||||
round_tripped = json.loads(json.dumps(context))
|
||||
decision = round_tripped[SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY]
|
||||
assert decision["version"] == 2
|
||||
assert decision["source"] == "skill_context"
|
||||
tool_request = ToolRequestStub("web_search", state=state, context=round_tripped)
|
||||
|
||||
assert middleware.wrap_tool_call(tool_request, lambda _: "executed") == "executed"
|
||||
assert storage.load_calls == 1
|
||||
|
||||
|
||||
def test_forged_or_malformed_policy_decisions_fall_back_to_live_resolution():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
malformed_decisions = [
|
||||
None,
|
||||
[],
|
||||
{"version": 999, "owner_token": "forged", "active_paths": [restricted.get_container_file_path()], "allowed_names": ["task"]},
|
||||
{"version": True, "owner_token": "forged", "active_paths": [restricted.get_container_file_path()], "allowed_names": ["task"]},
|
||||
{"version": 2, "owner_token": "forged", "source": "skill_context", "active_paths": [restricted.get_container_file_path()], "allowed_names": ["task"]},
|
||||
{"version": 2, "owner_token": "forged", "active_paths": [restricted.get_container_file_path()], "allowed_names": ["task"]},
|
||||
{"version": 2, "owner_token": "forged", "source": "unknown", "active_paths": [restricted.get_container_file_path()], "allowed_names": ["task"]},
|
||||
{"version": 2, "owner_token": "forged", "source": "skill_context", "active_paths": "not-a-list", "allowed_names": ["task"]},
|
||||
]
|
||||
|
||||
for decision in malformed_decisions:
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda storage=storage: storage
|
||||
context = {SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY: decision}
|
||||
request = ToolRequestStub(
|
||||
"task",
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
context=context,
|
||||
)
|
||||
|
||||
result = middleware.wrap_tool_call(request, lambda _: "executed")
|
||||
|
||||
assert result.status == "error"
|
||||
assert storage.load_calls == 1
|
||||
|
||||
|
||||
def test_policy_decision_path_mismatch_falls_back_to_live_resolution():
|
||||
first = _skill("first", ["web_search"])
|
||||
second = _skill("second", ["bash"])
|
||||
storage = StorageStub([first, second])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
context = {}
|
||||
first_state = {"skill_context": [{"path": first.get_container_file_path()}]}
|
||||
second_state = {"skill_context": [{"path": second.get_container_file_path()}]}
|
||||
|
||||
middleware.wrap_model_call(ModelRequestStub([NamedTool("web_search")], state=first_state, context=context), lambda request: request)
|
||||
result = middleware.wrap_tool_call(ToolRequestStub("web_search", state=second_state, context=context), lambda _: "executed")
|
||||
|
||||
assert result.status == "error"
|
||||
assert storage.load_calls == 2
|
||||
|
||||
|
||||
def test_policy_decision_source_mismatch_falls_back_to_live_resolution():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
context = {}
|
||||
state = {"skill_context": [{"path": restricted.get_container_file_path()}]}
|
||||
|
||||
middleware.wrap_model_call(ModelRequestStub([NamedTool("web_search")], state=state, context=context), lambda request: request)
|
||||
write_slash_skill_source_path(
|
||||
context,
|
||||
restricted.get_container_file_path(),
|
||||
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
|
||||
)
|
||||
result = middleware.wrap_tool_call(ToolRequestStub("web_search", state=state, context=context), lambda _: "executed")
|
||||
|
||||
assert result == "executed"
|
||||
assert storage.load_calls == 2
|
||||
|
||||
|
||||
def test_active_paths_support_attribute_based_state():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
middleware = _middleware([restricted])
|
||||
state = SimpleNamespace(skill_context=[{"path": restricted.get_container_file_path()}])
|
||||
request = ModelRequestStub([NamedTool("task"), NamedTool("web_search")], state=state)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == ["web_search"]
|
||||
|
||||
|
||||
def test_active_paths_support_falsey_attribute_based_state():
|
||||
class FalseyState:
|
||||
skill_context = []
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
state = FalseyState()
|
||||
state.skill_context = [{"path": restricted.get_container_file_path()}]
|
||||
middleware = _middleware([restricted])
|
||||
request = ModelRequestStub([NamedTool("task"), NamedTool("web_search")])
|
||||
request.state = state
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == ["web_search"]
|
||||
|
||||
|
||||
def test_unknown_state_shape_is_logged_instead_of_silently_ignored(caplog):
|
||||
middleware = _middleware([])
|
||||
request = ModelRequestStub([NamedTool("task")], state=object())
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == ["task"]
|
||||
assert "Unsupported agent state shape" in caplog.text
|
||||
|
||||
|
||||
def test_next_model_call_refreshes_the_policy_decision():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
context = {}
|
||||
state = {"skill_context": [{"path": restricted.get_container_file_path()}]}
|
||||
|
||||
first = ModelRequestStub([NamedTool("bash"), NamedTool("web_search")], state=state, context=context)
|
||||
assert _tool_names(middleware.wrap_model_call(first, lambda request: request)) == ["web_search"]
|
||||
|
||||
storage._skills = [_skill("restricted", ["bash"])]
|
||||
second = ModelRequestStub([NamedTool("bash"), NamedTool("web_search")], state=state, context=context)
|
||||
assert _tool_names(middleware.wrap_model_call(second, lambda request: request)) == ["bash"]
|
||||
assert storage.load_calls == 2
|
||||
|
||||
|
||||
def test_tool_call_without_matching_model_decision_revalidates_registry():
|
||||
restricted = _skill("restricted", ["web_search"])
|
||||
storage = StorageStub([restricted])
|
||||
middleware = _middleware([])
|
||||
middleware._storage = lambda: storage
|
||||
request = ToolRequestStub(
|
||||
"task",
|
||||
state={"skill_context": [{"path": restricted.get_container_file_path()}]},
|
||||
context={},
|
||||
)
|
||||
|
||||
result = middleware.wrap_tool_call(request, lambda _: "executed")
|
||||
|
||||
assert result.status == "error"
|
||||
assert storage.load_calls == 1
|
||||
|
||||
|
||||
def test_active_policy_load_failure_fails_closed_to_framework_tools():
|
||||
middleware = _middleware([])
|
||||
|
||||
def fail_storage():
|
||||
raise RuntimeError("storage unavailable")
|
||||
|
||||
middleware._storage = fail_storage
|
||||
request = ModelRequestStub(
|
||||
[
|
||||
NamedTool("task"),
|
||||
NamedTool("read_file"),
|
||||
NamedTool("review_skill_package"),
|
||||
NamedTool("tool_search"),
|
||||
NamedTool("describe_skill"),
|
||||
],
|
||||
state={"skill_context": [{"path": "/mnt/skills/public/restricted/SKILL.md"}]},
|
||||
)
|
||||
|
||||
assert _tool_names(middleware._filter_model_request(request)) == [
|
||||
"read_file",
|
||||
"review_skill_package",
|
||||
"tool_search",
|
||||
"describe_skill",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_call", [False, True])
|
||||
def test_explicit_multi_skill_policy_unions_paths_and_refreshes_cached_decision(async_call):
|
||||
from deerflow.runtime.secret_context import write_slash_skill_source_paths
|
||||
|
||||
first = _skill("research", ["web_search"])
|
||||
second = _skill("writer", ["write_file"])
|
||||
context = {}
|
||||
middleware = _middleware([first, second])
|
||||
tools = [NamedTool(name) for name in ["read_file", "web_search", "write_file", "task"]]
|
||||
request = ModelRequestStub(tools, context=context)
|
||||
|
||||
def invoke():
|
||||
async def handler(value):
|
||||
return value
|
||||
|
||||
return asyncio.run(middleware.awrap_model_call(request, handler)) if async_call else middleware.wrap_model_call(request, lambda value: value)
|
||||
|
||||
write_slash_skill_source_paths(context, (first.get_container_file_path(), second.get_container_file_path()), owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
assert _tool_names(invoke()) == ["read_file", "web_search", "write_file"]
|
||||
signature = context[SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY].copy()
|
||||
write_slash_skill_source_paths(context, (first.get_container_file_path(),), owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
assert _tool_names(invoke()) == ["read_file", "web_search"]
|
||||
assert context[SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY] != signature
|
||||
blocked = middleware.wrap_tool_call(ToolRequestStub("write_file", context=context), lambda _: pytest.fail("removed path must not authorize execution"))
|
||||
assert "not allowed" in blocked.content.lower() or "blocked" in blocked.content.lower()
|
||||
@ -13,6 +13,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.authz.enforcement import filter_resources_by_authorization
|
||||
from deerflow.authz.principal import build_principal_from_context
|
||||
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||
@ -756,6 +758,23 @@ def _middleware_for(tmp_path, monkeypatch, skills, **kwargs):
|
||||
return SkillActivationMiddleware(slash_source_owner_token="test-token", **kwargs)
|
||||
|
||||
|
||||
def test_activation_middleware_requires_shared_slash_source_token():
|
||||
"""SkillActivationMiddleware's run-context publications are token-bound so
|
||||
caller-forged context cannot impersonate a slash activation."""
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
|
||||
with pytest.raises(TypeError, match="slash_source_owner_token"):
|
||||
SkillActivationMiddleware()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_token", [None, "", 7])
|
||||
def test_activation_middleware_rejects_invalid_slash_source_tokens(invalid_token):
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
SkillActivationMiddleware(slash_source_owner_token=invalid_token)
|
||||
|
||||
|
||||
def test_activation_enforces_action_scoped_activate_decision(tmp_path, monkeypatch):
|
||||
"""A skill visible in the Layer 1 set but denied by authorize(activate)
|
||||
must not slash-activate, while an allowed one still does (review repro)."""
|
||||
@ -1337,7 +1356,7 @@ def test_lead_runtime_chain_forwards_skill_authorization_to_stamp_gate():
|
||||
ToolErrorHandlingMiddleware so the skill-read stamp gate runs with the same
|
||||
provider instance the activation middleware uses — and the effective user
|
||||
id, so the stamp gate canonicalizes read paths against the same
|
||||
user-scoped registry the activation/policy middlewares resolve (a global
|
||||
user-scoped registry the activation middleware resolves (a global
|
||||
registry would fall back to path-derived names for per-user custom
|
||||
skills, producing a wrong authorization target)."""
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware, build_lead_runtime_middlewares
|
||||
@ -1495,81 +1514,6 @@ def test_in_context_secret_sources_reauthorize_persisted_entries(tmp_path, monke
|
||||
assert sources == []
|
||||
|
||||
|
||||
def test_tool_policy_reauthorizes_persisted_entries(monkeypatch, tmp_path):
|
||||
"""[P1 regression] allow-read-then-deny-next-run for allowed-tools: the
|
||||
persisted entry no longer applies its declaration once skill:activate is
|
||||
denied; an allowed skill keeps applying (partial deny does not poison)."""
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
provider = _ActionAwareProvider(denied_activate=set())
|
||||
resolved = _resolved_skill_authorization(provider, fail_closed=True)
|
||||
|
||||
skills = {}
|
||||
for name, allowed_tools in (("demo-skill", ("bash",)), ("ok-skill", ("web_search",))):
|
||||
skill_dir = tmp_path / name
|
||||
skill_dir.mkdir(exist_ok=True)
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text("# x", encoding="utf-8")
|
||||
from deerflow.skills.types import Skill as SkillObject
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
skills[name] = SkillObject(
|
||||
name=name,
|
||||
description="d",
|
||||
license=None,
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_file,
|
||||
relative_path=Path(name),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
allowed_tools=allowed_tools,
|
||||
)
|
||||
|
||||
container_root = "/mnt/skills"
|
||||
registry_entries = list(skills.values())
|
||||
|
||||
def _registry_path(skill):
|
||||
return posixpath_normpath(skill.get_container_file_path(container_root))
|
||||
|
||||
paths = [_registry_path(skills["demo-skill"]), _registry_path(skills["ok-skill"])]
|
||||
|
||||
monkeypatch.setattr(
|
||||
SkillToolPolicyMiddleware,
|
||||
"_storage",
|
||||
lambda self: SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: registry_entries,
|
||||
get_container_root=lambda: container_root,
|
||||
),
|
||||
)
|
||||
|
||||
middleware = SkillToolPolicyMiddleware(
|
||||
available_skills={"demo-skill", "ok-skill"},
|
||||
slash_source_owner_token="test-token",
|
||||
skill_authorization=resolved,
|
||||
)
|
||||
# Both allowed: the union of both declarations applies.
|
||||
provider.denied_activate = set()
|
||||
allowed = middleware._allowed_names_for_paths(tuple(paths))
|
||||
assert "bash" in allowed and "web_search" in allowed
|
||||
|
||||
# Deny-next-run for demo-skill only: its declaration stops applying, the
|
||||
# still-allowed skill's declaration keeps applying.
|
||||
provider.denied_activate = {"demo-skill"}
|
||||
allowed = middleware._allowed_names_for_paths(tuple(paths))
|
||||
assert "bash" not in allowed
|
||||
assert "web_search" in allowed
|
||||
|
||||
# All denied: no active reference survives -> fail closed to builtins.
|
||||
provider.denied_activate = {"demo-skill", "ok-skill"}
|
||||
allowed = middleware._allowed_names_for_paths(tuple(paths))
|
||||
from deerflow.skills.tool_policy import ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES
|
||||
|
||||
assert allowed == set(ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES)
|
||||
|
||||
|
||||
# ── Async provider API on async execution paths (review round 5) ──────
|
||||
|
||||
|
||||
def test_async_activation_path_uses_aauthorize(tmp_path, monkeypatch):
|
||||
"""awrap_model_call resolves skill:activate with aauthorize() on the loop —
|
||||
a loop-affine provider whose sync API always fails still activates."""
|
||||
@ -1675,83 +1619,6 @@ def test_describe_async_uses_aauthorize():
|
||||
assert sorted(provider.async_calls) == ["demo-skill", "other-skill"]
|
||||
|
||||
|
||||
def test_async_tool_policy_uses_aauthorize(monkeypatch, tmp_path):
|
||||
"""SkillToolPolicyMiddleware's async hooks re-authorize persisted entries
|
||||
with aauthorize() — the loop-affine provider's decision is respected."""
|
||||
import asyncio
|
||||
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.skills.types import Skill as SkillObject
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
# aauthorize ALLOWS demo-skill: with correct wiring the skill's
|
||||
# declaration applies ("bash" kept). If the async hook wrongly fell back to
|
||||
# the sync API, the provider error would fail-closed and drop "bash".
|
||||
provider = _AsyncOnlyProvider()
|
||||
resolved = _resolved_skill_authorization(provider, fail_closed=True)
|
||||
|
||||
skill_dir = tmp_path / "demo-skill"
|
||||
skill_dir.mkdir(exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("# x", encoding="utf-8")
|
||||
skill = SkillObject(
|
||||
name="demo-skill",
|
||||
description="d",
|
||||
license=None,
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_dir / "SKILL.md",
|
||||
relative_path=Path("demo-skill"),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
allowed_tools=("bash",),
|
||||
)
|
||||
container_root = "/mnt/skills"
|
||||
skill_path = posixpath_normpath(skill.get_container_file_path(container_root))
|
||||
|
||||
monkeypatch.setattr(
|
||||
SkillToolPolicyMiddleware,
|
||||
"_storage",
|
||||
lambda self: SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: container_root,
|
||||
),
|
||||
)
|
||||
|
||||
middleware = SkillToolPolicyMiddleware(
|
||||
available_skills={"demo-skill"},
|
||||
slash_source_owner_token="test-token",
|
||||
skill_authorization=resolved,
|
||||
)
|
||||
request = SimpleNamespace(state={"skill_context": [{"name": "demo-skill", "path": skill_path}]})
|
||||
|
||||
from deerflow.authz.activation_decisions import ActivationDecisions
|
||||
|
||||
async def _run():
|
||||
return await middleware._collect_activation_decisions(["demo-skill"])
|
||||
|
||||
decisions = asyncio.run(_run())
|
||||
assert isinstance(decisions, ActivationDecisions)
|
||||
assert decisions.decision_for("demo-skill") is True
|
||||
|
||||
# End to end through the async hook: the aauthorize-granted entry applies.
|
||||
class _ToolsRequest(SimpleNamespace):
|
||||
def override(self, **kwargs):
|
||||
updates = dict(self.__dict__)
|
||||
updates.update(kwargs)
|
||||
return _ToolsRequest(**updates)
|
||||
|
||||
async def _identity(prepared):
|
||||
return prepared
|
||||
|
||||
tools_request = _ToolsRequest(state=request.state, tools=[SimpleNamespace(name="bash"), SimpleNamespace(name="read_file")], runtime=None)
|
||||
filtered = asyncio.run(middleware.awrap_model_call(tools_request, _identity))
|
||||
|
||||
kept = [getattr(t, "name", None) for t in filtered.tools]
|
||||
assert kept == ["bash", "read_file"]
|
||||
|
||||
|
||||
# ── Canonical skill names: Skill.name != directory name ───────────────
|
||||
#
|
||||
# Bundled skills may declare a name that differs from their directory
|
||||
# (``skills/public/vercel-deploy-claimable/SKILL.md`` declares ``name:
|
||||
# vercel-deploy``). Layer 1, slash activation, and describe_skill authorize
|
||||
# the declared ``Skill.name``; every runtime gate that starts from a path
|
||||
@ -1864,83 +1731,6 @@ def test_async_stamp_path_authorizes_declared_name(tmp_path, monkeypatch):
|
||||
assert SKILL_CONTEXT_DENIED_KEY not in stamped.additional_kwargs
|
||||
|
||||
|
||||
def test_async_slash_to_tool_policy_uses_declared_name(tmp_path, monkeypatch):
|
||||
"""[P2 regression] Composed async slash activation → tool-policy with
|
||||
Skill.name != directory name: the decision maps are keyed by the declared
|
||||
name resolved from the registry, the loop-affine provider answers every
|
||||
check through aauthorize() (a map miss would fall back to the sync API and
|
||||
fail closed, dropping the skill's tools), and the allowed skill keeps its
|
||||
declared allowed-tools."""
|
||||
import asyncio
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares import skill_activation_middleware as activation_module
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
|
||||
skill = _mismatched_skill(tmp_path)
|
||||
storage = SimpleNamespace(
|
||||
load_skills=lambda *, enabled_only: [skill],
|
||||
get_container_root=lambda: "/mnt/skills",
|
||||
get_skills_root_path=lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(activation_module, "get_or_new_skill_storage", lambda **kw: storage)
|
||||
_patch_registry_storage(monkeypatch, SkillToolPolicyMiddleware, [skill])
|
||||
|
||||
provider = _AsyncOnlyProvider()
|
||||
resolved = _resolved_skill_authorization(provider, fail_closed=True)
|
||||
token = "test-token"
|
||||
activation = SkillActivationMiddleware(
|
||||
available_skills={"vercel-deploy"},
|
||||
skill_authorization=resolved,
|
||||
slash_source_owner_token=token,
|
||||
)
|
||||
policy = SkillToolPolicyMiddleware(
|
||||
available_skills={"vercel-deploy"},
|
||||
slash_source_owner_token=token,
|
||||
skill_authorization=resolved,
|
||||
)
|
||||
|
||||
class _Request(SimpleNamespace):
|
||||
def override(self, **kwargs):
|
||||
updates = dict(self.__dict__)
|
||||
updates.update(kwargs)
|
||||
return _Request(**updates)
|
||||
|
||||
run_context: dict = {}
|
||||
|
||||
async def _identity(prepared):
|
||||
return prepared
|
||||
|
||||
# Turn 1: the user slash-activates /vercel-deploy. The activation
|
||||
# middleware resolves the skill by its declared name and records the
|
||||
# canonical container path on the run context.
|
||||
activation_request = _Request(
|
||||
messages=[HumanMessage(content="/vercel-deploy deploy it")],
|
||||
state={},
|
||||
runtime=SimpleNamespace(context=run_context),
|
||||
)
|
||||
prepared = asyncio.run(activation.awrap_model_call(activation_request, _identity))
|
||||
assert any(is_slash_activation_reminder(m) for m in prepared.messages)
|
||||
|
||||
# Turn 2 (tool loop): the tool-policy middleware picks the slash source up
|
||||
# from the shared run context and re-authorizes before applying the
|
||||
# skill's allowed-tools declaration.
|
||||
policy_request = _Request(
|
||||
messages=[],
|
||||
state={},
|
||||
tools=[SimpleNamespace(name="bash"), SimpleNamespace(name="read_file")],
|
||||
runtime=SimpleNamespace(context=run_context),
|
||||
)
|
||||
filtered = asyncio.run(policy.awrap_model_call(policy_request, _identity))
|
||||
|
||||
assert provider.async_calls == ["vercel-deploy", "vercel-deploy"]
|
||||
assert "vercel-deploy-claimable" not in provider.async_calls
|
||||
kept = [getattr(t, "name", None) for t in filtered.tools]
|
||||
assert kept == ["bash", "read_file"]
|
||||
|
||||
|
||||
def test_async_secret_binding_canonicalizes_persisted_entry_paths(tmp_path, monkeypatch):
|
||||
"""[P2 regression] A persisted skill_context entry whose stamped name is
|
||||
path-derived (directory != declared Skill.name) is canonicalized through
|
||||
@ -2190,91 +1980,6 @@ def test_async_secret_binding_resolves_entries_against_prepass_snapshot(tmp_path
|
||||
assert run_context.get(ACTIVE_SECRETS_CONTEXT_KEY) == {"ENTRY_KEY": "entry-value"}, "the entry must resolve against the prepass snapshot (allowed name), not a fresh load (denied name)"
|
||||
|
||||
|
||||
def test_async_policy_preserves_prepass_registry_failure(monkeypatch, tmp_path):
|
||||
"""[P2 regression] A transient prepass registry failure must be preserved:
|
||||
the worker-side filter must not retry storage — a successful retry would
|
||||
resolve skills missing from the (empty) decision map and fall back to the
|
||||
synchronous provider API from the thread, turning a loop-affine provider's
|
||||
denial into a fail-open allow (fail_closed=false keeps the denied skill's
|
||||
allowed-tools) or falsely denying permitted skills (fail_closed=true)."""
|
||||
import asyncio
|
||||
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.skills.types import Skill as SkillObject
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
skill_dir = tmp_path / "demo-skill"
|
||||
skill_dir.mkdir(exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("# x", encoding="utf-8")
|
||||
skill = SkillObject(
|
||||
name="demo-skill",
|
||||
description="d",
|
||||
license=None,
|
||||
skill_dir=skill_dir,
|
||||
skill_file=skill_dir / "SKILL.md",
|
||||
relative_path=Path("demo-skill"),
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
allowed_tools=("bash",),
|
||||
)
|
||||
container_root = "/mnt/skills"
|
||||
skill_path = posixpath_normpath(skill.get_container_file_path(container_root))
|
||||
|
||||
load_calls = {"count": 0}
|
||||
|
||||
def _flaky_load(*, enabled_only):
|
||||
load_calls["count"] += 1
|
||||
if load_calls["count"] == 1:
|
||||
raise RuntimeError("transient registry failure")
|
||||
return [skill]
|
||||
|
||||
monkeypatch.setattr(
|
||||
SkillToolPolicyMiddleware,
|
||||
"_storage",
|
||||
lambda self: SimpleNamespace(
|
||||
load_skills=_flaky_load,
|
||||
get_container_root=lambda: container_root,
|
||||
),
|
||||
)
|
||||
|
||||
# fail_closed=False is the dangerous direction: an accidental sync-API
|
||||
# fallback raises inside skill_activation_allowed and fail-opens into an
|
||||
# allow, retaining the aauthorize-denied skill's tools.
|
||||
provider = _AsyncOnlyProvider(denied_activate={"demo-skill"})
|
||||
resolved = _resolved_skill_authorization(provider, fail_closed=False)
|
||||
middleware = SkillToolPolicyMiddleware(
|
||||
available_skills={"demo-skill"},
|
||||
slash_source_owner_token="test-token",
|
||||
skill_authorization=resolved,
|
||||
)
|
||||
|
||||
class _Request(SimpleNamespace):
|
||||
def override(self, **kwargs):
|
||||
updates = dict(self.__dict__)
|
||||
updates.update(kwargs)
|
||||
return _Request(**updates)
|
||||
|
||||
request = _Request(
|
||||
state={"skill_context": [{"name": "demo-skill", "path": skill_path}]},
|
||||
tools=[SimpleNamespace(name="bash"), SimpleNamespace(name="read_file")],
|
||||
runtime=None,
|
||||
)
|
||||
|
||||
async def _identity(prepared):
|
||||
return prepared
|
||||
|
||||
filtered = asyncio.run(middleware.awrap_model_call(request, _identity))
|
||||
|
||||
kept = [getattr(t, "name", None) for t in filtered.tools]
|
||||
# The prepass failure is preserved: exactly one storage attempt (no
|
||||
# worker-side retry), no provider call at all, and the policy fails
|
||||
# closed to framework-safe builtins — not the denied skill's "bash".
|
||||
assert load_calls["count"] == 1
|
||||
assert provider.async_calls == []
|
||||
assert "bash" not in kept
|
||||
assert "read_file" in kept
|
||||
|
||||
|
||||
def test_async_model_call_without_skill_refs_skips_registry_scan(tmp_path, monkeypatch):
|
||||
"""[P2 regression] An ordinary authorization-enabled async model step (no
|
||||
slash reference, no persisted entries, no supplied secrets) must not pay
|
||||
|
||||
@ -212,7 +212,7 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
# ToolErrorHandling)
|
||||
# + 1 ReadBeforeWriteMiddleware + 1 LoopDetectionMiddleware
|
||||
# + 1 TokenBudgetMiddleware (subagents.token_budget enabled by default, #3875 Phase 2)
|
||||
# + 1 SkillActivationMiddleware + 1 SkillToolPolicyMiddleware
|
||||
# + 1 SkillActivationMiddleware (allowed-tools is metadata, no policy wrapper)
|
||||
# + 1 SafetyFinishReasonMiddleware + 1 DurableContextMiddleware
|
||||
# + 1 SubagentDateContextMiddleware
|
||||
# + 1 SystemMessageCoalescingMiddleware + 1 ToolReceiptMiddleware
|
||||
@ -226,13 +226,12 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
from deerflow.agents.middlewares.knowledge_scope_middleware import KnowledgeScopeMiddleware
|
||||
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.agents.middlewares.system_message_coalescing_middleware import SystemMessageCoalescingMiddleware
|
||||
from deerflow.agents.middlewares.token_budget_middleware import TokenBudgetMiddleware
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
||||
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
|
||||
|
||||
assert len(middlewares) == 23
|
||||
assert len(middlewares) == 22
|
||||
assert isinstance(middlewares[0], FakeMiddleware) # InputSanitizationMiddleware stub
|
||||
assert isinstance(middlewares[1], KnowledgeScopeMiddleware)
|
||||
assert isinstance(middlewares[2], ToolOutputBudgetMiddleware)
|
||||
@ -246,9 +245,6 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
assert any(isinstance(m, TokenBudgetMiddleware) for m in middlewares)
|
||||
assert any(isinstance(m, SafetyFinishReasonMiddleware) for m in middlewares)
|
||||
activation_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SkillActivationMiddleware))
|
||||
policy_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SkillToolPolicyMiddleware))
|
||||
assert policy_idx == activation_idx + 1
|
||||
assert middlewares[activation_idx]._slash_source_owner_token == middlewares[policy_idx]._slash_source_owner_token
|
||||
# DurableContextMiddleware is present but not last: the coalescer (#4040) is
|
||||
# appended innermost so it can merge the SystemMessage DurableContext injects.
|
||||
# The coalescer is appended unconditionally (after the optional summarization
|
||||
@ -257,7 +253,7 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
durable_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, DurableContextMiddleware))
|
||||
date_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SubagentDateContextMiddleware))
|
||||
assert isinstance(middlewares[-1], SystemMessageCoalescingMiddleware)
|
||||
assert policy_idx < durable_idx < date_idx == len(middlewares) - 2
|
||||
assert activation_idx < durable_idx < date_idx == len(middlewares) - 2
|
||||
|
||||
|
||||
def test_subagent_runtime_sandbox_does_not_own_lead_skill_projection() -> None:
|
||||
@ -770,7 +766,7 @@ def test_subagent_runtime_middlewares_attach_deferred_filter_when_setup_has_name
|
||||
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.tool_promotion_audit_middleware import DeferredToolPromotionAuditMiddleware
|
||||
from deerflow.tools.builtins.tool_search import build_deferred_tool_setup
|
||||
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
||||
@ -793,10 +789,10 @@ def test_subagent_runtime_middlewares_attach_deferred_filter_when_setup_has_name
|
||||
assert len(filters) == 1
|
||||
assert len(audits) == 1
|
||||
audit_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, DeferredToolPromotionAuditMiddleware))
|
||||
policy_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SkillToolPolicyMiddleware))
|
||||
activation_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SkillActivationMiddleware))
|
||||
filter_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, DeferredToolFilterMiddleware))
|
||||
safety_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SafetyFinishReasonMiddleware))
|
||||
assert audit_idx < policy_idx < filter_idx
|
||||
assert activation_idx < audit_idx < filter_idx
|
||||
assert filter_idx < safety_idx
|
||||
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ still describes source truncation, not excerpt clipping; `next_offset` is comput
|
||||
against that source. Search again after compaction, retention expiry, or state
|
||||
changes; old offsets are not snapshots independent of the source lifecycle.
|
||||
|
||||
An active skill's tool policy and runtime authorization still apply. The model
|
||||
The agent's tool configuration and runtime authorization still apply. The model
|
||||
may need more than one keyword search. Search is lexical; paraphrases are not
|
||||
reliably matched. Notes and retrieved text are historical data, never new
|
||||
instructions or proof that a reported action actually succeeded. Task notes are
|
||||
|
||||
@ -57,8 +57,9 @@ file above stores user bookmarks, not deployment settings.
|
||||
`/workspace/extensions/community.bookmarks/library`, and survives refresh.
|
||||
4. Search text or labels, edit a name, open the source conversation, or confirm deletion.
|
||||
5. Ask the Agent to find a saved answer. Its normal tool assembly contains a
|
||||
namespace-qualified `search_bookmarks` tool; the existing tool/group/skill policy
|
||||
still applies. Agents restricted to other tool groups do not receive it.
|
||||
namespace-qualified `search_bookmarks` tool; the existing tool/group
|
||||
configuration and host authorization still apply. Agents restricted to other
|
||||
tool groups do not receive it.
|
||||
|
||||
## Boundaries
|
||||
|
||||
|
||||
@ -158,7 +158,7 @@ ModelTool(name, description, input_schema, handler, group="extensions")
|
||||
- **Limits.** The host validates each call's input against the schema and caps it at 256 KiB. The handler must return within 30 seconds, and its JSON-encoded result must fit in 64 KiB.
|
||||
- **Context.** The handler receives a `ToolContext`: `settings`, `thread_id` (`None` outside a thread), and `principal`. For tools, the principal carries **only** `user_id`, so `is_admin` is always `False` and `roles` is empty. Do not make tool authorization depend on them.
|
||||
- **Errors.** Any failure reaches the model as a generic tool error: `Invalid plugin tool input.`, `Plugin result exceeds 64 KiB.`, `Plugin disabled by administrator.`, or `Plugin tool unavailable or input rejected.`. The Gateway log records the plugin, tool, and exception type.
|
||||
- **Assembly and policy.** Plugin tools join the ordinary tool assembly. An agent restricted by `tool_groups` receives a tool only when the list contains the tool's `group`, which is `extensions` by default. The host's later authorization and skill tool policies apply as for any other tool.
|
||||
- **Assembly and policy.** Plugin tools join the ordinary tool assembly. An agent restricted by `tool_groups` receives a tool only when the list contains the tool's `group`, which is `extensions` by default. The host's later authorization applies as for any other tool.
|
||||
|
||||
Tool descriptions are prompt text. State what the tool returns, that results are data rather than instructions, and whether it is read-only.
|
||||
|
||||
|
||||
@ -32,25 +32,24 @@ The middleware chain is built once per agent invocation, based on the current co
|
||||
1. Runtime middlewares (`InputSanitizationMiddleware` for input sanitization → `ToolOutputBudgetMiddleware` for output-budget truncation → `ToolResultSanitizationMiddleware` for tool-result sanitization, then thread data, uploads, sandbox, dangling tool-call patching, and LLM error handling; tool receipts (if enabled), authorization/guardrail (if enabled), sandbox audit, read-before-write (if enabled), tool progress (if enabled), and tool error handling follow)
|
||||
2. `DynamicContextMiddleware` — current date and optional memory context
|
||||
3. `SkillActivationMiddleware` — slash-skill activation
|
||||
4. `SkillToolPolicyMiddleware` — filters skill tools to the active skill
|
||||
5. `DurableContextMiddleware` — captures durable summary, delegation, and skill-reference state
|
||||
6. `SummarizationMiddleware` — context compression (if enabled)
|
||||
7. `TodoMiddleware` — task list management (plan mode only)
|
||||
8. `TokenUsageMiddleware` — token tracking (if enabled)
|
||||
9. `TitleMiddleware` — automatic thread title generation
|
||||
10. `MemoryMiddleware` — cross-session memory injection and queuing
|
||||
11. `ViewImageMiddleware` — image details injection (if model supports vision)
|
||||
12. `DeferredToolFilterMiddleware` — hides deferred tool schemas (if tool search enabled)
|
||||
13. `SystemMessageCoalescingMiddleware` — coalesces provider-facing system messages
|
||||
14. `SubagentLimitMiddleware` — limits parallel subagent calls (if subagents enabled)
|
||||
15. `LoopDetectionMiddleware` — breaks repetitive tool call loops
|
||||
16. `TokenBudgetMiddleware` — per-run token budget enforcement (if enabled)
|
||||
17. Custom middlewares (if any)
|
||||
18. Configured extension middlewares (if any)
|
||||
19. `TerminalResponseMiddleware` — retries an empty final response once
|
||||
20. `ModelLengthFinishReasonMiddleware` — records a length-capped completion
|
||||
21. `SafetyFinishReasonMiddleware` — suppresses tool execution after safety-terminated responses (if enabled)
|
||||
22. `ClarificationMiddleware` — intercepts clarification requests (always last)
|
||||
4. `DurableContextMiddleware` — captures durable summary, delegation, and skill-reference state
|
||||
5. `SummarizationMiddleware` — context compression (if enabled)
|
||||
6. `TodoMiddleware` — task list management (plan mode only)
|
||||
7. `TokenUsageMiddleware` — token tracking (if enabled)
|
||||
8. `TitleMiddleware` — automatic thread title generation
|
||||
9. `MemoryMiddleware` — cross-session memory injection and queuing
|
||||
10. `ViewImageMiddleware` — image details injection (if model supports vision)
|
||||
11. `DeferredToolFilterMiddleware` — hides deferred tool schemas (if tool search enabled)
|
||||
12. `SystemMessageCoalescingMiddleware` — coalesces provider-facing system messages
|
||||
13. `SubagentLimitMiddleware` — limits parallel subagent calls (if subagents enabled)
|
||||
14. `LoopDetectionMiddleware` — breaks repetitive tool call loops
|
||||
15. `TokenBudgetMiddleware` — per-run token budget enforcement (if enabled)
|
||||
16. Custom middlewares (if any)
|
||||
17. Configured extension middlewares (if any)
|
||||
18. `TerminalResponseMiddleware` — retries an empty final response once
|
||||
19. `ModelLengthFinishReasonMiddleware` — records a length-capped completion
|
||||
20. `SafetyFinishReasonMiddleware` — suppresses tool execution after safety-terminated responses (if enabled)
|
||||
21. `ClarificationMiddleware` — intercepts clarification requests (always last)
|
||||
|
||||
The ordering is significant. Durable context capture runs before summarization so delegated task dispatches, terminal delegation results, and loaded skill references survive compaction. Clarification always runs last so it can intercept after all other middlewares have had their turn.
|
||||
|
||||
|
||||
@ -123,7 +123,7 @@ Progress, item queries, pause / resume / cancel, retrying failed items, and JSON
|
||||
|
||||
- A subagent loads the skill catalog under the parent run's user identity: the skills that user has enabled, including custom skills and same-name shadowing. Without a user identity it falls back to the default user.
|
||||
- The `skills` allowlist decides what the subagent can discover and activate: `null` inherits everything, `[]` exposes nothing.
|
||||
- Skills are **lazily activated**: the system prompt contains only the skill index. The body is loaded when the skill is activated by slash command or read with `read_file`, and only then does its `allowed-tools` apply. A passive skill that was never selected does not strip the subagent's ordinary tools.
|
||||
- Skills are **lazily activated**: the system prompt contains only the skill index. The body is loaded when the skill is activated by slash command or read with `read_file`. `allowed-tools` is compatibility metadata only and never restricts the subagent's tools, even after activation.
|
||||
- The allowlist scopes discovery and activation; it is not filesystem isolation. Concurrent subagents share the Lead Agent's thread sandbox, and the `/mnt/skills` projection is owned by the Lead Agent's run.
|
||||
|
||||
## MCP tools inside a subagent
|
||||
|
||||
@ -62,7 +62,7 @@ Treat both shell variables as **informational, not authenticated identity**. Any
|
||||
`build_subagent_runtime_middlewares` assembles the chain. It shares a base with the Lead Agent and then appends subagent-specific middlewares. Roughly, from outermost to innermost:
|
||||
|
||||
1. The shared base: input sanitization, knowledge scope, tool-output budget, remote-content sanitization, optional PII redaction, thread data, sandbox (without owning the skill projection), dangling tool-call patching, LLM error handling, tool receipts (always rendered on the subagent chain), optional authorization and guardrails, sandbox audit, read-before-write, tool progress, and tool error handling.
|
||||
2. Skill activation (with a fresh slash-source owner token per build), deferred-tool promotion audit, skill tool policy, the optional image viewer, MCP routing, and deferred-tool filtering.
|
||||
2. Skill activation (with a fresh slash-source owner token per build), deferred-tool promotion audit, the optional image viewer, MCP routing, and deferred-tool filtering.
|
||||
3. Loop detection, token budget, extension-contributed middlewares, and safety finish-reason handling.
|
||||
4. `DurableContextMiddleware`, summarization, and `SubagentDateContextMiddleware`.
|
||||
5. System-message coalescing as the innermost layer.
|
||||
|
||||
@ -116,7 +116,7 @@ The subagent's system prompt is the first message in state, and older index-base
|
||||
|
||||
### A subagent cannot see the user's custom skills
|
||||
|
||||
Older versions read the global skill catalog only. Since [#4356](https://github.com/bytedance/deer-flow/pull/4356) skills load under the parent run's user identity. If a passive skill declaring `allowed-tools` stripped ordinary tools such as `write_file` from a subagent, that was the behavior before [#4497](https://github.com/bytedance/deer-flow/pull/4497); skills are now lazily activated and only a selected skill applies its tool restrictions.
|
||||
Older versions read the global skill catalog only. Since [#4356](https://github.com/bytedance/deer-flow/pull/4356) skills load under the parent run's user identity. Before [#4497](https://github.com/bytedance/deer-flow/pull/4497) a skill declaring `allowed-tools` could strip ordinary tools such as `write_file` from a subagent; skills are now lazily activated, and `allowed-tools` is compatibility metadata only — it never restricts or grants tools, even for an activated skill. Tool authority lives in the agent's tool configuration and runtime authorization.
|
||||
|
||||
### A subagent does not know today's date, or is a day off
|
||||
|
||||
|
||||
@ -156,7 +156,7 @@ ModelTool(name, description, input_schema, handler, group="extensions")
|
||||
- **限制。** 宿主会按 schema 校验每次调用的输入,上限 256 KiB。处理函数必须在 30 秒内返回,JSON 编码后的结果不能超过 64 KiB。
|
||||
- **上下文。** 处理函数收到一个 `ToolContext`:`settings`、`thread_id`(不在会话中时为 `None`)和 `principal`。对工具来说,principal **只**带 `user_id`,`is_admin` 恒为 `False`,`roles` 为空。不要让工具的授权依赖它们。
|
||||
- **错误。** 任何失败都以通用工具错误的形式交给模型:`Invalid plugin tool input.`、`Plugin result exceeds 64 KiB.`、`Plugin disabled by administrator.` 或 `Plugin tool unavailable or input rejected.`。Gateway 日志会记录插件、工具和异常类型。
|
||||
- **组装与策略。** 插件工具加入常规的工具组装流程。通过 `tool_groups` 限制过的 Agent,只有在列表包含该工具的 `group`(默认为 `extensions`)时才会拿到它。宿主之后的授权和技能工具策略与其他工具一样适用。
|
||||
- **组装与策略。** 插件工具加入常规的工具组装流程。通过 `tool_groups` 限制过的 Agent,只有在列表包含该工具的 `group`(默认为 `extensions`)时才会拿到它。宿主之后的授权与其他工具一样适用。
|
||||
|
||||
工具描述就是提示词。写清楚工具返回什么、返回的是数据而不是指令,以及它是否只读。
|
||||
|
||||
|
||||
@ -28,25 +28,24 @@ import { Callout } from "nextra/components";
|
||||
1. 运行时中间件(`InputSanitizationMiddleware` 输入清理 → `ToolOutputBudgetMiddleware` 输出预算截断 → `ToolResultSanitizationMiddleware` 工具结果清理,随后是线程数据、上传、沙箱、悬空工具调用修补和 LLM 错误处理;工具回执(如启用)、授权/guardrail(如启用)、沙箱审计、读前写后(如启用)、工具进度(如启用)和工具错误处理随后执行)
|
||||
2. `DynamicContextMiddleware` — 当前日期和可选记忆上下文
|
||||
3. `SkillActivationMiddleware` — slash skill 激活
|
||||
4. `SkillToolPolicyMiddleware` — 将技能工具限制为当前激活的技能
|
||||
5. `DurableContextMiddleware` — 捕获持久摘要、委托和技能引用状态
|
||||
6. `SummarizationMiddleware` — 上下文压缩(如果启用)
|
||||
7. `TodoMiddleware` — 任务列表管理(仅计划模式)
|
||||
8. `TokenUsageMiddleware` — token 追踪(如果启用)
|
||||
9. `TitleMiddleware` — 自动生成线程标题
|
||||
10. `MemoryMiddleware` — 跨会话记忆注入和队列
|
||||
11. `ViewImageMiddleware` — 图像细节注入(如果模型支持视觉)
|
||||
12. `DeferredToolFilterMiddleware` — 隐藏延迟工具 schema(如果启用工具搜索)
|
||||
13. `SystemMessageCoalescingMiddleware` — 合并面向 provider 的 system messages
|
||||
14. `SubagentLimitMiddleware` — 限制并行子 Agent 调用(如果启用子 Agent)
|
||||
15. `LoopDetectionMiddleware` — 打破重复工具调用循环
|
||||
16. `TokenBudgetMiddleware` — 每次运行的 token 预算限制(如果启用)
|
||||
17. 自定义中间件(如有)
|
||||
18. 配置的扩展中间件(如有)
|
||||
19. `TerminalResponseMiddleware` — 重试一次空的最终响应
|
||||
20. `ModelLengthFinishReasonMiddleware` — 记录受长度限制的完成
|
||||
21. `SafetyFinishReasonMiddleware` — provider 安全终止后抑制工具执行(如果启用)
|
||||
22. `ClarificationMiddleware` — 拦截澄清请求(始终最后)
|
||||
4. `DurableContextMiddleware` — 捕获持久摘要、委托和技能引用状态
|
||||
5. `SummarizationMiddleware` — 上下文压缩(如果启用)
|
||||
6. `TodoMiddleware` — 任务列表管理(仅计划模式)
|
||||
7. `TokenUsageMiddleware` — token 追踪(如果启用)
|
||||
8. `TitleMiddleware` — 自动生成线程标题
|
||||
9. `MemoryMiddleware` — 跨会话记忆注入和队列
|
||||
10. `ViewImageMiddleware` — 图像细节注入(如果模型支持视觉)
|
||||
11. `DeferredToolFilterMiddleware` — 隐藏延迟工具 schema(如果启用工具搜索)
|
||||
12. `SystemMessageCoalescingMiddleware` — 合并面向 provider 的 system messages
|
||||
13. `SubagentLimitMiddleware` — 限制并行子 Agent 调用(如果启用子 Agent)
|
||||
14. `LoopDetectionMiddleware` — 打破重复工具调用循环
|
||||
15. `TokenBudgetMiddleware` — 每次运行的 token 预算限制(如果启用)
|
||||
16. 自定义中间件(如有)
|
||||
17. 配置的扩展中间件(如有)
|
||||
18. `TerminalResponseMiddleware` — 重试一次空的最终响应
|
||||
19. `ModelLengthFinishReasonMiddleware` — 记录受长度限制的完成
|
||||
20. `SafetyFinishReasonMiddleware` — provider 安全终止后抑制工具执行(如果启用)
|
||||
21. `ClarificationMiddleware` — 拦截澄清请求(始终最后)
|
||||
|
||||
顺序很重要。Durable context 会在摘要压缩之前捕获已委托任务的派发状态、终态结果和已加载的技能引用,确保它们不会随原始 transcript 压缩而丢失。澄清总是最后运行,这样它可以在所有其他中间件完成后拦截。
|
||||
|
||||
|
||||
@ -121,7 +121,7 @@ batch_task(
|
||||
|
||||
- 子 Agent 用父 run 的用户身份加载技能目录,看到的是该用户已启用的技能,包括用户自定义技能和同名遮蔽规则。没有用户身份时回退到默认用户。
|
||||
- `skills` 允许列表决定子 Agent 能发现和激活哪些技能:`null` 继承全部,`[]` 一个也不给。
|
||||
- 技能是**懒激活**的:系统提示里只有技能索引;通过斜杠命令激活或用 `read_file` 读取 `SKILL.md` 时才加载正文,也只在那时应用该技能的 `allowed-tools`。一个从未被选用的被动技能不会剥夺子 Agent 的普通工具。
|
||||
- 技能是**懒激活**的:系统提示里只有技能索引;通过斜杠命令激活或用 `read_file` 读取 `SKILL.md` 时才加载正文。`allowed-tools` 始终只是兼容性元数据,即使激活后也不会限制子 Agent 的工具。
|
||||
- 允许列表是发现与激活的范围,不是沙箱文件系统隔离。并发的子 Agent 共享 Lead Agent 的线程沙箱,`/mnt/skills` 的投影由 Lead Agent 拥有。
|
||||
|
||||
## 子 Agent 里的 MCP 工具
|
||||
|
||||
@ -62,7 +62,7 @@ AIO 镜像默认最多 10 个 shell 会话(`MAX_SHELL_SESSIONS`),超出时
|
||||
子 Agent 的链由 `build_subagent_runtime_middlewares` 装配,与 Lead Agent 共享同一个基础部分,再追加子 Agent 专属的中间件。大致顺序(外到内):
|
||||
|
||||
1. 基础部分:输入净化、知识范围、工具输出预算、远端内容净化、可选的 PII 脱敏、线程数据、沙箱(不拥有技能投影)、悬空工具调用修补、LLM 错误处理、工具回执(子 Agent 链始终渲染账本)、可选的授权 / 护栏、沙箱审计、先读后写、工具进度、工具错误处理。
|
||||
2. 技能激活(每次装配一个独立的斜杠命令来源令牌)、延迟工具提升审计、技能工具策略、可选的看图工具、MCP 路由、延迟工具过滤。
|
||||
2. 技能激活(每次装配一个独立的斜杠命令来源令牌)、延迟工具提升审计、可选的看图工具、MCP 路由、延迟工具过滤。
|
||||
3. 循环检测、token 预算、扩展贡献的中间件、安全结束原因处理。
|
||||
4. `DurableContextMiddleware`、摘要压缩、`SubagentDateContextMiddleware`。
|
||||
5. 最内层是系统消息合并。
|
||||
|
||||
@ -116,7 +116,7 @@ import { Callout } from "nextra/components";
|
||||
|
||||
### 子 Agent 看不到用户自定义技能
|
||||
|
||||
旧版本只读全局技能目录。[#4356](https://github.com/bytedance/deer-flow/pull/4356) 之后按父 run 的用户身份加载。若某个被动技能声明了 `allowed-tools` 导致子 Agent 丢失 `write_file` 等普通工具,那是 [#4497](https://github.com/bytedance/deer-flow/pull/4497) 之前的行为,现在技能懒激活,只有被选用的技能才施加其工具限制。
|
||||
旧版本只读全局技能目录。[#4356](https://github.com/bytedance/deer-flow/pull/4356) 之后按父 run 的用户身份加载。[#4497](https://github.com/bytedance/deer-flow/pull/4497) 之前,声明了 `allowed-tools` 的技能可能剥夺子 Agent 的 `write_file` 等普通工具;现在技能懒激活,而 `allowed-tools` 只是兼容性元数据,即使对已激活的技能也不限制或授予工具。工具权限由 Agent 的工具配置和运行时授权决定。
|
||||
|
||||
### 子 Agent 不知道今天的日期,或日期差了一天
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user