fix(host): harden skill.invoke at the enforcement boundary

Review fixes: recheck isUserInvocable on the loaded definition (list and
get collect independently, so a provider change between them could swap in
a user-disabled body — the skill-tool execute template's second check);
thread the carrier signal through the lookup and refuse an abandoned
caller's turn as cancelled; fold lookup/loader failures into the
structured internal error the list face already uses; refuse cwd-less
sessions with the skill.list stance; and reject blank trailing text at the
wire schema instead of relying on client trimming.
This commit is contained in:
Yichen Jiang
2026-08-08 11:30:14 +08:00
parent 69bd00ae76
commit c4c2355b50
6 changed files with 155 additions and 30 deletions
+43 -17
View File
@@ -2390,32 +2390,58 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async invoke(request) {
async invoke(request, signal) {
const { sessionId, name, text } = request.payload
const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId)
if ('refused' in resolved) return resolved.refused
const agent = resolved.agent
if (agent.session.header.cwd === undefined) {
// Same stance as skill.list: a cwd-less header is a pre-project
// legacy log, and skill discovery has no root to resolve against.
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
}
const skillRegistry = ctx.get('skills')
if (skillRegistry === undefined) {
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
}
const lookup = { cwd: agent.session.header.cwd }
// isSkillName guards the registry contract; an ill-formed name is
// indistinguishable from an absent one for the caller.
const summary = isSkillName(name)
? (await skillRegistry.list(lookup)).find(skill => skill.name === name)
: undefined
if (summary === undefined) {
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
const lookup = { cwd: agent.session.header.cwd, signal }
let skill
try {
// isSkillName guards the registry contract; an ill-formed name is
// indistinguishable from an absent one for the caller.
const summary = isSkillName(name)
? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name)
: undefined
if (summary === undefined) {
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
}
// The operation boundary owns user-invocation policy: client menus
// filtering their candidates is an affordance, not enforcement.
if (!isUserInvocable(summary)) {
return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
}
const loaded = await skillRegistry.get(name, lookup)
if (loaded === undefined) {
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
}
// Recheck on the loaded definition (the skill-tool execute template):
// list and get collect independently, so a provider change between
// the two awaits can swap the winning candidate for a user-disabled
// one — the boundary must judge what it actually injects.
if (!isUserInvocable(loaded)) {
return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
}
skill = loaded
} catch (error: unknown) {
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} })
}
return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} })
}
// The operation boundary owns user-invocation policy: client menus
// filtering their candidates is an affordance, not enforcement.
if (!isUserInvocable(summary)) {
return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
}
const skill = await skillRegistry.get(name, lookup)
if (skill === undefined) {
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
if (signal.aborted) {
// The caller already gave up (unary deadline or navigation): a turn
// it will never observe must not start.
return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} })
}
const body = renderSkillContent(skill)
const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } }
@@ -27,11 +27,14 @@ export const skillListValueSchema = z.object({
skills: z.array(skillEntrySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
/** skill.invoke request payload. */
/**
* skill.invoke request payload. `text` is the user's trailing message; a
* blank one stays off the wire (the boundary, not client courtesy, refuses it).
*/
export const skillInvokeRequestSchema = z.object({
sessionId: sessionIdSchema,
name: z.string().min(1),
text: z.string().optional(),
text: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'skill.invoke'>>>
/** skill.invoke response value. */
+7 -3
View File
@@ -29,9 +29,13 @@ export interface SkillsApi {
* Injects one user-invocable skill into the addressed agent as a user-role
* message (the canonical `<skill_content>` rendering, with `text` appended
* when present) and starts a turn. The host enforces user-invocation policy
* here: a model-only or unknown name is refused regardless of what a client
* menu offered. Session-backed subagents reject with `agent-busy`.
* here — on the discovery summary and again on the loaded definition, so a
* catalog change between the two lookups cannot slip a user-disabled body
* through — a model-only or unknown name is refused regardless of what a
* client menu offered. The carrier's request signal aborts the skill
* lookup and refuses injection once the caller has given up (`cancelled`).
* Session-backed subagents reject with `agent-busy`.
*/
invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>):
invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal):
Promise<RpcResponse<{ accepted: true }>>
}
+1 -1
View File
@@ -109,7 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) },
'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r, signal) => api.skills.invoke(r, signal) },
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },