diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 07afc45..35ed953 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -865,6 +865,89 @@ test("POST /v1/tokens/workspace-path", async (t) => { }); }); + await t.test("allows provider-subtree scopes that cover requested writeback paths", async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:read:*", + "relayfile:fs:write:*", + ], + }, + }); + + const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { + body: { + workspaceId: "ws_tokens_route", + paths: ["/linear/issues/123.json"], + scopes: [ + "relayfile:fs:read:/linear/**", + "relayfile:fs:write:/linear/**", + ], + }, + headers: authHeaders, + }); + + const body = await assertJsonResponse(response, 201); + assert.deepEqual(body.paths, ["/linear/issues/123.json"]); + + const accessClaims = decodeJwtJsonSegment(body.accessToken, 1); + assert.deepEqual(accessClaims.scopes, [ + "relayfile:fs:read:/linear/*", + "relayfile:fs:write:/linear/*", + ]); + }); + + await t.test("rejects provider-subtree scopes that do not cover requested paths", async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:write:*", + ], + }, + }); + + const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { + body: { + workspaceId: "ws_tokens_route", + paths: ["/linear/issues/123.json"], + scopes: ["relayfile:fs:write:/github/**"], + }, + headers: authHeaders, + }); + + await assertJsonResponse(response, 400, (body) => { + assert.equal(body.code, "invalid_scope"); + }); + }); + + await t.test("continues rejecting whole-tree path-token scopes", async () => { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: [ + "relayauth:api-key:manage:*", + "relayfile:fs:write:*", + ], + }, + }); + + for (const scopePath of ["*", "/", "/*", "/**"]) { + const response = await requestRoute(app, "POST", "/v1/tokens/workspace-path", { + body: { + workspaceId: "ws_tokens_route", + paths: ["/linear/issues/123.json"], + scopes: [`relayfile:fs:write:${scopePath}`], + }, + headers: authHeaders, + }); + + await assertJsonResponse(response, 400, (body) => { + assert.equal(body.code, "invalid_scope"); + }); + } + }); + await t.test("rejects degenerate or traversal paths", async () => { const { app, authHeaders } = await createHarness({ authClaims: { diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 41374d0..51fe038 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -1490,15 +1490,23 @@ function normalizePathTokenScope(value: unknown): string | null { } function scopeWithinPaths(scope: string, paths: string[]): boolean { - return paths.some((path) => { - const readGrant = `relayfile:fs:read:${path}`; - const writeGrant = `relayfile:fs:write:${path}`; - try { - return matchScope(scope, [readGrant, writeGrant]); - } catch { - return false; + const match = /^relayfile:fs:(read|write):/.exec(scope); + if (!match) { + return false; + } + + const operation = match[1]; + const pathGrants = paths.map((path) => `relayfile:fs:${operation}:${path}`); + + try { + if (pathGrants.some((grant) => matchScope(scope, [grant]))) { + return true; } - }); + + return pathGrants.every((grant) => matchScope(grant, [scope])); + } catch { + return false; + } } function relayTokenPrefix(prefix: string | undefined): typeof RELAY_AGENT_TOKEN_PREFIX | typeof RELAY_PATH_TOKEN_PREFIX {