Skip to content
Merged
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
82 changes: 0 additions & 82 deletions packages/codingcode/src/approval/confirmation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as readline from 'node:readline';
import { Effect } from 'effect';
import type { PermissionRule } from './types.js';
import { ApprovalWaitService } from './async-confirm.js';
Expand All @@ -9,87 +8,6 @@ export type ConfirmResult =
| { type: 'always'; rule: PermissionRule }
| { type: 'never'; rule: PermissionRule };

async function promptUser(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
try {
return await new Promise<string>((resolve) => {
rl.question(question, (ans) => resolve(ans.trim().toLowerCase()));
});
} finally {
rl.close();
}
}

function buildResult(answer: string, tool: string): ConfirmResult {
switch (answer) {
case 'y':
return { type: 'allow' };
case 'n':
return { type: 'deny' };
case 'a':
return {
type: 'always',
rule: {
id: `user-allow-${tool}-${Date.now()}`,
action: 'allow',
toolPattern: tool,
reason: 'User always allows',
source: 'user',
},
};
case 'r':
return {
type: 'never',
rule: {
id: `user-deny-${tool}-${Date.now()}`,
action: 'deny',
toolPattern: tool,
reason: 'User never allows',
source: 'user',
},
};
default:
return { type: 'deny' };
}
}

export function userConfirm(
tool: string,
args: Record<string, unknown>,
mode: 'interactive' | 'default-deny' = 'default-deny'
): Effect.Effect<ConfirmResult> {
if (mode === 'default-deny') {
return Effect.succeed({ type: 'deny' } as ConfirmResult);
}

return Effect.gen(function* () {
const serializedArgs = Object.entries(args)
.map(([k, v]) => ` ${k}: ${String(v).slice(0, 200)}`)
.join('\n');

const question = `\n[Approval] Tool "${tool}" wants to run:\n${serializedArgs}\nAllow? (Y)es / (N)o / (A)lways / Neve_r / (V)iew full: `;

const answer = yield* Effect.promise(() => promptUser(question));

if (answer === 'v') {
console.log('\nFull arguments:', JSON.stringify(args, null, 2));
const result = yield* userConfirm(tool, args, 'interactive');
return result;
}

return buildResult(answer, tool);
});
}

/**
* Async confirmation via SSE: sends an approval request to the TUI client
* and waits for the response via Effect.async + Deferred.
* @param waitSvc injected as parameter to keep R channel clean.
*/
export function userConfirmAsync(
tool: string,
args: Record<string, unknown>,
Expand Down
4 changes: 1 addition & 3 deletions packages/codingcode/src/approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class ApprovalService extends Effect.Service<ApprovalService>()('Approval
if (result && result.decision === 'continue') {
return null;
}
return result as any;
return result;
}),

recordAudit: (entry) =>
Expand Down Expand Up @@ -71,7 +71,6 @@ export class ApprovalService extends Effect.Service<ApprovalService>()('Approval
destructiveTools: destTools,
permissionMode: currentPermMode,
hooks: buildPipelineHooks(),
interactive: process.stdin.isTTY ?? false,
asyncConfirm: hasEmitter(request.sessionId),
asyncConfirmService: approvalWait,
onAlways: (rule) => engine.addRule(rule),
Expand Down Expand Up @@ -142,7 +141,6 @@ export class ApprovalService extends Effect.Service<ApprovalService>()('Approval
destructiveTools,
permissionMode: _globalPermissionMode,
hooks: buildPipelineHooks(),
interactive: process.stdin.isTTY ?? false,
asyncConfirm: hasEmitter(request.sessionId),
asyncConfirmService: approvalWait,
onAlways: (rule) => ruleEngine.addRule(rule),
Expand Down
41 changes: 20 additions & 21 deletions packages/codingcode/src/approval/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import { Effect } from 'effect';
import type { ApprovalDecision, PermissionMode, PermissionRule, ToolCallRequest } from './types.js';
import type { RuleEngine } from './rule-engine.js';
import { userConfirm, userConfirmAsync } from './confirmation.js';
import { userConfirmAsync } from './confirmation.js';
import type { ApprovalWaitService } from './async-confirm.js';
import type { HookDecision } from '../hooks/registry.js';

export interface PipelineHooks {
/** Emit decision from PreToolUse hooks (Layer 4). Returns first non-null HookDecision or null. */
emitPreToolUseDecision: (payload: {
toolName: string;
args: Record<string, unknown>;
}) => Effect.Effect<{
decision?: 'allow' | 'deny' | 'ask';
reason?: string;
modifiedInput?: Record<string, unknown>;
} | null>;
}) => Effect.Effect<HookDecision | null>;
/** Record audit log for the final decision (Layer 6). */
recordAudit: (entry: {
tool: string;
Expand All @@ -29,8 +26,6 @@ export interface PipelineOptions {
destructiveTools: Set<string>;
permissionMode: PermissionMode;
hooks: PipelineHooks;
/** Whether TTY is available for interactive confirmation. */
interactive: boolean;
/** Use async SSE-based confirmation instead of blocking readline. */
asyncConfirm?: boolean;
/** Service for async confirmation (injected to keep R clean). */
Expand Down Expand Up @@ -132,19 +127,23 @@ export function runPipeline(
// Layer 5: User Confirmation
{
layers.push(LAYER_NAMES[4]);
const confirmResult = yield* opts.asyncConfirm && opts.asyncConfirmService
? userConfirmAsync(
request.tool,
request.input,
opts.asyncConfirmService,
opts.sessionId,
opts.callId
)
: userConfirm(
request.tool,
request.input,
opts.interactive ? 'interactive' : 'default-deny'
);
if (!opts.asyncConfirm || !opts.asyncConfirmService) {
const result: ApprovalDecision = {
type: 'deny',
reason: 'Approval required but no UI available',
source: 'system',
};
const final = yield* layer6Audit(request, result, layers, opts);
return final;
}

const confirmResult = yield* userConfirmAsync(
request.tool,
request.input,
opts.asyncConfirmService,
opts.sessionId,
opts.callId
);

let result: ApprovalDecision;
switch (confirmResult.type) {
Expand Down
2 changes: 1 addition & 1 deletion packages/codingcode/src/approval/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const READONLY_TOOL_NAMES: string[] = [
'search_files',
'fetch_url',
'web_search',
'tool_search',
'dispatch_agent',
'todo_write',
];

Expand Down
2 changes: 1 addition & 1 deletion packages/codingcode/src/client/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function* agentEventToStreamChunk(
yield { type: 'approval_request', id: event.id, tool: event.tool, args: event.args };
break;
case 'Error':
yield { type: 'error', message: event.error.message ?? String(event.error) };
yield { type: 'error', message: event.error.message ?? String(event.error), code: event.error.code };
break;
case 'Done':
yield { type: 'done' };
Expand Down
3 changes: 2 additions & 1 deletion packages/codingcode/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export async function createHttpClient(serverUrl: string): Promise<AgentClient>
};
break;
case 'error':
throw new Error(data.message as string);
yield { type: 'error', message: data.message as string, code: data.code as string };
return;
case 'done':
break;
case 'complete':
Expand Down
3 changes: 2 additions & 1 deletion packages/codingcode/src/client/http/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export function createHttpAgentClient(
};
break;
case 'error':
throw new Error(data.message as string);
yield { type: 'error', message: data.message as string, code: data.code as string };
return;
case 'done':
break;
case 'complete':
Expand Down
2 changes: 1 addition & 1 deletion packages/codingcode/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type StreamChunk =
| { type: 'tool_start'; id: string; name: string; args: Record<string, unknown> }
| { type: 'tool_result'; id: string; name: string; output: string; ok: boolean }
| { type: 'tool_denied'; id: string; name: string; reason: string }
| { type: 'error'; message: string }
| { type: 'error'; message: string; code: string }
| { type: 'done' }
| { type: 'todo_update'; items: ReadonlyArray<{ step: string; status: string }> }
| { type: 'usage'; prompt: number; completion: number; total: number }
Expand Down
6 changes: 0 additions & 6 deletions packages/codingcode/src/core/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,4 @@ export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error:
export const Result = {
ok: <T>(value: T): Result<T, never> => ({ ok: true, value }),
err: <E>(error: E): Result<never, E> => ({ ok: false, error }),

map: <T, U, E>(r: Result<T, E>, fn: (v: T) => U): Result<U, E> =>
r.ok ? Result.ok(fn(r.value)) : r,

flatMap: <T, U, E>(r: Result<T, E>, fn: (v: T) => Result<U, E>): Result<U, E> =>
r.ok ? fn(r.value) : r,
};
10 changes: 0 additions & 10 deletions packages/codingcode/src/core/scope.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/codingcode/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function agentEventToSseEvent(event: AgentEvent): SseEvent | null {
case 'ToolDenied':
return { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason };
case 'Error':
return { type: 'error', message: event.error.message ?? String(event.error) };
return { type: 'error', message: event.error.message ?? String(event.error), code: event.error.code };
case 'Done':
return { type: 'done' };
case 'TodoUpdate':
Expand Down
2 changes: 2 additions & 0 deletions packages/codingcode/src/server/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Context } from 'hono';
import { registerEmitter, unregisterEmitter } from '../approval/async-confirm.js';
import type { SseEvent } from './adapter.js';
import { AgentError } from '../core/error.js';

export function sseHandler(
createGenerator: () => AsyncGenerator<SseEvent, void, unknown>,
Expand Down Expand Up @@ -34,6 +35,7 @@ export function sseHandler(
enqueue({
type: 'error',
message: e instanceof Error ? e.message : String(e),
...(e instanceof AgentError ? { code: e.code } : {}),
});
} finally {
unregisterEmitter(sessionId);
Expand Down
14 changes: 2 additions & 12 deletions packages/codingcode/test/approval/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(),
permissionMode: 'default',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -45,7 +44,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(),
permissionMode: 'default',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -64,7 +62,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(['Bash']),
permissionMode: 'plan',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -83,7 +80,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(),
permissionMode: 'plan',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -101,7 +97,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(['Bash']),
permissionMode: 'bypass',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -120,7 +115,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(['Bash', 'execute_command']),
permissionMode: 'acceptEdits',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -138,14 +132,13 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(['Bash', 'execute_command']),
permissionMode: 'acceptEdits',
hooks: mockHooks,
interactive: false,
sessionId: 'test',
}
)
);
// Should continue to user confirmation layer (which returns deny in non-interactive mode)
// Destructive tool in acceptEdits mode with no UI available → system deny
expect(decision.type).toBe('deny');
expect((decision as any).source).toBe('user-confirm');
expect((decision as any).source).toBe('system');
});

it('Layer 4: PreToolUse hook can deny (non-readonly tool)', async () => {
Expand All @@ -163,7 +156,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(['Bash']),
permissionMode: 'default',
hooks: hooksWithDeny,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -186,7 +178,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(['Bash']),
permissionMode: 'default',
hooks: hooksWithAllow,
interactive: false,
sessionId: 'test',
}
)
Expand All @@ -213,7 +204,6 @@ describe('Approval Pipeline', () => {
destructiveTools: new Set(),
permissionMode: 'default',
hooks: hooksWithAudit,
interactive: false,
sessionId: 'test',
}
)
Expand Down
Loading
Loading