fix(core): enforce V2 tool permissions (#31061)

This commit is contained in:
Kit Langton 2026-06-06 08:00:24 -04:00 committed by GitHub
commit 4814ab3a3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 180 additions and 10 deletions

View file

@ -37,6 +37,26 @@ const contextual = (contexts: Tool.Context[]) =>
})
describe("ApplicationTools", () => {
it.effect("filters an application tool by its name without adding execution authorization", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) })
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
}),
)
it.effect("advertises and executes a scoped application tool with Session context", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
@ -169,13 +189,18 @@ describe("ApplicationTools", () => {
yield* transform((editor) =>
editor.set("shared", {
tool: location.definition,
permission: { action: "question", resource: "*" },
execute: ({ parameters, sessionID, call }) =>
location.execute(parameters, { sessionID, id: call.id, name: call.name }),
}),
)
yield* applications.attach({ shared: contextual(applicationContexts) })
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["shared"])
expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual([])
expect(
yield* registry.settle({
sessionID,

View file

@ -449,6 +449,7 @@ describe("Config", () => {
permission: {
bash: "ask",
edit: { "*.md": "allow", "*": "deny" },
question: "deny",
},
agent: {
reviewer: {
@ -526,6 +527,7 @@ describe("Config", () => {
{ action: "bash", resource: "*", effect: "ask" },
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" },
])
expect(documents[0]?.info.agents?.reviewer).toMatchObject({
system: "Review changes.",

View file

@ -45,6 +45,96 @@ const echo = Tool.make({
})
describe("ToolRegistry", () => {
it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_filter")
yield* transform((editor) => {
editor.set("question", { tool: echo })
editor.set("bash", { tool: echo })
editor.set("edit", { tool: echo })
editor.set("write", { tool: echo })
editor.set("apply_patch", { tool: echo })
})
const names = (rules: PermissionV2.Ruleset) =>
registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
"edit",
"write",
"apply_patch",
])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "question", resource: "private", effect: "allow" },
]),
).toEqual(["question"])
expect(
yield* names([
{ action: "question", resource: "private", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question")
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
expect(
yield* names([
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: "*.md", effect: "ask" },
]),
).toEqual(["question", "bash", "edit", "write", "apply_patch"])
expect(
yield* names([
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
]),
).toEqual(["question", "bash"])
}),
)
it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_stale")
let executed = false
yield* transform((editor) =>
editor.set("question", {
tool: echo,
permission: { action: "question", resource: "*" },
authorize: ({ assertPermission }) =>
assertPermission({ action: "question", resources: ["actual"] }).pipe(
Effect.mapError(() => new ToolFailure({ message: "Denied" })),
),
execute: () =>
Effect.sync(() => {
executed = true
return { text: "unexpected" }
}),
}),
)
expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual([])
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
}),
).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
expect(executed).toBe(true)
}),
)
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service

View file

@ -149,6 +149,7 @@ describe("BashTool", () => {
const definitions = yield* registry.definitions()
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {

View file

@ -110,6 +110,7 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([])
const settled = yield* registry.settle(
call({ path: "hello.txt", oldString: "before", newString: "after" }),
)

View file

@ -11,11 +11,15 @@ const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let captured: QuestionV2.AskInput | undefined
let reject = false
let deny = false
const capturedInput = () => captured
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) => Effect.sync(() => assertions.push(input)),
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
@ -40,11 +44,30 @@ const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(ques
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
describe("QuestionTool", () => {
it.effect("omits a denied built-in question and terminally settles a stale call", () =>
Effect.gen(function* () {
captured = undefined
deny = true
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
expect(capturedInput()).toBeUndefined()
deny = false
}),
)
it.effect("registers question and projects user answers without a permission assertion", () =>
Effect.gen(function* () {
assertions.length = 0
captured = undefined
reject = false
deny = false
const registry = yield* ToolRegistry.Service
const questions = [
{
@ -81,7 +104,7 @@ describe("QuestionTool", () => {
],
},
})
expect(assertions).toEqual([])
expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
}),
)
@ -90,6 +113,7 @@ describe("QuestionTool", () => {
Effect.gen(function* () {
captured = undefined
reject = false
deny = false
const registryService = yield* ToolRegistry.Service
yield* registryService.execute({
@ -104,6 +128,7 @@ describe("QuestionTool", () => {
Effect.gen(function* () {
captured = undefined
reject = true
deny = false
const registryService = yield* ToolRegistry.Service
const fiber = yield* registryService
.execute({

View file

@ -101,6 +101,7 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* registry.execute({
sessionID,