diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index 911c643ecfa..a64b38bc67e 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,6 +1,6 @@ { "schemaVersion": 5, - "subtreeTree": "ae826da4bed6a57241dbed9da2faef4594a2dea5", + "subtreeTree": "6be868e420fae6cc28cd832d21eccd505eedbc46", "openapiSha256": "189c98cd2535829d75b090a70c22dac720a9e9349b8b2cde1602abc31d14f8b8", "capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", diff --git a/knowledge-fs/.harness/changes/2026-08-04-knowledge-fs-capability-v2-cli-authorization.md b/knowledge-fs/.harness/changes/2026-08-04-knowledge-fs-capability-v2-cli-authorization.md new file mode 100644 index 00000000000..338cc3d7127 --- /dev/null +++ b/knowledge-fs/.harness/changes/2026-08-04-knowledge-fs-capability-v2-cli-authorization.md @@ -0,0 +1,50 @@ +# KnowledgeFS Capability v2 CLI authorization + +## What changed + +- Passed the authenticated Capability v2 grant into candidate-content authorization for all + KnowledgeFS command handlers: `ls`, `tree`, `grep`, `find`, `diff`, `open_node`, `cat`, `stat`, + `write`, and `append`. +- Added a handler regression covering Capability v2 requests without the legacy authorization + decision and verifying that the grant's normalized content scope reaches every command. + +## Why + +The Capability v2 gateway had already authenticated and authorized CLI requests, but the command +handlers only consulted the legacy authorization decision. Capability v2 requests therefore lost +their candidate scope at the handler boundary and were rejected with `403 Knowledge space access +denied`; Dify surfaced that product response as `503 knowledge_fs_unavailable` for every CLI +filesystem command. + +## Performance and safety + +- The fix only forwards the sanitized grant already stored on the request context; it adds no + database, model, or network calls. +- `currentCandidateGrants` continues to bind the grant to the exact tenant, subject, and knowledge + space and to fail closed for malformed or mismatched scopes. +- Legacy authorization decisions remain supported as the fallback path. + +## Verification + +- RED: the new focused regression returned 403 for Capability v2 `ls` before the implementation. +- `pnpm --filter @knowledge/api exec vitest run src/knowledge-fs-handlers-branch-coverage.test.ts`: + passed, 16 tests. +- `pnpm --filter @knowledge/api typecheck`: passed. +- Targeted Biome check for both changed TypeScript files: passed. +- `CI=1 pnpm check`: passed, including the full workspace test suite, coverage gates, + retrieval evaluations, migration checks, and deployment/static guards. +- `CI=1 pnpm build`: passed for all 12 KnowledgeFS packages. +- `CI=1 pnpm lint:backend`: passed, 980 files checked. +- `CI=1 pnpm lint`: attempted but remains blocked by 10 pre-existing, unrelated + whole-workspace Admin/generated-contract findings. These include formatting in Admin files + outside this change and the checked-in 1.5 MiB OpenAPI artifact exceeding Biome's 1 MiB limit. +- `uv run --project api python api/dev/generate_knowledge_fs_contract.py --check`: passed after + intentionally refreshing the staged KnowledgeFS subtree lock; OpenAPI and Capability v2 + contract digests were unchanged. + +## Risks and follow-up + +- The change affects authorization plumbing for ten command handlers but does not broaden access: + the existing subject, tenant, resource, and content-scope checks are unchanged. +- Dify's translation of upstream authorization failures into the generic + `knowledge_fs_unavailable` response is separate from this root-cause fix. diff --git a/knowledge-fs/packages/api/src/knowledge-fs-handlers-branch-coverage.test.ts b/knowledge-fs/packages/api/src/knowledge-fs-handlers-branch-coverage.test.ts index 19e7024c070..c44891ff1e0 100644 --- a/knowledge-fs/packages/api/src/knowledge-fs-handlers-branch-coverage.test.ts +++ b/knowledge-fs/packages/api/src/knowledge-fs-handlers-branch-coverage.test.ts @@ -63,6 +63,31 @@ describe("KnowledgeFS handler branch coverage", () => { } }); + it("executes every command with a Capability v2 candidate scope", async () => { + const fixture = handlersFixture({ + capability: capabilityGrant(), + decision: undefined, + }); + + for (const [route, command] of ROUTES) { + const response = await fixture.invoke(route); + expect(response).toEqual({ body: { command }, status: 200 }); + } + + expect(fixture.execute).toHaveBeenCalledTimes(ROUTES.length); + for (const [, command] of ROUTES) { + expect(fixture.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + candidatePermissionScope: ["scope:a", "scope:b"], + knowledgeSpaceId: SPACE_ID, + }), + name: command, + }), + ); + } + }); + it("returns not found before executing every command when the space is absent", async () => { const fixture = handlersFixture({ space: null }); @@ -149,6 +174,7 @@ describe("KnowledgeFS handler branch coverage", () => { }); interface FixtureOptions { + readonly capability?: unknown; readonly decision?: unknown; readonly executeError?: Error; readonly space?: unknown; @@ -183,6 +209,7 @@ function handlersFixture(options: FixtureOptions = {}) { if (!callback) throw new Error("route was not registered"); return callback( context({ + capability: options.capability, decision: "decision" in options ? options.decision @@ -200,9 +227,16 @@ function handlersFixture(options: FixtureOptions = {}) { }; } -function context({ decision }: { readonly decision: unknown }) { +function context({ + capability, + decision, +}: { + readonly capability: unknown; + readonly decision: unknown; +}) { const values = new Map([ ["authorizationDecision", decision], + ["capabilityV2Grant", capability], ["subject", SUBJECT], ["traceId", "trace-1"], ]); @@ -218,3 +252,12 @@ function context({ decision }: { readonly decision: unknown }) { }, }; } + +function capabilityGrant() { + return { + contentScopeIds: ["scope:b", "scope:a", "scope:a"], + namespaceId: SUBJECT.tenantId, + resource: { id: SPACE_ID, parent_id: null, type: "knowledge_space" }, + subject: SUBJECT.subjectId, + }; +} diff --git a/knowledge-fs/packages/api/src/knowledge-fs-handlers.ts b/knowledge-fs/packages/api/src/knowledge-fs-handlers.ts index f943a6493f5..73d0df6ed30 100644 --- a/knowledge-fs/packages/api/src/knowledge-fs-handlers.ts +++ b/knowledge-fs/packages/api/src/knowledge-fs-handlers.ts @@ -65,6 +65,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -125,6 +126,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -185,6 +187,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -245,6 +248,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -305,6 +309,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -355,6 +360,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -402,6 +408,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -449,6 +456,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -496,6 +504,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject, @@ -560,6 +569,7 @@ export function registerKnowledgeFsHandlers({ } const candidatePermissionScope = currentCandidateGrants({ + capabilityGrant: context.get("capabilityV2Grant"), decision: context.get("authorizationDecision"), knowledgeSpaceId: params.id, subject,