diff --git a/.gitignore b/.gitignore index d989f8f..786543c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ coverage/ *.db-wal *.db-shm .DS_Store +.run-guard/* +.cli-gateway/* +nohup.out diff --git a/AGENTS.md b/AGENTS.md index a9db793..a99be54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,12 +19,14 @@ This file is for coding agents (Codex/Claude/etc) working in this repository. - DB schema/migrations: `src/db/migrations.ts` - Scheduler: `src/scheduler/scheduler.ts` - Channel sinks: `src/channels/discord.ts`, `src/channels/telegram.ts`, `src/channels/feishu.ts` +- Process guard/restart bridge: `scripts/run-guard.sh`, `scripts/restart-watcher.sh` ## UI - UI modes: `verbose` (default) and `summary`. - Per-conversation override: `/ui verbose|summary` (stored in DB `ui_prefs`). - Per-conversation runtime prefs include `/workspace` and `/cli` (persist across `/new`). +- Permission allowlist can be managed per conversation via `/whitelist` (`tool_kind` + optional prefix scoped, persisted via `tool_policies` and `tool_allow_prefixes`). ## Local dev @@ -55,6 +57,7 @@ ACP sessions are process-local; after restart/GC the agent will start with no st Current mitigation: - Context replay on fresh ACP sessions via `CONTEXT_REPLAY_*` (DB-backed replay of recent runs). +- Discord fresh sessions also prepend channel topic/description as global context when available. If you change how context is built or injected, update: - `src/gateway/history.ts` diff --git a/README.md b/README.md index 554bbce..18aa1d6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ npm run start:guard Restart/stop/status/logs: ```bash -bash scripts/run-guard.sh restart +bash scripts/run-guard.sh request-restart bash scripts/run-guard.sh stop bash scripts/run-guard.sh status bash scripts/run-guard.sh logs @@ -98,10 +98,10 @@ Custom command is supported: ```bash bash scripts/run-guard.sh start -- npm run dev -bash scripts/run-guard.sh restart -- npm run dev +bash scripts/run-guard.sh request-restart -- npm run dev ``` -`start`/`restart` automatically runs: +`start`/`request-restart` automatically runs: ```bash npm i @@ -111,6 +111,21 @@ npm run build Then guard keeps restarting the app on abnormal exit with exponential backoff. Before each launch attempt, guard also checks `gateway.lock` under `CLI_GATEWAY_HOME` (or `~/.cli-gateway`), terminates the lock PID if still alive, and removes stale lock files. +Sandbox-friendly restart bridge: + +- Run `scripts/restart-watcher.sh` on the host (outside sandbox). It watches `.run-guard/restart.request` and calls `run-guard.sh restart`. +- From sandbox, only send a restart request marker: + +```bash +bash scripts/run-guard.sh request-restart +``` + +- Host watcher startup example: + +```bash +nohup bash scripts/restart-watcher.sh >> .run-guard/restart-watcher.log 2>&1 & +``` + Useful env vars: - `RESTART_BASE_DELAY_SECONDS` (default `2`) @@ -120,6 +135,8 @@ Useful env vars: - `STOP_TIMEOUT_SECONDS` (default `20`) - `SKIP_UPDATE=1` to skip `npm i` + `npm run build` - `GUARD_STATE_DIR` to override pid/log directory (default `./.run-guard`) +- `RESTART_REQUEST_SOURCE` payload source for `request-restart` (default `manual`) +- `RESTART_REQUEST_COOLDOWN_SECONDS` watcher debounce window (default `10`) ## Feishu setup (MVP) @@ -135,6 +152,7 @@ Feishu currently runs in webhook event-subscription mode: - `/new` start a fresh ACP session for this conversation - `/allow ` select a pending permission option by index (fallback) - `/deny` reject a pending permission request (fallback) +- `/whitelist list|add|del|clear` manage per-conversation permission whitelist by `tool_kind` (optional prefix scope) - `/cron help|list|add|del|enable|disable` manage scheduled prompts - `/last` show last run output for this session - `/replay [runId]` replay stored `session/update` output for a run (best-effort) @@ -149,15 +167,19 @@ Telegram note: - Chat-scoped command menu is synced best-effort from `cli-inline` commands. Commands with `-` are mapped to `_` in Telegram UI. Discord note: -- Built-in commands are available as slash commands (`/help`, `/ui`, `/cli`, `/workspace`, `/new`, `/last`, `/replay`, `/allow`, `/deny`, `/cron`). +- Built-in commands are available as slash commands (`/help`, `/ui`, `/cli`, `/workspace`, `/new`, `/last`, `/replay`, `/allow`, `/deny`, `/whitelist`, `/cron`). - Slash commands are synced at startup (global + per-guild best-effort). Global command propagation may take time on Discord side. - ACP `cli-inline` dynamic commands are not yet exposed as Discord slash commands. - Inbound message processing uses reaction acks (`馃` while running, then `馃晩` on success or `馃槩` on error), aligned with Telegram behavior. +- On fresh ACP sessions, the channel topic/description is injected as a global context block before the user prompt. ## Security model (default) - File system and terminal tool calls are restricted to the active workspace root (per conversation; see `/workspace`). - Tool execution is **deny-by-default**; the user must approve via ACP permission flow. +- You can pre-allow specific `tool_kind` values per conversation via `/whitelist add ` (`read|edit|delete|move|search|execute|think|fetch|switch_mode|other`). +- You can also scope allow rules by prefix: `/whitelist add read /abs/path/prefix` (path kinds) or `/whitelist add execute npm run` (argument prefix). Non-matching calls still require approval. +- If an agent calls a tool directly without first sending `session/request_permission`, the gateway synthesizes an interactive permission prompt and blocks the tool call until approved/denied. - Approvals are interactive on Discord/Telegram (buttons). Discord permission cards also add reaction shortcuts (`馃憤` allow, `馃憥` deny; `鉁卄/`鉂宍 still accepted); `/allow`/`/deny` remain as fallback. - You can persist policy choices (e.g. `allow_always` / `reject_always`) per conversation. @@ -189,6 +211,7 @@ To reduce this, `cli-gateway` can replay recent conversation runs from the DB in - Config keys: `contextReplayEnabled`, `contextReplayRuns`, `contextReplayMaxChars` - Default: enabled, last 8 runs, max 12k chars (used only on fresh ACP sessions) +- Discord-only: fresh sessions also include channel topic/description as global context. ## Status diff --git a/ROADMAP.md b/ROADMAP.md index 64ea85e..1c9ef6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,6 +38,7 @@ This document lists current gaps (vs a "production gateway") and the planned dir - Process guard script for auto-restart on abnormal exit (`scripts/run-guard.sh`). - Process guard supports daemon lifecycle commands (`start/stop/restart/status/logs`) with `nohup` background mode and auto `npm i && npm run build` on `start/restart`. - Process guard now pre-cleans `gateway.lock` (kill lock PID if alive, remove stale lock) before each launch attempt. +- Added sandbox-safe restart bridge: `run-guard.sh request-restart` marker + host-side `scripts/restart-watcher.sh`. - Feishu inbound webhook + outbound send (MVP). - First-run interactive config wizard (TTY) + lock directory bootstrap. - Default UI mode switched to `summary` (conversation-level `/ui` override still supported). @@ -45,7 +46,10 @@ This document lists current gaps (vs a "production gateway") and the planned dir - Conversation preferences can now be changed before first prompt (`/ui`, `/workspace`, `/cli`) and survive `/new` session reset. - ACP transport now fails fast on child exit/bootstrap timeout, returning explicit errors instead of leaving runs hanging. - Discord permission approvals now support emoji reactions (`鉁卄/`馃憤` allow, `鉂宍/`馃憥` deny) in addition to buttons. +- Gateway now synthesizes interactive permission prompts when an agent calls tools directly without `session/request_permission`, preserving deny-by-default UX. - Agent text streaming now auto-splits around tool calls (text-only updates keep editing one message; post-tool assistant output resumes in a new message). +- Fresh Discord sessions now inject channel topic/description as global context (alongside context replay when enabled). +- Added `/whitelist` command to manage per-conversation permission allowlist by `tool_kind` with optional path/argument prefix scoping. ## Suggested Next Steps (Priority) diff --git a/scripts/restart-watcher.sh b/scripts/restart-watcher.sh new file mode 100755 index 0000000..bb8f13c --- /dev/null +++ b/scripts/restart-watcher.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +STATE_DIR="${GUARD_STATE_DIR:-${PROJECT_ROOT}/.run-guard}" +REQ_FILE="${STATE_DIR}/restart.request" +BUSY_FILE="${STATE_DIR}/restart.busy" +COOLDOWN_SECONDS="${RESTART_REQUEST_COOLDOWN_SECONDS:-10}" +PROCESSING_FILE="${REQ_FILE}.processing.$$" + +if ! [[ "${COOLDOWN_SECONDS}" =~ ^[0-9]+$ ]]; then + COOLDOWN_SECONDS=10 +fi + +mkdir -p "${STATE_DIR}" + +last_restart_ts=0 + +cleanup_busy() { + rm -f "${BUSY_FILE}" +} + +trap cleanup_busy EXIT INT TERM + +echo "[watcher] watching ${REQ_FILE}" +echo "[watcher] cooldown=${COOLDOWN_SECONDS}s" + +while true; do + if [[ -f "${REQ_FILE}" && ! -f "${BUSY_FILE}" ]]; then + now="$(date +%s)" + + if (( now - last_restart_ts < COOLDOWN_SECONDS )); then + echo "[watcher] request ignored (cooldown)" + rm -f "${REQ_FILE}" + else + touch "${BUSY_FILE}" + + if mv "${REQ_FILE}" "${PROCESSING_FILE}" 2>/dev/null; then + echo "[watcher] restart requested at $(date -Iseconds)" + bash "${PROJECT_ROOT}/scripts/run-guard.sh" restart || true + fi + + rm -f "${PROCESSING_FILE}" "${BUSY_FILE}" + last_restart_ts="${now}" + fi + fi + + sleep 1 +done diff --git a/scripts/run-guard.sh b/scripts/run-guard.sh index 46920c0..f7a39ae 100755 --- a/scripts/run-guard.sh +++ b/scripts/run-guard.sh @@ -10,6 +10,7 @@ PID_FILE="${STATE_DIR}/guard.pid" APP_PID_FILE="${STATE_DIR}/app.pid" CMD_FILE="${STATE_DIR}/command.args" LOG_FILE="${STATE_DIR}/guard.log" +RESTART_REQUEST_FILE="${STATE_DIR}/restart.request" BASE_DELAY="${RESTART_BASE_DELAY_SECONDS:-2}" MAX_DELAY="${RESTART_MAX_DELAY_SECONDS:-30}" @@ -181,7 +182,9 @@ resolve_command() { fi if [[ -f "${CMD_FILE}" ]]; then - mapfile -t loaded < "${CMD_FILE}" + while IFS= read -r line || [[ -n "${line}" ]]; do + loaded+=("${line}") + done < "${CMD_FILE}" if [[ ${#loaded[@]} -gt 0 ]]; then RESOLVED_CMD=("${loaded[@]}") return @@ -225,7 +228,11 @@ start_guard() { cleanup_gateway_lock - resolve_command "${cmd[@]}" + if [[ ${#cmd[@]} -gt 0 ]]; then + resolve_command "${cmd[@]}" + else + resolve_command + fi cmd=("${RESOLVED_CMD[@]}") save_command "${cmd[@]}" @@ -293,7 +300,11 @@ restart_guard() { local -a cmd=("$@") stop_guard - start_guard "${cmd[@]}" + if [[ ${#cmd[@]} -gt 0 ]]; then + start_guard "${cmd[@]}" + else + start_guard + fi } status_guard() { @@ -338,13 +349,32 @@ logs_guard() { tail -n "${LOG_TAIL_LINES}" "${LOG_FILE}" } +request_restart() { + local source="${RESTART_REQUEST_SOURCE:-manual}" + local now_iso + local tmp_file + + ensure_state_dir + now_iso="$(date -Iseconds)" + tmp_file="${RESTART_REQUEST_FILE}.tmp.$$" + + printf '{"requestedAt":"%s","source":"%s"}\n' "${now_iso}" "${source}" > "${tmp_file}" + mv "${tmp_file}" "${RESTART_REQUEST_FILE}" + + echo "[guard] restart request queued: ${RESTART_REQUEST_FILE}" +} + run_loop() { local -a cmd=("$@") local attempt=0 local child_pid="" ensure_state_dir - resolve_command "${cmd[@]}" + if [[ ${#cmd[@]} -gt 0 ]]; then + resolve_command "${cmd[@]}" + else + resolve_command + fi cmd=("${RESOLVED_CMD[@]}") echo "$$" > "${PID_FILE}" @@ -422,11 +452,13 @@ Usage: bash scripts/run-guard.sh [start] [-- ] bash scripts/run-guard.sh stop bash scripts/run-guard.sh restart [-- ] + bash scripts/run-guard.sh request-restart bash scripts/run-guard.sh status bash scripts/run-guard.sh logs [-f] Notes: - `start`/`restart` will run `npm i` and `npm run build` before launching. + - `request-restart` only drops a marker file; `scripts/restart-watcher.sh` consumes it. - Default command is `node dist/main.js`. - Legacy form `bash scripts/run-guard.sh npm run dev` is still supported. @@ -438,12 +470,13 @@ Useful env vars: STOP_TIMEOUT_SECONDS (default: 20) SKIP_UPDATE=1 to skip npm i/build GUARD_STATE_DIR to change pid/log directory + RESTART_REQUEST_SOURCE to annotate request-restart payload source EOF_USAGE } is_known_action() { case "$1" in - start|stop|restart|status|logs|help|_run-loop) + start|stop|restart|request-restart|status|logs|help|_run-loop) return 0 ;; *) @@ -470,25 +503,44 @@ main() { case "${action}" in start) - start_guard "${args[@]}" + if [[ ${#args[@]} -gt 0 ]]; then + start_guard "${args[@]}" + else + start_guard + fi ;; stop) stop_guard ;; restart) - restart_guard "${args[@]}" + if [[ ${#args[@]} -gt 0 ]]; then + restart_guard "${args[@]}" + else + restart_guard + fi + ;; + request-restart) + request_restart ;; status) status_guard ;; logs) - logs_guard "${args[@]}" + if [[ ${#args[@]} -gt 0 ]]; then + logs_guard "${args[@]}" + else + logs_guard + fi ;; help) usage ;; _run-loop) - run_loop "${args[@]}" + if [[ ${#args[@]} -gt 0 ]]; then + run_loop "${args[@]}" + else + run_loop + fi ;; *) usage diff --git a/src/acp/client.ts b/src/acp/client.ts index 1c70b72..8d23e3f 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -100,6 +100,13 @@ export class AcpClient { // run-scoped state private currentRun: AcpRun | null = null; private readonly runSeq = new Map(); + private readonly pendingLocalPermissions = new Map< + JsonRpcId, + { + resolve: (decision: PermissionDecision) => void; + reject: (error: Error) => void; + } + >(); private readonly events: AcpClientEvents; @@ -133,11 +140,17 @@ export class AcpClient { ')', ), ); + this.rejectAllLocalPermissions( + this.makeTransportError( + 'ACP agent exited while waiting for permission response', + ), + ); }); } close(): void { this.rejectAllPending(this.makeTransportError('ACP client closed')); + this.rejectAllLocalPermissions(this.makeTransportError('ACP client closed')); this.rpc.kill(); } @@ -204,6 +217,13 @@ export class AcpClient { req: PermissionRequest, decision: PermissionDecision, ): Promise { + const local = this.pendingLocalPermissions.get(req.requestId); + if (local) { + this.pendingLocalPermissions.delete(req.requestId); + local.resolve(decision); + return; + } + const outcome: RequestPermissionResult['outcome'] = decision.kind === 'cancelled' ? { outcome: 'cancelled' } @@ -288,7 +308,11 @@ export class AcpClient { const params = req.params as FsReadTextFileParams; emitTool({ phase: 'start', method: req.method, params }); - this.assertAuthorized('read'); + await this.ensureAuthorized({ + kind: 'read', + method: req.method, + params, + }); const resolvedPath = resolveWorkspacePath( this.workspaceRoot, params.path, @@ -313,7 +337,11 @@ export class AcpClient { const params = req.params as FsWriteTextFileParams; emitTool({ phase: 'start', method: req.method, params: { path: params.path } }); - this.assertAuthorized('edit'); + await this.ensureAuthorized({ + kind: 'edit', + method: req.method, + params, + }); const resolvedPath = resolveWorkspacePath( this.workspaceRoot, params.path, @@ -343,7 +371,11 @@ export class AcpClient { }, }); - this.assertAuthorized('execute'); + await this.ensureAuthorized({ + kind: 'execute', + method: req.method, + params, + }); const terminalId = await this.terminalCreate(params); this.respond(req.id, { terminalId } satisfies TerminalCreateResult); @@ -389,7 +421,11 @@ export class AcpClient { case 'terminal/kill': { const params = req.params as TerminalKillParams; - this.assertAuthorized('execute'); + await this.ensureAuthorized({ + kind: 'execute', + method: req.method, + params, + }); this.terminalKill(params); this.respond(req.id, {}); return; @@ -493,6 +529,16 @@ export class AcpClient { } } + private rejectAllLocalPermissions(error: Error): void { + for (const [id, pending] of this.pendingLocalPermissions.entries()) { + this.pendingLocalPermissions.delete(id); + const detail = this.makeTransportError( + error.message + '; permission_request_id=' + String(id), + ); + pending.reject(detail); + } + } + private makeTransportError(message: string): Error { const err = new Error(message); err.name = 'AcpTransportError'; @@ -521,7 +567,12 @@ export class AcpClient { return seq; } - private assertAuthorized(kind: ToolKind): void { + private async ensureAuthorized(params: { + kind: ToolKind; + method: string; + params: unknown; + }): Promise { + const { kind } = params; const sessionKey = this.currentRun?.sessionKey; // Tool calls should only occur within a prompt turn. @@ -536,10 +587,59 @@ export class AcpClient { throw new Error(`Tool call denied (no ToolAuth): ${kind}`); } - const ok = this.toolAuth.consume(sessionKey, kind); - if (!ok) { - throw new Error(`Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow .`); + if ( + this.toolAuth.consume(sessionKey, kind, { + method: params.method, + params: params.params, + workspaceRoot: this.workspaceRoot, + }) + ) { + return; + } + + if (!this.events.onPermissionRequest) { + throw new Error( + `Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow .`, + ); + } + + const req = buildLocalPermissionRequest({ + sessionKey, + kind, + method: params.method, + params: params.params, + }); + + const decision = await new Promise((resolve, reject) => { + this.pendingLocalPermissions.set(req.requestId, { resolve, reject }); + + try { + this.events.onPermissionRequest?.(req); + } catch (error: any) { + this.pendingLocalPermissions.delete(req.requestId); + reject(new Error(String(error?.message ?? error))); + } + }); + + if (decision.kind === 'cancelled') { + throw new Error( + `Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow .`, + ); + } + + if ( + this.toolAuth.consume(sessionKey, kind, { + method: params.method, + params: params.params, + workspaceRoot: this.workspaceRoot, + }) + ) { + return; } + + throw new Error( + `Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow .`, + ); } // terminal management (minimal) @@ -637,6 +737,126 @@ export class AcpClient { } } +function buildLocalPermissionRequest(params: { + sessionKey: string; + kind: ToolKind; + method: string; + params: unknown; +}): PermissionRequest { + const sessionId = + typeof (params.params as { sessionId?: unknown } | null)?.sessionId === + 'string' + ? String((params.params as { sessionId?: string }).sessionId) + : 'unknown'; + + return { + requestId: `localperm-${randomUUID()}`, + sessionKey: params.sessionKey, + sessionId, + createdAtMs: Date.now(), + params: { + sessionId, + toolCall: { + title: buildLocalToolTitle(params.method, params.params), + kind: params.kind, + name: params.method, + arguments: buildLocalPermissionArgs(params.params), + }, + options: [ + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + { + optionId: 'allow_always', + name: 'Always allow', + kind: 'allow_always', + }, + { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' }, + { + optionId: 'reject_always', + name: 'Always reject', + kind: 'reject_always', + }, + ], + }, + }; +} + +function buildLocalToolTitle(method: string, rawParams: unknown): string { + const params = (rawParams ?? {}) as Record; + + if (method === 'fs/read_text_file') { + const target = stringOrFallback(params.path, ''); + return truncateInline(`read: ${target}`, 180); + } + + if (method === 'fs/write_text_file') { + const target = stringOrFallback(params.path, ''); + return truncateInline(`edit: ${target}`, 180); + } + + if (method === 'terminal/create') { + const command = stringOrFallback(params.command, ''); + const args = Array.isArray(params.args) + ? params.args + .filter((item): item is string => typeof item === 'string') + .join(' ') + : ''; + const full = args ? `${command} ${args}` : command; + return truncateInline(`run: ${full}`, 180); + } + + if (method === 'terminal/kill') { + const terminalId = stringOrFallback(params.terminalId, ''); + return truncateInline(`run: kill terminal ${terminalId}`, 180); + } + + return truncateInline(method, 180); +} + +function buildLocalPermissionArgs(rawParams: unknown): unknown { + if (!rawParams || typeof rawParams !== 'object' || Array.isArray(rawParams)) { + return rawParams; + } + + const source = rawParams as Record; + const args: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (key === 'sessionId') continue; + args[key] = sanitizePermissionArgValue(key, value); + } + + return args; +} + +function sanitizePermissionArgValue(key: string, value: unknown): unknown { + if (key === 'content' && typeof value === 'string') { + return value.length > 240 + ? `${value.slice(0, 237)}... (${value.length} chars)` + : value; + } + + if (key === 'env' && Array.isArray(value)) { + return value.map((item) => { + if (!item || typeof item !== 'object') return ''; + const name = (item as { name?: unknown }).name; + return typeof name === 'string' && name.trim() ? name.trim() : ''; + }); + } + + return value; +} + +function truncateInline(text: string, maxLen: number): string { + const clean = text.replace(/\s+/g, ' ').trim(); + if (clean.length <= maxLen) return clean; + return clean.slice(0, maxLen - 3) + '...'; +} + +function stringOrFallback(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + return trimmed || fallback; +} + function readTextFileWithLimit( filePath: string, line?: number, diff --git a/src/channels/bufferedSink.ts b/src/channels/bufferedSink.ts index e3c9a74..935022f 100644 --- a/src/channels/bufferedSink.ts +++ b/src/channels/bufferedSink.ts @@ -20,32 +20,44 @@ export function createBufferedSink(params: { let currentText = params.initialState?.text ?? ''; let currentMessageId: string | null = params.initialState?.messageId ?? null; let flushTimer: NodeJS.Timeout | null = null; - let flushing = false; + let flushInFlight: Promise | null = null; - async function doFlush(): Promise { - if (flushing) return; - flushing = true; - try { - if (!currentText) return; + async function runFlush(): Promise { + if (!currentText) return; + + if (!currentMessageId) { + const res = await params.send(currentText); + currentMessageId = res.id; + return; + } - if (!currentMessageId) { - const res = await params.send(truncate(currentText, params.maxLen)); - currentMessageId = res.id; + try { + await params.edit(currentMessageId, currentText); + } catch (error) { + if (isNoopEditError(error)) { return; } + const res = await params.send(currentText); + currentMessageId = res.id; + } + } - try { - await params.edit(currentMessageId, truncate(currentText, params.maxLen)); - } catch (error) { - if (isNoopEditError(error)) { - return; - } - const res = await params.send(truncate(currentText, params.maxLen)); - currentMessageId = res.id; - } - } finally { - flushing = false; + async function doFlush(): Promise { + if (flushInFlight) { + await flushInFlight; + return; } + + flushInFlight = runFlush().finally(() => { + flushInFlight = null; + }); + await flushInFlight; + } + + function clearScheduledFlush(): void { + if (!flushTimer) return; + clearTimeout(flushTimer); + flushTimer = null; } function scheduleFlush(): void { @@ -61,43 +73,51 @@ export function createBufferedSink(params: { async function sendText(delta: string): Promise { if (!delta) return; - if (currentText.length + delta.length > params.maxLen * 1.5) { - await doFlush(); - currentText = ''; - currentMessageId = null; + let remain = delta; + + while (remain.length > 0) { + const capacity = params.maxLen - currentText.length; + if (capacity <= 0) { + clearScheduledFlush(); + await doFlush(); + currentText = ''; + currentMessageId = null; + continue; + } + + const next = remain.slice(0, capacity); + currentText += next; + remain = remain.slice(next.length); + + if (currentText.length >= params.maxLen) { + clearScheduledFlush(); + await doFlush(); + currentText = ''; + currentMessageId = null; + } } - currentText += delta; - scheduleFlush(); + if (currentText) { + scheduleFlush(); + } } return { sendText, breakMessage: async () => { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } + clearScheduledFlush(); await doFlush(); currentText = ''; currentMessageId = null; }, flush: async () => { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } + clearScheduledFlush(); await doFlush(); }, getState: () => ({ text: currentText, messageId: currentMessageId }), }; } -function truncate(text: string, maxLen: number): string { - if (text.length <= maxLen) return text; - return text.slice(0, maxLen - 3) + '...'; -} - function isNoopEditError(error: unknown): boolean { const message = typeof error === 'string' diff --git a/src/channels/discord.ts b/src/channels/discord.ts index df15ef0..9220f6b 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -195,9 +195,13 @@ export async function startDiscord( }; const channel = message.channel as TextBasedChannel; + const globalContextText = extractDiscordChannelDescription(channel) ?? undefined; const sink = createDiscordSink(channel, message.author.id); - await router.handleUserMessage(key, text, sink, { resources }); + await router.handleUserMessage(key, text, sink, { + resources, + globalContextText, + }); await finalizeDiscordInboundReaction(message, '馃晩'); } catch (error) { log.error('Discord message handler error', error); @@ -245,6 +249,33 @@ function extractDiscordImageResources( return out; } +export function extractDiscordChannelDescription(channel: unknown): string | null { + if (!channel || typeof channel !== 'object') return null; + + const topic = normalizeDiscordChannelText( + (channel as { topic?: unknown }).topic, + ); + if (topic) return topic; + + const description = normalizeDiscordChannelText( + (channel as { description?: unknown }).description, + ); + if (description) return description; + + const parent = (channel as { + parent?: { topic?: unknown; description?: unknown } | null; + }).parent; + const parentTopic = normalizeDiscordChannelText(parent?.topic); + if (parentTopic) return parentTopic; + + return normalizeDiscordChannelText(parent?.description); +} + +function normalizeDiscordChannelText(value: unknown): string | null { + const text = String(value ?? '').trim(); + return text ? text : null; +} + async function syncDiscordSlashCommands( client: Client, slashCommands: ReturnType, @@ -371,6 +402,8 @@ export function createDiscordInteractionSink( requestId: string; toolTitle: string; toolKind: string | null; + toolName?: string; + toolArgs?: unknown; }): Promise => { const allowId = `acpperm:${req.sessionKey}:${req.requestId}:allow`; const denyId = `acpperm:${req.sessionKey}:${req.requestId}:deny`; @@ -387,19 +420,11 @@ export function createDiscordInteractionSink( ); const embed = new EmbedBuilder() - .setTitle('Permission required') .setColor(0xffcc00) .addFields( - { name: 'Tool', value: truncate(req.toolTitle, 256) }, - { name: 'Kind', value: req.toolKind ?? 'unknown' }, - ); - - if (req.uiMode === 'verbose') { - embed.addFields( - { name: 'Session', value: truncate(req.sessionKey, 512) }, - { name: 'Request', value: truncate(req.requestId, 256) }, + { name: 'Tool', value: truncate(resolvePermissionToolName(req), 256) }, + ...buildPermissionDetailFields(req), ); - } if (!hasResponded) { hasResponded = true; @@ -464,6 +489,15 @@ export function createDiscordInteractionSink( } }, sendUi: async (event) => { + if (event.kind === 'tool') { + const body = + event.mode === 'verbose' && event.detail + ? `[tool] ${event.title}\n\n${event.detail}` + : `[tool] ${event.title}`; + await sendChunk(formatTextCodeBlock(body, 1900)); + return; + } + const head = `[${event.kind}] ${event.title}`; const body = event.mode === 'verbose' && event.detail ? `\n\n${event.detail}` : ''; @@ -494,6 +528,503 @@ function truncate(text: string, maxLen: number): string { return text.slice(0, maxLen - 3) + '...'; } +function resolvePermissionToolName(req: { + toolTitle: string; + toolKind: string | null; + toolName?: string; +}): string { + const preferred = typeof req.toolName === 'string' ? req.toolName.trim() : ''; + if (preferred) return preferred; + + const title = req.toolTitle.trim(); + if (title) return title; + + return req.toolKind ?? 'unknown'; +} + +function buildPermissionDetailFields(req: { + toolKind: string | null; + toolTitle: string; + toolArgs?: unknown; +}): Array<{ name: string; value: string }> { + const kind = String(req.toolKind ?? '').trim().toLowerCase(); + const reason = extractFirstString(req.toolArgs, [ + 'reason', + 'why', + 'rationale', + 'justification', + 'description', + 'input.reason', + 'input.why', + 'input.rationale', + 'input.justification', + 'arguments.reason', + 'arguments.why', + 'arguments.rationale', + 'arguments.justification', + 'params.reason', + 'params.why', + 'params.rationale', + 'params.justification', + ]); + + if (kind === 'execute') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Command', + value: formatPermissionCodeBlock( + extractCommand(req.toolArgs, req.toolTitle) ?? '(not provided)', + 'bash', + ), + }, + ]; + } + + if (kind === 'read' || kind === 'edit' || kind === 'delete') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Path', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'path', + 'file', + 'filepath', + 'target', + 'input.path', + 'arguments.path', + 'params.path', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'move') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'From', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'from', + 'source', + 'src', + 'input.from', + 'arguments.from', + 'params.from', + ]) ?? '(not provided)', + 'text', + ), + }, + { + name: 'To', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'to', + 'destination', + 'dest', + 'input.to', + 'arguments.to', + 'params.to', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'search') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Query', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'query', + 'pattern', + 'text', + 'input.query', + 'arguments.query', + 'params.query', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'fetch') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'URL', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'url', + 'uri', + 'target', + 'input.url', + 'arguments.url', + 'params.url', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'switch_mode') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Mode', + value: formatPermissionText( + extractFirstString(req.toolArgs, [ + 'mode', + 'input.mode', + 'arguments.mode', + 'params.mode', + ]) ?? '(not provided)', + ), + }, + ]; + } + + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Arguments', + value: formatPermissionCodeBlock(stringifyPermissionArgs(req.toolArgs), 'json'), + }, + ]; +} + +function extractCommand(args: unknown, title: string): string | null { + const command = extractFirstString(args, [ + 'command', + 'cmd', + 'commandLine', + 'cmdline', + 'input.command', + 'input.cmd', + 'input.commandLine', + 'input.cmdline', + 'arguments.command', + 'arguments.cmd', + 'arguments.commandLine', + 'arguments.cmdline', + 'params.command', + 'params.cmd', + 'params.commandLine', + 'params.cmdline', + ]); + + const argList = extractStringArray(args, [ + 'args', + 'argv', + 'input.args', + 'input.argv', + 'arguments.args', + 'arguments.argv', + 'params.args', + 'params.argv', + ]); + + if (!command) { + if (argList.length > 0) return argList.join(' '); + const fromTitle = title.match(/^(?:run|execute):\s+(.+)$/i)?.[1]; + return fromTitle ? fromTitle.trim() : null; + } + + return argList.length > 0 ? `${command} ${argList.join(' ')}` : command; +} + +function extractFirstString(root: unknown, paths: string[]): string | null { + for (const pathExpr of paths) { + const value = getPathValue(root, pathExpr); + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + + const fallbackKeys = collectPermissionKeyAliases(paths); + return extractDeepStringByKeys(root, fallbackKeys); +} + +function extractStringArray(root: unknown, paths: string[]): string[] { + for (const pathExpr of paths) { + const value = getPathValue(root, pathExpr); + if (!Array.isArray(value)) continue; + const out = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + if (out.length > 0) return out; + } + + const fallbackKeys = collectPermissionKeyAliases(paths); + return extractDeepStringArrayByKeys(root, fallbackKeys); +} + +function getPathValue(root: unknown, pathExpr: string): unknown { + const segments = pathExpr.split('.'); + let current: unknown = parseJsonContainer(root); + for (const segment of segments) { + current = parseJsonContainer(current); + if (!current || typeof current !== 'object') return undefined; + current = parseJsonContainer((current as Record)[segment]); + } + return current; +} + +function parseJsonContainer(value: unknown): unknown { + let current: unknown = value; + for (let depth = 0; depth < 2; depth += 1) { + if (typeof current !== 'string') return current; + const trimmed = current.trim(); + if (!trimmed || !looksLikeJsonValue(trimmed)) return current; + try { + current = JSON.parse(trimmed); + } catch { + return current; + } + } + + return current; +} + +function collectPermissionKeyAliases(paths: string[]): string[] { + const aliases = new Set(); + for (const pathExpr of paths) { + const pieces = pathExpr + .split('.') + .map((piece) => piece.trim()) + .filter(Boolean); + const last = pieces.at(-1); + if (last) aliases.add(last); + } + return Array.from(aliases); +} + +function extractDeepStringByKeys(root: unknown, keys: string[]): string | null { + const wanted = new Set( + keys + .map((item) => normalizePermissionKey(item)) + .filter(Boolean), + ); + if (wanted.size === 0) return null; + + for (const node of iteratePermissionNodes(root)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) continue; + const record = node as Record; + + for (const [rawKey, rawValue] of Object.entries(record)) { + const normalizedKey = normalizePermissionKey(rawKey); + const value = parseJsonContainer(rawValue); + if (wanted.has(normalizedKey)) { + const direct = coercePermissionString(value); + if (direct) return direct; + + const joined = joinPermissionStringArray(value); + if (joined) return joined; + } + + if ( + (normalizedKey === 'name' || + normalizedKey === 'key' || + normalizedKey === 'field') && + typeof value === 'string' + ) { + const namedKey = normalizePermissionKey(value); + if (!wanted.has(namedKey)) continue; + + const pairValue = parseJsonContainer( + record.value ?? + record.val ?? + record.argument ?? + record.arg ?? + record.content, + ); + const pairString = + coercePermissionString(pairValue) ?? + joinPermissionStringArray(pairValue); + if (pairString) return pairString; + } + } + } + + return null; +} + +function extractDeepStringArrayByKeys(root: unknown, keys: string[]): string[] { + const wanted = new Set( + keys + .map((item) => normalizePermissionKey(item)) + .filter(Boolean), + ); + if (wanted.size === 0) return []; + + for (const node of iteratePermissionNodes(root)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) continue; + const record = node as Record; + + for (const [rawKey, rawValue] of Object.entries(record)) { + const normalizedKey = normalizePermissionKey(rawKey); + const value = parseJsonContainer(rawValue); + if (wanted.has(normalizedKey)) { + const direct = coercePermissionStringArray(value); + if (direct.length > 0) return direct; + + const single = coercePermissionString(value); + if (single) return [single]; + } + + if ( + (normalizedKey === 'name' || + normalizedKey === 'key' || + normalizedKey === 'field') && + typeof value === 'string' + ) { + const namedKey = normalizePermissionKey(value); + if (!wanted.has(namedKey)) continue; + + const pairValue = parseJsonContainer( + record.value ?? + record.val ?? + record.argument ?? + record.arg ?? + record.content, + ); + + const pairArray = coercePermissionStringArray(pairValue); + if (pairArray.length > 0) return pairArray; + + const pairSingle = coercePermissionString(pairValue); + if (pairSingle) return [pairSingle]; + } + } + } + + return []; +} + +function* iteratePermissionNodes(root: unknown): Generator { + const queue: unknown[] = [parseJsonContainer(root)]; + const seen = new Set(); + let budget = 600; + + while (queue.length > 0 && budget > 0) { + budget -= 1; + const current = parseJsonContainer(queue.shift()); + if (!current || typeof current !== 'object') continue; + + const identity = current as object; + if (seen.has(identity)) continue; + seen.add(identity); + yield current; + + if (Array.isArray(current)) { + for (const item of current) { + queue.push(item); + } + continue; + } + + for (const value of Object.values(current as Record)) { + const parsed = parseJsonContainer(value); + if (parsed && typeof parsed === 'object') { + queue.push(parsed); + } + } + } +} + +function coercePermissionString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function coercePermissionStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); +} + +function joinPermissionStringArray(value: unknown): string | null { + const values = coercePermissionStringArray(value); + if (values.length === 0) return null; + return values.join(' '); +} + +function looksLikeJsonValue(value: string): boolean { + return ( + (value.startsWith('{') && value.endsWith('}')) || + (value.startsWith('[') && value.endsWith(']')) || + (value.startsWith('"') && value.endsWith('"')) + ); +} + +function normalizePermissionKey(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function stringifyPermissionArgs(value: unknown): string { + if (value === null || value === undefined) return '(none)'; + if (typeof value === 'string') return value.trim() || '(none)'; + + try { + const compact = JSON.stringify(value); + if (!compact) return '(none)'; + return truncate(compact, 960); + } catch { + return truncate(String(value), 960); + } +} + +function formatPermissionText(value: string): string { + return truncate(value.trim() || '(none)', 1000); +} + +function formatPermissionCodeBlock(value: string, language: string): string { + const safe = (value.trim() || '(none)').replace(/```/g, '``\u200b`'); + return `\`\`\`${language}\n${truncate(safe, 960)}\n\`\`\``; +} + +function formatTextCodeBlock(text: string, maxLen: number): string { + const open = '```text\n'; + const close = '\n```'; + const safe = text.replace(/```/g, '``\u200b`').trimEnd(); + const body = truncate(safe, maxLen - open.length - close.length); + return `${open}${body}${close}`; +} + type PermissionDecision = 'allow' | 'deny'; type PermissionRoute = { @@ -563,19 +1094,21 @@ async function setDiscordInboundReaction( message: unknown, emoji: string, ): Promise { + const discordMessage = message as { + react?: (nextEmoji: string) => Promise; + }; if ( !message || typeof message !== 'object' || - typeof (message as { react?: unknown }).react !== 'function' + typeof discordMessage.react !== 'function' ) { return; } - const react = (message as { react: (nextEmoji: string) => Promise }).react; try { - await react(emoji); + await discordMessage.react(emoji); } catch { - // best effort + // best-effort only } } @@ -612,28 +1145,30 @@ async function clearDiscordInboundReaction( try { await remove(clientUserId); } catch { - // best effort + // best-effort only } } async function addDiscordPermissionReactions(message: unknown): Promise { + const discordMessage = message as { + react?: (nextEmoji: string) => Promise; + }; if ( !message || typeof message !== 'object' || - typeof (message as { react?: unknown }).react !== 'function' + typeof discordMessage.react !== 'function' ) { return; } - const react = (message as { react: (emoji: string) => Promise }).react; try { - await react('馃憤'); + await discordMessage.react('馃憤'); } catch { - // best-effort shortcut; buttons still available + // best-effort only } try { - await react('馃憥'); + await discordMessage.react('馃憥'); } catch { - // best-effort shortcut; buttons still available + // best-effort only } } diff --git a/src/channels/discordCommands.ts b/src/channels/discordCommands.ts index c977a37..e19a670 100644 --- a/src/channels/discordCommands.ts +++ b/src/channels/discordCommands.ts @@ -2,6 +2,7 @@ import { SlashCommandBuilder, type RESTPostAPIChatInputApplicationCommandsJSONBody, } from 'discord.js'; +import { TOOL_KINDS } from '../gateway/toolAuth.js'; export type DiscordSlashInteractionLike = { commandName: string; @@ -88,6 +89,56 @@ export function buildDiscordSlashCommands(): RESTPostAPIChatInputApplicationComm .setMinValue(1), ), + new SlashCommandBuilder() + .setName('whitelist') + .setDescription('Manage permission whitelist for this conversation') + .addSubcommand((sub) => + sub.setName('list').setDescription('List whitelisted tool kinds'), + ) + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a whitelisted tool kind') + .addStringOption((opt) => + TOOL_KINDS.reduce( + (builder, kind) => builder.addChoices({ name: kind, value: kind }), + opt + .setName('tool_kind') + .setDescription('Tool kind to whitelist') + .setRequired(true), + ), + ) + .addStringOption((opt) => + opt + .setName('prefix') + .setDescription('Optional path/argument prefix') + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('del') + .setDescription('Remove a tool kind from whitelist') + .addStringOption((opt) => + TOOL_KINDS.reduce( + (builder, kind) => builder.addChoices({ name: kind, value: kind }), + opt + .setName('tool_kind') + .setDescription('Tool kind to remove') + .setRequired(true), + ), + ) + .addStringOption((opt) => + opt + .setName('prefix') + .setDescription('Optional path/argument prefix') + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub.setName('clear').setDescription('Clear whitelist entries'), + ), + new SlashCommandBuilder() .setName('cron') .setDescription('Manage scheduler jobs') @@ -185,6 +236,19 @@ export function mapDiscordSlashToRouterCommand( const idx = interaction.options.getInteger('index', true); return idx ? `/allow ${idx}` : '/allow'; } + case 'whitelist': { + const sub = interaction.options.getSubcommand(true) ?? 'list'; + if (sub === 'add' || sub === 'del') { + const toolKind = interaction.options.getString('tool_kind', true); + const prefix = interaction.options.getString('prefix'); + if (!toolKind) return '/whitelist list'; + const trimmedPrefix = prefix?.trim(); + return trimmedPrefix + ? `/whitelist ${sub} ${toolKind} ${trimmedPrefix}` + : `/whitelist ${sub} ${toolKind}`; + } + return `/whitelist ${sub}`; + } case 'cron': { const sub = interaction.options.getSubcommand(true) ?? 'help'; if (sub === 'add') { diff --git a/src/channels/discordSink.ts b/src/channels/discordSink.ts index c262a94..2b282d8 100644 --- a/src/channels/discordSink.ts +++ b/src/channels/discordSink.ts @@ -51,20 +51,12 @@ export function createDiscordSink( ); const embed = new EmbedBuilder() - .setTitle('Permission required') .setColor(0xffcc00) .addFields( - { name: 'Tool', value: truncate(req.toolTitle, 256) }, - { name: 'Kind', value: req.toolKind ?? 'unknown' }, + { name: 'Tool', value: truncate(resolvePermissionToolName(req), 256) }, + ...buildPermissionDetailFields(req), ); - if (req.uiMode === 'verbose') { - embed.addFields( - { name: 'Session', value: truncate(req.sessionKey, 512) }, - { name: 'Request', value: truncate(req.requestId, 256) }, - ); - } - const msg = await sendChannel.send({ content: `<@${userId}> Please approve this tool call.`, embeds: [embed], @@ -74,8 +66,34 @@ export function createDiscordSink( await addDiscordPermissionReactions(msg); }, sendUi: async (event) => { + if (event.kind === 'tool') { + const text = formatToolUiPlainText(event); + + if (event.toolCallId?.trim()) { + const key = event.toolCallId.trim(); + const existingId = toolUiMessageById.get(key); + if (existingId) { + try { + const existing = await sendChannel.messages.fetch(existingId); + await existing.edit(text); + return; + } catch { + // fall through and resend + } + } + + const msg = await sendChannel.send(text); + toolUiMessageById.set(key, msg.id); + return; + } + + await sendChannel.send(text); + return; + } + + const title = truncate(`[${event.kind}] ${event.title}`, 256); const embed = new EmbedBuilder() - .setTitle(`[${event.kind}] ${event.title}`) + .setTitle(title) .setColor(colorForKind(event.kind)); if (event.detail && event.mode === 'verbose') { @@ -84,48 +102,32 @@ export function createDiscordSink( ); } - if (event.kind === 'tool' && event.toolCallId?.trim()) { - const key = event.toolCallId.trim(); - const existingId = toolUiMessageById.get(key); - if (existingId) { - try { - const existing = await sendChannel.messages.fetch(existingId); - await existing.edit({ embeds: [embed] }); - return; - } catch { - // fall through and resend - } - } - - const msg = await sendChannel.send({ embeds: [embed] }); - toolUiMessageById.set(key, msg.id); - return; - } - await sendChannel.send({ embeds: [embed] }); }, }; } async function addDiscordPermissionReactions(message: unknown): Promise { + const discordMessage = message as { + react?: (nextEmoji: string) => Promise; + }; if ( !message || typeof message !== 'object' || - typeof (message as { react?: unknown }).react !== 'function' + typeof discordMessage.react !== 'function' ) { return; } - const react = (message as { react: (emoji: string) => Promise }).react; try { - await react('馃憤'); + await discordMessage.react('馃憤'); } catch { - // best effort + // best-effort only } try { - await react('馃憥'); + await discordMessage.react('馃憥'); } catch { - // best effort + // best-effort only } } @@ -134,6 +136,515 @@ function truncate(text: string, maxLen: number): string { return text.slice(0, maxLen - 3) + '...'; } +function resolvePermissionToolName(req: { + toolTitle: string; + toolKind: string | null; + toolName?: string; +}): string { + const preferred = typeof req.toolName === 'string' ? req.toolName.trim() : ''; + if (preferred) return preferred; + + const title = req.toolTitle.trim(); + if (title) return title; + + return req.toolKind ?? 'unknown'; +} + +function buildPermissionDetailFields(req: { + toolKind: string | null; + toolTitle: string; + toolArgs?: unknown; +}): Array<{ name: string; value: string }> { + const kind = String(req.toolKind ?? '').trim().toLowerCase(); + const reason = extractFirstString(req.toolArgs, [ + 'reason', + 'why', + 'rationale', + 'justification', + 'description', + 'input.reason', + 'input.why', + 'input.rationale', + 'input.justification', + 'arguments.reason', + 'arguments.why', + 'arguments.rationale', + 'arguments.justification', + 'params.reason', + 'params.why', + 'params.rationale', + 'params.justification', + ]); + + if (kind === 'execute') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Command', + value: formatPermissionCodeBlock( + extractCommand(req.toolArgs, req.toolTitle) ?? '(not provided)', + 'bash', + ), + }, + ]; + } + + if (kind === 'read' || kind === 'edit' || kind === 'delete') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Path', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'path', + 'file', + 'filepath', + 'target', + 'input.path', + 'arguments.path', + 'params.path', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'move') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'From', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'from', + 'source', + 'src', + 'input.from', + 'arguments.from', + 'params.from', + ]) ?? '(not provided)', + 'text', + ), + }, + { + name: 'To', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'to', + 'destination', + 'dest', + 'input.to', + 'arguments.to', + 'params.to', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'search') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Query', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'query', + 'pattern', + 'text', + 'input.query', + 'arguments.query', + 'params.query', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'fetch') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'URL', + value: formatPermissionCodeBlock( + extractFirstString(req.toolArgs, [ + 'url', + 'uri', + 'target', + 'input.url', + 'arguments.url', + 'params.url', + ]) ?? '(not provided)', + 'text', + ), + }, + ]; + } + + if (kind === 'switch_mode') { + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Mode', + value: formatPermissionText( + extractFirstString(req.toolArgs, [ + 'mode', + 'input.mode', + 'arguments.mode', + 'params.mode', + ]) ?? '(not provided)', + ), + }, + ]; + } + + return [ + { + name: 'Reason', + value: formatPermissionText(reason ?? '(not provided)'), + }, + { + name: 'Arguments', + value: formatPermissionCodeBlock(stringifyPermissionArgs(req.toolArgs), 'json'), + }, + ]; +} + +function extractCommand(args: unknown, title: string): string | null { + const command = extractFirstString(args, [ + 'command', + 'cmd', + 'commandLine', + 'cmdline', + 'input.command', + 'input.cmd', + 'input.commandLine', + 'input.cmdline', + 'arguments.command', + 'arguments.cmd', + 'arguments.commandLine', + 'arguments.cmdline', + 'params.command', + 'params.cmd', + 'params.commandLine', + 'params.cmdline', + ]); + + const argList = extractStringArray(args, [ + 'args', + 'argv', + 'input.args', + 'input.argv', + 'arguments.args', + 'arguments.argv', + 'params.args', + 'params.argv', + ]); + + if (!command) { + if (argList.length > 0) return argList.join(' '); + const fromTitle = title.match(/^(?:run|execute):\s+(.+)$/i)?.[1]; + return fromTitle ? fromTitle.trim() : null; + } + + return argList.length > 0 ? `${command} ${argList.join(' ')}` : command; +} + +function extractFirstString(root: unknown, paths: string[]): string | null { + for (const pathExpr of paths) { + const value = getPathValue(root, pathExpr); + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + + const fallbackKeys = collectPermissionKeyAliases(paths); + return extractDeepStringByKeys(root, fallbackKeys); +} + +function extractStringArray(root: unknown, paths: string[]): string[] { + for (const pathExpr of paths) { + const value = getPathValue(root, pathExpr); + if (!Array.isArray(value)) continue; + const out = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + if (out.length > 0) return out; + } + + const fallbackKeys = collectPermissionKeyAliases(paths); + return extractDeepStringArrayByKeys(root, fallbackKeys); +} + +function getPathValue(root: unknown, pathExpr: string): unknown { + const segments = pathExpr.split('.'); + let current: unknown = parseJsonContainer(root); + for (const segment of segments) { + current = parseJsonContainer(current); + if (!current || typeof current !== 'object') return undefined; + current = parseJsonContainer((current as Record)[segment]); + } + return current; +} + +function parseJsonContainer(value: unknown): unknown { + let current: unknown = value; + for (let depth = 0; depth < 2; depth += 1) { + if (typeof current !== 'string') return current; + const trimmed = current.trim(); + if (!trimmed || !looksLikeJsonValue(trimmed)) return current; + try { + current = JSON.parse(trimmed); + } catch { + return current; + } + } + + return current; +} + +function collectPermissionKeyAliases(paths: string[]): string[] { + const aliases = new Set(); + for (const pathExpr of paths) { + const pieces = pathExpr + .split('.') + .map((piece) => piece.trim()) + .filter(Boolean); + const last = pieces.at(-1); + if (last) aliases.add(last); + } + return Array.from(aliases); +} + +function extractDeepStringByKeys(root: unknown, keys: string[]): string | null { + const wanted = new Set( + keys + .map((item) => normalizePermissionKey(item)) + .filter(Boolean), + ); + if (wanted.size === 0) return null; + + for (const node of iteratePermissionNodes(root)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) continue; + const record = node as Record; + + for (const [rawKey, rawValue] of Object.entries(record)) { + const normalizedKey = normalizePermissionKey(rawKey); + const value = parseJsonContainer(rawValue); + if (wanted.has(normalizedKey)) { + const direct = coercePermissionString(value); + if (direct) return direct; + + const joined = joinPermissionStringArray(value); + if (joined) return joined; + } + + if ( + (normalizedKey === 'name' || + normalizedKey === 'key' || + normalizedKey === 'field') && + typeof value === 'string' + ) { + const namedKey = normalizePermissionKey(value); + if (!wanted.has(namedKey)) continue; + + const pairValue = parseJsonContainer( + record.value ?? + record.val ?? + record.argument ?? + record.arg ?? + record.content, + ); + const pairString = + coercePermissionString(pairValue) ?? + joinPermissionStringArray(pairValue); + if (pairString) return pairString; + } + } + } + + return null; +} + +function extractDeepStringArrayByKeys(root: unknown, keys: string[]): string[] { + const wanted = new Set( + keys + .map((item) => normalizePermissionKey(item)) + .filter(Boolean), + ); + if (wanted.size === 0) return []; + + for (const node of iteratePermissionNodes(root)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) continue; + const record = node as Record; + + for (const [rawKey, rawValue] of Object.entries(record)) { + const normalizedKey = normalizePermissionKey(rawKey); + const value = parseJsonContainer(rawValue); + if (wanted.has(normalizedKey)) { + const direct = coercePermissionStringArray(value); + if (direct.length > 0) return direct; + + const single = coercePermissionString(value); + if (single) return [single]; + } + + if ( + (normalizedKey === 'name' || + normalizedKey === 'key' || + normalizedKey === 'field') && + typeof value === 'string' + ) { + const namedKey = normalizePermissionKey(value); + if (!wanted.has(namedKey)) continue; + + const pairValue = parseJsonContainer( + record.value ?? + record.val ?? + record.argument ?? + record.arg ?? + record.content, + ); + + const pairArray = coercePermissionStringArray(pairValue); + if (pairArray.length > 0) return pairArray; + + const pairSingle = coercePermissionString(pairValue); + if (pairSingle) return [pairSingle]; + } + } + } + + return []; +} + +function* iteratePermissionNodes(root: unknown): Generator { + const queue: unknown[] = [parseJsonContainer(root)]; + const seen = new Set(); + let budget = 600; + + while (queue.length > 0 && budget > 0) { + budget -= 1; + const current = parseJsonContainer(queue.shift()); + if (!current || typeof current !== 'object') continue; + + const identity = current as object; + if (seen.has(identity)) continue; + seen.add(identity); + yield current; + + if (Array.isArray(current)) { + for (const item of current) { + queue.push(item); + } + continue; + } + + for (const value of Object.values(current as Record)) { + const parsed = parseJsonContainer(value); + if (parsed && typeof parsed === 'object') { + queue.push(parsed); + } + } + } +} + +function coercePermissionString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function coercePermissionStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); +} + +function joinPermissionStringArray(value: unknown): string | null { + const values = coercePermissionStringArray(value); + if (values.length === 0) return null; + return values.join(' '); +} + +function looksLikeJsonValue(value: string): boolean { + return ( + (value.startsWith('{') && value.endsWith('}')) || + (value.startsWith('[') && value.endsWith(']')) || + (value.startsWith('"') && value.endsWith('"')) + ); +} + +function normalizePermissionKey(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function stringifyPermissionArgs(value: unknown): string { + if (value === null || value === undefined) return '(none)'; + if (typeof value === 'string') return value.trim() || '(none)'; + + try { + const compact = JSON.stringify(value); + if (!compact) return '(none)'; + return truncate(compact, 960); + } catch { + return truncate(String(value), 960); + } +} + +function formatPermissionText(value: string): string { + return truncate(value.trim() || '(none)', 1000); +} + +function formatPermissionCodeBlock(value: string, language: string): string { + const safe = (value.trim() || '(none)').replace(/```/g, '``\u200b`'); + return `\`\`\`${language}\n${truncate(safe, 960)}\n\`\`\``; +} + +function formatToolUiPlainText(event: { + title: string; + detail?: string; + mode: 'verbose' | 'summary'; +}): string { + const body = + event.mode === 'verbose' && event.detail + ? `[tool] ${event.title}\n\n${event.detail}` + : `[tool] ${event.title}`; + return formatTextCodeBlock(body, 1900); +} + +function formatTextCodeBlock(text: string, maxLen: number): string { + const open = '```text\n'; + const close = '\n```'; + const safe = text.replace(/```/g, '``\u200b`').trimEnd(); + const body = truncate(safe, maxLen - open.length - close.length); + return `${open}${body}${close}`; +} + function colorForKind(kind: string): number { switch (kind) { case 'tool': diff --git a/src/channels/telegramSink.ts b/src/channels/telegramSink.ts index 58b8286..ce771ca 100644 --- a/src/channels/telegramSink.ts +++ b/src/channels/telegramSink.ts @@ -221,13 +221,13 @@ async function upsertToolUiMessage(params: { } function formatToolUiText(event: ToolUiEvent): string { - const safeTitle = truncate(event.title, 500); - const header = `[tool] ${escapeHtml(safeTitle)}`; - if (event.detail && event.mode === 'verbose') { - const code = escapeHtml(truncate(event.detail, 3200)); - return `${header}\n\n
${code}
`; - } - return header; + const header = `[tool] ${truncate(event.title, 500)}`; + const content = + event.detail && event.mode === 'verbose' + ? `${header}\n\n${event.detail}` + : header; + const code = escapeHtml(truncate(content, 3300)); + return `
${code}
`; } function truncate(text: string, maxLen: number): string { diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 6b91b63..7e6580a 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -1,6 +1,6 @@ import type { Db } from './db.js'; -const LATEST_VERSION = 4; +const LATEST_VERSION = 5; export function migrate(db: Db): void { db.exec( @@ -142,4 +142,25 @@ export function migrate(db: Db): void { `, ); } + + if (current < 5) { + db.exec( + ` + CREATE TABLE IF NOT EXISTS tool_allow_prefixes ( + binding_key TEXT NOT NULL, + tool_kind TEXT NOT NULL, + arg_prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(binding_key, tool_kind, arg_prefix), + FOREIGN KEY(binding_key) REFERENCES bindings(binding_key) + ); + + CREATE INDEX IF NOT EXISTS idx_tool_allow_prefixes_binding_kind + ON tool_allow_prefixes(binding_key, tool_kind); + + UPDATE schema_version SET version = 5; + `, + ); + } } diff --git a/src/gateway/bindingRuntime.ts b/src/gateway/bindingRuntime.ts index b60469e..9df84a0 100644 --- a/src/gateway/bindingRuntime.ts +++ b/src/gateway/bindingRuntime.ts @@ -9,7 +9,7 @@ import { updateAcpSessionId, updateLoadSupported, } from './sessionStore.js'; -import { ToolAuth, type ToolKind } from './toolAuth.js'; +import { ToolAuth, parseToolKind, type ToolKind } from './toolAuth.js'; export class BindingRuntime { private readonly db: Db; @@ -123,9 +123,11 @@ export class BindingRuntime { }); } else { await sink.sendText( - this.currentUiMode === 'verbose' - ? `\n[tool]\n${ui.title}\n${detail ?? ''}\n` - : `\n[tool] ${ui.title}`, + formatTextCodeBlock( + this.currentUiMode === 'verbose' + ? `[tool]\n${ui.title}\n${detail ?? ''}` + : `[tool] ${ui.title}`, + ), ); } } @@ -181,11 +183,19 @@ export class BindingRuntime { const toolKind = toToolKind(req.params.toolCall?.kind); if (toolKind) { - const policy = this.toolAuth.getPersistentPolicy( + const policy = this.toolAuth.evaluatePersistentPolicy( this.bindingKey, toolKind, + { + toolCall: req.params.toolCall, + workspaceRoot: this.workspaceRoot, + }, ); - if (policy === 'allow') { + const hasScopedAllowRules = + this.toolAuth.listAllowPrefixRules(this.bindingKey, toolKind) + .length > 0; + + if (policy === 'allow' || (policy !== 'reject' && hasScopedAllowRules)) { const option = req.params.options.find( (o) => o.kind === 'allow_always' || o.kind === 'allow_once', ); @@ -200,7 +210,11 @@ export class BindingRuntime { this.enqueueSinkWrite(async () => { const sink = this.activeSink; if (!sink) return; - await sink.sendText(`[permission] auto-allowed (${toolKind})`); + await sink.sendText( + formatTextCodeBlock( + `[permission] auto-allowed (${toolKind})`, + ), + ); }); return; } @@ -219,7 +233,11 @@ export class BindingRuntime { this.enqueueSinkWrite(async () => { const sink = this.activeSink; if (!sink) return; - await sink.sendText(`[permission] auto-rejected (${toolKind})`); + await sink.sendText( + formatTextCodeBlock( + `[permission] auto-rejected (${toolKind})`, + ), + ); }); return; } @@ -230,6 +248,12 @@ export class BindingRuntime { req.params.toolCall?.title ?? req.params.toolCall?.toolCallId ?? 'tool_call'; + const toolName = resolvePermissionToolName( + req.params.toolCall, + toolKind, + title, + ); + const toolArgs = resolvePermissionToolArgs(req.params.toolCall); this.enqueueSinkWrite(async () => { const sink = this.activeSink; @@ -242,6 +266,8 @@ export class BindingRuntime { requestId: String(req.requestId), toolTitle: title, toolKind: toolKind ?? null, + toolName, + toolArgs, }); return; } @@ -590,22 +616,7 @@ export class BindingRuntime { } function toToolKind(kind: unknown): ToolKind | null { - if (typeof kind !== 'string') return null; - - const allowed: ToolKind[] = [ - 'read', - 'edit', - 'delete', - 'move', - 'search', - 'execute', - 'think', - 'fetch', - 'switch_mode', - 'other', - ]; - - return allowed.includes(kind as ToolKind) ? (kind as ToolKind) : null; + return parseToolKind(kind); } function formatPermissionRequest(req: PermissionRequest): string { @@ -613,7 +624,19 @@ function formatPermissionRequest(req: PermissionRequest): string { .map((o, i) => `${i + 1}. ${o.name} (${o.kind})`) .join('\n'); - return `\n[permission required]\nTool: ${req.params.toolCall?.title ?? req.params.toolCall?.toolCallId ?? 'tool_call'}\n${options}\nReply with /allow or /deny`; + return formatTextCodeBlock( + [ + '[permission required]', + `Tool: ${req.params.toolCall?.title ?? req.params.toolCall?.toolCallId ?? 'tool_call'}`, + options, + 'Reply with /allow or /deny', + ].join('\n'), + ); +} + +function formatTextCodeBlock(text: string): string { + const safe = text.trim().replace(/```/g, '``\u200b`'); + return `\n\`\`\`text\n${safe}\n\`\`\`\n`; } function renderJson(value: unknown, maxChars: number): string { @@ -1024,3 +1047,99 @@ function getPathValue(root: unknown, pathExpr: string): unknown { } return current; } + +function resolvePermissionToolName( + toolCall: unknown, + fallbackKind: ToolKind | null, + fallbackTitle: string, +): string { + const record = asRecord(toolCall); + const candidates: unknown[] = [ + record?.name, + record?.method, + record?.tool, + record?.kind, + fallbackKind, + fallbackTitle, + ]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (trimmed) return trimmed; + } + return 'tool_call'; +} + +function resolvePermissionToolArgs(toolCall: unknown): unknown { + const record = asRecord(toolCall); + if (!record) return null; + + const direct = parsePermissionJson( + record.arguments ?? record.input ?? record.params, + ); + const directRecord = asRecord(direct); + + const remainder: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (value === undefined) continue; + if (PERMISSION_TOOL_META_FIELDS.has(key)) continue; + if (PERMISSION_TOOL_ARG_CONTAINER_FIELDS.has(key)) continue; + remainder[key] = parsePermissionJson(value); + } + + if (directRecord) { + if (Object.keys(remainder).length === 0) return directRecord; + return { ...remainder, ...directRecord }; + } + if (direct !== undefined && direct !== null) return direct; + return Object.keys(remainder).length > 0 ? remainder : null; +} + +function parsePermissionJson(value: unknown): unknown { + let current: unknown = value; + for (let depth = 0; depth < 2; depth += 1) { + if (typeof current !== 'string') return current; + + const trimmed = current.trim(); + if (!trimmed || !looksLikeJsonValue(trimmed)) return current; + + try { + current = JSON.parse(trimmed); + } catch { + return current; + } + } + + return current; +} + +function looksLikeJsonValue(value: string): boolean { + return ( + (value.startsWith('{') && value.endsWith('}')) || + (value.startsWith('[') && value.endsWith(']')) || + (value.startsWith('"') && value.endsWith('"')) + ); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +const PERMISSION_TOOL_META_FIELDS = new Set([ + 'title', + 'kind', + 'name', + 'method', + 'tool', + 'toolCallId', + 'tool_call_id', + 'id', +]); + +const PERMISSION_TOOL_ARG_CONTAINER_FIELDS = new Set([ + 'arguments', + 'input', + 'params', +]); diff --git a/src/gateway/router.ts b/src/gateway/router.ts index f9408be..c856eae 100644 --- a/src/gateway/router.ts +++ b/src/gateway/router.ts @@ -28,10 +28,11 @@ import { } from '../db/jobStore.js'; import { upsertDeliveryCheckpoint } from '../db/deliveryCheckpointStore.js'; import { getUiMode, setUiMode } from '../db/uiPrefStore.js'; -import { ToolAuth } from './toolAuth.js'; +import { ToolAuth, parseToolKind, TOOL_KINDS } from './toolAuth.js'; import { BindingRuntime } from './bindingRuntime.js'; import type { OutboundSink, UiMode } from './types.js'; import { buildReplayContextFromRecentRuns } from './history.js'; +import { resolveWorkspacePath } from '../tools/workspace.js'; export type { OutboundSink } from './types.js'; @@ -48,6 +49,7 @@ export type UserResource = { export type UserMessageOptions = { resources?: UserResource[]; + globalContextText?: string; }; type CliPresetId = 'codex' | 'claude'; @@ -361,6 +363,7 @@ export class GatewayRouter { '/replay [runId]', '/allow ', '/deny', + '/whitelist list|add|del|clear', '/cron help', ...inlineLines, ].join('\n'), @@ -676,6 +679,148 @@ export class GatewayRouter { return true; } + case '/whitelist': + case '/wl': { + const { bindingKey, sessionKey } = this.ensureBindingExists(key); + const sub = String(parts[1] ?? 'list').trim().toLowerCase(); + const sess = getSession(this.db, sessionKey); + const workspaceRoot = sess?.cwd ?? this.config.workspaceRoot; + + if (!sub || sub === 'list' || sub === 'show') { + const globalRows = this.toolAuth.listPersistentPolicies( + bindingKey, + 'allow', + ); + const prefixRows = this.toolAuth.listAllowPrefixRules(bindingKey); + + if (globalRows.length === 0 && prefixRows.length === 0) { + await sink.sendText('Whitelist: (empty)'); + return true; + } + + const lines = ['Whitelist:']; + for (const row of globalRows) { + lines.push(`- ${row.toolKind} (all)`); + } + for (const row of prefixRows) { + lines.push(`- ${row.toolKind} prefix: ${row.argPrefix}`); + } + + await sink.sendText(lines.join('\n')); + return true; + } + + if (sub === 'add') { + const toolKind = parseToolKind(parts[2]); + const rawPrefix = parts.slice(3).join(' ').trim(); + if (!toolKind) { + await sink.sendText(whitelistUsageText()); + return true; + } + + if (!rawPrefix) { + this.toolAuth.setPersistentPolicy(bindingKey, toolKind, 'allow'); + await sink.sendText(`OK: whitelisted ${toolKind} (all)`); + return true; + } + + const normalizedPrefix = normalizeWhitelistPrefix( + toolKind, + rawPrefix, + workspaceRoot, + ); + if (!normalizedPrefix) { + await sink.sendText(whitelistUsageText()); + return true; + } + + // Scoped prefix rules should not accidentally inherit broader allow-all. + this.toolAuth.clearPersistentPolicy(bindingKey, toolKind, 'allow'); + this.toolAuth.setAllowPrefixRule( + bindingKey, + toolKind, + normalizedPrefix, + ); + await sink.sendText( + `OK: whitelisted ${toolKind} prefix ${normalizedPrefix}`, + ); + return true; + } + + if ( + sub === 'del' || + sub === 'delete' || + sub === 'remove' || + sub === 'rm' + ) { + const toolKind = parseToolKind(parts[2]); + const rawPrefix = parts.slice(3).join(' ').trim(); + if (!toolKind) { + await sink.sendText(whitelistUsageText()); + return true; + } + + if (rawPrefix) { + const normalizedPrefix = normalizeWhitelistPrefix( + toolKind, + rawPrefix, + workspaceRoot, + ); + if (!normalizedPrefix) { + await sink.sendText(whitelistUsageText()); + return true; + } + + const removed = this.toolAuth.clearAllowPrefixRule( + bindingKey, + toolKind, + normalizedPrefix, + ); + await sink.sendText( + removed + ? `OK: removed ${toolKind} prefix ${normalizedPrefix}` + : `Whitelist did not include ${toolKind} prefix ${normalizedPrefix}.`, + ); + return true; + } + + const removedAll = this.toolAuth.clearPersistentPolicy( + bindingKey, + toolKind, + 'allow', + ); + const removedPrefixes = this.toolAuth.clearAllowPrefixRules( + bindingKey, + toolKind, + ); + + await sink.sendText( + removedAll || removedPrefixes > 0 + ? `OK: removed ${toolKind} from whitelist` + : `Whitelist did not include ${toolKind}.`, + ); + return true; + } + + if (sub === 'clear') { + const removedAll = this.toolAuth.clearPersistentPolicies( + bindingKey, + 'allow', + ); + const removedPrefixes = this.toolAuth.clearAllowPrefixRules(bindingKey); + const removed = removedAll + removedPrefixes; + await sink.sendText( + removed > 0 + ? `OK: cleared whitelist (${removed} entries).` + : 'Whitelist already empty.', + ); + return true; + } + + await sink.sendText(whitelistUsageText()); + return true; + } + case '/cron': { const sub = parts[1]; const bindingKey = bindingKeyFromConversationKey(key); @@ -825,17 +970,30 @@ export class GatewayRouter { }); let contextText = ''; - if ( - this.config.contextReplayEnabled && - this.config.contextReplayRuns > 0 && - !rt.hasSessionId() - ) { - contextText = buildReplayContextFromRecentRuns(this.db, { - sessionKey, - excludeRunId: runId, - maxRuns: this.config.contextReplayRuns, - maxChars: this.config.contextReplayMaxChars, - }); + const isFreshSession = !rt.hasSessionId(); + if (isFreshSession) { + const contextParts: string[] = []; + + const globalContextText = formatGlobalContextText( + options?.globalContextText, + ); + if (globalContextText) { + contextParts.push(globalContextText); + } + + if (this.config.contextReplayEnabled && this.config.contextReplayRuns > 0) { + const replayContextText = buildReplayContextFromRecentRuns(this.db, { + sessionKey, + excludeRunId: runId, + maxRuns: this.config.contextReplayRuns, + maxChars: this.config.contextReplayMaxChars, + }); + if (replayContextText) { + contextParts.push(replayContextText); + } + } + + contextText = contextParts.join('\n\n'); } try { @@ -1006,9 +1164,11 @@ function renderSessionUpdateDelta(update: any): string { ? 'started' : String(update?.status ?? update?.state ?? 'running').trim() || 'running'; const title = String(update?.title ?? id ?? 'tool_call').trim() || 'tool_call'; - return id - ? `\n[tool] ${title} 路 ${status} (${id})` - : `\n[tool] ${title} 路 ${status}`; + return formatTextCodeBlock( + id + ? `[tool] ${title} 路 ${status} (${id})` + : `[tool] ${title} 路 ${status}`, + ); } if (update.sessionUpdate === 'plan') { @@ -1023,6 +1183,55 @@ function truncate(text: string, max: number): string { return text.slice(0, max - 3) + '...'; } +function formatTextCodeBlock(text: string): string { + const safe = text.trim().replace(/```/g, '``\u200b`'); + return `\n\`\`\`text\n${safe}\n\`\`\`\n`; +} + +function whitelistUsageText(): string { + return [ + 'Usage:', + '/whitelist list', + '/whitelist add [prefix]', + '/whitelist del [prefix]', + '/whitelist clear', + '', + 'prefix rules:', + '- read|edit|delete|move: absolute path prefix under current workspace', + '- execute/others: string prefix on command/arguments', + '', + `tool_kind: ${TOOL_KINDS.join('|')}`, + ].join('\n'); +} + +const PATH_PREFIX_WHITELIST_KINDS = new Set([ + 'read', + 'edit', + 'delete', + 'move', +]); + +function normalizeWhitelistPrefix( + toolKind: string, + rawPrefix: string, + workspaceRoot: string, +): string | null { + const trimmed = rawPrefix.trim(); + if (!trimmed) return null; + + if (PATH_PREFIX_WHITELIST_KINDS.has(toolKind)) { + if (!path.isAbsolute(trimmed)) return null; + try { + return resolveWorkspacePath(workspaceRoot, trimmed); + } catch { + return null; + } + } + + const normalized = trimmed.replace(/\s+/g, ' '); + return normalized || null; +} + function normalizeCommand(raw: string | undefined): string { if (!raw) return ''; const command = raw.toLowerCase(); @@ -1055,6 +1264,12 @@ function sanitizeResources(resources: UserMessageOptions['resources']): UserReso return out; } +function formatGlobalContextText(input: string | undefined): string { + const text = String(input ?? '').trim(); + if (!text) return ''; + return `Global context (channel description):\n${text}`; +} + function formatPromptTextForStorage(text: string, resources: UserResource[]): string { const trimmed = text.trim(); if (resources.length === 0) return text; diff --git a/src/gateway/sessionStore.ts b/src/gateway/sessionStore.ts index 25cc3f8..553b661 100644 --- a/src/gateway/sessionStore.ts +++ b/src/gateway/sessionStore.ts @@ -83,6 +83,7 @@ export function deleteBinding(db: Db, key: ConversationKey): void { // Bindings are referenced by several tables; delete dependents first. db.prepare('DELETE FROM jobs WHERE binding_key = ?').run(bindingKey); db.prepare('DELETE FROM tool_policies WHERE binding_key = ?').run(bindingKey); + db.prepare('DELETE FROM tool_allow_prefixes WHERE binding_key = ?').run(bindingKey); db.prepare('DELETE FROM ui_prefs WHERE binding_key = ?').run(bindingKey); db.prepare('DELETE FROM delivery_checkpoints WHERE binding_key = ?').run(bindingKey); diff --git a/src/gateway/toolAuth.ts b/src/gateway/toolAuth.ts index 608dfea..914f43b 100644 --- a/src/gateway/toolAuth.ts +++ b/src/gateway/toolAuth.ts @@ -1,4 +1,7 @@ +import path from 'node:path'; + import type { Db } from '../db/db.js'; +import { resolveWorkspacePath } from '../tools/workspace.js'; export type ToolKind = | 'read' @@ -14,6 +17,46 @@ export type ToolKind = export type PersistentToolPolicy = 'allow' | 'reject'; +export const TOOL_KINDS: ToolKind[] = [ + 'read', + 'edit', + 'delete', + 'move', + 'search', + 'execute', + 'think', + 'fetch', + 'switch_mode', + 'other', +]; + +export function parseToolKind(value: unknown): ToolKind | null { + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + return TOOL_KINDS.includes(normalized as ToolKind) + ? (normalized as ToolKind) + : null; +} + +export type ToolMatchContext = { + method?: string; + params?: unknown; + toolCall?: unknown; + workspaceRoot?: string; +}; + +export type ToolAllowPrefixRule = { + toolKind: ToolKind; + argPrefix: string; +}; + +const PATH_PREFIX_TOOL_KINDS = new Set([ + 'read', + 'edit', + 'delete', + 'move', +]); + export class ToolAuth { private readonly db: Db; private readonly onceGrants = new Map>(); @@ -64,7 +107,231 @@ export class ToolAuth { return row?.policy ?? null; } - consume(sessionKey: string, toolKind: ToolKind): boolean { + listPersistentPolicies( + bindingKey: string, + policy?: PersistentToolPolicy, + ): Array<{ toolKind: ToolKind; policy: PersistentToolPolicy }> { + const rows = policy + ? (this.db + .prepare( + ` + SELECT tool_kind as toolKind, policy + FROM tool_policies + WHERE binding_key = ? AND policy = ? + ORDER BY tool_kind ASC + `, + ) + .all(bindingKey, policy) as Array<{ + toolKind: string; + policy: PersistentToolPolicy; + }>) + : (this.db + .prepare( + ` + SELECT tool_kind as toolKind, policy + FROM tool_policies + WHERE binding_key = ? + ORDER BY tool_kind ASC + `, + ) + .all(bindingKey) as Array<{ + toolKind: string; + policy: PersistentToolPolicy; + }>); + + return rows + .map((row) => { + const toolKind = parseToolKind(row.toolKind); + if (!toolKind) return null; + return { toolKind, policy: row.policy }; + }) + .filter(Boolean) as Array<{ + toolKind: ToolKind; + policy: PersistentToolPolicy; + }>; + } + + clearPersistentPolicy( + bindingKey: string, + toolKind: ToolKind, + policy?: PersistentToolPolicy, + ): boolean { + const result = policy + ? this.db + .prepare( + ` + DELETE FROM tool_policies + WHERE binding_key = ? AND tool_kind = ? AND policy = ? + `, + ) + .run(bindingKey, toolKind, policy) + : this.db + .prepare( + ` + DELETE FROM tool_policies + WHERE binding_key = ? AND tool_kind = ? + `, + ) + .run(bindingKey, toolKind); + + return result.changes > 0; + } + + clearPersistentPolicies( + bindingKey: string, + policy?: PersistentToolPolicy, + ): number { + const result = policy + ? this.db + .prepare( + ` + DELETE FROM tool_policies + WHERE binding_key = ? AND policy = ? + `, + ) + .run(bindingKey, policy) + : this.db + .prepare( + ` + DELETE FROM tool_policies + WHERE binding_key = ? + `, + ) + .run(bindingKey); + + return result.changes; + } + + setAllowPrefixRule( + bindingKey: string, + toolKind: ToolKind, + argPrefix: string, + ): void { + const normalizedPrefix = normalizeStoredPrefix(toolKind, argPrefix); + if (!normalizedPrefix) { + throw new Error('Invalid allow prefix.'); + } + + const now = Date.now(); + this.db + .prepare( + ` + INSERT INTO tool_allow_prefixes(binding_key, tool_kind, arg_prefix, created_at, updated_at) + VALUES(?, ?, ?, ?, ?) + ON CONFLICT(binding_key, tool_kind, arg_prefix) DO UPDATE SET + updated_at = excluded.updated_at + `, + ) + .run(bindingKey, toolKind, normalizedPrefix, now, now); + } + + listAllowPrefixRules( + bindingKey: string, + toolKind?: ToolKind, + ): ToolAllowPrefixRule[] { + const rows = toolKind + ? (this.db + .prepare( + ` + SELECT tool_kind as toolKind, arg_prefix as argPrefix + FROM tool_allow_prefixes + WHERE binding_key = ? AND tool_kind = ? + ORDER BY tool_kind ASC, arg_prefix ASC + `, + ) + .all(bindingKey, toolKind) as Array<{ + toolKind: string; + argPrefix: string; + }>) + : (this.db + .prepare( + ` + SELECT tool_kind as toolKind, arg_prefix as argPrefix + FROM tool_allow_prefixes + WHERE binding_key = ? + ORDER BY tool_kind ASC, arg_prefix ASC + `, + ) + .all(bindingKey) as Array<{ + toolKind: string; + argPrefix: string; + }>); + + return rows + .map((row) => { + const parsedKind = parseToolKind(row.toolKind); + if (!parsedKind) return null; + const normalizedPrefix = normalizeStoredPrefix(parsedKind, row.argPrefix); + if (!normalizedPrefix) return null; + return { + toolKind: parsedKind, + argPrefix: normalizedPrefix, + } satisfies ToolAllowPrefixRule; + }) + .filter(Boolean) as ToolAllowPrefixRule[]; + } + + clearAllowPrefixRule( + bindingKey: string, + toolKind: ToolKind, + argPrefix: string, + ): boolean { + const normalizedPrefix = normalizeStoredPrefix(toolKind, argPrefix); + if (!normalizedPrefix) return false; + + const result = this.db + .prepare( + ` + DELETE FROM tool_allow_prefixes + WHERE binding_key = ? AND tool_kind = ? AND arg_prefix = ? + `, + ) + .run(bindingKey, toolKind, normalizedPrefix); + + return result.changes > 0; + } + + clearAllowPrefixRules(bindingKey: string, toolKind?: ToolKind): number { + const result = toolKind + ? this.db + .prepare( + ` + DELETE FROM tool_allow_prefixes + WHERE binding_key = ? AND tool_kind = ? + `, + ) + .run(bindingKey, toolKind) + : this.db + .prepare( + ` + DELETE FROM tool_allow_prefixes + WHERE binding_key = ? + `, + ) + .run(bindingKey); + + return result.changes; + } + + evaluatePersistentPolicy( + bindingKey: string, + toolKind: ToolKind, + context?: ToolMatchContext, + ): PersistentToolPolicy | null { + const policy = this.getPersistentPolicy(bindingKey, toolKind); + if (policy === 'reject') return 'reject'; + if (policy === 'allow') return 'allow'; + + return this.matchesAllowPrefixRule(bindingKey, toolKind, context) + ? 'allow' + : null; + } + + consume( + sessionKey: string, + toolKind: ToolKind, + context?: ToolMatchContext, + ): boolean { const bindingRow = this.db .prepare( 'SELECT binding_key as bindingKey FROM bindings WHERE session_key = ? LIMIT 1', @@ -73,9 +340,13 @@ export class ToolAuth { if (!bindingRow) return false; - const policy = this.getPersistentPolicy(bindingRow.bindingKey, toolKind); - if (policy === 'reject') return false; - if (policy === 'allow') return true; + const persistent = this.evaluatePersistentPolicy( + bindingRow.bindingKey, + toolKind, + context, + ); + if (persistent === 'reject') return false; + if (persistent === 'allow') return true; const perSession = this.onceGrants.get(sessionKey); const remaining = perSession?.get(toolKind) ?? 0; @@ -84,4 +355,187 @@ export class ToolAuth { perSession!.set(toolKind, remaining - 1); return true; } + + private matchesAllowPrefixRule( + bindingKey: string, + toolKind: ToolKind, + context?: ToolMatchContext, + ): boolean { + if (!context) return false; + + const rules = this.listAllowPrefixRules(bindingKey, toolKind); + if (rules.length === 0) return false; + + const candidates = extractMatchCandidates(toolKind, context); + if (candidates.length === 0) return false; + + for (const rule of rules) { + if (candidates.some((candidate) => prefixMatches(toolKind, candidate, rule.argPrefix))) { + return true; + } + } + + return false; + } +} + +function extractMatchCandidates( + toolKind: ToolKind, + context: ToolMatchContext, +): string[] { + const out: string[] = []; + const seen = new Set(); + + const push = (raw: unknown) => { + if (typeof raw !== 'string') return; + const normalized = normalizeCandidate(toolKind, raw, context.workspaceRoot); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + out.push(normalized); + }; + + const params = asRecord(context.params); + const method = String(context.method ?? '').trim(); + + if (method === 'fs/read_text_file' || method === 'fs/write_text_file') { + push(params?.path); + } + + if (method === 'terminal/create') { + push(formatCommandLine(params?.command, params?.args)); + } + + const toolCall = asRecord(context.toolCall); + if (toolCall) { + push(toolCall.path); + push(getPathValue(toolCall, 'arguments.path')); + push(getPathValue(toolCall, 'input.path')); + push(formatCommandLine(toolCall.command, toolCall.args)); + push(formatCommandLine(getPathValue(toolCall, 'arguments.command'), getPathValue(toolCall, 'arguments.args'))); + push(extractTargetFromToolTitle(toolKind, toolCall.title)); + } + + if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) { + push(params?.file); + push(params?.target); + push(params?.uri); + } else if (toolKind === 'execute') { + push(params?.command); + } else { + push(params?.path); + push(params?.query); + push(params?.pattern); + push(params?.text); + } + + return out; +} + +function normalizeCandidate( + toolKind: ToolKind, + raw: string, + workspaceRoot?: string, +): string | null { + if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) { + return normalizePathPrefix(raw, workspaceRoot); + } + return normalizeTextPrefix(raw); +} + +function normalizeStoredPrefix(toolKind: ToolKind, raw: string): string | null { + if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) { + return normalizePathPrefix(raw); + } + return normalizeTextPrefix(raw); +} + +function normalizePathPrefix(raw: string, workspaceRoot?: string): string | null { + const trimmed = raw.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) return null; + + if (workspaceRoot) { + try { + return resolveWorkspacePath(workspaceRoot, trimmed); + } catch { + return null; + } + } + + return path.resolve(trimmed); +} + +function normalizeTextPrefix(raw: string): string | null { + const normalized = raw.replace(/\s+/g, ' ').trim(); + return normalized || null; +} + +function prefixMatches(toolKind: ToolKind, candidate: string, prefix: string): boolean { + if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) { + return pathPrefixMatches(candidate, prefix); + } + return candidate.startsWith(prefix); +} + +function pathPrefixMatches(candidate: string, prefix: string): boolean { + const normalizedCandidate = path.resolve(candidate); + const normalizedPrefix = path.resolve(prefix); + + if (normalizedCandidate === normalizedPrefix) return true; + return normalizedCandidate.startsWith(normalizedPrefix + path.sep); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function getPathValue(source: unknown, pathExpr: string): unknown { + const parts = pathExpr.split('.'); + let current: unknown = source; + + for (const part of parts) { + const obj = asRecord(current); + if (!obj) return undefined; + current = obj[part]; + } + + return current; +} + +function formatCommandLine(commandRaw: unknown, argsRaw: unknown): string | null { + if (typeof commandRaw !== 'string' || !commandRaw.trim()) return null; + const command = commandRaw.trim(); + + const args = Array.isArray(argsRaw) + ? argsRaw.filter((item): item is string => typeof item === 'string') + : []; + + const full = args.length > 0 ? `${command} ${args.join(' ')}` : command; + return normalizeTextPrefix(full); +} + +function extractTargetFromToolTitle( + toolKind: ToolKind, + titleRaw: unknown, +): string | null { + if (typeof titleRaw !== 'string') return null; + const title = titleRaw.trim(); + if (!title) return null; + + if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) { + const match = title.match(/^(?:read|edit|delete|move)\s*:\s*(.+)$/i); + if (match) { + return match[1]?.trim() ?? null; + } + return null; + } + + if (toolKind === 'execute') { + const match = title.match(/^run\s*:\s*(.+)$/i); + if (match) { + return match[1]?.trim() ?? null; + } + } + + return null; } diff --git a/src/gateway/types.ts b/src/gateway/types.ts index 75d23dd..f46922d 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -12,6 +12,8 @@ export type PermissionUiRequest = { requestId: string; toolTitle: string; toolKind: string | null; + toolName?: string; + toolArgs?: unknown; }; export type UiEvent = diff --git a/test/bindingRuntime.prompt.test.ts b/test/bindingRuntime.prompt.test.ts index 25d048f..9cb7f71 100644 --- a/test/bindingRuntime.prompt.test.ts +++ b/test/bindingRuntime.prompt.test.ts @@ -175,6 +175,105 @@ class FakeRpc implements StdioProcess { } } +class DirectToolRpc implements StdioProcess { + private messageHandlers: Array<(m: JsonRpcMessage) => void> = []; + private sessionId = 'sess-direct-tool'; + private promptRequestId: number | null = null; + private workspaceFile: string; + + constructor(params: { workspaceFile: string }) { + this.workspaceFile = params.workspaceFile; + } + + write(message: JsonRpcMessage): void { + if (!('method' in message)) { + if ('id' in message && 'result' in message) { + const res = message as JsonRpcResponse; + if (res.id === 700) { + queueMicrotask(() => { + this.emit({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: this.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'done' }, + }, + }, + } as any); + + this.emit({ + jsonrpc: '2.0', + id: this.promptRequestId!, + result: { stopReason: 'end' }, + } as JsonRpcResponse); + }); + } + } + return; + } + + const req = message as JsonRpcRequest; + + if (req.method === 'initialize') { + queueMicrotask(() => { + this.emit({ + jsonrpc: '2.0', + id: req.id, + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: false }, + }, + } as JsonRpcResponse); + }); + return; + } + + if (req.method === 'session/new') { + queueMicrotask(() => { + this.emit({ + jsonrpc: '2.0', + id: req.id, + result: { sessionId: this.sessionId }, + } as JsonRpcResponse); + }); + return; + } + + if (req.method === 'session/prompt') { + this.promptRequestId = Number(req.id); + queueMicrotask(() => { + this.emit({ + jsonrpc: '2.0', + id: 700, + method: 'fs/read_text_file', + params: { + sessionId: this.sessionId, + path: this.workspaceFile, + }, + } as any); + }); + } + } + + onMessage(cb: (message: JsonRpcMessage) => void): void { + this.messageHandlers.push(cb); + } + + onStderr(): void { + // noop + } + + kill(): void { + // noop + } + + private emit(message: JsonRpcMessage): void { + this.messageHandlers.forEach((h) => h(message)); + } +} + class SummaryFilterRpc implements StdioProcess { private messageHandlers: Array<(m: JsonRpcMessage) => void> = []; private promptRequestId: number | null = null; @@ -630,6 +729,104 @@ test('BindingRuntime prompt emits plan/tool UI and supports interactive permissi db.close(); }); +test('BindingRuntime prompts interactively when tool is called without session/request_permission', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrate(db); + + const workspaceRoot = fs.mkdtempSync('/tmp/cli-gateway-test-'); + const filePath = path.join(workspaceRoot, 'hello.txt'); + fs.writeFileSync(filePath, 'hello', 'utf8'); + + const key: ConversationKey = { + platform: 'discord', + chatId: 'c', + threadId: null, + userId: 'u', + }; + + const sessionKey = 's-direct'; + createSession(db, { + sessionKey, + agentCommand: 'agent', + agentArgs: [], + cwd: workspaceRoot, + loadSupported: false, + }); + + const bindingKey = upsertBinding(db, key, sessionKey).bindingKey; + const toolAuth = new ToolAuth(db); + + const rt = new BindingRuntime({ + db, + config: { + discordToken: undefined, + discordAllowChannelId: undefined, + telegramToken: undefined, + feishuAppId: undefined, + feishuAppSecret: undefined, + feishuVerificationToken: undefined, + feishuListenPort: 3030, + acpAgentCommand: 'node', + acpAgentArgs: [], + workspaceRoot, + dbPath: ':memory:', + schedulerEnabled: false, + runtimeIdleTtlSeconds: 999, + maxBindingRuntimes: 5, + uiDefaultMode: 'verbose', + uiJsonMaxChars: 10_000, + contextReplayEnabled: false, + contextReplayRuns: 0, + contextReplayMaxChars: 0, + } as any, + toolAuth, + sessionKey, + bindingKey, + acpRpc: new DirectToolRpc({ workspaceFile: filePath }), + workspaceRoot, + }); + + createRun(db, { runId: 'r-direct', sessionKey, promptText: 'go' }); + + const permissionRequests: any[] = []; + const chunks: string[] = []; + + const sink: OutboundSink = { + sendText: async (t) => { + chunks.push(t); + }, + requestPermission: async (req) => { + permissionRequests.push(req); + const decision = await rt.decidePermission({ + decision: 'allow', + requestId: req.requestId, + actorUserId: 'u', + }); + assert.equal(decision.ok, true); + }, + }; + + const out = await rt.prompt({ + runId: 'r-direct', + promptText: 'go', + sink, + uiMode: 'verbose', + actorUserId: 'u', + }); + + assert.equal(out.stopReason, 'end'); + assert.equal(permissionRequests.length, 1); + assert.equal(permissionRequests[0].toolKind, 'read'); + assert.equal(permissionRequests[0].toolName, 'fs/read_text_file'); + assert.equal(permissionRequests[0].toolArgs?.path, filePath); + assert.ok(String(permissionRequests[0].toolTitle).includes(filePath)); + assert.ok(chunks.join('').includes('done')); + + rt.close(); + db.close(); +}); + test('BindingRuntime summary filters call_* tool titles and keeps named tools', async () => { const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); diff --git a/test/bufferedSink.test.ts b/test/bufferedSink.test.ts index 95dc88b..b980c0f 100644 --- a/test/bufferedSink.test.ts +++ b/test/bufferedSink.test.ts @@ -53,7 +53,7 @@ test('buffered sink background flush runs on timer', async () => { assert.equal(sent.length, 1); }); -test('buffered sink rotates message when buffer too large', async () => { +test('buffered sink rotates messages without losing trailing text', async () => { const sent: string[] = []; const sink = createBufferedSink({ @@ -66,11 +66,11 @@ test('buffered sink rotates message when buffer too large', async () => { edit: async () => {}, }); - await sink.sendText('0123456789'); - await sink.sendText('0123456789'); + // Regression: previously this could be truncated to a single "0123456789". + await sink.sendText('0123456789ABCDE'); await sink.flush(); - assert.ok(sent.length >= 1); + assert.deepEqual(sent, ['0123456789', 'ABCDE']); }); test('buffered sink falls back to send if edit fails', async () => { diff --git a/test/discordCommands.test.ts b/test/discordCommands.test.ts index 722edb6..3acabec 100644 --- a/test/discordCommands.test.ts +++ b/test/discordCommands.test.ts @@ -37,6 +37,7 @@ test('buildDiscordSlashCommands includes base command names', () => { 'new', 'replay', 'ui', + 'whitelist', 'workspace', ]); }); @@ -130,6 +131,38 @@ test('mapDiscordSlashToRouterCommand maps optional args', () => { ), '/allow 3', ); + + assert.equal( + mapDiscordSlashToRouterCommand( + makeInteraction({ + commandName: 'whitelist', + subcommand: 'add', + strings: { tool_kind: 'read', prefix: '/tmp/work' }, + }), + ), + '/whitelist add read /tmp/work', + ); + + assert.equal( + mapDiscordSlashToRouterCommand( + makeInteraction({ + commandName: 'whitelist', + subcommand: 'list', + }), + ), + '/whitelist list', + ); + + assert.equal( + mapDiscordSlashToRouterCommand( + makeInteraction({ + commandName: 'whitelist', + subcommand: 'del', + strings: { tool_kind: 'read', prefix: '/tmp/work' }, + }), + ), + '/whitelist del read /tmp/work', + ); }); test('mapDiscordSlashToRouterCommand maps cron subcommands', () => { diff --git a/test/discordPermissionUi.test.ts b/test/discordPermissionUi.test.ts index 8fc581a..f9276a3 100644 --- a/test/discordPermissionUi.test.ts +++ b/test/discordPermissionUi.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + extractDiscordChannelDescription, extractPermissionRouteFromComponents, parsePermissionCustomId, permissionDecisionFromEmoji, @@ -62,3 +63,31 @@ test('extractPermissionRouteFromComponents returns null when absent', () => { null, ); }); + +test('extractDiscordChannelDescription prefers channel topic then parent fallback', () => { + assert.equal( + extractDiscordChannelDescription({ + topic: ' team playbook ', + description: 'ignored', + parent: { topic: 'parent topic' }, + }), + 'team playbook', + ); + + assert.equal( + extractDiscordChannelDescription({ + topic: ' ', + parent: { topic: ' parent rules ' }, + }), + 'parent rules', + ); + + assert.equal( + extractDiscordChannelDescription({ + topic: '', + description: '', + parent: { topic: ' ', description: '' }, + }), + null, + ); +}); diff --git a/test/discordSink.test.ts b/test/discordSink.test.ts index b3bf375..1369f8d 100644 --- a/test/discordSink.test.ts +++ b/test/discordSink.test.ts @@ -42,19 +42,151 @@ test('discord sink renders permission as embed + buttons', async () => { requestId: 'r', toolTitle: 'fs/read_text_file', toolKind: 'read', + toolName: 'fs/read_text_file', + toolArgs: { path: '/tmp/a.txt', line: 3 }, }); const msg = sent.at(-1); assert.ok(msg.embeds?.length); assert.ok(msg.components?.length); assert.ok(String(msg.content).includes('<@user1>')); + const embed = msg.embeds[0]?.data ?? {}; + assert.equal(embed.title, undefined); + const fields = Array.isArray(embed.fields) ? embed.fields : []; + assert.deepEqual( + fields.map((f: any) => f.name), + ['Tool', 'Reason', 'Path'], + ); + assert.equal(fields[0]?.value, 'fs/read_text_file'); + assert.equal(fields[1]?.value, '(not provided)'); + assert.ok(String(fields[2]?.value).includes('/tmp/a.txt')); assert.deepEqual( reactions.map((item) => item.emoji), ['馃憤', '馃憥'], ); }); -test('discord sink renders UI events as embed', async () => { +test('discord sink renders execute permission with reason and command only', async () => { + const { channel, sent } = createFakeChannel(); + + const sink = createDiscordSink(channel, 'user1'); + + await sink.requestPermission!({ + uiMode: 'verbose', + sessionKey: 's', + requestId: 'r', + toolTitle: 'terminal/create', + toolKind: 'execute', + toolName: 'terminal/create', + toolArgs: { + reason: 'Run tests before patching', + command: 'npm', + args: ['test', '--', '--runInBand'], + cwd: '/tmp', + }, + }); + + const msg = sent.at(-1); + const embed = msg.embeds[0]?.data ?? {}; + const fields = Array.isArray(embed.fields) ? embed.fields : []; + assert.deepEqual( + fields.map((f: any) => f.name), + ['Tool', 'Reason', 'Command'], + ); + assert.equal(fields[1]?.value, 'Run tests before patching'); + assert.ok(String(fields[2]?.value).includes('npm test -- --runInBand')); + assert.equal(fields.length, 3); +}); + +test('discord sink renders execute permission from json-string args payload', async () => { + const { channel, sent } = createFakeChannel(); + const sink = createDiscordSink(channel, 'user1'); + + await sink.requestPermission!({ + uiMode: 'verbose', + sessionKey: 's', + requestId: 'r', + toolTitle: 'functions.exec_command', + toolKind: 'execute', + toolName: 'functions.exec_command', + toolArgs: JSON.stringify({ + cmd: 'bash scripts/run-guard.sh request-restart', + justification: 'Request restart through run-guard', + }), + }); + + const msg = sent.at(-1); + const embed = msg.embeds[0]?.data ?? {}; + const fields = Array.isArray(embed.fields) ? embed.fields : []; + assert.deepEqual( + fields.map((f: any) => f.name), + ['Tool', 'Reason', 'Command'], + ); + assert.equal(fields[1]?.value, 'Request restart through run-guard'); + assert.ok(String(fields[2]?.value).includes('bash scripts/run-guard.sh request-restart')); +}); + +test('discord sink renders execute permission from double-encoded json payload', async () => { + const { channel, sent } = createFakeChannel(); + const sink = createDiscordSink(channel, 'user1'); + + await sink.requestPermission!({ + uiMode: 'verbose', + sessionKey: 's', + requestId: 'r', + toolTitle: 'functions.exec_command', + toolKind: 'execute', + toolName: 'functions.exec_command', + toolArgs: JSON.stringify( + JSON.stringify({ + cmd: 'echo hello world', + justification: 'Run a quick health check', + }), + ), + }); + + const msg = sent.at(-1); + const embed = msg.embeds[0]?.data ?? {}; + const fields = Array.isArray(embed.fields) ? embed.fields : []; + assert.deepEqual( + fields.map((f: any) => f.name), + ['Tool', 'Reason', 'Command'], + ); + assert.equal(fields[1]?.value, 'Run a quick health check'); + assert.ok(String(fields[2]?.value).includes('echo hello world')); +}); + +test('discord sink renders execute permission from key-value list payload', async () => { + const { channel, sent } = createFakeChannel(); + const sink = createDiscordSink(channel, 'user1'); + + await sink.requestPermission!({ + uiMode: 'verbose', + sessionKey: 's', + requestId: 'r', + toolTitle: 'functions.exec_command', + toolKind: 'execute', + toolName: 'functions.exec_command', + toolArgs: { + input: [ + { name: 'justification', value: 'Run a quick health check' }, + { key: 'cmd', value: 'echo hello world' }, + ], + }, + }); + + const msg = sent.at(-1); + const embed = msg.embeds[0]?.data ?? {}; + const fields = Array.isArray(embed.fields) ? embed.fields : []; + assert.deepEqual( + fields.map((f: any) => f.name), + ['Tool', 'Reason', 'Command'], + ); + assert.equal(fields[1]?.value, 'Run a quick health check'); + assert.ok(String(fields[2]?.value).includes('echo hello world')); +}); + +test('discord sink renders tool UI events as plain text', async () => { const { channel, sent } = createFakeChannel(); const sink = createDiscordSink(channel, 'user1'); @@ -67,7 +199,47 @@ test('discord sink renders UI events as embed', async () => { }); const msg = sent.at(-1); - assert.ok(msg.embeds?.length); + assert.equal(typeof msg, 'string'); + assert.ok(msg.includes('[tool] fs/read_text_file')); + assert.ok(msg.includes('{"a":1}')); +}); + +test('discord sink updates tool UI text when toolCallId repeats', async () => { + const { channel, sent, edits } = createFakeChannel(); + const sink = createDiscordSink(channel, 'user1'); + + await sink.sendUi!({ + kind: 'tool', + mode: 'summary', + title: 'fs/read_text_file 路 started', + toolCallId: 'tc-1', + }); + await sink.sendUi!({ + kind: 'tool', + mode: 'summary', + title: 'fs/read_text_file 路 completed', + toolCallId: 'tc-1', + }); + + assert.equal(sent.length, 1); + assert.equal(edits.length, 1); + assert.ok(String(edits[0]?.text).includes('completed')); +}); + +test('discord sink truncates overlong embed titles for non-tool UI', async () => { + const { channel, sent } = createFakeChannel(); + const sink = createDiscordSink(channel, 'user1'); + + await sink.sendUi!({ + kind: 'plan', + mode: 'summary', + title: 'x'.repeat(400), + }); + + const msg = sent.at(-1); + const raw = msg.embeds?.[0]?.data?.title ?? ''; + assert.ok(typeof raw === 'string'); + assert.ok(raw.length <= 256); }); test('discord sink supports buffered streaming', async () => { diff --git a/test/helpCommand.test.ts b/test/helpCommand.test.ts index 73b2a5e..e973c8f 100644 --- a/test/helpCommand.test.ts +++ b/test/helpCommand.test.ts @@ -60,6 +60,7 @@ test('/help prints command list without requiring binding', async () => { assert.ok(text.includes('/ui')); assert.ok(text.includes('/cli')); assert.ok(text.includes('/workspace')); + assert.ok(text.includes('/whitelist')); router.close(); db.close(); diff --git a/test/migrations.test.ts b/test/migrations.test.ts index 8e6eb9e..47fd87c 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -15,7 +15,7 @@ test('migrate creates schema at latest version', () => { .prepare('SELECT version FROM schema_version') .get() as { version: number }; - assert.equal(version.version, 4); + assert.equal(version.version, 5); const tables = db .prepare( @@ -34,6 +34,7 @@ test('migrate creates schema at latest version', () => { 'schema_version', 'sessions', 'tool_policies', + 'tool_allow_prefixes', 'ui_prefs', ]) { assert.ok(names.includes(expected), `missing table: ${expected}`); diff --git a/test/routerCommands.test.ts b/test/routerCommands.test.ts index b0954c0..1bd83eb 100644 --- a/test/routerCommands.test.ts +++ b/test/routerCommands.test.ts @@ -182,6 +182,78 @@ test('/allow and /deny show missing binding when no binding exists', async () => router.close(); }); +test('/whitelist list/add/del/clear manages allow policies per binding', async () => { + const db = createDb(); + const router = new GatewayRouter({ db, config: createConfig() as any }); + + const key: ConversationKey = { + platform: 'discord', + chatId: 'c', + threadId: null, + userId: 'u', + }; + + const { sink, texts } = createSink(); + + await router.handleUserMessage(key, '/whitelist list', sink as any); + assert.equal(texts.at(-1), 'Whitelist: (empty)'); + + await router.handleUserMessage(key, '/whitelist add read', sink as any); + assert.equal(texts.at(-1), 'OK: whitelisted read (all)'); + + await router.handleUserMessage(key, '/whitelist list', sink as any); + assert.ok(String(texts.at(-1)).includes('- read (all)')); + + await router.handleUserMessage( + key, + '/whitelist add read /tmp/cli-gateway-test/safe', + sink as any, + ); + assert.ok(String(texts.at(-1)).includes('OK: whitelisted read prefix')); + + await router.handleUserMessage(key, '/whitelist add invalid', sink as any); + assert.ok(String(texts.at(-1)).includes('Usage:')); + + await router.handleUserMessage( + key, + '/whitelist del read /tmp/cli-gateway-test/safe', + sink as any, + ); + assert.ok(String(texts.at(-1)).includes('OK: removed read prefix')); + + await router.handleUserMessage(key, '/whitelist del read', sink as any); + assert.equal(texts.at(-1), 'Whitelist did not include read.'); + + await router.handleUserMessage(key, '/whitelist list', sink as any); + assert.equal(texts.at(-1), 'Whitelist: (empty)'); + + await router.handleUserMessage(key, '/whitelist add execute', sink as any); + await router.handleUserMessage(key, '/whitelist clear', sink as any); + assert.ok(String(texts.at(-1)).includes('OK: cleared whitelist')); + + const binding = db + .prepare( + 'SELECT binding_key as bindingKey FROM bindings WHERE platform = ? AND chat_id = ? AND user_id = ? LIMIT 1', + ) + .get('discord', 'c', 'u') as { bindingKey: string }; + + const row = db + .prepare( + 'SELECT COUNT(*) as n FROM tool_policies WHERE binding_key = ? AND policy = ?', + ) + .get(binding.bindingKey, 'allow') as { n: number }; + assert.equal(row.n, 0); + + const prefixRow = db + .prepare( + 'SELECT COUNT(*) as n FROM tool_allow_prefixes WHERE binding_key = ?', + ) + .get(binding.bindingKey) as { n: number }; + assert.equal(prefixRow.n, 0); + + router.close(); +}); + test('handlePermissionUi validates actor and dispatches to runtime', async () => { const db = createDb(); const router = new GatewayRouter({ db, config: createConfig() as any }); diff --git a/test/routerPromptFlow.test.ts b/test/routerPromptFlow.test.ts index 7d0e8e8..868dcef 100644 --- a/test/routerPromptFlow.test.ts +++ b/test/routerPromptFlow.test.ts @@ -101,7 +101,7 @@ class FakeRpc implements StdioProcess { } } -test('router non-command flow creates run/events/checkpoint and uses context replay', async () => { +test('router non-command flow creates run/events/checkpoint and uses global+replay context', async () => { const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); migrate(db); @@ -188,7 +188,9 @@ test('router non-command flow creates run/events/checkpoint and uses context rep getDeliveryState: () => state, }; - await router.handleUserMessage(key, 'hello', sink as any); + await router.handleUserMessage(key, 'hello', sink as any, { + globalContextText: 'Channel rule: always provide concrete patch steps.', + }); assert.ok(state.text.includes('ok')); @@ -207,12 +209,96 @@ test('router non-command flow creates run/events/checkpoint and uses context rep ) as any; assert.ok(promptReq); const blocks = promptReq.params.prompt as Array<{ type: string; text: string }>; + assert.ok(blocks[0].text.includes('Global context (channel description):')); + assert.ok(blocks[0].text.includes('Channel rule: always provide concrete patch steps.')); assert.ok(blocks[0].text.includes('Context (previous messages')); router.close(); db.close(); }); +test('router injects global context only on fresh sessions', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrate(db); + + const workspaceRoot = fs.mkdtempSync('/tmp/cli-gateway-router-'); + + const key: ConversationKey = { + platform: 'discord', + chatId: 'fresh-check', + threadId: null, + userId: 'u', + }; + + const sessionKey = 's-fresh-check'; + createSession(db, { + sessionKey, + agentCommand: 'agent', + agentArgs: [], + cwd: workspaceRoot, + loadSupported: false, + }); + upsertBinding(db, key, sessionKey); + + const hasSessionAnswers = [false, true]; + const seenContexts: string[] = []; + + const router = new GatewayRouter({ + db, + config: { + discordToken: undefined, + discordAllowChannelId: undefined, + telegramToken: undefined, + feishuAppId: undefined, + feishuAppSecret: undefined, + feishuVerificationToken: undefined, + feishuListenPort: 3030, + acpAgentCommand: 'node', + acpAgentArgs: [], + workspaceRoot, + dbPath: ':memory:', + schedulerEnabled: false, + runtimeIdleTtlSeconds: 999, + maxBindingRuntimes: 5, + uiDefaultMode: 'verbose', + uiJsonMaxChars: 10_000, + contextReplayEnabled: false, + contextReplayRuns: 0, + contextReplayMaxChars: 0, + } as any, + runtimeFactory: () => + ({ + hasSessionId: () => hasSessionAnswers.shift() ?? true, + prompt: async (params: { contextText?: string }) => { + seenContexts.push(params.contextText ?? ''); + return { stopReason: 'end', lastSeq: 0 }; + }, + close: () => {}, + }) as any, + }); + + const sink = { + sendText: async () => {}, + flush: async () => {}, + }; + + await router.handleUserMessage(key, 'first', sink as any, { + globalContextText: 'Project charter: keep responses concise.', + }); + await router.handleUserMessage(key, 'second', sink as any, { + globalContextText: 'Project charter: keep responses concise.', + }); + + assert.equal(seenContexts.length, 2); + assert.ok(seenContexts[0].includes('Global context (channel description):')); + assert.ok(seenContexts[0].includes('Project charter: keep responses concise.')); + assert.equal(seenContexts[1], ''); + + router.close(); + db.close(); +}); + test('router forwards image resources as prompt resource_link blocks', async () => { const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); diff --git a/test/toolAuth.test.ts b/test/toolAuth.test.ts index 8837511..56518b0 100644 --- a/test/toolAuth.test.ts +++ b/test/toolAuth.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import Database from 'better-sqlite3'; import { migrate } from '../src/db/migrations.js'; -import { ToolAuth } from '../src/gateway/toolAuth.js'; +import { parseToolKind, ToolAuth } from '../src/gateway/toolAuth.js'; import { createSession, upsertBinding, type ConversationKey } from '../src/gateway/sessionStore.js'; test('ToolAuth consume supports once grants and persistent policy', () => { @@ -44,4 +44,49 @@ test('ToolAuth consume supports once grants and persistent policy', () => { toolAuth.setPersistentPolicy(binding.bindingKey, 'execute', 'allow'); assert.equal(toolAuth.consume(sessionKey, 'execute'), true); + + const allowList = toolAuth.listPersistentPolicies(binding.bindingKey, 'allow'); + assert.deepEqual( + allowList.map((row) => row.toolKind), + ['execute'], + ); + + const removed = toolAuth.clearPersistentPolicy( + binding.bindingKey, + 'execute', + 'allow', + ); + assert.equal(removed, true); + assert.equal(toolAuth.listPersistentPolicies(binding.bindingKey, 'allow').length, 0); + + toolAuth.setAllowPrefixRule(binding.bindingKey, 'read', '/tmp/allow'); + assert.equal( + toolAuth.consume(sessionKey, 'read', { + method: 'fs/read_text_file', + params: { path: '/tmp/allow/a.txt' }, + }), + true, + ); + assert.equal( + toolAuth.consume(sessionKey, 'read', { + method: 'fs/read_text_file', + params: { path: '/tmp/nope/a.txt' }, + }), + false, + ); + + const removedPrefix = toolAuth.clearAllowPrefixRule( + binding.bindingKey, + 'read', + '/tmp/allow', + ); + assert.equal(removedPrefix, true); + assert.equal(toolAuth.listAllowPrefixRules(binding.bindingKey, 'read').length, 0); +}); + +test('parseToolKind normalizes values and rejects unknown kinds', () => { + assert.equal(parseToolKind('READ'), 'read'); + assert.equal(parseToolKind(' execute '), 'execute'); + assert.equal(parseToolKind('unknown_kind'), null); + assert.equal(parseToolKind(null), null); });