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
5 changes: 5 additions & 0 deletions .changeset/append-pretool-hook-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix successful PreToolUse hook output not being added to the model context.
12 changes: 11 additions & 1 deletion packages/agent-core/src/agent/permission/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Agent } from '..';
import type { PrepareToolExecutionResult } from '../../loop';
import type { RenderedHookResult } from '../../session/hooks';
import { createPermissionDecisionPolicies } from './policies';
import type {
ApprovalResponse,
Expand Down Expand Up @@ -28,6 +29,7 @@ interface PolicyEvaluation {
export class PermissionManager {
readonly policies: PermissionPolicy[];
readonly rules: PermissionRule[] = [];
private readonly allowedPreToolHookResults = new Map<string, RenderedHookResult>();
private modeOverride: PermissionMode | undefined;
private readonly parent: PermissionManager | undefined;
private readonly localSessionApprovalRulePatterns = new Set<string>();
Expand All @@ -38,7 +40,9 @@ export class PermissionManager {
) {
this.rules = [...(options.initialRules ?? [])];
this.parent = options.parent;
this.policies = createPermissionDecisionPolicies(this.agent);
this.policies = createPermissionDecisionPolicies(this.agent, (toolCallId, result) => {
this.allowedPreToolHookResults.set(toolCallId, result);
});
}

get mode(): PermissionMode {
Expand Down Expand Up @@ -93,6 +97,12 @@ export class PermissionManager {
];
}

takeAllowedPreToolHookResult(toolCallId: string): RenderedHookResult | undefined {
const result = this.allowedPreToolHookResults.get(toolCallId);
this.allowedPreToolHookResults.delete(toolCallId);
return result;
}

async beforeToolCall(
context: PermissionPolicyContext,
): Promise<PrepareToolExecutionResult | undefined> {
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-core/src/agent/permission/policies/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Agent } from '../..';
import type { RenderedHookResult } from '../../../session/hooks';
import type { PermissionPolicy } from '../types';
import { AgentSwarmExclusiveDenyPermissionPolicy } from './agent-swarm-exclusive-deny';
import { AutoModeApprovePermissionPolicy } from './auto-mode-approve';
Expand All @@ -25,10 +26,13 @@ import {
import { YoloModeApprovePermissionPolicy } from './yolo-mode-approve';

/** Permission policies run in order; the first non-undefined result wins. */
export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy[] {
export function createPermissionDecisionPolicies(
agent: Agent,
onAllowedPreToolHookResult?: (toolCallId: string, result: RenderedHookResult) => void,
): PermissionPolicy[] {
return [
// PreToolUse hook returned a block → deny.
new PreToolCallHookPermissionPolicy(agent),
new PreToolCallHookPermissionPolicy(agent, onAllowedPreToolHookResult),
// AgentSwarm is batch-exclusive and must run alone, regardless of permission mode.
new AgentSwarmExclusiveDenyPermissionPolicy(),
// auto mode + AskUserQuestion → deny.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import type { Agent } from '../..';
import { isPlainRecord } from '../../turn/canonical-args';
import {
renderAllowedHookResult,
resolveHookBlockDecision,
type RenderedHookResult,
} from '../../../session/hooks';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';

export class PreToolCallHookPermissionPolicy implements PermissionPolicy {
readonly name = 'pre-tool-call-hook';

constructor(private readonly agent: Agent) {}
constructor(
private readonly agent: Agent,
private readonly onAllowedResult?: (toolCallId: string, result: RenderedHookResult) => void,
) {}

async evaluate(context: PermissionPolicyContext): Promise<PermissionPolicyResult | undefined> {
const hookResult = await this.agent.hooks?.triggerBlock('PreToolUse', {
const hookResults = await this.agent.hooks?.trigger?.('PreToolUse', {
matcherValue: context.toolCall.name,
signal: context.signal,
inputData: {
Expand All @@ -18,10 +26,16 @@ export class PreToolCallHookPermissionPolicy implements PermissionPolicy {
},
});
context.signal.throwIfAborted();
if (hookResult === undefined) return;
if (hookResults === undefined) return;
const block = resolveHookBlockDecision('PreToolUse', hookResults);
if (block === undefined) {
const allowed = renderAllowedHookResult('PreToolUse', hookResults);
if (allowed !== undefined) this.onAllowedResult?.(context.toolCall.id, allowed);
return;
}
return {
kind: 'deny',
message: hookResult.reason,
message: block.reason,
};
}
}
15 changes: 15 additions & 0 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,21 @@ export class TurnFlow {
return this.agent.permission.beforeToolCall(ctx);
},
finalizeToolResult: async (ctx) => {
const preToolHookResult = this.agent.permission.takeAllowedPreToolHookResult(
ctx.toolCall.id,
);
if (preToolHookResult !== undefined) {
this.agent.context.appendUserMessage(
[{ type: 'text', text: preToolHookResult.text }],
{ kind: 'hook_result', event: 'PreToolUse' },
);
this.agent.emitEvent({
type: 'hook.result',
turnId,
hookEvent: preToolHookResult.event,
content: preToolHookResult.message,
});
}
// Calls rejected in preflight (e.g. invalid args) never reach
// prepareToolExecution, so register them here — otherwise the
// repeat breaker cannot count them and the model can re-issue
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-core/src/session/hooks/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class HookEngine {
event: string,
args: HookEngineTriggerArgs = {},
): Promise<HookBlockDecision | undefined> {
return blockDecision(event, await this.trigger(event, args));
return resolveHookBlockDecision(event, await this.trigger(event, args));
}

fireAndForgetTrigger(
Expand Down Expand Up @@ -155,14 +155,14 @@ function aggregateResults(
readonly action: 'allow' | 'block';
readonly reason?: string;
} {
const block = blockDecision(event, results);
const block = resolveHookBlockDecision(event, results);
if (block !== undefined) {
return { action: 'block', reason: block.reason };
}
return { action: 'allow' };
}

function blockDecision(
export function resolveHookBlockDecision(
event: string,
results: readonly HookResult[],
): HookBlockDecision | undefined {
Expand Down
15 changes: 11 additions & 4 deletions packages/agent-core/src/session/hooks/user-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,26 @@ export interface RenderedHookResult {

export function renderUserPromptHookResult(
results: readonly HookResult[] | undefined,
): RenderedHookResult | undefined {
return renderAllowedHookResult('UserPromptSubmit', results);
}

export function renderAllowedHookResult(
event: string,
results: readonly HookResult[] | undefined,
): RenderedHookResult | undefined {
const messages =
results
?.filter((result) => result.action !== 'block')
?.map(userPromptHookMessage)
?.map(allowedHookMessage)
.filter(isNonEmptyString) ??
[];
if (messages.length === 0) return undefined;
const displayMessage = messages.join('\n\n');
return {
event: 'UserPromptSubmit',
event,
message: displayMessage,
text: messages.map((message) => renderHookResult('UserPromptSubmit', message)).join('\n'),
text: messages.map((message) => renderHookResult(event, message)).join('\n'),
};
}

Expand All @@ -51,7 +58,7 @@ export function renderUserPromptHookBlockResult(
};
}

function userPromptHookMessage(result: HookResult): string | undefined {
function allowedHookMessage(result: HookResult): string | undefined {
if (result.timedOut === true || (result.exitCode !== undefined && result.exitCode !== 0)) {
return undefined;
}
Expand Down
33 changes: 14 additions & 19 deletions packages/agent-core/test/agent/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,13 +981,10 @@ describe('Simple permission policy direct behavior', () => {

describe('PreToolUse permission policy', () => {
it('blocks before approval and records the hook policy decision', async () => {
const triggerBlock = vi.fn(async () => ({
block: true,
reason: 'blocked by hook',
}));
const trigger = vi.fn(async () => [{ action: 'block' as const, reason: 'blocked by hook' }]);
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
async () => ({ decision: 'approved' }),
{ hooks: { triggerBlock } as unknown as Agent['hooks'] },
{ hooks: { trigger } as unknown as Agent['hooks'] },
);

await expect(manager.beforeToolCall(hookContext({ id: 'call_hook_block' }))).resolves
Expand All @@ -997,7 +994,7 @@ describe('PreToolUse permission policy', () => {
});

expect(requestApproval).not.toHaveBeenCalled();
expect(triggerBlock).toHaveBeenCalledWith('PreToolUse', {
expect(trigger).toHaveBeenCalledWith('PreToolUse', {
matcherValue: 'Bash',
signal: expect.any(AbortSignal),
inputData: {
Expand All @@ -1015,13 +1012,12 @@ describe('PreToolUse permission policy', () => {
});

it.each(['auto', 'yolo'] as const)('runs before %s mode bypass', async (mode) => {
const triggerBlock = vi.fn(async () => ({
block: true,
reason: `${mode} hook block`,
}));
const trigger = vi.fn(async () => [
{ action: 'block' as const, reason: `${mode} hook block` },
]);
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
async () => ({ decision: 'approved' }),
{ hooks: { triggerBlock } as unknown as Agent['hooks'] },
{ hooks: { trigger } as unknown as Agent['hooks'] },
);
manager.setMode(mode);

Expand All @@ -1043,10 +1039,10 @@ describe('PreToolUse permission policy', () => {
});

it('continues through later policies when the hook does not block', async () => {
const triggerBlock = vi.fn(async () => undefined);
const trigger = vi.fn(async () => []);
const { manager, requestApproval, telemetryTrack } = makePermissionManager(
async () => ({ decision: 'approved' }),
{ hooks: { triggerBlock } as unknown as Agent['hooks'] },
{ hooks: { trigger } as unknown as Agent['hooks'] },
);

await expect(manager.beforeToolCall(hookContext({ id: 'call_hook_allow' }))).resolves
Expand All @@ -1064,13 +1060,12 @@ describe('PreToolUse permission policy', () => {
});

it('passes an empty hook input object for non-plain arguments', async () => {
const triggerBlock = vi.fn(async () => ({
block: true,
reason: 'array args blocked',
}));
const trigger = vi.fn(async () => [
{ action: 'block' as const, reason: 'array args blocked' },
]);
const { manager } = makePermissionManager(
async () => ({ decision: 'approved' }),
{ hooks: { triggerBlock } as unknown as Agent['hooks'] },
{ hooks: { trigger } as unknown as Agent['hooks'] },
);

await manager.beforeToolCall(
Expand All @@ -1081,7 +1076,7 @@ describe('PreToolUse permission policy', () => {
}),
);

expect(triggerBlock).toHaveBeenCalledWith(
expect(trigger).toHaveBeenCalledWith(
'PreToolUse',
expect.objectContaining({
inputData: {
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-core/test/agent/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ describe('Agent tools', () => {
expect(JSON.stringify(ctx.agent.context.data().history)).toContain('blocked by PreToolUse');
});

it('appends successful PreToolUse stdout to model context after the tool result', async () => {
const hookEngine = new HookEngine([
{
event: 'PreToolUse',
matcher: 'Bash',
command: 'node -e "process.stdout.write(\'UNTRUSTED-CONTENT-MARKER\')"',
},
]);
const ctx = testAgent({
kaos: createCommandKaos('tool output'),
hookEngine,
});
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'auto' });

ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
ctx.mockNextResponse({ type: 'text', text: 'The command completed.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run Bash' }] });

await ctx.untilTurnEnd();

expect(ctx.llmCalls).toHaveLength(2);
const history = ctx.llmCalls[1]!.history;
const toolResultIndex = history.findIndex((message) => message.role === 'tool');
const hookResultIndex = history.findIndex((message) =>
JSON.stringify(message).includes('UNTRUSTED-CONTENT-MARKER'),
);
expect(hookResultIndex).toBeGreaterThan(toolResultIndex);
expect(history[hookResultIndex]?.role).toBe('user');
});

it('emits PostToolUse after successful tools', async () => {
const triggered: Array<[string, string, number]> = [];
const hookEngine = new HookEngine(
Expand Down