Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion apps/ai/src/chat/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { mapleToolCatalog } from "../mcp/tools/registry"
import { MUTATING_TOOL_NAMES } from "./permissions"
import type { TenantContext } from "@maple/domain/http"
import { AGENTS, agentForSession, isAutonomousTurn, sessionOf } from "./agents"
import { profileForTurn } from "./profiles"
import { profileForRoles, profileForTurn } from "./profiles"
import { CONNECTOR_SYSTEM_PROMPT } from "./prompts"
import { buildChatToolkit } from "./maple-toolkit"

Expand Down Expand Up @@ -137,6 +137,25 @@ describe("profileForTurn", () => {
}
})

it("offers an observer's turn the read tools and none that change data", () => {
const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [
"org:observer",
])
const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit
assert.property(tools, "search_traces")
for (const name of MUTATING_TOOL_NAMES) {
assert.notProperty(tools, name, `${name} was offered to an observer`)
}
// A member's turn on the same session still proposes them.
const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [
"org:member",
])
assert.isNotEmpty([...MUTATING_TOOL_NAMES])
for (const name of MUTATING_TOOL_NAMES) {
assert.equal(evaluatePermission(member.ruleset, name), "ask", name)
}
})

it("offers a connector the repository sandbox but no internal-audience tool", () => {
// The reviewer's pull request tools act on the org's behalf; a channel never reaches them.
const { tools } = toolsFor(connectorSession, CONNECTOR_ORIGIN)
Expand Down
14 changes: 13 additions & 1 deletion apps/ai/src/chat/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* surface and be billed, traced or prompted as another.
*/
import type { ChatTurnOrigin } from "@maple/domain/chat-session"
import type { PermissionRuleset } from "@maple/domain/permission"
import { PermissionRule, type PermissionRuleset } from "@maple/domain/permission"
import * as Policy from "@maple/domain/policy"
import { Array as Arr } from "effect"
import type { AgentDefinition } from "./agents"
import { READ_ONLY_RULESET } from "./permissions"
import { CONNECTOR_SYSTEM_PROMPT } from "./prompts"
Expand All @@ -21,6 +23,16 @@ export interface TurnProfile {
readonly prompt: string
}

/** The ruleset with every approval-gated tool withheld: an observer could not approve one. */
const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset =>
Arr.map(ruleset, (rule) =>
rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule,
)

/** The profile for whoever the turn's tools run as: an observer is offered nothing that changes data. */
export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray<string>): TurnProfile =>
Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile

export const profileForTurn = (agent: AgentDefinition, origin: ChatTurnOrigin): TurnProfile => {
switch (origin.kind) {
case "app":
Expand Down
4 changes: 2 additions & 2 deletions apps/ai/src/chat/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { agentSessionSpanAttributes, type ResolvedModel } from "../platform/Llm"
import { withRunSessionAttributes } from "../platform/genai-spans"
import { summarizeCause } from "@maple/backend/platform/describe-cause"
import { type AgentDefinition, agentForSession, chatAgent } from "./agents"
import { profileForTurn } from "./profiles"
import { profileForRoles, profileForTurn } from "./profiles"
import { MUTATING_TOOL_NAMES } from "./permissions"
import {
makeTextSanitizer,
Expand Down Expand Up @@ -165,7 +165,7 @@ export const runChatTurn = (input: ChatRunInput) =>

const runChatTurnFrom = (input: ChatRunInput, startedAt: number) => {
const agent = input.agent ?? agentForSession(input.sessionId)
const profile = profileForTurn(agent, input.origin)
const profile = profileForRoles(profileForTurn(agent, input.origin), input.tenant.roles)
const sessionAttributes = agentSessionSpanAttributes(input.model.tags)
const completion = input.completion?.({
turnId: input.messageId,
Expand Down
6 changes: 3 additions & 3 deletions apps/ai/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
McpToolsUnavailableError,
} from "./executor-loader"
import { CurrentMcpRequestTenant } from "./tenant"
import { refusesReadOnlyCredential, type McpToolResult } from "./tools/types"
import { isReadOnlyTenant, refusesReadOnlyCredential, type McpToolResult } from "./tools/types"
import type { McpToolDescriptor } from "@maple/domain/mcp-tool-contract"

/**
Expand Down Expand Up @@ -69,13 +69,13 @@
: Effect.void

/**
* Hides a tool from a read-only credential's `tools/list`, and answers a call to it as unknown:
* Hides a tool from a read-only credential's or an observer's `tools/list`, and answers a call to it as unknown:
* the registry would refuse every such call anyway, and a listed tool invites it.
*/
const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() =>
Effect.gen(function* () {
const tenant = yield* CurrentMcpRequestTenant
return tenant?.readOnly !== true
return tenant === undefined || !isReadOnlyTenant(tenant)

Check notice on line 78 in apps/ai/src/mcp/server.ts

View check run for this annotation

Maple Review Bot / Maple / review

tests: The observer's `tools/list` hiding has no test

`app.test.ts:738` pins per-credential visibility for a read-only key, and this line extends the same gate to a role, but nothing asserts an observer's `tools/list` omits `create_*`. A revert to `tenant?.readOnly !== true` would pass every test in the change.
}),
)

Expand Down
32 changes: 28 additions & 4 deletions apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ import { executeRegisteredMcpToolUnscoped } from "../registry"
const createdDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(createdDbs))

const tenant = (readOnly: boolean, scopes?: ReadonlyArray<string>): TenantContext => ({
const tenant = (readOnly: boolean, scopes?: ReadonlyArray<string>, role = "root"): TenantContext => ({
orgId: Schema.decodeSync(OrgId)("org_read_only_key"),
userId: Schema.decodeSync(UserId)("user_read_only_key"),
roles: [Schema.decodeSync(RoleName)("root")],
roles: [Schema.decodeSync(RoleName)(role)],
authMode: "self_hosted",
...(readOnly ? { readOnly: true as const } : undefined),
...(scopes === undefined ? undefined : { scopes }),
})

const makeLayer = (readOnly: boolean, scopes?: ReadonlyArray<string>) => {
const makeLayer = (readOnly: boolean, scopes?: ReadonlyArray<string>, role?: string) => {
const database = createTestDb(createdDbs).layer
return Layer.mergeAll(
ErrorPolicyService.layer.pipe(Layer.provide(database)),
database,
Layer.succeed(CurrentMcpTenant, tenant(readOnly, scopes)),
Layer.succeed(CurrentMcpTenant, tenant(readOnly, scopes, role)),
Layer.succeed(AlertDestinationsService, {
listDestinations: () => Effect.succeed({ destinations: [] }),
} as never),
Expand Down Expand Up @@ -71,6 +71,30 @@ describe("read-only API key over MCP", () => {
)
})

describe("an observer over MCP", () => {
// A layer per test: each owns a database that `afterEach` removes.
const observer = () => makeLayer(false, undefined, "org:observer")

it.effect("runs read tools", () =>
Effect.gen(function* () {
const result = yield* call("get_error_notification_policy", {})
expect(Result.isSuccess(result)).toBe(true)
}).pipe(Effect.provide(observer())),
)

it.effect("refuses a tool that changes data and says which role can", () =>
Effect.gen(function* () {
const result = yield* call("update_error_notification_policy", updatePolicy)
expect(Result.isFailure(result)).toBe(true)
if (Result.isFailure(result)) {
expect(result.failure._tag).toBe("@maple/mcp/errors/McpAuthInvalidError")
expect(result.failure.message).toContain("update_error_notification_policy")
expect(result.failure.message).toContain("Ask an org admin for the Member role")
}
}).pipe(Effect.provide(observer())),
)
})

describe("a scoped key without users:read over MCP", () => {
const scopes = Arr.map(INVESTIGATION_READ_FAMILIES, (family) => `${family}:write`)

Expand Down
8 changes: 6 additions & 2 deletions apps/ai/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// BOUNDARY-FILE: This module owns unparsed external values and narrows them before domain use.
import * as Policy from "@maple/domain/policy"
import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract"
import { scopeAllows } from "@maple/domain/http/v2"
import type { McpToolSurface } from "@maple/domain/mcp-manifest"
Expand Down Expand Up @@ -133,6 +134,7 @@ import {
type McpToolPhrases,
type McpToolRegistrar,
type McpToolResult,
isReadOnlyTenant,
refusesReadOnlyCredential,
} from "./types"
import { unknownToolMessage } from "../lib/tool-suggestions"
Expand Down Expand Up @@ -750,9 +752,11 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu

if (refusesReadOnlyCredential(definition.name, definition.hints.readOnly)) {
const tenant = yield* CurrentMcpTenant
if (tenant.readOnly === true) {
if (isReadOnlyTenant(tenant)) {
return yield* new McpAuthInvalidError({
message: `\`${definition.name}\` changes data, and this API key is read-only. Use a key with write access, or connect with OAuth.`,
message: Policy.isReadOnly(tenant.roles)
? `\`${definition.name}\` changes data. ${Policy.readOnlyMessage}`
: `\`${definition.name}\` changes data, and this API key is read-only. Use a key with write access, or connect with OAuth.`,
reason: "insufficient_scope",
})
}
Expand Down
7 changes: 7 additions & 0 deletions apps/ai/src/mcp/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { McpToolSurface } from "@maple/domain/mcp-manifest"
import type { McpToolName, McpToolOutputs } from "@maple/domain/mcp-outputs"
import type { McpToolRequirements } from "./runtime-services"
import type { ToolDoc } from "../lib/tool-doc"
import * as Policy from "@maple/domain/policy"

export class McpAuthMissingError extends Schema.TaggedError<McpAuthMissingError>()(
"@maple/mcp/errors/McpAuthMissingError",
Expand Down Expand Up @@ -123,6 +124,12 @@ export const FEEDBACK_TOOL = "send_maple_feedback"
export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean =>
!readOnly && name !== FEEDBACK_TOOL

/** Whether the caller may only read: a read-only API key, or anyone whose role is observer. */
export const isReadOnlyTenant = (tenant: {
readonly readOnly?: true
readonly roles: ReadonlyArray<string>
}): boolean => tenant.readOnly === true || Policy.isReadOnly(tenant.roles)

/**
* Who a tool is for.
*
Expand Down
Loading