From f52f871976aa75f85c7cd792774aa10c44d0dca6 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:23:59 +0200 Subject: [PATCH 1/4] feat: unified permission resolver across all approval seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One resolvePermissionDecision pipeline (hard-deny -> deny rules -> task lease -> allow sources -> boundary asks -> mode policy) now backs the native/API preflight, the XML and eager tool paths, the companion buildOnApproval gate, the MCP side-channel watcher, and the delegated agent callback. The three divergent per-gate permissionMode reads are retired; notably the smart-mode blanket auto-approve for delegated agents is replaced by an auto-edit floor clamp (file edits run free, Bash mutations still prompt), and an Always rule now suppresses the lease prompt instead of double-gating. New agonPermissionMode config key (ask|auto-edit|auto) migrates from the legacy permissionMode (auto->auto, smart->auto-edit, ask/deny-all->ask); deny-all stays a hard-deny override checked first. Leaseless delegated pushes and other external side effects still ask even in auto mode. The approval prompt fallback without a dispatch surface now denies instead of approving. 34-case golden decision table in tests/unit/permission-resolver.test.ts. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/cesar/brain.ts | 85 ++--- .../generated/cesar/permission-resolver.ts | 317 ++++++++++++++++++ packages/cli/src/generated/cesar/session.ts | 220 +++++------- .../generated/cesar/task-execution-lease.ts | 4 +- packages/cli/src/generated/cesar/tools.ts | 22 +- packages/cli/src/generated/handlers/agent.ts | 51 ++- packages/cli/src/kern/cesar/brain.kern | 75 ++--- .../src/kern/cesar/permission-resolver.kern | 283 ++++++++++++++++ packages/cli/src/kern/cesar/session.kern | 161 +++------ .../src/kern/cesar/task-execution-lease.kern | 4 +- packages/cli/src/kern/cesar/tools.kern | 12 +- packages/cli/src/kern/handlers/agent.kern | 36 +- packages/core/src/generated/models/types.ts | 60 ++-- packages/core/src/kern/models/types.kern | 6 + tests/unit/permission-resolver.test.ts | 298 ++++++++++++++++ 15 files changed, 1197 insertions(+), 437 deletions(-) create mode 100644 packages/cli/src/generated/cesar/permission-resolver.ts create mode 100644 packages/cli/src/kern/cesar/permission-resolver.kern create mode 100644 tests/unit/permission-resolver.test.ts diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 12398b34a..3fac609f4 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -46,7 +46,11 @@ import { markSteeringTurn, drainSteering, releaseSteeringTurn } from './steering import { beginCesarTurn, createCesarTurnRuntimeHost, resetStaleCesarTurnState, transitionCesarTurn, resolveCesarAbortOutcome, classifyCesarStreamError, releaseCesarTurnHandles, fenceStaleCesarTurn } from './turn-runtime.js'; -import { createTaskExecutionLease, authorizeTaskAction, isTaskFileMutationAction, isApprovedPermissionResponse, taskActionApprovalMessage } from './task-execution-lease.js'; +import { createTaskExecutionLease, isTaskFileMutationAction, isApprovedPermissionResponse, taskActionApprovalMessage, approveTaskAction } from './task-execution-lease.js'; + +import { resolveAgonPermissionMode, resolvePermissionDecision, authorizeResolvedTaskAction } from './permission-resolver.js'; + +import { getSessionAllowList } from '../signals/output.js'; import { resolveCesarHarnessProfile, evaluateAgenticTaskState, buildAgenticProgressSignature, buildAgenticAutoTurnDirective, resolveCesarToolReadOnlyMode, extractAgenticBashCommand, isAgenticMutationOutcome } from './task-controller.js'; @@ -58,7 +62,7 @@ import { consumeCesarPlanControlSignals } from './plan-control-signals.js'; import { hostNowIso, hostWaitForInteractiveChoice } from '../lib/kern-host.js'; -// @kern-source: brain:31 +// @kern-source: brain:33 export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input: string, response: string, cesarEngineId: string, streaming: boolean, dispatch: Dispatch, ctx: HandlerContext, telemetry?: Record, turnAlreadyCommitted?: boolean): Promise { // streaming-end commits a real stream OR (when only a speculative preview // draft sits on the pane) drops the draft without committing — safe no-op when @@ -91,7 +95,7 @@ export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input return { delegated: false, responded: true, decisionReason: 'delegation-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:58 +// @kern-source: brain:60 export async function commitTurnAndSuggest(suggestion: {action:string, rest?:string, hardened?:boolean, tribunalMode?:string, team?:boolean}, input: string, response: string, cesarEngineId: string, color: number, streaming: boolean, dispatch: Dispatch, ctx: HandlerContext, telemetry?: Record): Promise { // streaming-end commits a real stream OR drops a lingering speculative preview // draft without committing — safe no-op when there's no entry at all. @@ -122,10 +126,10 @@ export async function commitTurnAndSuggest(suggestion: {action:string, rest?:str return { delegated: false, responded: true, decisionReason: 'suggestion-cancelled', ...telemetry ?? {} }; } -// @kern-source: brain:83 +// @kern-source: brain:85 export const _noBriefNudged: WeakMap = new WeakMap(); -// @kern-source: brain:85 +// @kern-source: brain:87 export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: HandlerContext, images?: ImageAttachment[]): Promise { const abort = new AbortController(); const _turnStart = Date.now(); @@ -516,7 +520,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H _timelineEnabled = config.cesarToolTimeline !== false; ctx.cesar!.taskExecutionLease = createTaskExecutionLease( input, - ctx.autoModeQueued === true || ctx.cesar!.autoModeQueued === true || config.permissionMode === 'auto', + ctx.autoModeQueued === true || ctx.cesar!.autoModeQueued === true || resolveAgonPermissionMode(config) === 'auto', resolveWorkingDir(), { important: config.cesarImportantTaskPattern, @@ -955,10 +959,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H // Check if already responded const respPath = reqPath.replace('.json', '-response.json'); if (existsSync(respPath)) continue; - // Check auto-approved commands const cfg = loadConfig(); - const allowed: string[] = (cfg as any).allowedCommands ?? []; - const cmdBase = (req.args?.command ?? '').toString().trim().split(/\s+/)[0]; const reqTool = String(req.tool ?? 'tool'); const reqArgs = (req.args ?? {}) as Record; const logMcpApproval = (decision: 'approved'|'denied'|'prompted'|'blocked', source: string, reason?: string) => { @@ -988,35 +989,29 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H input: reqArgs, }); }; - // deny-all kill-switch wins over everything, including an allow rule. - // Checked BEFORE the rule gate so a permissionMode=deny-all session - // cannot be re-opened by a stray allow rule in .agon.json (F4). - if (String((cfg as any).permissionMode ?? 'ask') === 'deny-all') { - logMcpApproval('denied', 'settings.permissionMode', 'permissionMode=deny-all'); - writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: false, reason: 'All tool execution is denied (permissionMode=deny-all)' })); - continue; - } - // CC-parity allow/deny rules (.agon.json permissions) — deny ALWAYS - // wins and refuses without prompting; allow auto-approves; neither - // falls through to allowedCommands / self-turn / UI prompt. Uses the - // F2/F3-hardened gate: Bash compound-splitting + file-path resolution. - const mcpRuleSet = parsePermissionRuleSet((cfg as any).permissions); + // Unified resolver decision — same seam as the native preflight and + // buildOnApproval: deny-all kill switch first, then CC-parity rules + // (deny always wins, F2/F3-hardened), the turn's task lease, allow + // sources incl. allowedCommands compat, then the mode policy. const mcpRuleArg = reqTool === 'Bash' ? String(req.args?.command ?? '') : String(req.args?.file_path ?? ''); - const mcpRuleDecision = evaluateToolRules(reqTool, mcpRuleArg, resolveWorkingDir(), mcpRuleSet); - if (mcpRuleDecision === 'deny') { - logMcpApproval('denied', 'settings.permissions', `${reqTool} denied by permissions rule`); - writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: false, reason: `${reqTool} blocked by deny rule in .agon.json permissions` })); - continue; - } - if (mcpRuleDecision === 'allow') { - logMcpApproval('approved', 'settings.permissions', `${reqTool} allowed by permissions rule`); - writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: true })); + const mcpResolution = resolvePermissionDecision({ + tool: reqTool, + target: mcpRuleArg, + cwd: resolveWorkingDir(), + source: 'native', + config: cfg, + lease: ctx.cesar?.taskExecutionLease, + sessionAllowList: getSessionAllowList(), + }); + if (mcpResolution.decision === 'deny') { + logMcpApproval('denied', `resolver.${mcpResolution.stage}`, mcpResolution.reason); + writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: false, reason: `${reqTool} blocked (${mcpResolution.reason})` })); continue; } - if (cmdBase && allowed.some((a: string) => cmdBase.toLowerCase().startsWith(a.toLowerCase()))) { - logMcpApproval('approved', 'mcp.allowedCommands', 'command matched allowedCommands'); + if (mcpResolution.decision === 'allow') { + logMcpApproval('approved', `resolver.${mcpResolution.stage}`, mcpResolution.reason); writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: true })); continue; } @@ -1029,7 +1024,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H readFileState: (approvalCache as any).cache, permissionMode: ((cfg as any).permissionMode ?? 'ask') as any, explorationMode: ctx.explorationMode ?? false, - allowedCommands: allowed, + allowedCommands: (cfg as any).allowedCommands ?? [], toolPermissions: (cfg as any).toolPermissions ?? {}, readOnlyMode: !!(activePlan && ['planning', 'awaiting_approval'].includes(activePlan.state)), source: 'orchestrator' as const, @@ -1058,17 +1053,13 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H catch { /* diff is best-effort — fall back to command preview */ } } dispatch({ type: 'permission-ask', tool: req.tool, command: askCommand, reason: askReason, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' : approved; + const wasApproved = isApprovedPermissionResponse(approved); logMcpApproval(wasApproved ? 'approved' : 'denied', 'mcp.user-prompt', wasApproved ? 'user approved' : 'user denied'); - // Handle "Always" — persist to config - if ((typeof approved === 'string' && approved === 'a') || approved === true) { - if (cmdBase && typeof approved === 'string' && approved === 'a') { - const curAllowed: string[] = (loadConfig() as any).allowedCommands ?? []; - if (!curAllowed.includes(cmdBase)) { - curAllowed.push(cmdBase); - configSet('allowedCommands', curAllowed); - } - } + // Rule persistence for Always/Never is owned by the permission + // UI (signals/output.kern); here we only record the lease + // approval so the same boundary does not re-prompt this turn. + if (wasApproved && ctx.cesar?.taskExecutionLease) { + approveTaskAction(ctx.cesar.taskExecutionLease, reqTool, mcpRuleArg); } writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: wasApproved, reason: wasApproved ? undefined : 'User denied' })); }} as any); @@ -1755,10 +1746,8 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H const authorizeXmlTaskAction = async (tool: string, args: Record): Promise => { if (!taskExecutionLease || !needsTaskAuthorization(tool, args)) return true; const target = taskActionTarget(tool, args); - const authorization = await authorizeTaskAction( - taskExecutionLease, - tool, - target, + const authorization = await authorizeResolvedTaskAction( + { tool, target, cwd: resolveWorkingDir(), source: 'native', config, lease: taskExecutionLease, sessionAllowList: getSessionAllowList() }, (evaluation: any) => requestXmlTaskApproval(tool, args, evaluation), ); return authorization.decision === 'allow' diff --git a/packages/cli/src/generated/cesar/permission-resolver.ts b/packages/cli/src/generated/cesar/permission-resolver.ts new file mode 100644 index 000000000..c7a87fc95 --- /dev/null +++ b/packages/cli/src/generated/cesar/permission-resolver.ts @@ -0,0 +1,317 @@ +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/permission-resolver.kern + +import { resolve, relative, isAbsolute } from 'node:path'; + +import { parsePermissionRuleSet, parsePermissionRule, evaluateToolRules, hasShellControl, isReadOnlyCommand, loadConfig, configSet } from '@kernlang/agon-core'; + +import type { PermissionRuleSet } from '@kernlang/agon-core'; + +import { evaluateTaskAction, authorizeTaskAction, canonicalTaskActionSignature, isTaskFileMutationAction, relativePathEscapesWorkspace, isExternalSideEffectCommand, DEFAULT_DANGEROUS_PATTERN } from './task-execution-lease.js'; + +import type { TaskExecutionLease, TaskActionEvaluation } from './task-execution-lease.js'; + +// @kern-source: permission-resolver:15 +export type AgonPermissionMode = 'ask' | 'auto-edit' | 'auto'; + +// @kern-source: permission-resolver:16 +export type PermissionRequestSource = 'native' | 'delegated' | 'self-turn'; + +// @kern-source: permission-resolver:17 +export type PermissionDecisionKind = 'allow' | 'ask' | 'deny'; + +// @kern-source: permission-resolver:19 +export interface PermissionResolutionRequest { + tool: string; + target: string; + cwd: string; + source: PermissionRequestSource; + config: any; + lease?: TaskExecutionLease|undefined; + sessionAllowList?: string[]; +} + +// @kern-source: permission-resolver:28 +export interface PermissionResolution { + decision: PermissionDecisionKind; + reason: string; + stage: string; + signature: string; +} + +// @kern-source: permission-resolver:34 +export const AGON_PERMISSION_MODES: AgonPermissionMode[] = ['ask', 'auto-edit', 'auto'] as AgonPermissionMode[]; + +// @kern-source: permission-resolver:36 +export const READ_ONLY_PERMISSION_TOOLS: Set = new Set(['read', 'grep', 'glob', 'ls', 'notebookread', 'webfetch', 'websearch']); + +/** + * Resolve the effective permission mode from config. `agonPermissionMode` is the source of truth; when absent, legacy `permissionMode` migrates in place: auto→auto, smart→auto-edit (smart's heuristics auto-approved enough that ask would read as a regression), ask/deny-all→ask. deny-all additionally hard-denies via isPermissionHardDeny before the mode is ever consulted. + */ +// @kern-source: permission-resolver:38 +export function resolveAgonPermissionMode(config: any): AgonPermissionMode { + const raw = String(config?.agonPermissionMode ?? '').trim().toLowerCase(); + if ((AGON_PERMISSION_MODES as string[]).includes(raw)) return raw as AgonPermissionMode; + const legacy = String(config?.permissionMode ?? 'smart').trim().toLowerCase(); + if (legacy === 'auto') return 'auto'; + if (legacy === 'ask' || legacy === 'deny-all') return 'ask'; + return 'auto-edit'; +} + +/** + * Legacy deny-all kill switch. Kept out of the Shift+Tab cycle; the resolver checks it FIRST so a stray allow rule can never re-open a deny-all session. + */ +// @kern-source: permission-resolver:49 +export function isPermissionHardDeny(config: any): boolean { + return String(config?.permissionMode ?? '').trim().toLowerCase() === 'deny-all'; +} + +// @kern-source: permission-resolver:54 +export function cycleAgonPermissionMode(mode: AgonPermissionMode): AgonPermissionMode { + const index = AGON_PERMISSION_MODES.indexOf(mode); + return AGON_PERMISSION_MODES[(index + 1) % AGON_PERMISSION_MODES.length] ?? 'ask'; +} + +/** + * Delegated-agent floor clamp (brainstorm 5/6 consensus): orchestration runs execute at least auto-edit so a 3-engine forge run does not queue dozens of file-edit prompts, while Bash mutations still prompt. Strictly tighter than the retired smart-mode blanket approve. + */ +// @kern-source: permission-resolver:60 +export function clampDelegatedPermissionMode(mode: AgonPermissionMode): AgonPermissionMode { + return (mode === 'ask') ? 'auto-edit' : mode; +} + +// @kern-source: permission-resolver:65 +export function describeAgonPermissionMode(mode: AgonPermissionMode): {label:string,hint:string} { + if (mode === 'auto') return { label: 'AUTO', hint: 'workspace autonomy — pushes, publishing, and workspace escapes still prompt' }; + if (mode === 'auto-edit') return { label: 'auto-edit', hint: 'workspace file edits auto-approved — Bash mutations still prompt' }; + return { label: 'ask', hint: 'prompts before file edits and mutating commands' }; +} + +// @kern-source: permission-resolver:76 +export const _sessionPermissionRules: string[] = [] as string[]; + +// @kern-source: permission-resolver:78 +export function getSessionPermissionRules(): string[] { + return _sessionPermissionRules.slice(); +} + +// @kern-source: permission-resolver:80 +export function addSessionPermissionRule(rule: string): boolean { + const trimmed = String(rule ?? '').trim(); + if (!trimmed || !parsePermissionRule(trimmed)) return false; + if (!_sessionPermissionRules.includes(trimmed)) _sessionPermissionRules.push(trimmed); + return true; +} + +// @kern-source: permission-resolver:88 +export function clearSessionPermissionRules(): void { + _sessionPermissionRules.length = 0; +} + +/** + * Parse the persisted permissions object and merge in session-scoped allow rules. Deny rules are persisted-only: a session grant can widen, never narrow. + */ +// @kern-source: permission-resolver:92 +export function buildEffectivePermissionRuleSet(config: any): PermissionRuleSet { + const persisted = (config?.permissions && typeof config.permissions === 'object') ? config.permissions : {}; + const allow = Array.isArray((persisted as any).allow) ? (persisted as any).allow : []; + const deny = Array.isArray((persisted as any).deny) ? (persisted as any).deny : []; + return parsePermissionRuleSet({ allow: [...allow, ..._sessionPermissionRules], deny }); +} + +/** + * Containment check for the auto-edit mode policy. An empty cwd means the executing layer owns containment (delegated agents run in their own worktrees whose path this seam cannot know) and passes. Missing targets fail closed. + */ +// @kern-source: permission-resolver:101 +export function fileTargetInsideWorkspace(cwd: string, target: string): boolean { + if (!cwd) return true; + const candidate = String(target ?? '').trim(); + if (!candidate) return false; + const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(cwd, candidate); + return !relativePathEscapesWorkspace(relative(resolve(cwd), absolute)); +} + +/** + * Boundary backstop for approval seams that carry no task lease (delegated agent callbacks). Auto mode must not blanket-approve pushes, publishing, or other external side effects just because the lease classifier is out of reach. + */ +// @kern-source: permission-resolver:111 +export function isLeaselessBashBoundary(command: string): boolean { + const cmd = String(command ?? ''); + if (isExternalSideEffectCommand(cmd)) return true; + try { return new RegExp(DEFAULT_DANGEROUS_PATTERN, 'i').test(cmd); } + catch { return true; } +} + +/** + * The ONE seam where mode, rules, allowlists, and the task lease combine. Ordered: hard-deny → toolPermissions deny → deny rules → lease deny/allow → allow sources (toolPermissions allow, allow rules incl. session, allowedCommands compat, session base tokens) → lease boundary asks (dangerous/important — never absorbed by any mode) → mode policy with the delegated floor clamp. Callers map allow/deny directly and route ask into their prompt surface. + */ +// @kern-source: permission-resolver:120 +export function resolvePermissionDecision(request: PermissionResolutionRequest): PermissionResolution { + const tool = String(request.tool ?? '').trim(); + const target = String(request.target ?? ''); + const cwd = String(request.cwd ?? ''); + const cfg = request.config ?? {}; + const signature = canonicalTaskActionSignature(tool, target); + + if (isPermissionHardDeny(cfg)) { + return { decision: 'deny', reason: 'permissionMode=deny-all', stage: 'hard-deny', signature }; + } + const toolPermissions = (cfg.toolPermissions && typeof cfg.toolPermissions === 'object') ? cfg.toolPermissions : {}; + if (toolPermissions[tool] === 'deny') { + return { decision: 'deny', reason: `${tool} denied in settings`, stage: 'tool-permissions', signature }; + } + + const ruleSet = buildEffectivePermissionRuleSet(cfg); + const ruleDecision = evaluateToolRules(tool, target, cwd || process.cwd(), ruleSet); + if (ruleDecision === 'deny') { + return { decision: 'deny', reason: `${tool} denied by permissions rule`, stage: 'deny-rule', signature }; + } + + let leaseEvaluation: TaskActionEvaluation | null = null; + if (request.lease) { + leaseEvaluation = evaluateTaskAction(request.lease, tool, target); + if (leaseEvaluation.decision === 'deny') { + return { decision: 'deny', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + if (leaseEvaluation.decision === 'allow') { + return { decision: 'allow', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + } + + if (toolPermissions[tool] === 'allow') { + return { decision: 'allow', reason: `${tool} allowed in settings`, stage: 'tool-permissions', signature }; + } + if (ruleDecision === 'allow') { + return { decision: 'allow', reason: `${tool} allowed by permissions rule`, stage: 'allow-rule', signature }; + } + if (tool === 'Bash') { + const commandLower = target.trim().toLowerCase(); + const base = target.trim().split(/\s+/)[0] ?? ''; + const allowedCommands: string[] = Array.isArray(cfg.allowedCommands) ? cfg.allowedCommands : []; + if (commandLower && allowedCommands.some((entry: string) => commandLower.startsWith(String(entry).toLowerCase()))) { + return { decision: 'allow', reason: 'command matched allowedCommands', stage: 'allowed-commands', signature }; + } + const sessionList = Array.isArray(request.sessionAllowList) ? request.sessionAllowList : []; + if (commandLower && sessionList.some((entry: string) => commandLower.startsWith(String(entry).toLowerCase()) || base === entry)) { + return { decision: 'allow', reason: 'command matched session allowlist', stage: 'session-allowlist', signature }; + } + } + + if (leaseEvaluation && (leaseEvaluation.reason === 'dangerous_boundary' || leaseEvaluation.reason === 'important_task')) { + return { decision: 'ask', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + + const baseMode = resolveAgonPermissionMode(cfg); + const mode = request.source === 'delegated' ? clampDelegatedPermissionMode(baseMode) : baseMode; + if (mode === 'auto') { + if (tool === 'Bash' && !request.lease && isLeaselessBashBoundary(target)) { + return { decision: 'ask', reason: 'dangerous_boundary', stage: 'mode', signature }; + } + return { decision: 'allow', reason: 'auto mode', stage: 'mode', signature }; + } + const normalizedTool = tool.toLowerCase().replace(/^agon/, ''); + if (READ_ONLY_PERMISSION_TOOLS.has(normalizedTool)) { + return { decision: 'allow', reason: 'read-only tool', stage: 'mode', signature }; + } + if (tool === 'Bash' && isReadOnlyCommand(target)) { + return { decision: 'allow', reason: 'read-only command', stage: 'mode', signature }; + } + if (mode === 'auto-edit' && isTaskFileMutationAction(tool) && fileTargetInsideWorkspace(cwd, target)) { + return { decision: 'allow', reason: 'auto-edit mode approves workspace file edits', stage: 'mode', signature }; + } + return { decision: 'ask', reason: leaseEvaluation ? leaseEvaluation.reason : `${mode} mode requires approval`, stage: 'mode', signature }; +} + +/** + * Resolver-first replacement for raw authorizeTaskAction at the native/XML/eager preflights. allow/deny map straight through (an Always rule now suppresses the lease prompt instead of double-gating); ask routes into the lease's join/claim machinery when a lease exists so concurrent identical boundaries share ONE prompt, and falls back to a single direct approval otherwise. + */ +// @kern-source: permission-resolver:198 +export async function authorizeResolvedTaskAction(request: PermissionResolutionRequest, requestApproval: (evaluation:TaskActionEvaluation)=>Promise): Promise<{decision:'allow'|'deny',reason:string}> { + const resolution = resolvePermissionDecision(request); + if (resolution.decision === 'allow') return { decision: 'allow', reason: resolution.reason }; + if (resolution.decision === 'deny') return { decision: 'deny', reason: resolution.reason }; + if (request.lease) { + const authorization = await authorizeTaskAction(request.lease, request.tool, request.target, requestApproval); + return authorization.decision === 'allow' + ? { decision: 'allow', reason: authorization.reason } + : { decision: 'deny', reason: authorization.reason }; + } + const approved = await requestApproval({ decision: 'ask_boundary_once', signature: resolution.signature, reason: resolution.reason }); + return approved ? { decision: 'allow', reason: 'user_approved' } : { decision: 'deny', reason: 'user_denied' }; +} + +/** + * Synthesize a Claude-Code-parity rule string from an approved action. Bash uses the two-token scope (binary + first non-flag argument → 'Bash(git push:*)'); compound commands, substitution, globs, and bare single verbs synthesize nothing (fall back to a one-time approval). File tools synthesize an exact resolved-path rule so 'Always' never silently widens to a directory. + */ +// @kern-source: permission-resolver:216 +export function synthesizePermissionRule(tool: string, target: string, cwd: string): string|null { + const t = String(tool ?? '').trim(); + const raw = String(target ?? '').trim(); + if (!t || !raw) return null; + if (t === 'Bash') { + if (hasShellControl(raw)) return null; + const tokens = raw.split(/\s+/).map((token) => token.replace(/^['"]|['"]$/g, '')); + const base = tokens[0] ?? ''; + const sub = tokens.slice(1).find((token) => !token.startsWith('-')) ?? ''; + if (!base || !sub) return null; + if (/[*?`$(){}\[\]\\]/.test(base) || /[*?`$(){}\[\]\\]/.test(sub)) return null; + return `Bash(${base} ${sub}:*)`; + } + if (t === 'Edit' || t === 'Write' || t === 'MultiEdit' || t === 'Read') { + if (/[*?`$(){}\[\]\n]/.test(raw)) return null; + const absolute = isAbsolute(raw) ? resolve(raw) : resolve(cwd || process.cwd(), raw); + return `${t}(${absolute})`; + } + return null; +} + +/** + * Hard guard before persisting a synthesized rule: it must parse, a Bash rule must carry a two-token scope (never a bare verb like 'Bash(git)' or a star), and re-running the rule engine against the originating action must yield allow. + */ +// @kern-source: permission-resolver:239 +export function validateSynthesizedRule(rule: string, tool: string, target: string, cwd: string): boolean { + const parsed = parsePermissionRule(rule); + if (!parsed || !parsed.command) return false; + if (parsed.command.includes('*')) return false; + if (parsed.tool === 'Bash' && parsed.command.trim().split(/\s+/).length < 2) return false; + const outcome = evaluateToolRules(tool, target, cwd || process.cwd(), { allow: [parsed], deny: [] }); + return outcome === 'allow'; +} + +/** + * Append one rule string to the persisted permissions object (config scope resolution is configSet's job). Returns false without writing when the rule is already present. + */ +// @kern-source: permission-resolver:250 +export function persistPermissionRule(kind: 'allow'|'deny', rule: string): boolean { + const cfg = loadConfig() as any; + const current = (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {}; + const allow: string[] = Array.isArray(current.allow) ? current.allow.slice() : []; + const deny: string[] = Array.isArray(current.deny) ? current.deny.slice() : []; + const bucket = kind === 'deny' ? deny : allow; + if (bucket.includes(rule)) return false; + bucket.push(rule); + configSet('permissions' as any, { allow, deny } as any); + return true; +} + +/** + * Remove a rule string from BOTH persisted buckets and the session store. Returns true when anything was actually removed. + */ +// @kern-source: permission-resolver:264 +export function removePermissionRule(rule: string): boolean { + let removed = false; + const cfg = loadConfig() as any; + const current = (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {}; + const allow: string[] = Array.isArray(current.allow) ? current.allow.filter((entry: string) => entry !== rule) : []; + const deny: string[] = Array.isArray(current.deny) ? current.deny.filter((entry: string) => entry !== rule) : []; + if ((Array.isArray(current.allow) ? current.allow.length : 0) !== allow.length + || (Array.isArray(current.deny) ? current.deny.length : 0) !== deny.length) { + configSet('permissions' as any, { allow, deny } as any); + removed = true; + } + const sessionIndex = _sessionPermissionRules.indexOf(rule); + if (sessionIndex >= 0) { + _sessionPermissionRules.splice(sessionIndex, 1); + removed = true; + } + return removed; +} diff --git a/packages/cli/src/generated/cesar/session.ts b/packages/cli/src/generated/cesar/session.ts index 1e5c2f92b..6ec0eddea 100644 --- a/packages/cli/src/generated/cesar/session.ts +++ b/packages/cli/src/generated/cesar/session.ts @@ -38,13 +38,15 @@ import { approvalToolIsFileMutating, buildApprovalDiffPreview } from './approval import { isActiveCesarTurn, runTurnPermissionOnce, runTurnToolOnce } from './turn-runtime.js'; -import { approveTaskAction, authorizeTaskAction, claimTaskActionPrompt, evaluateTaskAction, isTaskFileMutationAction, taskActionApprovalMessage } from './task-execution-lease.js'; +import { approveTaskAction, claimTaskActionPrompt, isTaskFileMutationAction, taskActionApprovalMessage } from './task-execution-lease.js'; + +import { resolvePermissionDecision, authorizeResolvedTaskAction } from './permission-resolver.js'; import { recordCesarApprovalDecision, recordCesarToolTimeline, recordCesarConfidence, buildToolErrorDiagnostic } from './tool-observability.js'; import { resolveCesarHarnessProfile, isAgenticAutoMode } from './task-controller.js'; -// @kern-source: session:24 +// @kern-source: session:25 export const CESAR_SYSTEM_PROMPT: string = `You are Cesar, Agon AI orchestrator. CHARACTER — the most trusted advisor who doesn't need you to like him. @@ -202,7 +204,7 @@ RULE 10 — TURN CLOSURE: End every turn with one clear closing line so the user /** * Compact controller prompt for agentic AUTO. Deterministic tool leases, task state, epochs, and verification enforce the mechanics; this prompt states intent instead of duplicating the implementation manual. Keep below 10,000 characters before project/tool context. */ -// @kern-source: session:181 +// @kern-source: session:182 export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ "You are Cesar, Agon's autonomous coding orchestrator. Be precise, direct, calm, and useful. Match the user's language and level. Lead with outcomes, not process narration.", '', 'TASK OWNERSHIP', @@ -238,25 +240,25 @@ export const CESAR_AGENTIC_SYSTEM_PROMPT: string = [ /** * The EXACT RULE 1 — CONFIDENCE paragraph baked into CESAR_SYSTEM_PROMPT (the every-turn ReportConfidence ceremony). Held here verbatim so the invariants-mode rewrite is an exact string replacement: strict/shadow keep CESAR_SYSTEM_PROMPT byte-identical, invariants swaps this paragraph for CESAR_RULE_1_INVARIANTS. If RULE 1's wording in CESAR_SYSTEM_PROMPT ever changes, this const MUST change in lockstep or the replacement silently no-ops (the prompt stays strict). applyInvariantsRule1 fail-safes to the strict prompt on a mismatch AND emits a one-time console.warn so the drift is observable instead of silent. */ -// @kern-source: session:214 +// @kern-source: session:215 export const CESAR_RULE_1_STRICT: string = `RULE 1 — CONFIDENCE: Call ReportConfidence(value) FIRST on every turn. If you cannot call tools, write ~X% at the very start instead. No exceptions. On the FIRST turn about a topic, low confidence is expected — investigate, then report your INFORMED confidence. BUT you carry the whole conversation: files you already read, searches you already ran, and conclusions you already reached EARLIER THIS SESSION are still valid context — build on them and report informed confidence immediately. Re-read a file ONLY if it changed or you never saw it. Do NOT restart every turn from zero with "let me check what's going on" when the answer is already in your history — re-discovering what you already know makes you look lost and wastes the user's time.`; /** * RULE 1 rewrite for guard mode 'invariants'. The GuardPipeline's grounded-write/evidence invariants now ENFORCE the confidence signal structurally (a well-formed Edit after a Read IS the proof), so the every-turn ReportConfidence ceremony is demoted to on-demand. RULE 1b is kept verbatim via the strict template — only this paragraph is swapped. */ -// @kern-source: session:220 +// @kern-source: session:221 export const CESAR_RULE_1_INVARIANTS: string = `RULE 1 — CONFIDENCE: Report confidence via ReportConfidence ONLY when you are about to run a risky command (Bash mutations, multi-file writes, delegation) or when genuinely uncertain. Do NOT call it ritually every turn — a well-formed Edit after reading the file IS the confidence signal.`; /** * FIX 3 (R4) — module-level once-flag for applyInvariantsRule1's drift warning. A mutable {warned} holder (mutated in place, never frozen at module load) so the console.warn fires AT MOST ONCE per process even though buildCesarSystemPrompt calls applyInvariantsRule1 on every invariants-mode prompt assembly. Resettable in tests via the exported _resetInvariantsRule1DriftWarning seam. */ -// @kern-source: session:226 +// @kern-source: session:227 export const invariantsRule1DriftState = { warned: false }; /** * Test-only seam: reset the once-flag so a unit test can re-trigger applyInvariantsRule1's drift warning with a deliberately drifted prompt. Not used in production. */ -// @kern-source: session:229 +// @kern-source: session:230 export function _resetInvariantsRule1DriftWarning(): void { invariantsRule1DriftState.warned = false; } @@ -264,7 +266,7 @@ export function _resetInvariantsRule1DriftWarning(): void { /** * Rewrite the every-turn RULE 1 — CONFIDENCE ceremony to the on-demand 'invariants' form. Pure string transform on the assembled CESAR_SYSTEM_PROMPT: replaces the exact CESAR_RULE_1_STRICT paragraph with CESAR_RULE_1_INVARIANTS, leaving RULE 1b and everything else byte-identical. Only called on guard mode 'invariants' — strict/shadow never reach here, so the base prompt stays byte-identical for them by construction. If the strict text isn't found (RULE 1 wording in CESAR_SYSTEM_PROMPT drifted out of sync with the CESAR_RULE_1_STRICT const) it FAILS SAFE: it returns the prompt UNCHANGED (serving the stricter every-turn ceremony rather than silently dropping RULE 1) AND emits a one-time console.warn so the drift is observable instead of passing unnoticed. The warning is gated by a module-level once-flag (invariantsRule1DriftState) so it fires at most once per process despite the per-turn call cadence. */ -// @kern-source: session:235 +// @kern-source: session:236 export function applyInvariantsRule1(prompt: string): string { if (!prompt.includes(CESAR_RULE_1_STRICT)) { if (!invariantsRule1DriftState.warned) { @@ -279,19 +281,19 @@ export function applyInvariantsRule1(prompt: string): string { /** * FIX 6a — re-read ~/.agon/config.json's guardModes at most once per 60s. resolveCesarGuardMode runs on EVERY prompt assembly; the config rarely changes mid-session, so a minute-stale view is fine and keeps the synchronous file read off the per-turn prompt-build path. Mirrors GUARD_TELEMETRY_SNAPSHOT_TTL_MS in status-helpers. */ -// @kern-source: session:248 +// @kern-source: session:249 export const GUARD_MODES_CONFIG_TTL_MS: number = 60 * 1000; /** * FIX 6a — module-level {at, home, value} memo for readGuardModesFromConfig(). `home` keys the entry to the AGON_HOME that produced it so an in-process AGON_HOME change (tests, embedded use) can never serve another home's config for up to a TTL. Mutated in place; never frozen at module load. */ -// @kern-source: session:251 +// @kern-source: session:252 export const guardModesConfigCache = { at: 0, home: '', value: null as (ReturnType) }; /** * FIX 6a — memoized wrapper over readGuardModesFromConfig(): the synchronous ~/.agon/config.json read happens at most once per GUARD_MODES_CONFIG_TTL_MS, keyed by AGON_HOME so an in-process home change invalidates. Best-effort: a read failure caches null for the TTL. Mirrors loadGuardTelemetrySnapshot's memo pattern in status-helpers.kern. */ -// @kern-source: session:254 +// @kern-source: session:255 function readGuardModesFromConfigMemoized(): ReturnType { const now = Date.now(); const home = process.env.AGON_HOME?.trim() ?? ''; @@ -317,7 +319,7 @@ function readGuardModesFromConfigMemoized(): ReturnTypePromise): Promise { const targetCwd = cwd ?? resolveWorkingDir(); let spine = ''; @@ -578,19 +580,19 @@ export async function prepareCesarSystemPrompt(ctx: HandlerContext, cwd?: string return buildCesarSystemPrompt(ctx); } -// @kern-source: session:536 +// @kern-source: session:537 export const CESAR_SNAPSHOT_MSG_CHAR_CAP: number = 4000; -// @kern-source: session:538 +// @kern-source: session:539 export const CONFIDENCE_BLOCK_LIMIT: number = 2; -// @kern-source: session:540 +// @kern-source: session:541 export const SEARCH_NUDGE_THRESHOLD: number = 40; /** * Bound one message's text to CESAR_SNAPSHOT_MSG_CHAR_CAP with a truncation marker. Applied on BOTH snapshot paths (direct session history AND the chat-transcript fallback) so oversized content never floods Cesar's continuity context regardless of which path produced it. */ -// @kern-source: session:542 +// @kern-source: session:543 export function capSnapshotMessageContent(content: string): string { if (content.length <= CESAR_SNAPSHOT_MSG_CHAR_CAP) return content; return `${content.slice(0, CESAR_SNAPSHOT_MSG_CHAR_CAP)}\n… [${content.length - CESAR_SNAPSHOT_MSG_CHAR_CAP} chars truncated for Cesar context]`; @@ -599,7 +601,7 @@ export function capSnapshotMessageContent(content: string): string { /** * Render the `command` string shown in a Cesar permission prompt for a tool call. SaveMemory renders the human-readable '[
] ' (the durable fact the user is confirming) instead of an opaque JSON args blob; every other tool keeps the existing precedence: args.command -> args.file_path -> JSON.stringify(args). Shared across the API-native and both XML-loop permission builders so they render identically (the MCP watcher in brain.kern already special-cases SaveMemory the same way). Pure; tolerant of non-object args. */ -// @kern-source: session:549 +// @kern-source: session:550 export function renderToolPermissionCommand(tool: string, args: unknown): string { const a = (args && typeof args === 'object') ? (args as Record) : {}; if (tool === 'SaveMemory') { @@ -617,7 +619,7 @@ export function renderToolPermissionCommand(tool: string, args: unknown): string /** * Build a normalized continuity snapshot. Prefer the session's internal history; fall back to the visible chat transcript. Per-message string content is capped on EITHER path so review/brainstorm spam (or a huge tool result) doesn't flood Cesar's context; tool_calls/tool_call_id and non-string content are preserved untouched. */ -// @kern-source: session:565 +// @kern-source: session:566 export function buildCesarConversationSnapshot(session: PersistentSession|null, chatSession: any): Array<{role:string,content:any,tool_calls?:any[],tool_call_id?:string}> { const directHistory = session?.getMessageHistory?.() ?? []; if (directHistory.length > 0) { @@ -650,7 +652,7 @@ export function buildCesarConversationSnapshot(session: PersistentSession|null, /** * Persist the active Cesar conversation before the session is discarded. */ -// @kern-source: session:590 +// @kern-source: session:591 export function saveCesarConversationSnapshot(session: PersistentSession|null, chatSession: any): void { if (!session) return; const snapshot = buildCesarConversationSnapshot(session, chatSession); @@ -672,7 +674,7 @@ export function saveCesarConversationSnapshot(session: PersistentSession|null, c /** * Build the onToolCall callback for API engines with native function calling. */ -// @kern-source: session:610 +// @kern-source: session:611 export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, config: any): ((name:string, args:Record, callId:string, controlPlane?:any) => Promise) | undefined { const cwd = resolveWorkingDir(); const fsc = getProjectFileStateCache(cwd); @@ -944,7 +946,10 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, const needsLeasePreflight = isTaskFileMutationAction(name) || (name === 'Bash' && !isReadOnlyCommand(taskTarget)); if (lease && needsLeasePreflight) { - const authorization = await authorizeTaskAction(lease, name, taskTarget, (evaluation: any) => requestTaskApproval(name, evaluation)); + const authorization = await authorizeResolvedTaskAction( + { tool: name, target: taskTarget, cwd: resolveWorkingDir(), source: 'native', config: ctx.config, lease, sessionAllowList: getSessionAllowList() }, + (evaluation: any) => requestTaskApproval(name, evaluation), + ); if (authorization.decision !== 'allow') return `DENIED: ${name} was not executed (${authorization.reason}).`; } @@ -957,7 +962,10 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, ? String((args as any).command ?? '') : String((args as any).file_path ?? (args as any).path ?? (args as any).task ?? ''); if (lease) { - const authorization = await authorizeTaskAction(lease, tool, target, (evaluation: any) => requestTaskApproval(tool, evaluation)); + const authorization = await authorizeResolvedTaskAction( + { tool, target, cwd: resolveWorkingDir(), source: 'native', config: ctx.config, lease, sessionAllowList: getSessionAllowList() }, + (evaluation: any) => requestTaskApproval(tool, evaluation), + ); if (authorization.decision === 'deny') return false; if (authorization.decision === 'allow') return true; } @@ -1048,7 +1056,7 @@ export function buildOnToolCall(ctx: HandlerContext, toolRegistry: ToolRegistry, /** * Build the onApproval callback for engine tool approvals. Returns true to approve, false to deny silently, or a string to deny with a reason the engine can see. */ -// @kern-source: session:984 +// @kern-source: session:991 export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:string, command:string, controlPlane?:any) => Promise { const engine = ctx.registry.get(engineId); const evaluateApproval = async (tool: string, command: string): Promise => { @@ -1060,18 +1068,14 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st // Map engine tool names to Agon tool names const toolMap: Record = { shell: 'Bash', bash: 'Bash', edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', read: 'Read', grep: 'Grep', glob: 'Glob' }; const agonTool = toolMap[tool.toLowerCase()] ?? tool; - const perm = perms[agonTool]; - // CC-parity allow/deny rules (.agon.json permissions). For Bash the - // rule command is the shell command (F2: compound `a && b` is split so a - // prefix-allow can't approve an appended `rm -rf /`); for file tools it is - // the path argument, resolved absolute + symlink-canonical so `Edit(/etc:*)` - // blocks `/etc/passwd` and `../` escapes are caught (F3). A bare tool rule - // (Edit/Write) matches any invocation. deny ALWAYS wins. - const ruleSet = parsePermissionRuleSet((cfg as any).permissions); + // For Bash the resolver target is the shell command (F2: compound + // `a && b` is split so a prefix-allow can't approve an appended + // `rm -rf /`); for file tools it is the path argument, resolved + // absolute + symlink-canonical (F3). Rule evaluation itself happens + // inside resolvePermissionDecision. const ruleArg = agonTool === 'Bash' ? command : String((approvalArgsFromCommand(agonTool, command) as any)?.file_path ?? ''); - const ruleDecision = evaluateToolRules(agonTool, ruleArg, resolveWorkingDir(), ruleSet); const turnId = ctx.cesar?.turnId; const cwd = resolveWorkingDir(); const logApproval = (decision: 'approved'|'denied'|'prompted'|'blocked', source: string, reason?: string, args?: Record | string) => { @@ -1161,70 +1165,38 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st } } - // CC-parity deny rule → refuse without prompting (deny ALWAYS wins). - if (ruleDecision === 'deny') { - logApproval('denied', 'settings.permissions', `${agonTool} denied by permissions rule`); - return `DENIED: ${agonTool}${agonTool === 'Bash' ? ` (${command})` : ''} is blocked by a deny rule in .agon.json permissions. Do not retry this — choose a different approach or ask the user to amend the rule.`; - } - - // deny → block immediately - if (perm === 'deny' || mode === 'deny-all') { - logApproval('denied', perm === 'deny' ? 'settings.toolPermissions' : 'settings.permissionMode', perm === 'deny' ? `${agonTool} denied in settings` : 'permissionMode=deny-all'); + // Unified permission decision — the ONE seam where hard-deny, CC-parity + // rules (deny always wins), the turn's task lease, allow sources, and the + // agonPermissionMode policy combine. The retired per-gate mode reads + // (deny-all, allow/auto, smart blanket-approve, allowedCommands) all live + // inside resolvePermissionDecision now. + const taskLease = ctx.cesar?.taskExecutionLease; + const taskTarget = agonTool === 'Bash' ? command : ruleArg; + const resolution = resolvePermissionDecision({ + tool: agonTool, + target: taskTarget, + cwd, + source: 'native', + config: cfg, + lease: taskLease, + sessionAllowList: getSessionAllowList(), + }); + if (resolution.decision === 'deny') { + logApproval('denied', `resolver.${resolution.stage}`, resolution.reason); + if (resolution.stage === 'deny-rule') { + return `DENIED: ${agonTool}${agonTool === 'Bash' ? ` (${command})` : ''} is blocked by a deny rule in .agon.json permissions. Do not retry this — choose a different approach or ask the user to amend the rule.`; + } return false; } - - // CC-parity allow rule → auto-approve without prompting. - if (ruleDecision === 'allow') { - logApproval('approved', 'settings.permissions', `${agonTool} allowed by permissions rule`); + if (resolution.decision === 'allow') { + logApproval('approved', `resolver.${resolution.stage}`, resolution.reason); return true; } - // One turn-scoped authority decision shared by companion and native - // adapters. Routine AUTO work proceeds without keyboard prompts; - // important work asks once for the task; dangerous boundaries ask for - // the exact action+target unless the user's request already named both. - const taskLease = ctx.cesar?.taskExecutionLease; - if (taskLease) { - const taskTarget = agonTool === 'Bash' ? command : ruleArg; - const evaluation = evaluateTaskAction(taskLease, agonTool, taskTarget); - if (evaluation.decision === 'deny') { - logApproval('denied', 'task-lease', evaluation.reason); - return false; - } - if (evaluation.decision === 'allow') { - logApproval('approved', 'task-lease', evaluation.reason); - return true; - } - if (!claimTaskActionPrompt(taskLease, evaluation.signature)) { - logApproval('denied', 'task-lease', 'duplicate or previously declined approval boundary'); - return false; - } - return new Promise((resolve) => { - const dispatch = ctx.cesar!.lastDispatch; - if (!dispatch) { - logApproval('denied', 'task-lease', 'approval required but no prompt surface is available'); - resolve(false); - return; - } - logApproval('prompted', 'task-lease', evaluation.reason); - let permDiffPreview: any = undefined; - if (approvalToolIsFileMutating(agonTool)) { - try { permDiffPreview = buildApprovalDiffPreview(agonTool, approvalArgsFromCommand(agonTool, command)); } - catch { /* diff is best-effort — fall back to command preview */ } - } - dispatch({ type: 'permission-ask', tool: agonTool, command, reason: taskActionApprovalMessage(evaluation), diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved; - if (wasApproved) approveTaskAction(taskLease, agonTool, taskTarget); - logApproval(wasApproved ? 'approved' : 'denied', 'task-lease', wasApproved ? 'user approved' : 'user denied'); - resolve(wasApproved); - } } as any); - }); - } - // Cesar self-turn fast path: bounded edits/writes on files already read // in the project cache do not need to interrupt the stream for another - // Y/N prompt. This intentionally runs after exploration/plan/confidence - // gates and explicit denies, and before the generic ask-mode fallback. + // Y/N prompt. Runs only after the resolver said ask, so explicit denies + // and read-only modes can never be bypassed by it. if (agonTool === 'Edit' || agonTool === 'Write' || agonTool === 'MultiEdit') { const approvalCwd = resolveWorkingDir(); const approvalCache = getProjectFileStateCache(approvalCwd); @@ -1246,43 +1218,20 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st } } - // allow → auto-approve - if (perm === 'allow' || mode === 'auto') { - logApproval('approved', perm === 'allow' ? 'settings.toolPermissions' : 'settings.permissionMode', perm === 'allow' ? `${agonTool} allowed in settings` : 'permissionMode=auto'); - return true; - } - - // smart → auto-approve orchestrator context and session allowlist, ask otherwise - if (mode === 'smart') { - // Session allowlist check - const sessionList = getSessionAllowList(); - if (agonTool === 'Bash' && sessionList.length > 0) { - const cmdLower = command.toLowerCase(); - const base = command.trim().split(/\s+/)[0]; - if (sessionList.some((a: string) => cmdLower.startsWith(a.toLowerCase()) || base === a)) { - logApproval('approved', 'session-allowlist', 'Bash command matched session allowlist'); - return true; - } - } - // Cesar tool loop is always orchestrator — auto-approve non-dangerous - logApproval('approved', 'settings.smart-orchestrator', 'permissionMode=smart orchestrator context'); - return true; - } - - // For Bash: check allowedCommands whitelist - if (agonTool === 'Bash' && allowed.length > 0) { - const cmdLower = command.toLowerCase(); - if (allowed.some((a: string) => cmdLower.startsWith(a.toLowerCase()))) { - logApproval('approved', 'settings.allowedCommands', 'Bash command matched allowedCommands'); - return true; - } + // ask → one prompt per lease signature (duplicate/declined boundaries + // do not re-prompt), then the same permission UI as Claude Code. + if (taskLease && !claimTaskActionPrompt(taskLease, resolution.signature)) { + logApproval('denied', 'task-lease', 'duplicate or previously declined approval boundary'); + return false; } - - // ask → show permission prompt (same UI as Claude Code) + const boundaryReasons = ['auto_off', 'dangerous_boundary', 'important_task']; + const promptReason = boundaryReasons.includes(resolution.reason) + ? taskActionApprovalMessage({ decision: resolution.reason === 'important_task' ? 'ask_task_once' : 'ask_boundary_once', signature: resolution.signature, reason: resolution.reason }) + : `Cesar (${engineId}) wants to execute`; return new Promise((resolve) => { const dispatch = ctx.cesar!.lastDispatch; if (dispatch) { - logApproval('prompted', 'user-prompt', `Cesar (${engineId}) wants to execute`); + logApproval('prompted', 'user-prompt', promptReason); // File-mutating tools carry a unified diff (computed from the tool // input before execution) so the approval shows the real change. let permDiffPreview: any = undefined; @@ -1290,14 +1239,15 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st try { permDiffPreview = buildApprovalDiffPreview(agonTool, approvalArgsFromCommand(agonTool, command)); } catch { /* diff is best-effort — fall back to command preview */ } } - dispatch({ type: 'permission-ask', tool: agonTool, command, reason: `Cesar (${engineId}) wants to execute`, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved; + dispatch({ type: 'permission-ask', tool: agonTool, command, reason: promptReason, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { + const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' || approved === 's' : !!approved; + if (wasApproved && taskLease) approveTaskAction(taskLease, agonTool, taskTarget); logApproval(wasApproved ? 'approved' : 'denied', 'user-prompt', wasApproved ? 'user approved' : 'user denied'); resolve(wasApproved); } } as any); } else { - logApproval('approved', 'fallback.no-dispatch', 'no dispatch available for approval prompt'); - resolve(true); + logApproval('denied', 'fallback.no-dispatch', 'approval required but no prompt surface is available'); + resolve(false); } }); }; @@ -1314,7 +1264,7 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st }; } -// @kern-source: session:1251 +// @kern-source: session:1200 export function normalizeCesarMcpServers(raw: unknown): Array> { const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); @@ -1348,7 +1298,7 @@ export function normalizeCesarMcpServers(raw: unknown): Array>|undefined { if (!(config as any).cesarMcpEnabled) return undefined; @@ -1372,7 +1322,7 @@ export function loadCesarMcpServers(config: any, cwd: string): Array/mcp/index.js (see tsup.config.ts), so the published install is self-contained — no @kernlang/agon-mcp npm dependency. Resolution order: (0) the bundled sibling /mcp/index.js (the published, self-contained path), (1) node module resolution of @kernlang/agon-mcp (monorepo-via-symlink / legacy installs), (2) walk up to the repo root containing packages/mcp/dist/index.js (monorepo without a symlink), (3) the original relative guess as a last resort. `fromUrl` is for tests; defaults to this module's URL. */ -// @kern-source: session:1334 +// @kern-source: session:1283 export function resolveAgonMcpServerPath(fromUrl?: string): string { const raw = fromUrl ?? import.meta.url; // Accept either a file: URL (normal) or a bare path (defensive): fileURLToPath @@ -1438,7 +1388,7 @@ export function resolveAgonMcpServerPath(fromUrl?: string): string { /** * Single source of truth for which backend a Cesar engine will actually use. Honours config.cesarBackend preference ('auto' | 'cli' | 'api'). Pure — no side effects beyond registry lookups. Returns backend='none' when the engine has neither a usable binary nor an API key; callers decide how to handle that. */ -// @kern-source: session:1366 +// @kern-source: session:1315 export function resolveCesarBackend(ctx: HandlerContext, engineId?: string): { backend: 'cli'|'api'|'none', binaryPath: string, hasBinary: boolean, hasApi: boolean, engine: any } { const config = ctx.config; const cesarEngineId = engineId ?? (config as any).cesarEngine ?? config.forgeFixedStarter ?? 'claude'; @@ -1463,7 +1413,7 @@ export function resolveCesarBackend(ctx: HandlerContext, engineId?: string): { b return { backend: 'none', binaryPath: '', hasBinary, hasApi, engine }; } -// @kern-source: session:1392 +// @kern-source: session:1341 export async function ensureCesarSession(ctx: HandlerContext): Promise { const config = ctx.config; const cesarEngineId = (config as any).cesarEngine ?? config.forgeFixedStarter ?? 'claude'; diff --git a/packages/cli/src/generated/cesar/task-execution-lease.ts b/packages/cli/src/generated/cesar/task-execution-lease.ts index 1aebe9451..ef53c948b 100644 --- a/packages/cli/src/generated/cesar/task-execution-lease.ts +++ b/packages/cli/src/generated/cesar/task-execution-lease.ts @@ -12,11 +12,11 @@ export type TaskActionDecision = 'allow' | 'ask_task_once' | 'ask_boundary_once' export type TaskHarnessProfile = 'legacy' | 'agentic'; /** - * Normalize the REPL permission UI contract: y approves once, a approves the session, booleans pass through. + * Normalize the REPL permission UI contract: y approves once, s approves for the session, a persists an allow rule, booleans pass through. */ // @kern-source: task-execution-lease:7 export function isApprovedPermissionResponse(value: boolean|string): boolean { - return (typeof value === 'string') ? (value === 'y' || value === 'a') : (value === true); + return (typeof value === 'string') ? (value === 'y' || value === 'a' || value === 's') : (value === true); } // @kern-source: task-execution-lease:12 diff --git a/packages/cli/src/generated/cesar/tools.ts b/packages/cli/src/generated/cesar/tools.ts index 9f2a4c5af..eae499937 100644 --- a/packages/cli/src/generated/cesar/tools.ts +++ b/packages/cli/src/generated/cesar/tools.ts @@ -1,6 +1,6 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/tools.kern -import { ToolRegistry, getProjectFileStateCache, createReadTool, createEditTool, createMultiEditTool, createWriteTool, createBashTool, createGrepTool, createGlobTool, createForgeTool, createBrainstormTool, createTribunalTool, createCampfireTool, createPipelineTool, createGoalTool, createConquerTool, createReviewTool, createDelegateTool, createAgentTool, createReportConfidenceTool, createProposePlanTool, createExitPlanModeTool, createListPlansTool, createRetrieveResultTool, createQuickNeroTool, createTodoWriteTool, createSaveMemoryTool, executeToolCall, resolveWorkingDir, parsePermissionRuleSet, parseToolHooks, isReadOnlyCommand } from '@kernlang/agon-core'; +import { ToolRegistry, getProjectFileStateCache, createReadTool, createEditTool, createMultiEditTool, createWriteTool, createBashTool, createGrepTool, createGlobTool, createForgeTool, createBrainstormTool, createTribunalTool, createCampfireTool, createPipelineTool, createGoalTool, createConquerTool, createReviewTool, createDelegateTool, createAgentTool, createReportConfidenceTool, createProposePlanTool, createExitPlanModeTool, createListPlansTool, createRetrieveResultTool, createQuickNeroTool, createTodoWriteTool, createSaveMemoryTool, executeToolCall, resolveWorkingDir, parsePermissionRuleSet, parseToolHooks, isReadOnlyCommand, loadConfig } from '@kernlang/agon-core'; import type { ToolContext, ToolCallResult } from '@kernlang/agon-core'; @@ -8,14 +8,18 @@ import type { Dispatch, HandlerContext } from '../../handlers/types.js'; import { createCouncilTool } from './council-tool.js'; -import { authorizeTaskAction, isTaskFileMutationAction, taskActionApprovalMessage } from './task-execution-lease.js'; +import { isTaskFileMutationAction, taskActionApprovalMessage } from './task-execution-lease.js'; + +import { authorizeResolvedTaskAction } from './permission-resolver.js'; + +import { getSessionAllowList } from '../signals/output.js'; import { isBashToolName } from './brain-helpers.js'; /** * Create and populate the standard Cesar tool registry. Single source of truth — no more duplication. */ -// @kern-source: tools:8 +// @kern-source: tools:10 export function createCesarToolRegistry(engineId?: string): ToolRegistry { const toolRegistry = new ToolRegistry(); toolRegistry.register(createReadTool()); @@ -50,7 +54,7 @@ export function createCesarToolRegistry(engineId?: string): ToolRegistry { /** * Create a shared ToolContext for eager tool execution during streaming. */ -// @kern-source: tools:40 +// @kern-source: tools:42 export function createEagerToolContext(ctx: HandlerContext, config: any, signal: AbortSignal, dispatch: Dispatch): ToolContext { const cwd = resolveWorkingDir(); const fsc = getProjectFileStateCache(cwd); @@ -61,7 +65,7 @@ export function createEagerToolContext(ctx: HandlerContext, config: any, signal: /** * Parse a streaming tool input into a JSON object. Malformed input is returned as an explicit retryable error instead of being silently coerced. */ -// @kern-source: tools:48 +// @kern-source: tools:50 export function parseEagerToolInput(toolName: string, input: unknown): {ok:boolean,input?:Record,error?:string,raw:string} { const raw = typeof input === 'string' ? input @@ -113,7 +117,7 @@ export function parseEagerToolInput(toolName: string, input: unknown): {ok:boole /** * Execute a tool eagerly during streaming — parse input, run, dispatch result. */ -// @kern-source: tools:98 +// @kern-source: tools:100 export async function executeEagerTool(toolName: string, meta: Record, toolRegistry: ToolRegistry, toolCtx: ToolContext, dispatch: Dispatch, cesarEngineId: string): Promise { const callId = (meta.toolCallId as string) ?? `eager-${Date.now()}`; const parsed = parseEagerToolInput(toolName, meta.input); @@ -144,10 +148,8 @@ export async function executeEagerTool(toolName: string, meta: Record requestTaskApproval( tool, taskActionApprovalMessage(evaluation), diff --git a/packages/cli/src/generated/handlers/agent.ts b/packages/cli/src/generated/handlers/agent.ts index 121b23584..ecaa737e9 100644 --- a/packages/cli/src/generated/handlers/agent.ts +++ b/packages/cli/src/generated/handlers/agent.ts @@ -12,7 +12,9 @@ import { join } from 'node:path'; import { getSessionAllowList } from '../signals/output.js'; -// @kern-source: agent:24 +import { resolvePermissionDecision } from '../cesar/permission-resolver.js'; + +// @kern-source: agent:25 export interface RunAgentOptions { engineId?: string; maxTurns?: number; @@ -22,7 +24,7 @@ export interface RunAgentOptions { parentSignal?: AbortSignal; } -// @kern-source: agent:32 +// @kern-source: agent:33 export interface AgentContinuationResult { kind: string; status: string; @@ -37,7 +39,7 @@ export interface AgentContinuationResult { workspaceChangedInPlace: boolean; } -// @kern-source: agent:45 +// @kern-source: agent:46 export function clipAgentText(text: string|null|undefined, limit: number): string { const raw = String(text ?? '').trim(); if (!raw) { @@ -47,18 +49,14 @@ export function clipAgentText(text: string|null|undefined, limit: number): strin } /** - * Shared approval callback for delegated agent runs. Applies config-level allow/deny rules first, then falls back to the UI permission prompt. + * Shared approval callback for delegated agent runs, routed through the unified permission resolver with source='delegated' (floor-clamped to auto-edit: file edits run free, Bash mutations still prompt — the old smart-mode blanket auto-approve is retired). Exploration and plan-mode read-only blocks stay in front with instructive messages. cwd is intentionally empty: delegated agents execute in their own worktrees whose containment the executing tool layer owns. */ -// @kern-source: agent:52 +// @kern-source: agent:53 export function buildAgentApprovalCallback(dispatch: Dispatch, ctx: HandlerContext, engineId: string): (tool:string, command:string, reason?:string)=>Promise { return async (tool: string, command: string, reason?: string): Promise => { const cfg = ctx.config; - const perms = (cfg as any).toolPermissions ?? {}; - const allowed = (cfg as any).allowedCommands ?? []; - const mode = (cfg as any).permissionMode ?? 'ask'; const toolMap: Record = { shell: 'Bash', bash: 'Bash', edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', read: 'Read', grep: 'Grep', glob: 'Glob' }; const agonTool = toolMap[tool.toLowerCase()] ?? tool; - const perm = perms[agonTool]; if (ctx.explorationMode) { const WRITE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'Bash']; @@ -79,25 +77,16 @@ export function buildAgentApprovalCallback(dispatch: Dispatch, ctx: HandlerConte } } - if (perm === 'deny' || mode === 'deny-all') return false; - if (perm === 'allow' || mode === 'auto') return true; - - // smart mode: auto-approve session allowlist, ask for user-sourced mutating ops - if (mode === 'smart') { - const sessionList = getSessionAllowList(); - if (agonTool === 'Bash' && sessionList.length > 0) { - const cmdLower = command.toLowerCase(); - const base = command.trim().split(/\s+/)[0]; - if (sessionList.some((a: string) => cmdLower.startsWith(a.toLowerCase()) || base === a)) return true; - } - // Delegated agent runs are orchestrator context — auto-approve - return true; - } - - if (agonTool === 'Bash' && allowed.length > 0) { - const cmdLower = command.toLowerCase(); - if (allowed.some((a: string) => cmdLower.startsWith(a.toLowerCase()))) return true; - } + const resolution = resolvePermissionDecision({ + tool: agonTool, + target: command, + cwd: '', + source: 'delegated', + config: cfg, + sessionAllowList: getSessionAllowList(), + }); + if (resolution.decision === 'deny') return false; + if (resolution.decision === 'allow') return true; return new Promise((resolve) => { dispatch({ @@ -114,7 +103,7 @@ export function buildAgentApprovalCallback(dispatch: Dispatch, ctx: HandlerConte /** * Run one autonomous agent invocation. Creates a session, calls session.step() once (which internally loops up to maxInnerSteps tool calls), emits OutputEvents throughout, handles Ctrl+C via the KERN-generated abort signal bridged to session.cancel(). */ -// @kern-source: agent:115 +// @kern-source: agent:103 export async function runAgentMode(input: string, dispatch: Dispatch, ctx: HandlerContext, opts?: RunAgentOptions): Promise { const abort = new AbortController(); // ── Resolve engine ───────────────────────────────────────── @@ -511,7 +500,7 @@ export async function runAgentMode(input: string, dispatch: Dispatch, ctx: Handl return followUp; } -// @kern-source: agent:520 +// @kern-source: agent:508 export interface RunAgentTeamOptions { engines?: string[]; taskKind?: 'edit'|'investigate'; @@ -530,7 +519,7 @@ export interface RunAgentTeamOptions { /** * Run an autonomous agent team: N AgentSession instances in N worktrees with shared budget, synthesis, and explicit transcript events. Used by Cesar-driven team mode and by /agent-team slash command. Wraps AgentTeam from core/cesar/agent-team.kern. */ -// @kern-source: agent:534 +// @kern-source: agent:522 export async function runAgentTeam(input: string, dispatch: Dispatch, ctx: HandlerContext, opts?: RunAgentTeamOptions): Promise { const abort = new AbortController(); // ── Resolve members ─────────────────────────────────────── diff --git a/packages/cli/src/kern/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index f0eaf182d..3cad42c8a 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -21,7 +21,9 @@ import from="./approval-diff.js" names="approvalToolIsFileMutating,buildApproval import from="./tool-observability.js" names="createCesarTurnId,recordCesarApprovalDecision,recordCesarToolTimeline,recordCesarConfidence" import from="./steering.js" names="markSteeringTurn,drainSteering,releaseSteeringTurn" import from="./turn-runtime.js" names="beginCesarTurn,createCesarTurnRuntimeHost,resetStaleCesarTurnState,transitionCesarTurn,resolveCesarAbortOutcome,classifyCesarStreamError,releaseCesarTurnHandles,fenceStaleCesarTurn" -import from="./task-execution-lease.js" names="createTaskExecutionLease,authorizeTaskAction,isTaskFileMutationAction,isApprovedPermissionResponse,taskActionApprovalMessage" +import from="./task-execution-lease.js" names="createTaskExecutionLease,isTaskFileMutationAction,isApprovedPermissionResponse,taskActionApprovalMessage,approveTaskAction" +import from="./permission-resolver.js" names="resolveAgonPermissionMode,resolvePermissionDecision,authorizeResolvedTaskAction" +import from="../signals/output.js" names="getSessionAllowList" import from="./task-controller.js" names="resolveCesarHarnessProfile,evaluateAgenticTaskState,buildAgenticProgressSignature,buildAgenticAutoTurnDirective,resolveCesarToolReadOnlyMode,extractAgenticBashCommand,isAgenticMutationOutcome" import from="./brain-helpers.js" names="yieldToInk,recordCesarTurn,splitBeforeToolMarkup,XML_TOOL_MARKUP_HOLD_CHARS,createTodosDisplayStripper,createPreambleStripper,findTrailingUserQuestion,detectAwaitingUserInput,detectNarratedToolStall,detectMutationIntentStall,detectFabricatedDelegation,shouldDeescalateGuard,withEagerToolCallId,claimEagerToolExecution,eagerFailedToolNames,shouldRunEagerRepairTool,shouldStopAfterXmlToolCall,buildReviewFollowupPrompt,extractDelegation,isBashToolName,isWriteToolName" import from="./confirmation-follow-up.js" names="runCesarConfirmationFollowUp" @@ -473,7 +475,7 @@ fn name=handleCesarBrain async=true params="input:string, dispatch:Dispatch, ctx _timelineEnabled = config.cesarToolTimeline !== false; ctx.cesar!.taskExecutionLease = createTaskExecutionLease( input, - ctx.autoModeQueued === true || ctx.cesar!.autoModeQueued === true || config.permissionMode === 'auto', + ctx.autoModeQueued === true || ctx.cesar!.autoModeQueued === true || resolveAgonPermissionMode(config) === 'auto', resolveWorkingDir(), { important: config.cesarImportantTaskPattern, @@ -912,10 +914,7 @@ ${reviewFollowup.prompt}`; // Check if already responded const respPath = reqPath.replace('.json', '-response.json'); if (existsSync(respPath)) continue; - // Check auto-approved commands const cfg = loadConfig(); - const allowed: string[] = (cfg as any).allowedCommands ?? []; - const cmdBase = (req.args?.command ?? '').toString().trim().split(/\s+/)[0]; const reqTool = String(req.tool ?? 'tool'); const reqArgs = (req.args ?? {}) as Record; const logMcpApproval = (decision: 'approved'|'denied'|'prompted'|'blocked', source: string, reason?: string) => { @@ -945,35 +944,29 @@ ${reviewFollowup.prompt}`; input: reqArgs, }); }; - // deny-all kill-switch wins over everything, including an allow rule. - // Checked BEFORE the rule gate so a permissionMode=deny-all session - // cannot be re-opened by a stray allow rule in .agon.json (F4). - if (String((cfg as any).permissionMode ?? 'ask') === 'deny-all') { - logMcpApproval('denied', 'settings.permissionMode', 'permissionMode=deny-all'); - writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: false, reason: 'All tool execution is denied (permissionMode=deny-all)' })); - continue; - } - // CC-parity allow/deny rules (.agon.json permissions) — deny ALWAYS - // wins and refuses without prompting; allow auto-approves; neither - // falls through to allowedCommands / self-turn / UI prompt. Uses the - // F2/F3-hardened gate: Bash compound-splitting + file-path resolution. - const mcpRuleSet = parsePermissionRuleSet((cfg as any).permissions); + // Unified resolver decision — same seam as the native preflight and + // buildOnApproval: deny-all kill switch first, then CC-parity rules + // (deny always wins, F2/F3-hardened), the turn's task lease, allow + // sources incl. allowedCommands compat, then the mode policy. const mcpRuleArg = reqTool === 'Bash' ? String(req.args?.command ?? '') : String(req.args?.file_path ?? ''); - const mcpRuleDecision = evaluateToolRules(reqTool, mcpRuleArg, resolveWorkingDir(), mcpRuleSet); - if (mcpRuleDecision === 'deny') { - logMcpApproval('denied', 'settings.permissions', `${reqTool} denied by permissions rule`); - writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: false, reason: `${reqTool} blocked by deny rule in .agon.json permissions` })); - continue; - } - if (mcpRuleDecision === 'allow') { - logMcpApproval('approved', 'settings.permissions', `${reqTool} allowed by permissions rule`); - writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: true })); + const mcpResolution = resolvePermissionDecision({ + tool: reqTool, + target: mcpRuleArg, + cwd: resolveWorkingDir(), + source: 'native', + config: cfg, + lease: ctx.cesar?.taskExecutionLease, + sessionAllowList: getSessionAllowList(), + }); + if (mcpResolution.decision === 'deny') { + logMcpApproval('denied', `resolver.${mcpResolution.stage}`, mcpResolution.reason); + writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: false, reason: `${reqTool} blocked (${mcpResolution.reason})` })); continue; } - if (cmdBase && allowed.some((a: string) => cmdBase.toLowerCase().startsWith(a.toLowerCase()))) { - logMcpApproval('approved', 'mcp.allowedCommands', 'command matched allowedCommands'); + if (mcpResolution.decision === 'allow') { + logMcpApproval('approved', `resolver.${mcpResolution.stage}`, mcpResolution.reason); writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: true })); continue; } @@ -986,7 +979,7 @@ ${reviewFollowup.prompt}`; readFileState: (approvalCache as any).cache, permissionMode: ((cfg as any).permissionMode ?? 'ask') as any, explorationMode: ctx.explorationMode ?? false, - allowedCommands: allowed, + allowedCommands: (cfg as any).allowedCommands ?? [], toolPermissions: (cfg as any).toolPermissions ?? {}, readOnlyMode: !!(activePlan && ['planning', 'awaiting_approval'].includes(activePlan.state)), source: 'orchestrator' as const, @@ -1015,17 +1008,13 @@ ${reviewFollowup.prompt}`; catch { /* diff is best-effort — fall back to command preview */ } } dispatch({ type: 'permission-ask', tool: req.tool, command: askCommand, reason: askReason, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' : approved; + const wasApproved = isApprovedPermissionResponse(approved); logMcpApproval(wasApproved ? 'approved' : 'denied', 'mcp.user-prompt', wasApproved ? 'user approved' : 'user denied'); - // Handle "Always" — persist to config - if ((typeof approved === 'string' && approved === 'a') || approved === true) { - if (cmdBase && typeof approved === 'string' && approved === 'a') { - const curAllowed: string[] = (loadConfig() as any).allowedCommands ?? []; - if (!curAllowed.includes(cmdBase)) { - curAllowed.push(cmdBase); - configSet('allowedCommands', curAllowed); - } - } + // Rule persistence for Always/Never is owned by the permission + // UI (signals/output.kern); here we only record the lease + // approval so the same boundary does not re-prompt this turn. + if (wasApproved && ctx.cesar?.taskExecutionLease) { + approveTaskAction(ctx.cesar.taskExecutionLease, reqTool, mcpRuleArg); } writeFileSync(respPath, JSON.stringify({ type: 'permission-response', id: req.id, approved: wasApproved, reason: wasApproved ? undefined : 'User denied' })); }} as any); @@ -1712,10 +1701,8 @@ ${reviewFollowup.prompt}`; const authorizeXmlTaskAction = async (tool: string, args: Record): Promise => { if (!taskExecutionLease || !needsTaskAuthorization(tool, args)) return true; const target = taskActionTarget(tool, args); - const authorization = await authorizeTaskAction( - taskExecutionLease, - tool, - target, + const authorization = await authorizeResolvedTaskAction( + { tool, target, cwd: resolveWorkingDir(), source: 'native', config, lease: taskExecutionLease, sessionAllowList: getSessionAllowList() }, (evaluation: any) => requestXmlTaskApproval(tool, args, evaluation), ); return authorization.decision === 'allow' diff --git a/packages/cli/src/kern/cesar/permission-resolver.kern b/packages/cli/src/kern/cesar/permission-resolver.kern new file mode 100644 index 000000000..ed0ede40b --- /dev/null +++ b/packages/cli/src/kern/cesar/permission-resolver.kern @@ -0,0 +1,283 @@ +// ── Unified permission resolver ───────────────────────────────────── +// ONE ordered decision pipeline for every approval seam (native/API tool +// preflight, companion buildOnApproval, delegated agent callbacks, MCP +// side-channel). Stages: hard-deny → deny rules → task lease → allow +// sources (toolPermissions / rules / allowedCommands / session) → lease +// boundary asks → mode policy. Replaces the three divergent gates that +// each re-derived decisions from overlapping config reads. + +import from="node:path" names="resolve,relative,isAbsolute" +import from="@kernlang/agon-core" names="parsePermissionRuleSet,parsePermissionRule,evaluateToolRules,hasShellControl,isReadOnlyCommand,loadConfig,configSet" +import from="@kernlang/agon-core" names="PermissionRuleSet" types=true +import from="./task-execution-lease.js" names="evaluateTaskAction,authorizeTaskAction,canonicalTaskActionSignature,isTaskFileMutationAction,relativePathEscapesWorkspace,isExternalSideEffectCommand,DEFAULT_DANGEROUS_PATTERN" +import from="./task-execution-lease.js" names="TaskExecutionLease,TaskActionEvaluation" types=true + +type name=AgonPermissionMode values="ask|auto-edit|auto" export=true +type name=PermissionRequestSource values="native|delegated|self-turn" export=true +type name=PermissionDecisionKind values="allow|ask|deny" export=true + +interface name=PermissionResolutionRequest export=true + field name=tool type=string + field name=target type=string + field name=cwd type=string + field name=source type=PermissionRequestSource + field name=config type=any + field name=lease type="TaskExecutionLease|undefined" optional=true + field name=sessionAllowList type="string[]" optional=true + +interface name=PermissionResolution export=true + field name=decision type=PermissionDecisionKind + field name=reason type=string + field name=stage type=string + field name=signature type=string + +const name=AGON_PERMISSION_MODES type="AgonPermissionMode[]" value={{ ['ask', 'auto-edit', 'auto'] as AgonPermissionMode[] }} export=true + +const name=READ_ONLY_PERMISSION_TOOLS type="Set" value={{ new Set(['read', 'grep', 'glob', 'ls', 'notebookread', 'webfetch', 'websearch']) }} + +fn name=resolveAgonPermissionMode params="config:any" returns=AgonPermissionMode export=true + doc "Resolve the effective permission mode from config. `agonPermissionMode` is the source of truth; when absent, legacy `permissionMode` migrates in place: auto→auto, smart→auto-edit (smart's heuristics auto-approved enough that ask would read as a regression), ask/deny-all→ask. deny-all additionally hard-denies via isPermissionHardDeny before the mode is ever consulted." + handler <<< + const raw = String(config?.agonPermissionMode ?? '').trim().toLowerCase(); + if ((AGON_PERMISSION_MODES as string[]).includes(raw)) return raw as AgonPermissionMode; + const legacy = String(config?.permissionMode ?? 'smart').trim().toLowerCase(); + if (legacy === 'auto') return 'auto'; + if (legacy === 'ask' || legacy === 'deny-all') return 'ask'; + return 'auto-edit'; + >>> + +fn name=isPermissionHardDeny params="config:any" returns=boolean export=true + doc "Legacy deny-all kill switch. Kept out of the Shift+Tab cycle; the resolver checks it FIRST so a stray allow rule can never re-open a deny-all session." + handler lang="kern" + return value="String(config?.permissionMode ?? '').trim().toLowerCase() === 'deny-all'" + +fn name=cycleAgonPermissionMode params="mode:AgonPermissionMode" returns=AgonPermissionMode export=true + handler <<< + const index = AGON_PERMISSION_MODES.indexOf(mode); + return AGON_PERMISSION_MODES[(index + 1) % AGON_PERMISSION_MODES.length] ?? 'ask'; + >>> + +fn name=clampDelegatedPermissionMode params="mode:AgonPermissionMode" returns=AgonPermissionMode export=true + doc "Delegated-agent floor clamp (brainstorm 5/6 consensus): orchestration runs execute at least auto-edit so a 3-engine forge run does not queue dozens of file-edit prompts, while Bash mutations still prompt. Strictly tighter than the retired smart-mode blanket approve." + handler lang="kern" + return value="mode === 'ask' ? 'auto-edit' : mode" + +fn name=describeAgonPermissionMode params="mode:AgonPermissionMode" returns="{label:string,hint:string}" export=true + handler <<< + if (mode === 'auto') return { label: 'AUTO', hint: 'workspace autonomy — pushes, publishing, and workspace escapes still prompt' }; + if (mode === 'auto-edit') return { label: 'auto-edit', hint: 'workspace file edits auto-approved — Bash mutations still prompt' }; + return { label: 'ask', hint: 'prompts before file edits and mutating commands' }; + >>> + +// Session-scoped allow rules ("Yes for session" prompt choice). Memory-only, +// evaluated through the same CC-parity rule engine as persisted rules so one +// approval covers the same tool + target pattern across sibling agents in the +// current run (prompt coalescing). +const name=_sessionPermissionRules type="string[]" value={{ [] as string[] }} + +fn name=getSessionPermissionRules returns="string[]" export=true expr={{ return _sessionPermissionRules.slice(); }} + +fn name=addSessionPermissionRule params="rule:string" returns=boolean export=true + handler <<< + const trimmed = String(rule ?? '').trim(); + if (!trimmed || !parsePermissionRule(trimmed)) return false; + if (!_sessionPermissionRules.includes(trimmed)) _sessionPermissionRules.push(trimmed); + return true; + >>> + +fn name=clearSessionPermissionRules returns=void export=true + handler lang="kern" + assign target="_sessionPermissionRules.length" value="0" + +fn name=buildEffectivePermissionRuleSet params="config:any" returns=PermissionRuleSet export=true + doc "Parse the persisted permissions object and merge in session-scoped allow rules. Deny rules are persisted-only: a session grant can widen, never narrow." + handler <<< + const persisted = (config?.permissions && typeof config.permissions === 'object') ? config.permissions : {}; + const allow = Array.isArray((persisted as any).allow) ? (persisted as any).allow : []; + const deny = Array.isArray((persisted as any).deny) ? (persisted as any).deny : []; + return parsePermissionRuleSet({ allow: [...allow, ..._sessionPermissionRules], deny }); + >>> + +fn name=fileTargetInsideWorkspace params="cwd:string,target:string" returns=boolean export=true + doc "Containment check for the auto-edit mode policy. An empty cwd means the executing layer owns containment (delegated agents run in their own worktrees whose path this seam cannot know) and passes. Missing targets fail closed." + handler <<< + if (!cwd) return true; + const candidate = String(target ?? '').trim(); + if (!candidate) return false; + const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(cwd, candidate); + return !relativePathEscapesWorkspace(relative(resolve(cwd), absolute)); + >>> + +fn name=isLeaselessBashBoundary params="command:string" returns=boolean export=true + doc "Boundary backstop for approval seams that carry no task lease (delegated agent callbacks). Auto mode must not blanket-approve pushes, publishing, or other external side effects just because the lease classifier is out of reach." + handler <<< + const cmd = String(command ?? ''); + if (isExternalSideEffectCommand(cmd)) return true; + try { return new RegExp(DEFAULT_DANGEROUS_PATTERN, 'i').test(cmd); } + catch { return true; } + >>> + +fn name=resolvePermissionDecision params="request:PermissionResolutionRequest" returns=PermissionResolution export=true + doc "The ONE seam where mode, rules, allowlists, and the task lease combine. Ordered: hard-deny → toolPermissions deny → deny rules → lease deny/allow → allow sources (toolPermissions allow, allow rules incl. session, allowedCommands compat, session base tokens) → lease boundary asks (dangerous/important — never absorbed by any mode) → mode policy with the delegated floor clamp. Callers map allow/deny directly and route ask into their prompt surface." + handler <<< + const tool = String(request.tool ?? '').trim(); + const target = String(request.target ?? ''); + const cwd = String(request.cwd ?? ''); + const cfg = request.config ?? {}; + const signature = canonicalTaskActionSignature(tool, target); + + if (isPermissionHardDeny(cfg)) { + return { decision: 'deny', reason: 'permissionMode=deny-all', stage: 'hard-deny', signature }; + } + const toolPermissions = (cfg.toolPermissions && typeof cfg.toolPermissions === 'object') ? cfg.toolPermissions : {}; + if (toolPermissions[tool] === 'deny') { + return { decision: 'deny', reason: `${tool} denied in settings`, stage: 'tool-permissions', signature }; + } + + const ruleSet = buildEffectivePermissionRuleSet(cfg); + const ruleDecision = evaluateToolRules(tool, target, cwd || process.cwd(), ruleSet); + if (ruleDecision === 'deny') { + return { decision: 'deny', reason: `${tool} denied by permissions rule`, stage: 'deny-rule', signature }; + } + + let leaseEvaluation: TaskActionEvaluation | null = null; + if (request.lease) { + leaseEvaluation = evaluateTaskAction(request.lease, tool, target); + if (leaseEvaluation.decision === 'deny') { + return { decision: 'deny', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + if (leaseEvaluation.decision === 'allow') { + return { decision: 'allow', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + } + + if (toolPermissions[tool] === 'allow') { + return { decision: 'allow', reason: `${tool} allowed in settings`, stage: 'tool-permissions', signature }; + } + if (ruleDecision === 'allow') { + return { decision: 'allow', reason: `${tool} allowed by permissions rule`, stage: 'allow-rule', signature }; + } + if (tool === 'Bash') { + const commandLower = target.trim().toLowerCase(); + const base = target.trim().split(/\s+/)[0] ?? ''; + const allowedCommands: string[] = Array.isArray(cfg.allowedCommands) ? cfg.allowedCommands : []; + if (commandLower && allowedCommands.some((entry: string) => commandLower.startsWith(String(entry).toLowerCase()))) { + return { decision: 'allow', reason: 'command matched allowedCommands', stage: 'allowed-commands', signature }; + } + const sessionList = Array.isArray(request.sessionAllowList) ? request.sessionAllowList : []; + if (commandLower && sessionList.some((entry: string) => commandLower.startsWith(String(entry).toLowerCase()) || base === entry)) { + return { decision: 'allow', reason: 'command matched session allowlist', stage: 'session-allowlist', signature }; + } + } + + if (leaseEvaluation && (leaseEvaluation.reason === 'dangerous_boundary' || leaseEvaluation.reason === 'important_task')) { + return { decision: 'ask', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + + const baseMode = resolveAgonPermissionMode(cfg); + const mode = request.source === 'delegated' ? clampDelegatedPermissionMode(baseMode) : baseMode; + if (mode === 'auto') { + if (tool === 'Bash' && !request.lease && isLeaselessBashBoundary(target)) { + return { decision: 'ask', reason: 'dangerous_boundary', stage: 'mode', signature }; + } + return { decision: 'allow', reason: 'auto mode', stage: 'mode', signature }; + } + const normalizedTool = tool.toLowerCase().replace(/^agon/, ''); + if (READ_ONLY_PERMISSION_TOOLS.has(normalizedTool)) { + return { decision: 'allow', reason: 'read-only tool', stage: 'mode', signature }; + } + if (tool === 'Bash' && isReadOnlyCommand(target)) { + return { decision: 'allow', reason: 'read-only command', stage: 'mode', signature }; + } + if (mode === 'auto-edit' && isTaskFileMutationAction(tool) && fileTargetInsideWorkspace(cwd, target)) { + return { decision: 'allow', reason: 'auto-edit mode approves workspace file edits', stage: 'mode', signature }; + } + return { decision: 'ask', reason: leaseEvaluation ? leaseEvaluation.reason : `${mode} mode requires approval`, stage: 'mode', signature }; + >>> + +fn name=authorizeResolvedTaskAction async=true params="request:PermissionResolutionRequest,requestApproval:(evaluation:TaskActionEvaluation)=>Promise" returns="Promise<{decision:'allow'|'deny',reason:string}>" export=true + doc "Resolver-first replacement for raw authorizeTaskAction at the native/XML/eager preflights. allow/deny map straight through (an Always rule now suppresses the lease prompt instead of double-gating); ask routes into the lease's join/claim machinery when a lease exists so concurrent identical boundaries share ONE prompt, and falls back to a single direct approval otherwise." + handler <<< + const resolution = resolvePermissionDecision(request); + if (resolution.decision === 'allow') return { decision: 'allow', reason: resolution.reason }; + if (resolution.decision === 'deny') return { decision: 'deny', reason: resolution.reason }; + if (request.lease) { + const authorization = await authorizeTaskAction(request.lease, request.tool, request.target, requestApproval); + return authorization.decision === 'allow' + ? { decision: 'allow', reason: authorization.reason } + : { decision: 'deny', reason: authorization.reason }; + } + const approved = await requestApproval({ decision: 'ask_boundary_once', signature: resolution.signature, reason: resolution.reason }); + return approved ? { decision: 'allow', reason: 'user_approved' } : { decision: 'deny', reason: 'user_denied' }; + >>> + +// ── Rule synthesis ("Always"/"Never" prompt choices) ───────────────── + +fn name=synthesizePermissionRule params="tool:string,target:string,cwd:string" returns="string|null" export=true + doc "Synthesize a Claude-Code-parity rule string from an approved action. Bash uses the two-token scope (binary + first non-flag argument → 'Bash(git push:*)'); compound commands, substitution, globs, and bare single verbs synthesize nothing (fall back to a one-time approval). File tools synthesize an exact resolved-path rule so 'Always' never silently widens to a directory." + handler <<< + const t = String(tool ?? '').trim(); + const raw = String(target ?? '').trim(); + if (!t || !raw) return null; + if (t === 'Bash') { + if (hasShellControl(raw)) return null; + const tokens = raw.split(/\s+/).map((token) => token.replace(/^['"]|['"]$/g, '')); + const base = tokens[0] ?? ''; + const sub = tokens.slice(1).find((token) => !token.startsWith('-')) ?? ''; + if (!base || !sub) return null; + if (/[*?`$(){}\[\]\\]/.test(base) || /[*?`$(){}\[\]\\]/.test(sub)) return null; + return `Bash(${base} ${sub}:*)`; + } + if (t === 'Edit' || t === 'Write' || t === 'MultiEdit' || t === 'Read') { + if (/[*?`$(){}\[\]\n]/.test(raw)) return null; + const absolute = isAbsolute(raw) ? resolve(raw) : resolve(cwd || process.cwd(), raw); + return `${t}(${absolute})`; + } + return null; + >>> + +fn name=validateSynthesizedRule params="rule:string,tool:string,target:string,cwd:string" returns=boolean export=true + doc "Hard guard before persisting a synthesized rule: it must parse, a Bash rule must carry a two-token scope (never a bare verb like 'Bash(git)' or a star), and re-running the rule engine against the originating action must yield allow." + handler <<< + const parsed = parsePermissionRule(rule); + if (!parsed || !parsed.command) return false; + if (parsed.command.includes('*')) return false; + if (parsed.tool === 'Bash' && parsed.command.trim().split(/\s+/).length < 2) return false; + const outcome = evaluateToolRules(tool, target, cwd || process.cwd(), { allow: [parsed], deny: [] }); + return outcome === 'allow'; + >>> + +fn name=persistPermissionRule params="kind:'allow'|'deny',rule:string" returns=boolean export=true + doc "Append one rule string to the persisted permissions object (config scope resolution is configSet's job). Returns false without writing when the rule is already present." + handler <<< + const cfg = loadConfig() as any; + const current = (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {}; + const allow: string[] = Array.isArray(current.allow) ? current.allow.slice() : []; + const deny: string[] = Array.isArray(current.deny) ? current.deny.slice() : []; + const bucket = kind === 'deny' ? deny : allow; + if (bucket.includes(rule)) return false; + bucket.push(rule); + configSet('permissions' as any, { allow, deny } as any); + return true; + >>> + +fn name=removePermissionRule params="rule:string" returns=boolean export=true + doc "Remove a rule string from BOTH persisted buckets and the session store. Returns true when anything was actually removed." + handler <<< + let removed = false; + const cfg = loadConfig() as any; + const current = (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {}; + const allow: string[] = Array.isArray(current.allow) ? current.allow.filter((entry: string) => entry !== rule) : []; + const deny: string[] = Array.isArray(current.deny) ? current.deny.filter((entry: string) => entry !== rule) : []; + if ((Array.isArray(current.allow) ? current.allow.length : 0) !== allow.length + || (Array.isArray(current.deny) ? current.deny.length : 0) !== deny.length) { + configSet('permissions' as any, { allow, deny } as any); + removed = true; + } + const sessionIndex = _sessionPermissionRules.indexOf(rule); + if (sessionIndex >= 0) { + _sessionPermissionRules.splice(sessionIndex, 1); + removed = true; + } + return removed; + >>> diff --git a/packages/cli/src/kern/cesar/session.kern b/packages/cli/src/kern/cesar/session.kern index 3850ace88..bd2c46c04 100644 --- a/packages/cli/src/kern/cesar/session.kern +++ b/packages/cli/src/kern/cesar/session.kern @@ -17,7 +17,8 @@ import from="./brain-helpers.js" names="extractDelegation" import from="./self-turn-approval.js" names="applyCesarSelfTurnApproval,approvalArgsFromCommand" import from="./approval-diff.js" names="approvalToolIsFileMutating,buildApprovalDiffPreview" import from="./turn-runtime.js" names="isActiveCesarTurn,runTurnPermissionOnce,runTurnToolOnce" -import from="./task-execution-lease.js" names="approveTaskAction,authorizeTaskAction,claimTaskActionPrompt,evaluateTaskAction,isTaskFileMutationAction,taskActionApprovalMessage" +import from="./task-execution-lease.js" names="approveTaskAction,claimTaskActionPrompt,isTaskFileMutationAction,taskActionApprovalMessage" +import from="./permission-resolver.js" names="resolvePermissionDecision,authorizeResolvedTaskAction" import from="./tool-observability.js" names="recordCesarApprovalDecision,recordCesarToolTimeline,recordCesarConfidence,buildToolErrorDiagnostic" import from="./task-controller.js" names="resolveCesarHarnessProfile,isAgenticAutoMode" @@ -880,7 +881,10 @@ fn name=buildOnToolCall params="ctx:HandlerContext, toolRegistry:ToolRegistry, c const needsLeasePreflight = isTaskFileMutationAction(name) || (name === 'Bash' && !isReadOnlyCommand(taskTarget)); if (lease && needsLeasePreflight) { - const authorization = await authorizeTaskAction(lease, name, taskTarget, (evaluation: any) => requestTaskApproval(name, evaluation)); + const authorization = await authorizeResolvedTaskAction( + { tool: name, target: taskTarget, cwd: resolveWorkingDir(), source: 'native', config: ctx.config, lease, sessionAllowList: getSessionAllowList() }, + (evaluation: any) => requestTaskApproval(name, evaluation), + ); if (authorization.decision !== 'allow') return `DENIED: ${name} was not executed (${authorization.reason}).`; } @@ -893,7 +897,10 @@ fn name=buildOnToolCall params="ctx:HandlerContext, toolRegistry:ToolRegistry, c ? String((args as any).command ?? '') : String((args as any).file_path ?? (args as any).path ?? (args as any).task ?? ''); if (lease) { - const authorization = await authorizeTaskAction(lease, tool, target, (evaluation: any) => requestTaskApproval(tool, evaluation)); + const authorization = await authorizeResolvedTaskAction( + { tool, target, cwd: resolveWorkingDir(), source: 'native', config: ctx.config, lease, sessionAllowList: getSessionAllowList() }, + (evaluation: any) => requestTaskApproval(tool, evaluation), + ); if (authorization.decision === 'deny') return false; if (authorization.decision === 'allow') return true; } @@ -994,18 +1001,14 @@ fn name=buildOnApproval params="ctx:HandlerContext, engineId:string" returns="(t // Map engine tool names to Agon tool names const toolMap: Record = { shell: 'Bash', bash: 'Bash', edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', read: 'Read', grep: 'Grep', glob: 'Glob' }; const agonTool = toolMap[tool.toLowerCase()] ?? tool; - const perm = perms[agonTool]; - // CC-parity allow/deny rules (.agon.json permissions). For Bash the - // rule command is the shell command (F2: compound `a && b` is split so a - // prefix-allow can't approve an appended `rm -rf /`); for file tools it is - // the path argument, resolved absolute + symlink-canonical so `Edit(/etc:*)` - // blocks `/etc/passwd` and `../` escapes are caught (F3). A bare tool rule - // (Edit/Write) matches any invocation. deny ALWAYS wins. - const ruleSet = parsePermissionRuleSet((cfg as any).permissions); + // For Bash the resolver target is the shell command (F2: compound + // `a && b` is split so a prefix-allow can't approve an appended + // `rm -rf /`); for file tools it is the path argument, resolved + // absolute + symlink-canonical (F3). Rule evaluation itself happens + // inside resolvePermissionDecision. const ruleArg = agonTool === 'Bash' ? command : String((approvalArgsFromCommand(agonTool, command) as any)?.file_path ?? ''); - const ruleDecision = evaluateToolRules(agonTool, ruleArg, resolveWorkingDir(), ruleSet); const turnId = ctx.cesar?.turnId; const cwd = resolveWorkingDir(); const logApproval = (decision: 'approved'|'denied'|'prompted'|'blocked', source: string, reason?: string, args?: Record | string) => { @@ -1095,70 +1098,38 @@ fn name=buildOnApproval params="ctx:HandlerContext, engineId:string" returns="(t } } - // CC-parity deny rule → refuse without prompting (deny ALWAYS wins). - if (ruleDecision === 'deny') { - logApproval('denied', 'settings.permissions', `${agonTool} denied by permissions rule`); - return `DENIED: ${agonTool}${agonTool === 'Bash' ? ` (${command})` : ''} is blocked by a deny rule in .agon.json permissions. Do not retry this — choose a different approach or ask the user to amend the rule.`; - } - - // deny → block immediately - if (perm === 'deny' || mode === 'deny-all') { - logApproval('denied', perm === 'deny' ? 'settings.toolPermissions' : 'settings.permissionMode', perm === 'deny' ? `${agonTool} denied in settings` : 'permissionMode=deny-all'); + // Unified permission decision — the ONE seam where hard-deny, CC-parity + // rules (deny always wins), the turn's task lease, allow sources, and the + // agonPermissionMode policy combine. The retired per-gate mode reads + // (deny-all, allow/auto, smart blanket-approve, allowedCommands) all live + // inside resolvePermissionDecision now. + const taskLease = ctx.cesar?.taskExecutionLease; + const taskTarget = agonTool === 'Bash' ? command : ruleArg; + const resolution = resolvePermissionDecision({ + tool: agonTool, + target: taskTarget, + cwd, + source: 'native', + config: cfg, + lease: taskLease, + sessionAllowList: getSessionAllowList(), + }); + if (resolution.decision === 'deny') { + logApproval('denied', `resolver.${resolution.stage}`, resolution.reason); + if (resolution.stage === 'deny-rule') { + return `DENIED: ${agonTool}${agonTool === 'Bash' ? ` (${command})` : ''} is blocked by a deny rule in .agon.json permissions. Do not retry this — choose a different approach or ask the user to amend the rule.`; + } return false; } - - // CC-parity allow rule → auto-approve without prompting. - if (ruleDecision === 'allow') { - logApproval('approved', 'settings.permissions', `${agonTool} allowed by permissions rule`); + if (resolution.decision === 'allow') { + logApproval('approved', `resolver.${resolution.stage}`, resolution.reason); return true; } - // One turn-scoped authority decision shared by companion and native - // adapters. Routine AUTO work proceeds without keyboard prompts; - // important work asks once for the task; dangerous boundaries ask for - // the exact action+target unless the user's request already named both. - const taskLease = ctx.cesar?.taskExecutionLease; - if (taskLease) { - const taskTarget = agonTool === 'Bash' ? command : ruleArg; - const evaluation = evaluateTaskAction(taskLease, agonTool, taskTarget); - if (evaluation.decision === 'deny') { - logApproval('denied', 'task-lease', evaluation.reason); - return false; - } - if (evaluation.decision === 'allow') { - logApproval('approved', 'task-lease', evaluation.reason); - return true; - } - if (!claimTaskActionPrompt(taskLease, evaluation.signature)) { - logApproval('denied', 'task-lease', 'duplicate or previously declined approval boundary'); - return false; - } - return new Promise((resolve) => { - const dispatch = ctx.cesar!.lastDispatch; - if (!dispatch) { - logApproval('denied', 'task-lease', 'approval required but no prompt surface is available'); - resolve(false); - return; - } - logApproval('prompted', 'task-lease', evaluation.reason); - let permDiffPreview: any = undefined; - if (approvalToolIsFileMutating(agonTool)) { - try { permDiffPreview = buildApprovalDiffPreview(agonTool, approvalArgsFromCommand(agonTool, command)); } - catch { /* diff is best-effort — fall back to command preview */ } - } - dispatch({ type: 'permission-ask', tool: agonTool, command, reason: taskActionApprovalMessage(evaluation), diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved; - if (wasApproved) approveTaskAction(taskLease, agonTool, taskTarget); - logApproval(wasApproved ? 'approved' : 'denied', 'task-lease', wasApproved ? 'user approved' : 'user denied'); - resolve(wasApproved); - } } as any); - }); - } - // Cesar self-turn fast path: bounded edits/writes on files already read // in the project cache do not need to interrupt the stream for another - // Y/N prompt. This intentionally runs after exploration/plan/confidence - // gates and explicit denies, and before the generic ask-mode fallback. + // Y/N prompt. Runs only after the resolver said ask, so explicit denies + // and read-only modes can never be bypassed by it. if (agonTool === 'Edit' || agonTool === 'Write' || agonTool === 'MultiEdit') { const approvalCwd = resolveWorkingDir(); const approvalCache = getProjectFileStateCache(approvalCwd); @@ -1180,43 +1151,20 @@ fn name=buildOnApproval params="ctx:HandlerContext, engineId:string" returns="(t } } - // allow → auto-approve - if (perm === 'allow' || mode === 'auto') { - logApproval('approved', perm === 'allow' ? 'settings.toolPermissions' : 'settings.permissionMode', perm === 'allow' ? `${agonTool} allowed in settings` : 'permissionMode=auto'); - return true; - } - - // smart → auto-approve orchestrator context and session allowlist, ask otherwise - if (mode === 'smart') { - // Session allowlist check - const sessionList = getSessionAllowList(); - if (agonTool === 'Bash' && sessionList.length > 0) { - const cmdLower = command.toLowerCase(); - const base = command.trim().split(/\s+/)[0]; - if (sessionList.some((a: string) => cmdLower.startsWith(a.toLowerCase()) || base === a)) { - logApproval('approved', 'session-allowlist', 'Bash command matched session allowlist'); - return true; - } - } - // Cesar tool loop is always orchestrator — auto-approve non-dangerous - logApproval('approved', 'settings.smart-orchestrator', 'permissionMode=smart orchestrator context'); - return true; - } - - // For Bash: check allowedCommands whitelist - if (agonTool === 'Bash' && allowed.length > 0) { - const cmdLower = command.toLowerCase(); - if (allowed.some((a: string) => cmdLower.startsWith(a.toLowerCase()))) { - logApproval('approved', 'settings.allowedCommands', 'Bash command matched allowedCommands'); - return true; - } + // ask → one prompt per lease signature (duplicate/declined boundaries + // do not re-prompt), then the same permission UI as Claude Code. + if (taskLease && !claimTaskActionPrompt(taskLease, resolution.signature)) { + logApproval('denied', 'task-lease', 'duplicate or previously declined approval boundary'); + return false; } - - // ask → show permission prompt (same UI as Claude Code) + const boundaryReasons = ['auto_off', 'dangerous_boundary', 'important_task']; + const promptReason = boundaryReasons.includes(resolution.reason) + ? taskActionApprovalMessage({ decision: resolution.reason === 'important_task' ? 'ask_task_once' : 'ask_boundary_once', signature: resolution.signature, reason: resolution.reason }) + : `Cesar (${engineId}) wants to execute`; return new Promise((resolve) => { const dispatch = ctx.cesar!.lastDispatch; if (dispatch) { - logApproval('prompted', 'user-prompt', `Cesar (${engineId}) wants to execute`); + logApproval('prompted', 'user-prompt', promptReason); // File-mutating tools carry a unified diff (computed from the tool // input before execution) so the approval shows the real change. let permDiffPreview: any = undefined; @@ -1224,14 +1172,15 @@ fn name=buildOnApproval params="ctx:HandlerContext, engineId:string" returns="(t try { permDiffPreview = buildApprovalDiffPreview(agonTool, approvalArgsFromCommand(agonTool, command)); } catch { /* diff is best-effort — fall back to command preview */ } } - dispatch({ type: 'permission-ask', tool: agonTool, command, reason: `Cesar (${engineId}) wants to execute`, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved; + dispatch({ type: 'permission-ask', tool: agonTool, command, reason: promptReason, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { + const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' || approved === 's' : !!approved; + if (wasApproved && taskLease) approveTaskAction(taskLease, agonTool, taskTarget); logApproval(wasApproved ? 'approved' : 'denied', 'user-prompt', wasApproved ? 'user approved' : 'user denied'); resolve(wasApproved); } } as any); } else { - logApproval('approved', 'fallback.no-dispatch', 'no dispatch available for approval prompt'); - resolve(true); + logApproval('denied', 'fallback.no-dispatch', 'approval required but no prompt surface is available'); + resolve(false); } }); }; diff --git a/packages/cli/src/kern/cesar/task-execution-lease.kern b/packages/cli/src/kern/cesar/task-execution-lease.kern index 5fe32446f..7f5c48d7c 100644 --- a/packages/cli/src/kern/cesar/task-execution-lease.kern +++ b/packages/cli/src/kern/cesar/task-execution-lease.kern @@ -5,9 +5,9 @@ type name=TaskActionDecision values="allow|ask_task_once|ask_boundary_once|deny" type name=TaskHarnessProfile values="legacy|agentic" export=true fn name=isApprovedPermissionResponse params="value:boolean|string" returns=boolean export=true - doc "Normalize the REPL permission UI contract: y approves once, a approves the session, booleans pass through." + doc "Normalize the REPL permission UI contract: y approves once, s approves for the session, a persists an allow rule, booleans pass through." handler lang="kern" - return value="typeof value === 'string' ? value === 'y' || value === 'a' : value === true" + return value="typeof value === 'string' ? value === 'y' || value === 'a' || value === 's' : value === true" interface name=TaskExecutionLease export=true field name=input type=string diff --git a/packages/cli/src/kern/cesar/tools.kern b/packages/cli/src/kern/cesar/tools.kern index 4b9edd3bc..bf4436042 100644 --- a/packages/cli/src/kern/cesar/tools.kern +++ b/packages/cli/src/kern/cesar/tools.kern @@ -1,8 +1,10 @@ -import from="@kernlang/agon-core" names="ToolRegistry,getProjectFileStateCache,createReadTool,createEditTool,createMultiEditTool,createWriteTool,createBashTool,createGrepTool,createGlobTool,createForgeTool,createBrainstormTool,createTribunalTool,createCampfireTool,createPipelineTool,createGoalTool,createConquerTool,createReviewTool,createDelegateTool,createAgentTool,createReportConfidenceTool,createProposePlanTool,createExitPlanModeTool,createListPlansTool,createRetrieveResultTool,createQuickNeroTool,createTodoWriteTool,createSaveMemoryTool,executeToolCall,resolveWorkingDir,parsePermissionRuleSet,parseToolHooks,isReadOnlyCommand" +import from="@kernlang/agon-core" names="ToolRegistry,getProjectFileStateCache,createReadTool,createEditTool,createMultiEditTool,createWriteTool,createBashTool,createGrepTool,createGlobTool,createForgeTool,createBrainstormTool,createTribunalTool,createCampfireTool,createPipelineTool,createGoalTool,createConquerTool,createReviewTool,createDelegateTool,createAgentTool,createReportConfidenceTool,createProposePlanTool,createExitPlanModeTool,createListPlansTool,createRetrieveResultTool,createQuickNeroTool,createTodoWriteTool,createSaveMemoryTool,executeToolCall,resolveWorkingDir,parsePermissionRuleSet,parseToolHooks,isReadOnlyCommand,loadConfig" import from="@kernlang/agon-core" names="ToolContext,ToolCallResult" types=true import from="../../handlers/types.js" names="Dispatch,HandlerContext" types=true import from="./council-tool.js" names="createCouncilTool" -import from="./task-execution-lease.js" names="authorizeTaskAction,isTaskFileMutationAction,taskActionApprovalMessage" +import from="./task-execution-lease.js" names="isTaskFileMutationAction,taskActionApprovalMessage" +import from="./permission-resolver.js" names="authorizeResolvedTaskAction" +import from="../signals/output.js" names="getSessionAllowList" import from="./brain-helpers.js" names="isBashToolName" fn name=createCesarToolRegistry params="engineId?:string" returns="ToolRegistry" @@ -127,10 +129,8 @@ fn name=executeEagerTool async=true params="toolName:string, meta:Record requestTaskApproval( tool, taskActionApprovalMessage(evaluation), diff --git a/packages/cli/src/kern/handlers/agent.kern b/packages/cli/src/kern/handlers/agent.kern index d793d1d01..0aea56935 100644 --- a/packages/cli/src/kern/handlers/agent.kern +++ b/packages/cli/src/kern/handlers/agent.kern @@ -20,6 +20,7 @@ import from="../../handlers/types.js" names="Dispatch,HandlerContext" types=true import from="node:fs" names="writeFileSync,mkdirSync,existsSync" import from="node:path" names="join" import from="../signals/output.js" names="getSessionAllowList" +import from="../cesar/permission-resolver.js" names="resolvePermissionDecision" interface name=RunAgentOptions field name=engineId type=string optional=true @@ -50,16 +51,12 @@ fn name=clipAgentText params="text:string|null|undefined, limit:number" returns= return value="raw.length > limit ? raw.slice(0, limit) + `\\n\\n[... ${raw.length - limit} more chars truncated]` : raw" fn name=buildAgentApprovalCallback params="dispatch:Dispatch, ctx:HandlerContext, engineId:string" returns="(tool:string, command:string, reason?:string)=>Promise" export=true - doc "Shared approval callback for delegated agent runs. Applies config-level allow/deny rules first, then falls back to the UI permission prompt." + doc "Shared approval callback for delegated agent runs, routed through the unified permission resolver with source='delegated' (floor-clamped to auto-edit: file edits run free, Bash mutations still prompt — the old smart-mode blanket auto-approve is retired). Exploration and plan-mode read-only blocks stay in front with instructive messages. cwd is intentionally empty: delegated agents execute in their own worktrees whose containment the executing tool layer owns." handler <<< return async (tool: string, command: string, reason?: string): Promise => { const cfg = ctx.config; - const perms = (cfg as any).toolPermissions ?? {}; - const allowed = (cfg as any).allowedCommands ?? []; - const mode = (cfg as any).permissionMode ?? 'ask'; const toolMap: Record = { shell: 'Bash', bash: 'Bash', edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', read: 'Read', grep: 'Grep', glob: 'Glob' }; const agonTool = toolMap[tool.toLowerCase()] ?? tool; - const perm = perms[agonTool]; if (ctx.explorationMode) { const WRITE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'Bash']; @@ -80,25 +77,16 @@ fn name=buildAgentApprovalCallback params="dispatch:Dispatch, ctx:HandlerContext } } - if (perm === 'deny' || mode === 'deny-all') return false; - if (perm === 'allow' || mode === 'auto') return true; - - // smart mode: auto-approve session allowlist, ask for user-sourced mutating ops - if (mode === 'smart') { - const sessionList = getSessionAllowList(); - if (agonTool === 'Bash' && sessionList.length > 0) { - const cmdLower = command.toLowerCase(); - const base = command.trim().split(/\s+/)[0]; - if (sessionList.some((a: string) => cmdLower.startsWith(a.toLowerCase()) || base === a)) return true; - } - // Delegated agent runs are orchestrator context — auto-approve - return true; - } - - if (agonTool === 'Bash' && allowed.length > 0) { - const cmdLower = command.toLowerCase(); - if (allowed.some((a: string) => cmdLower.startsWith(a.toLowerCase()))) return true; - } + const resolution = resolvePermissionDecision({ + tool: agonTool, + target: command, + cwd: '', + source: 'delegated', + config: cfg, + sessionAllowList: getSessionAllowList(), + }); + if (resolution.decision === 'deny') return false; + if (resolution.decision === 'allow') return true; return new Promise((resolve) => { dispatch({ diff --git a/packages/core/src/generated/models/types.ts b/packages/core/src/generated/models/types.ts index 0ae541d6e..78f02e477 100644 --- a/packages/core/src/generated/models/types.ts +++ b/packages/core/src/generated/models/types.ts @@ -1,11 +1,5 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/models/types.kern -// @kern-source: types:503 -// @kern-source: types:504 -// @kern-source: types:505 -// @kern-source: types:506 -// @kern-source: types:507 -// @kern-source: types:508 // @kern-source: types:509 // @kern-source: types:510 // @kern-source: types:511 @@ -37,6 +31,12 @@ // @kern-source: types:537 // @kern-source: types:538 // @kern-source: types:539 +// @kern-source: types:540 +// @kern-source: types:541 +// @kern-source: types:542 +// @kern-source: types:543 +// @kern-source: types:544 +// @kern-source: types:545 // @kern-source: types:1 export type EngineMode = 'exec' | 'review' | 'agent'; @@ -351,6 +351,7 @@ export interface AgonConfig { campfireObserverStrategy?: 'lead-first'|'all-respond'; hooks: Record>; permissionMode?: 'auto'|'smart'|'ask'|'deny-all'; + agonPermissionMode: string; allowedCommands: string[]; toolPermissions: Record; permissions: {allow?:string[],deny?:string[]}; @@ -458,6 +459,7 @@ export const DEFAULT_AGON_CONFIG: Required = { campfireObserverStrategy: 'lead-first', hooks: {} as any, permissionMode: 'smart', + agonPermissionMode: '', allowedCommands: [], toolPermissions: {} as any, permissions: {} as any, @@ -484,7 +486,7 @@ export const DEFAULT_AGON_CONFIG: Required = { browserExtensionIds: [], }; -// @kern-source: types:369 +// @kern-source: types:375 export interface ScoutBid { engineId: string; confidence: number; @@ -495,7 +497,7 @@ export interface ScoutBid { needsCompetition: boolean; } -// @kern-source: types:378 +// @kern-source: types:384 export interface RoutingDecision { action: 'chat'|'build'|'pipeline'|'campfire'|'forge'|'brainstorm'|'tribunal'|'agent'|'team-agent'; leadEngine: string; @@ -507,14 +509,14 @@ export interface RoutingDecision { bids: ScoutBid[]; } -// @kern-source: types:388 +// @kern-source: types:394 export interface CampfireMessage { engineId: string; content: string; isLead: boolean; } -// @kern-source: types:393 +// @kern-source: types:399 export interface ForgeOptions { task: string; fitnessCmd: string; @@ -540,7 +542,7 @@ export interface ForgeOptions { synthEngine?: string; } -// @kern-source: types:422 +// @kern-source: types:428 export interface EngineResult { engineId: string; pass: boolean; @@ -562,14 +564,14 @@ export interface EngineResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:442 +// @kern-source: types:448 export interface SkippedEngine { engineId: string; status: string; reason?: string; } -// @kern-source: types:447 +// @kern-source: types:453 export interface DispatchMetric { engineId: string; phase: 'stage1'|'stage1-fallback'|'stage2-scout'|'stage2-scout-fallback'|'stage2-follower'|'stage2-fallback'|'synthesis'|'gauntlet'; @@ -583,7 +585,7 @@ export interface DispatchMetric { tokens?: {prompt:number, response:number, costUsd:number}; } -// @kern-source: types:459 +// @kern-source: types:465 export interface ForgeManifest { forgeId: string; forgeDir: string; @@ -615,7 +617,7 @@ export interface ForgeManifest { gauntlet?: GauntletResult; } -// @kern-source: types:489 +// @kern-source: types:495 export interface ConvergenceEntry { file: string; fn: string; @@ -623,7 +625,7 @@ export interface ConvergenceEntry { reason: string; } -// @kern-source: types:495 +// @kern-source: types:501 export interface ForgeJudgment { winner: string; strengths: { engineId: string; category: string; reason: string }[]; @@ -634,7 +636,7 @@ export interface ForgeJudgment { export type ForgeEventType = 'baseline:start' | 'baseline:done' | 'stage1:start' | 'stage1:dispatch' | 'stage1:score' | 'stage1:accepted' | 'stage2:start' | 'stage2:dispatch' | 'stage2:score' | 'stage2:done' | 'engine:failed' | 'engine:worktree' | 'winner:determined' | 'forge:no-candidate-diff' | 'synthesis:start' | 'synthesis:critique' | 'synthesis:refine' | 'synthesis:score' | 'synthesis:done' | 'elo:update' | 'gauntlet:start' | 'gauntlet:breaker-dispatch' | 'gauntlet:breaker-done' | 'gauntlet:attack-landed' | 'gauntlet:repair-start' | 'gauntlet:repair-done' | 'gauntlet:corpus-save' | 'gauntlet:done' | 'forge:done' | 'forge:engine-skipped' | 'forge:already-satisfied' | 'forge:single-survivor' | 'forge:no-engines-available' | 'forge:fatal' | 'forge:auto-finalize' | 'forge:health-check-start' | 'forge:health-check-done'; -// @kern-source: types:502 +// @kern-source: types:508 export interface ForgeEvent { type: ForgeEventType; engineId?: string; @@ -683,7 +685,7 @@ export interface ForgeEventMap { export type ForgeEventCallback = (event: ForgeEvent) => void; -// @kern-source: types:541 +// @kern-source: types:547 export interface BrainstormBid { engineId: string; confidence: number; @@ -692,26 +694,26 @@ export interface BrainstormBid { score?: number; } -// @kern-source: types:548 +// @kern-source: types:554 export interface BrainstormGroup { members: string[]; representative: string; similarity: number; } -// @kern-source: types:553 +// @kern-source: types:559 export interface BrainstormDedupStatus { status: 'not-needed' | 'applied' | 'unavailable' | 'failed' | 'timed-out'; detail?: string; } -// @kern-source: types:557 +// @kern-source: types:563 export interface BrainstormSynthesisStatus { status: 'completed' | 'fallback'; detail?: string; } -// @kern-source: types:564 +// @kern-source: types:570 export interface PanelHealth { requested: number; responded: number; @@ -720,7 +722,7 @@ export interface PanelHealth { banner: string | null; } -// @kern-source: types:571 +// @kern-source: types:577 export interface BrainstormResult { question: string; bids: BrainstormBid[]; @@ -732,7 +734,7 @@ export interface BrainstormResult { panelHealth?: PanelHealth; } -// @kern-source: types:581 +// @kern-source: types:587 export interface BreakerArtifact { engineId: string; testScript: string; @@ -742,7 +744,7 @@ export interface BreakerArtifact { validated: boolean; } -// @kern-source: types:589 +// @kern-source: types:595 export interface GauntletResult { winnerId: string; breakerArtifacts: BreakerArtifact[]; @@ -755,7 +757,7 @@ export interface GauntletResult { patchPath?: string; } -// @kern-source: types:600 +// @kern-source: types:606 export interface CorpusEntry { forgeId: string; taskClass: TaskClass; @@ -765,7 +767,7 @@ export interface CorpusEntry { pattern?: string; } -// @kern-source: types:608 +// @kern-source: types:614 export interface GapPattern { pattern: string; taskClass: TaskClass; @@ -776,7 +778,7 @@ export interface GapPattern { skillPath?: string; } -// @kern-source: types:617 +// @kern-source: types:623 export interface Critique { file: string; lines: string; @@ -784,5 +786,5 @@ export interface Critique { minimalFix: string; } -// @kern-source: types:623 +// @kern-source: types:629 export const DEFAULT_CONFIG: Required = DEFAULT_AGON_CONFIG; diff --git a/packages/core/src/kern/models/types.kern b/packages/core/src/kern/models/types.kern index 174676d73..89c126ebb 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -321,6 +321,12 @@ config name=AgonConfig field name=hooks type="Record>" optional=true doc "Shell hook commands keyed by event. Dispatch-lifecycle events (pre_dispatch, post_forge, …) use {command, engines?, timeout?}. CC-parity tool-call events `preToolUse`/`postToolUse` use {matcher?, command, timeout?}: a pre-hook gets {tool_name, tool_input} on stdin and may BLOCK with exit 2 (stderr = refusal); a post-hook gets {tool_name, tool_input, tool_response} and never blocks. All hooks are fail-safe (timeout/spawn-failure/other-exit → proceed)." field name=permissionMode type="'auto'|'smart'|'ask'|'deny-all'" default=smart + doc "LEGACY mode enum kept for compat + the deny-all kill switch. New sessions steer via agonPermissionMode; when that is unset this value migrates in place (auto→auto, smart→auto-edit, ask/deny-all→ask). deny-all stays honored as a hard-deny override in every mode." + // KERN-GAP: codegen — an OPTIONAL union-literal field emits `''` into + // DEFAULT_CONFIG, which is not assignable to the union type. Typed as plain + // string; resolveAgonPermissionMode validates and clamps every read. + field name=agonPermissionMode type=string optional=true + doc "Claude-Code-style permission mode ('ask'|'auto-edit'|'auto') cycled with Shift+Tab in the REPL. ask = prompt before file edits and mutating commands; auto-edit = workspace file edits auto-approved, Bash mutations still prompt; auto = workspace autonomy with lease boundaries (push/publish/escape) still interactive. Deny rules win in every mode. Unset/invalid values migrate from the legacy permissionMode (auto→auto, smart→auto-edit, ask/deny-all→ask)." field name=allowedCommands type="string[]" optional=true field name=toolPermissions type="Record" optional=true field name=permissions type="{allow?:string[],deny?:string[]}" optional=true diff --git a/tests/unit/permission-resolver.test.ts b/tests/unit/permission-resolver.test.ts new file mode 100644 index 000000000..88d1fc059 --- /dev/null +++ b/tests/unit/permission-resolver.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addSessionPermissionRule, + authorizeResolvedTaskAction, + buildEffectivePermissionRuleSet, + clampDelegatedPermissionMode, + clearSessionPermissionRules, + cycleAgonPermissionMode, + describeAgonPermissionMode, + fileTargetInsideWorkspace, + getSessionPermissionRules, + isLeaselessBashBoundary, + isPermissionHardDeny, + resolveAgonPermissionMode, + resolvePermissionDecision, + synthesizePermissionRule, + validateSynthesizedRule, +} from '../../packages/cli/src/generated/cesar/permission-resolver.js'; +import { createTaskExecutionLease } from '../../packages/cli/src/generated/cesar/task-execution-lease.js'; + +const WS = process.cwd(); + +const cfg = (overrides: Record = {}) => ({ + permissionMode: 'smart', + allowedCommands: [], + toolPermissions: {}, + permissions: {}, + ...overrides, +}); + +const request = (overrides: Record = {}) => ({ + tool: 'Bash', + target: 'npm run build', + cwd: WS, + source: 'native' as const, + config: cfg(), + ...overrides, +}); + +beforeEach(() => clearSessionPermissionRules()); + +describe('resolveAgonPermissionMode', () => { + it('honors an explicit agonPermissionMode', () => { + expect(resolveAgonPermissionMode(cfg({ agonPermissionMode: 'auto' }))).toBe('auto'); + expect(resolveAgonPermissionMode(cfg({ agonPermissionMode: 'ask' }))).toBe('ask'); + }); + it('migrates legacy permissionMode when agonPermissionMode is unset or invalid', () => { + expect(resolveAgonPermissionMode(cfg({ permissionMode: 'auto' }))).toBe('auto'); + expect(resolveAgonPermissionMode(cfg({ permissionMode: 'smart' }))).toBe('auto-edit'); + expect(resolveAgonPermissionMode(cfg({ permissionMode: 'ask' }))).toBe('ask'); + expect(resolveAgonPermissionMode(cfg({ permissionMode: 'deny-all' }))).toBe('ask'); + expect(resolveAgonPermissionMode(cfg({ agonPermissionMode: 'yolo', permissionMode: 'ask' }))).toBe('ask'); + expect(resolveAgonPermissionMode(cfg({ agonPermissionMode: '' }))).toBe('auto-edit'); + }); +}); + +describe('mode helpers', () => { + it('cycles ask → auto-edit → auto → ask', () => { + expect(cycleAgonPermissionMode('ask')).toBe('auto-edit'); + expect(cycleAgonPermissionMode('auto-edit')).toBe('auto'); + expect(cycleAgonPermissionMode('auto')).toBe('ask'); + }); + it('clamps delegated runs to at least auto-edit', () => { + expect(clampDelegatedPermissionMode('ask')).toBe('auto-edit'); + expect(clampDelegatedPermissionMode('auto-edit')).toBe('auto-edit'); + expect(clampDelegatedPermissionMode('auto')).toBe('auto'); + }); + it('describes every mode with a label and hint', () => { + for (const mode of ['ask', 'auto-edit', 'auto'] as const) { + const described = describeAgonPermissionMode(mode); + expect(described.label.length).toBeGreaterThan(0); + expect(described.hint.length).toBeGreaterThan(0); + } + }); + it('flags deny-all as hard deny', () => { + expect(isPermissionHardDeny(cfg({ permissionMode: 'deny-all' }))).toBe(true); + expect(isPermissionHardDeny(cfg())).toBe(false); + }); +}); + +describe('resolvePermissionDecision — deny stages', () => { + it('deny-all wins over an allow rule', () => { + const r = resolvePermissionDecision(request({ + config: cfg({ permissionMode: 'deny-all', permissions: { allow: ['Bash(npm run:*)'] } }), + })); + expect(r.decision).toBe('deny'); + expect(r.stage).toBe('hard-deny'); + }); + it('toolPermissions deny blocks the tool', () => { + const r = resolvePermissionDecision(request({ config: cfg({ toolPermissions: { Bash: 'deny' } }) })); + expect(r).toMatchObject({ decision: 'deny', stage: 'tool-permissions' }); + }); + it('a deny rule wins over an allow rule for the same command', () => { + const r = resolvePermissionDecision(request({ + target: 'git push origin main', + config: cfg({ permissions: { allow: ['Bash(git push:*)'], deny: ['Bash(git push:*)'] } }), + })); + expect(r).toMatchObject({ decision: 'deny', stage: 'deny-rule' }); + }); + it('lease workspace escape denies file mutations', () => { + const lease = createTaskExecutionLease('fix the bug', true, WS); + const r = resolvePermissionDecision(request({ tool: 'Edit', target: '/etc/passwd', lease })); + expect(r).toMatchObject({ decision: 'deny', stage: 'lease', reason: 'workspace_escape' }); + }); +}); + +describe('resolvePermissionDecision — allow sources', () => { + it('an allow rule auto-approves and beats the lease dangerous boundary', () => { + const lease = createTaskExecutionLease('build the feature', true, WS); + const r = resolvePermissionDecision(request({ + target: 'git push origin feature', + lease, + config: cfg({ permissions: { allow: ['Bash(git push:*)'] } }), + })); + expect(r).toMatchObject({ decision: 'allow', stage: 'allow-rule' }); + }); + it('legacy allowedCommands base-prefix still auto-approves', () => { + const r = resolvePermissionDecision(request({ + target: 'npm run build', + config: cfg({ permissionMode: 'ask', allowedCommands: ['npm run'] }), + })); + expect(r).toMatchObject({ decision: 'allow', stage: 'allowed-commands' }); + }); + it('the session allowlist auto-approves Bash', () => { + const r = resolvePermissionDecision(request({ + target: 'cargo fmt --all', + config: cfg({ permissionMode: 'ask' }), + sessionAllowList: ['cargo'], + })); + expect(r).toMatchObject({ decision: 'allow', stage: 'session-allowlist' }); + }); + it('a session rule added via addSessionPermissionRule auto-approves', () => { + expect(addSessionPermissionRule('Bash(cargo fmt:*)')).toBe(true); + const r = resolvePermissionDecision(request({ + target: 'cargo fmt --all', + config: cfg({ permissionMode: 'ask' }), + })); + expect(r).toMatchObject({ decision: 'allow', stage: 'allow-rule' }); + clearSessionPermissionRules(); + expect(getSessionPermissionRules()).toEqual([]); + }); + it('lease AUTO approves routine work', () => { + const lease = createTaskExecutionLease('refactor the parser', true, WS); + const r = resolvePermissionDecision(request({ target: 'npm run build', lease, config: cfg({ permissionMode: 'ask' }) })); + expect(r).toMatchObject({ decision: 'allow', stage: 'lease', reason: 'routine_auto' }); + }); +}); + +describe('resolvePermissionDecision — boundary asks survive every mode', () => { + it('a dangerous lease boundary asks even in auto mode', () => { + const lease = createTaskExecutionLease('build the feature', true, WS); + const r = resolvePermissionDecision(request({ + target: 'git push origin main', + lease, + config: cfg({ agonPermissionMode: 'auto' }), + })); + expect(r).toMatchObject({ decision: 'ask', stage: 'lease', reason: 'dangerous_boundary' }); + }); + it('a leaseless delegated push asks even at the auto floor', () => { + const r = resolvePermissionDecision(request({ + target: 'git push origin main', + source: 'delegated', + cwd: '', + config: cfg({ agonPermissionMode: 'auto' }), + })); + expect(r).toMatchObject({ decision: 'ask', reason: 'dangerous_boundary' }); + }); + it('isLeaselessBashBoundary catches publishing and mutating curl', () => { + expect(isLeaselessBashBoundary('npm publish')).toBe(true); + expect(isLeaselessBashBoundary('git push origin main')).toBe(true); + expect(isLeaselessBashBoundary('npm run build')).toBe(false); + }); +}); + +describe('resolvePermissionDecision — mode policy', () => { + it('ask mode: read-only allows, mutations ask', () => { + const config = cfg({ agonPermissionMode: 'ask' }); + expect(resolvePermissionDecision(request({ target: 'git status', config })).decision).toBe('allow'); + expect(resolvePermissionDecision(request({ tool: 'Read', target: 'src/index.ts', config })).decision).toBe('allow'); + expect(resolvePermissionDecision(request({ target: 'npm run build', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config })).decision).toBe('ask'); + }); + it('auto-edit mode: workspace file edits allow, Bash mutations ask', () => { + const config = cfg({ agonPermissionMode: 'auto-edit' }); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config })).decision).toBe('allow'); + expect(resolvePermissionDecision(request({ tool: 'Write', target: `${WS}/notes.md`, config })).decision).toBe('allow'); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: '/etc/passwd', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ target: 'npm run build', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ target: 'git diff', config })).decision).toBe('allow'); + }); + it('auto mode allows routine mutations', () => { + const config = cfg({ agonPermissionMode: 'auto' }); + expect(resolvePermissionDecision(request({ target: 'npm run build', config })).decision).toBe('allow'); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config })).decision).toBe('allow'); + }); + it('delegated source in ask mode floor-clamps to auto-edit', () => { + const config = cfg({ agonPermissionMode: 'ask' }); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', cwd: '', source: 'delegated', config })).decision).toBe('allow'); + expect(resolvePermissionDecision(request({ target: 'npm run build', cwd: '', source: 'delegated', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ target: 'git status', cwd: '', source: 'delegated', config })).decision).toBe('allow'); + }); + it('same action resolves identically from native and self-turn sources', () => { + const config = cfg({ agonPermissionMode: 'ask' }); + const native = resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config, source: 'native' })); + const selfTurn = resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config, source: 'self-turn' })); + expect(native.decision).toBe(selfTurn.decision); + }); +}); + +describe('workspace containment helper', () => { + it('passes with an empty cwd (delegated worktrees own containment)', () => { + expect(fileTargetInsideWorkspace('', '/anywhere/file.ts')).toBe(true); + }); + it('fails closed on empty targets and escapes', () => { + expect(fileTargetInsideWorkspace(WS, '')).toBe(false); + expect(fileTargetInsideWorkspace(WS, '../outside.ts')).toBe(false); + expect(fileTargetInsideWorkspace(WS, 'src/inside.ts')).toBe(true); + }); +}); + +describe('rule synthesis', () => { + it('synthesizes two-token Bash rules', () => { + expect(synthesizePermissionRule('Bash', 'git push origin main', WS)).toBe('Bash(git push:*)'); + expect(synthesizePermissionRule('Bash', 'npm run build', WS)).toBe('Bash(npm run:*)'); + }); + it('refuses bare verbs, flags-only, compounds, and substitution', () => { + expect(synthesizePermissionRule('Bash', 'ls', WS)).toBeNull(); + expect(synthesizePermissionRule('Bash', 'ls -la', WS)).toBeNull(); + expect(synthesizePermissionRule('Bash', 'npm test && rm -rf /', WS)).toBeNull(); + expect(synthesizePermissionRule('Bash', 'git commit $(cat x)', WS)).toBeNull(); + expect(synthesizePermissionRule('Bash', 'rm *', WS)).toBeNull(); + }); + it('synthesizes exact file rules and refuses rendered previews', () => { + expect(synthesizePermissionRule('Edit', `${WS}/src/index.ts`, WS)).toBe(`Edit(${WS}/src/index.ts)`); + expect(synthesizePermissionRule('Edit', 'src/index.ts (+3 -1)', WS)).toBeNull(); + }); + it('validates rules against the originating action', () => { + expect(validateSynthesizedRule('Bash(git push:*)', 'Bash', 'git push origin main', WS)).toBe(true); + expect(validateSynthesizedRule('Bash(git)', 'Bash', 'git push origin main', WS)).toBe(false); + expect(validateSynthesizedRule('Bash(*)', 'Bash', 'git push origin main', WS)).toBe(false); + expect(validateSynthesizedRule('Bash(npm test:*)', 'Bash', 'git push', WS)).toBe(false); + }); +}); + +describe('buildEffectivePermissionRuleSet', () => { + it('merges persisted and session allow rules; deny stays persisted-only', () => { + addSessionPermissionRule('Bash(cargo fmt:*)'); + const rules = buildEffectivePermissionRuleSet(cfg({ permissions: { allow: ['Edit'], deny: ['Bash(rm:*)'] } })); + expect(rules.allow.length).toBe(2); + expect(rules.deny.length).toBe(1); + }); +}); + +describe('authorizeResolvedTaskAction', () => { + it('allows via rule without prompting', async () => { + const prompt = vi.fn(); + const outcome = await authorizeResolvedTaskAction( + request({ target: 'git push origin main', config: cfg({ permissions: { allow: ['Bash(git push:*)'] } }) }) as never, + prompt as never, + ); + expect(outcome.decision).toBe('allow'); + expect(prompt).not.toHaveBeenCalled(); + }); + it('denies via deny rule without prompting', async () => { + const prompt = vi.fn(); + const outcome = await authorizeResolvedTaskAction( + request({ target: 'rm -rf node_modules', config: cfg({ permissions: { deny: ['Bash(rm:*)'] } }) }) as never, + prompt as never, + ); + expect(outcome.decision).toBe('deny'); + expect(prompt).not.toHaveBeenCalled(); + }); + it('routes ask through the lease join machinery and records the approval', async () => { + const lease = createTaskExecutionLease('build it', true, WS); + const prompt = vi.fn(async () => true); + const first = await authorizeResolvedTaskAction( + request({ target: 'git push origin feature', lease }) as never, + prompt as never, + ); + expect(first.decision).toBe('allow'); + expect(prompt).toHaveBeenCalledTimes(1); + const second = await authorizeResolvedTaskAction( + request({ target: 'git push origin feature', lease }) as never, + prompt as never, + ); + expect(second.decision).toBe('allow'); + expect(prompt).toHaveBeenCalledTimes(1); + }); + it('prompts once directly when no lease exists', async () => { + const prompt = vi.fn(async () => false); + const outcome = await authorizeResolvedTaskAction( + request({ target: 'npm run build', config: cfg({ agonPermissionMode: 'ask' }) }) as never, + prompt as never, + ); + expect(outcome.decision).toBe('deny'); + expect(prompt).toHaveBeenCalledTimes(1); + }); +}); From 40af816305e7f41974ed1fc147620c753c15e001 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:24:16 +0200 Subject: [PATCH 2/4] feat: Shift+Tab permission-mode cycling with a permanent footer segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+Tab now cycles ask -> auto-edit -> auto (Claude Code parity); Ctrl+A and /auto stay as the direct AUTO toggle, mapped onto the same mode enum (auto <-> auto-edit) through one applyPermissionMode writer so cesarAutoMode, agonPermissionMode, the lease, and the footer never disagree. The footer's AUTO slot becomes an always-visible permission segment: 'AUTO' when autonomous, otherwise the mode label with a shift+tab hint — restoring the approval-posture visibility the status bar rewrite dropped. New /mode command (bare cycles, /mode sets, /mode status explains). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../cli/src/generated/signals/app-input.ts | 39 +++++++++------ .../cli/src/generated/signals/keyboard.ts | 9 ++-- .../src/generated/surfaces/app-keyboard.ts | 31 ++++++++---- .../cli/src/generated/surfaces/app-submit.ts | 48 +++++++++++++------ .../cli/src/generated/surfaces/app-views.tsx | 4 +- .../cli/src/generated/surfaces/app.entry.tsx | 2 +- packages/cli/src/generated/surfaces/app.tsx | 26 ++++++---- .../src/generated/surfaces/status-helpers.ts | 42 ++++++++-------- .../src/generated/surfaces/status.entry.tsx | 2 +- .../cli/src/generated/surfaces/status.tsx | 16 ++++--- packages/cli/src/kern/signals/app-input.kern | 9 ++++ packages/cli/src/kern/signals/keyboard.kern | 7 ++- .../cli/src/kern/surfaces/app-keyboard.kern | 18 +++++-- .../cli/src/kern/surfaces/app-submit.kern | 25 ++++++++-- packages/cli/src/kern/surfaces/app-views.kern | 3 +- packages/cli/src/kern/surfaces/app.kern | 20 ++++++-- .../cli/src/kern/surfaces/status-helpers.kern | 12 +++-- packages/cli/src/kern/surfaces/status.kern | 5 +- tests/unit/app-input.test.ts | 12 ++++- tests/unit/keyboard.test.ts | 8 ++-- 20 files changed, 236 insertions(+), 102 deletions(-) diff --git a/packages/cli/src/generated/signals/app-input.ts b/packages/cli/src/generated/signals/app-input.ts index 9bdb04aa8..bff25e16a 100644 --- a/packages/cli/src/generated/signals/app-input.ts +++ b/packages/cli/src/generated/signals/app-input.ts @@ -93,9 +93,20 @@ export function parseAutoModeCommand(input: string): 'on'|'off'|'toggle'|'status } /** - * True when /btw should run as a side-channel instead of falling through to normal command dispatch. + * Parse first-class /mode controls: bare /mode cycles, /mode sets, /mode status shows. Anything else returns null so unrelated input falls through to normal routing. */ // @kern-source: app-input:68 +export function parsePermissionModeCommand(input: string): 'ask'|'auto-edit'|'auto'|'cycle'|'status'|null { + const trimmed = String(input ?? '').trim().toLowerCase(); + const match = trimmed.match(/^\/mode(?:\s+(ask|auto-edit|auto|status))?$/); + if (!match) return null; + return (match[1] as 'ask' | 'auto-edit' | 'auto' | 'status' | undefined) ?? 'cycle'; +} + +/** + * True when /btw should run as a side-channel instead of falling through to normal command dispatch. + */ +// @kern-source: app-input:77 export function hasBtwSideChannelTarget(opts: {replState:string,activePlanState?:string|null,runningJobCount?:number}): boolean { if (opts.replState !== 'idle') { return true; @@ -107,7 +118,7 @@ export function hasBtwSideChannelTarget(opts: {replState:string,activePlanState? return ['planning', 'awaiting_approval', 'running', 'paused'].includes(planState); } -// @kern-source: app-input:78 +// @kern-source: app-input:87 export interface EscapeDecision { action: 'close-slash'|'close-engine-picker'|'cancel-question'|'interrupt'|'clear-input'|'noop'; } @@ -115,7 +126,7 @@ export interface EscapeDecision { /** * Resolve Esc behavior without destructive transcript clearing. */ -// @kern-source: app-input:81 +// @kern-source: app-input:90 export function resolveEscapeAction(opts: {replState:string,inputValue:string,slashPickerOpen:boolean,enginePickerOpen:boolean,questionOpen:boolean,runningJobCount?:number}): EscapeDecision { if (opts.slashPickerOpen) { return { action: 'close-slash' }; @@ -138,7 +149,7 @@ export function resolveEscapeAction(opts: {replState:string,inputValue:string,sl /** * Get ghost text completion for current input. */ -// @kern-source: app-input:96 +// @kern-source: app-input:105 export function tryGhostComplete(inputValue: string, commands: any[], engineIds: string[]): string|null { return getGhostCompletion(inputValue, commands, engineIds); } @@ -146,7 +157,7 @@ export function tryGhostComplete(inputValue: string, commands: any[], engineIds: /** * Rank slash-command matches so prefix hits stay on top while substring hits remain reachable. */ -// @kern-source: app-input:99 +// @kern-source: app-input:108 export function getSlashMatches(filter: string, commands: any[]): any[] { const normalizedFilter = filter.trim().toLowerCase(); return commands @@ -166,7 +177,7 @@ export function getSlashMatches(filter: string, commands: any[]): any[] { /** * Move a picker cursor with wrap-around. */ -// @kern-source: app-input:117 +// @kern-source: app-input:126 export function movePickerCursor(direction: 'up'|'down', currentIndex: number, itemCount: number): number { if (itemCount <= 0) { return 0; @@ -180,7 +191,7 @@ export function movePickerCursor(direction: 'up'|'down', currentIndex: number, i /** * Only queue plan mode from Tab when the composer is idle and empty. */ -// @kern-source: app-input:126 +// @kern-source: app-input:135 export function shouldQueuePlanModeOnTab(opts: {replState:string,inputValue:string,activePlanState?:string|null}): boolean { if (opts.replState !== 'idle') { return false; @@ -194,13 +205,13 @@ export function shouldQueuePlanModeOnTab(opts: {replState:string,inputValue:stri return true; } -// @kern-source: app-input:159 +// @kern-source: app-input:168 export const MENTION_TRAILING_PUNCT: string = [',', '.', ';', ':', '!', '?', ')', ']', '}', "'", '"'].join(''); /** * Trim sentence/clause punctuation from the end of a captured @-mention path so trailing prose punctuation isn't read as part of the filename. */ -// @kern-source: app-input:161 +// @kern-source: app-input:170 export function stripMentionTrailingPunct(path: string): string { let end = path.length; while (end > 0 && MENTION_TRAILING_PUNCT.includes(path[end - 1])) end--; @@ -210,7 +221,7 @@ export function stripMentionTrailingPunct(path: string): string { /** * Extract @-mentioned relative paths from submitted composer text. A mention is '@' where '@' is at string start or follows whitespace (so user@host emails are NOT matched). Trailing prose punctuation is trimmed. Returns de-duplicated paths in first-seen order; never includes the leading '@'. */ -// @kern-source: app-input:169 +// @kern-source: app-input:178 export function extractFileMentions(text: string): string[] { const out: string[] = []; const seen = new Set(); @@ -233,7 +244,7 @@ export function extractFileMentions(text: string): string[] { /** * Given the current composer value, return the active trailing @-mention fragment the picker should filter on, or null if the caret (assumed end-of-input) is not inside a mention. Active means: the last '@' in the value is a valid mention boundary (start or after whitespace) AND no whitespace follows it. The query is the text typed after that '@' (may be empty right after typing '@'). */ -// @kern-source: app-input:190 +// @kern-source: app-input:199 export function parseActiveAtMention(value: string): { query: string } | null { const at = value.lastIndexOf('@'); if (at < 0) return null; @@ -248,7 +259,7 @@ export function parseActiveAtMention(value: string): { query: string } | null { /** * Subsequence fuzzy score of query against a candidate path (case-insensitive). Returns -1 when query is not a subsequence. Higher is better: contiguous runs, basename hits, and matches near a path segment boundary score higher; earlier matches score higher. Empty query scores all candidates 0 (caller keeps source order). */ -// @kern-source: app-input:203 +// @kern-source: app-input:212 export function fuzzyFileScore(query: string, candidate: string): number { if (!query) return 0; const q = query.toLowerCase(); @@ -284,7 +295,7 @@ export function fuzzyFileScore(query: string, candidate: string): number { /** * Rank project file paths against a fuzzy query (subsequence). Non-matches are dropped; ties break on shorter path then lexicographic. Empty query returns the first `limit` files in source order. Capped at `limit` (default 50). */ -// @kern-source: app-input:237 +// @kern-source: app-input:246 export function rankFileMatches(query: string, files: string[], limit?: number): string[] { const cap = Math.max(1, Math.floor(limit ?? 50)); if (!query.trim()) return files.slice(0, cap); @@ -304,7 +315,7 @@ export function rankFileMatches(query: string, files: string[], limit?: number): /** * collectSourceFiles wrapped so a bad cwd / fs error never throws into the keystroke path — returns [] instead. Used to lazily populate the @-file picker on first '@'. */ -// @kern-source: app-input:255 +// @kern-source: app-input:264 export function safeCollectSourceFiles(cwd: string): string[] { try { return collectSourceFiles(cwd); diff --git a/packages/cli/src/generated/signals/keyboard.ts b/packages/cli/src/generated/signals/keyboard.ts index 30bd7dcf5..a007a64c0 100644 --- a/packages/cli/src/generated/signals/keyboard.ts +++ b/packages/cli/src/generated/signals/keyboard.ts @@ -48,6 +48,7 @@ export type KeyboardAction = | { type: 'ghostComplete'; ghost: string } | { type: 'togglePlanQueued' } | { type: 'toggleAutoQueued' } + | { type: 'cyclePermissionMode' } | { type: 'submit'; value: string } | { type: 'toggleToolExpand' } | { type: 'openToolDetail' } @@ -81,7 +82,7 @@ export type KeyboardAction = /** * Pure keyboard decision tree. Takes current state, returns action to execute. */ -// @kern-source: keyboard:90 +// @kern-source: keyboard:91 export function resolveKeyboardInput(ctx: KeyboardCtx): KeyboardAction { const { input, key } = ctx; const keyName = typeof key.name === 'string' ? key.name.toLowerCase() : ''; @@ -221,10 +222,12 @@ export function resolveKeyboardInput(ctx: KeyboardCtx): KeyboardAction { && !ctx.questionState ) return { type: 'toggleLiveToolTail' }; - // Shift+Tab: queue auto mode + // Shift+Tab: cycle the permission mode (ask → auto-edit → auto). + // Ctrl+A above stays the direct AUTO toggle for muscle memory and for + // terminals that collapse Shift+Tab into a plain Tab. if (isShiftTab && !ctx.slashPickerOpen && !ctx.enginePickerOpen && !ctx.questionState && !ctx.reviewEventOpen) { if (canQueueMode) { - return { type: 'toggleAutoQueued' }; + return { type: 'cyclePermissionMode' }; } return { type: 'none' }; } diff --git a/packages/cli/src/generated/surfaces/app-keyboard.ts b/packages/cli/src/generated/surfaces/app-keyboard.ts index 5fae9b752..54ff5a54c 100644 --- a/packages/cli/src/generated/surfaces/app-keyboard.ts +++ b/packages/cli/src/generated/surfaces/app-keyboard.ts @@ -2,6 +2,8 @@ import { resolveKeyboardInput } from '../signals/keyboard.js'; +import { cycleAgonPermissionMode, describeAgonPermissionMode } from '../cesar/permission-resolver.js'; + import { isTerminalFocusReport } from '../../input-utils.js'; import { listFiles } from '../signals/file-tracker.js'; @@ -23,7 +25,7 @@ import { spawnSync } from 'node:child_process'; /** * Explicit dependencies for runHandleCancelOrExit — the question/repl/input state the Ctrl+C cancel path inspects, the active-abort ref it reads for the cancel-vs-interrupt message, and the setters/callbacks/dispatch it fires. */ -// @kern-source: app-keyboard:60 +// @kern-source: app-keyboard:61 export interface CancelOrExitDeps { questionState: any; replState: ReplStateState; @@ -38,7 +40,7 @@ export interface CancelOrExitDeps { dispatch: (event:any) => void; } -// @kern-source: app-keyboard:83 +// @kern-source: app-keyboard:84 export function runHandleCancelOrExit(opts: CancelOrExitDeps): void { if (opts.questionState) { opts.questionState.resolve(''); opts.setQuestionState(null); opts.setQuestionAnswer(''); opts.setSelectedChoiceIndex(0); opts.setQuestionOtherActive(false); } if (opts.replState !== 'idle') { @@ -65,7 +67,7 @@ export function runHandleCancelOrExit(opts: CancelOrExitDeps): void { /** * Explicit dependencies for runHandleComposerCtrlShortcut — the chord/handled refs it flips, the rail + composer + tool-output setters, and the cancel/submit/tool callbacks the per-key cases delegate to. */ -// @kern-source: app-keyboard:114 +// @kern-source: app-keyboard:115 export interface ComposerCtrlShortcutDeps { nestedCtrlShortcutRef: {current: { key: string; at: number }}; ctrlKeyHandledRef: {current: boolean}; @@ -81,7 +83,7 @@ export interface ComposerCtrlShortcutDeps { draftLatestFailedToolRetry: () => void; } -// @kern-source: app-keyboard:137 +// @kern-source: app-keyboard:138 export function runHandleComposerCtrlShortcut(opts: ComposerCtrlShortcutDeps, shortcut: string): void { opts.nestedCtrlShortcutRef.current = { key: shortcut, at: Date.now() }; switch (shortcut) { @@ -134,7 +136,7 @@ export function runHandleComposerCtrlShortcut(opts: ComposerCtrlShortcutDeps, sh /** * Explicit dependencies for runHandleKeyboardInput — the UI-state snapshot fed to resolveKeyboardInput, the refs the router consults/flips, and every setter/callback/dispatch the action switch fires. Passed in rather than captured from component scope; values are read at call time so staleness matches the original closure. */ -// @kern-source: app-keyboard:194 +// @kern-source: app-keyboard:195 export interface KeyboardInputDeps { modelPickerOpen: boolean; cesarPickerOpen: boolean; @@ -155,6 +157,8 @@ export interface KeyboardInputDeps { historyIndex: number; planModeQueued: boolean; autoModeQueued: boolean; + permissionMode: string; + applyPermissionMode: (mode:string) => void; planApprovalIndex: number; outputBlocks: OutputBlock[]; allSlashCommands: any[]; @@ -206,7 +210,7 @@ export interface KeyboardInputDeps { dispatch: (event:any) => void; } -// @kern-source: app-keyboard:283 +// @kern-source: app-keyboard:286 export function runHandleKeyboardInput(opts: KeyboardInputDeps, input: string, key: any): void { if (isTerminalFocusReport(input)) return; if (key?.paste) return; @@ -371,17 +375,26 @@ export function runHandleKeyboardInput(opts: KeyboardInputDeps, input: string, k return; case 'togglePlanQueued': opts.setPlanModeQueued((prev: boolean) => !prev); return; - case 'toggleAutoQueued': + case 'toggleAutoQueued': { const nextAutoModeQueued = !opts.autoModeQueued; opts.setPlanModeQueued(false); - opts.setPersistentAutoMode(nextAutoModeQueued); + opts.applyPermissionMode(nextAutoModeQueued ? 'auto' : 'auto-edit'); opts.dispatch({ type: 'info', message: nextAutoModeQueued ? 'AUTO ON by default. Plain tasks may self-escalate through Cesar. Ctrl+A toggles it off.' - : 'AUTO OFF by default.', + : 'AUTO OFF — permission mode auto-edit (workspace file edits auto-approved). Shift+Tab cycles modes.', } as any); return; + } + case 'cyclePermissionMode': { + const nextMode = cycleAgonPermissionMode(opts.permissionMode as any); + opts.setPlanModeQueued(false); + opts.applyPermissionMode(nextMode); + const described = describeAgonPermissionMode(nextMode); + opts.dispatch({ type: 'info', message: `Permission mode: ${described.label} — ${described.hint}. Shift+Tab cycles.` } as any); + return; + } case 'submit': opts.handleSubmit(action.value); return; case 'planControl': diff --git a/packages/cli/src/generated/surfaces/app-submit.ts b/packages/cli/src/generated/surfaces/app-submit.ts index 1336cc970..77d4df1ed 100644 --- a/packages/cli/src/generated/surfaces/app-submit.ts +++ b/packages/cli/src/generated/surfaces/app-submit.ts @@ -14,7 +14,9 @@ import { startCommandReplState, finishReplState } from '../signals/app-state.js' import type { ReplStateState } from '../signals/app-state.js'; -import { appendInputHistory, cleanSubmitValue, hasBtwSideChannelTarget, parseAutoModeCommand } from '../signals/app-input.js'; +import { appendInputHistory, cleanSubmitValue, hasBtwSideChannelTarget, parseAutoModeCommand, parsePermissionModeCommand } from '../signals/app-input.js'; + +import { cycleAgonPermissionMode, describeAgonPermissionMode } from '../cesar/permission-resolver.js'; import { pushSteering, peekSteeringCount, drainLeftoverSteering } from '../cesar/steering.js'; @@ -38,7 +40,7 @@ import { mkdirSync, readFileSync, statSync, realpathSync, openSync, readSync, cl // ── Module: AppSubmit ── -// @kern-source: app-submit:75 +// @kern-source: app-submit:76 export function buildInterruptedTurnRedirect(previous: string, latest: string, source?: 'foreground'|'job'): string { const previousLabel = source === 'job' ? 'Interrupted background task' : 'Interrupted request'; return [ @@ -54,21 +56,21 @@ export function buildInterruptedTurnRedirect(previous: string, latest: string, s ].join('\n'); } -// @kern-source: app-submit:95 +// @kern-source: app-submit:96 export const MENTION_MAX_FILE_BYTES: number = 65536; -// @kern-source: app-submit:96 +// @kern-source: app-submit:97 export const MENTION_MAX_TOTAL_BYTES: number = 262144; -// @kern-source: app-submit:97 +// @kern-source: app-submit:98 export const MENTION_MAX_FILES: number = 20; -// @kern-source: app-submit:106 +// @kern-source: app-submit:107 export function isLiteralCommandLine(input: string): boolean { return input.startsWith('/') || input.startsWith('! '); } -// @kern-source: app-submit:116 +// @kern-source: app-submit:117 export function buildMentionedFilesContext(text: string, cwd: string): string { const allMentions = extractFileMentions(text); const mentions = allMentions.slice(0, MENTION_MAX_FILES); @@ -145,7 +147,7 @@ export function buildMentionedFilesContext(text: string, cwd: string): string { return `\n\n[Attached files referenced with @ in the message above]\n${blocks.join('\n\n')}${note}`; } -// @kern-source: app-submit:197 +// @kern-source: app-submit:198 export function runProcessInputQueue(replState: ReplStateState, inputQueue: string[], setInputQueue: (updater:(prev:string[]) => string[]) => void, handleSubmit: (value:string) => void, setSteeringCount: (n:number) => void): void { if (replState !== 'idle') return; // The turn is over — clear the mid-turn steering hint count. @@ -172,7 +174,7 @@ export function runProcessInputQueue(replState: ReplStateState, inputQueue: stri /** * Explicit dependencies for runSendBtwMessage — the context builder + live runtime signals (mode/replState/streams/transcript/plan/jobs) it folds into the side-chat prompt, the prior btwPanel it continues, the separate btwAbortRef it swaps, and the panel setter + dispatch it drives. Passed in rather than captured from component scope; values read at call time so staleness matches the original closure. */ -// @kern-source: app-submit:229 +// @kern-source: app-submit:230 export interface SendBtwMessageDeps { buildContext: () => any; btwPanel: any; @@ -187,7 +189,7 @@ export interface SendBtwMessageDeps { dispatch: (event:any) => void; } -// @kern-source: app-submit:254 +// @kern-source: app-submit:255 export function runSendBtwMessage(opts: SendBtwMessageDeps, question: string): void { const q = (question ?? '').trim(); if (!q) return; @@ -282,7 +284,7 @@ export function runSendBtwMessage(opts: SendBtwMessageDeps, question: string): v /** * Explicit dependencies for runHandleSubmit — the refs it consults (epoch/paste/bell/plan/turn/job-timing), the composer + history + queue + mode state it resets, every modal/picker/engine setter the DispatchCallbacks object wires, and the callbacks it delegates to. Passed in rather than captured from component scope; values read at call time so staleness matches the original closure. */ -// @kern-source: app-submit:354 +// @kern-source: app-submit:355 export interface HandleSubmitDeps { inputEpochRef: {current: number}; pendingBellRef: {current: boolean}; @@ -298,6 +300,7 @@ export interface HandleSubmitDeps { mode: string; planModeQueued: boolean; autoModeQueued: boolean; + permissionMode: string; btwPanel: any; pendingImages: ImageAttachment[]; outputBlocks: any[]; @@ -321,6 +324,7 @@ export interface HandleSubmitDeps { setStatusDashboardOpen: (val:boolean) => void; setPlanModeQueued: (val:boolean) => void; setPersistentAutoMode: (enabled:boolean) => void; + applyPermissionMode: (mode:string) => void; setMode: (val:any) => void; setWorkspacePath: (val:string) => void; setReplState: (updater:any) => void; @@ -351,7 +355,7 @@ export interface HandleSubmitDeps { bell: () => void; } -// @kern-source: app-submit:438 +// @kern-source: app-submit:441 export async function runHandleSubmit(opts: HandleSubmitDeps, value: string): Promise { opts.inputEpochRef.current += 1; let input = cleanSubmitValue(value); @@ -377,6 +381,22 @@ export async function runHandleSubmit(opts: HandleSubmitDeps, value: string): Pr }); opts.setHistoryIndex(-1); + const modeControl = parsePermissionModeCommand(input); + if (modeControl) { + const currentMode = String(opts.permissionMode || 'auto-edit'); + if (modeControl === 'status') { + const current = describeAgonPermissionMode(currentMode as any); + opts.dispatch({ type: 'info', message: `Permission mode: ${current.label} — ${current.hint}. Shift+Tab cycles ask → auto-edit → auto.` } as any); + return; + } + const nextMode = modeControl === 'cycle' ? cycleAgonPermissionMode(currentMode as any) : modeControl; + opts.setPlanModeQueued(false); + opts.applyPermissionMode(nextMode); + const described = describeAgonPermissionMode(nextMode as any); + opts.dispatch({ type: 'info', message: `Permission mode: ${described.label} — ${described.hint}.` } as any); + return; + } + const autoControl = parseAutoModeCommand(input); if (autoControl) { if (autoControl === 'status') { @@ -385,12 +405,12 @@ export async function runHandleSubmit(opts: HandleSubmitDeps, value: string): Pr } const nextAutoModeQueued = autoControl === 'toggle' ? !opts.autoModeQueued : autoControl === 'on'; opts.setPlanModeQueued(false); - opts.setPersistentAutoMode(nextAutoModeQueued); + opts.applyPermissionMode(nextAutoModeQueued ? 'auto' : 'auto-edit'); opts.dispatch({ type: 'info', message: nextAutoModeQueued ? 'AUTO ON by default. Plain tasks may self-escalate through Cesar. Use /auto off or Ctrl+A to disable.' - : 'AUTO OFF by default.', + : 'AUTO OFF — permission mode auto-edit. Use /mode or Shift+Tab to change it.', } as any); return; } diff --git a/packages/cli/src/generated/surfaces/app-views.tsx b/packages/cli/src/generated/surfaces/app-views.tsx index 2d9d22a9b..f0c83a54e 100644 --- a/packages/cli/src/generated/surfaces/app-views.tsx +++ b/packages/cli/src/generated/surfaces/app-views.tsx @@ -688,7 +688,7 @@ const UpdateBanner = React.memo(function UpdateBanner({ updateInfo, updateChecki export { UpdateBanner }; // @kern-source: app-views:838 -const BottomChromeSection = React.memo(function BottomChromeSection({ updateInfo, updateChecking, questionState, pendingImages, imageVisionNote, inputQueue, steeringCount, liveSpinner, mode, statusDashboardOpen, telemetryVitals, recentFallbacks, termWidth, termHeight, statusDashboardFilter, replState, planModeQueued, autoModeQueued, activePlan, slashPickerOpen, atPickerOpen, atPickerFiles, atPickerPrefix, atPickerQuery, onAtSelect, onAtCancel, inputValue, handleInputChange, handlePasteInput, handleSubmit, allSlashCommands, availableEngines, onSlashSelect, onSlashCancel, questionAnswer, selectedChoiceIndex, questionOtherActive, onQuestionAnswerChange, onQuestionAnswerSubmit, updateBannerActive, onCtrlShortcut, cesarId, cesarConfidence, cesarContext, liveProgress, runningJobs, chatStartTime, streamSnippet, statusStats, statusCwd, statusBranch, explorationMode, toolOutputExpanded, fullscreenEnabled, uiMotion, interactionActive }: { updateInfo:any; updateChecking:boolean; questionState:any; pendingImages:any[]; imageVisionNote?:string|null; inputQueue:string[]; steeringCount:number; liveSpinner:any; mode:'chat'|'campfire'|'brainstorm'|'tribunal'; statusDashboardOpen:boolean; telemetryVitals:Map; recentFallbacks:any[]; termWidth:number; termHeight:number; statusDashboardFilter:any; replState:string; planModeQueued:boolean; autoModeQueued:boolean; activePlan:any; slashPickerOpen:boolean; atPickerOpen:boolean; atPickerFiles:string[]; atPickerPrefix:string; atPickerQuery:string; onAtSelect:(path:string) => void; onAtCancel:(typed:string) => void; inputValue:string; handleInputChange:(value:string) => void; handlePasteInput:(raw:string) => string; handleSubmit:(value:string) => void; allSlashCommands:any[]; availableEngines:string[]; onSlashSelect:(cmd:string) => void; onSlashCancel:() => void; questionAnswer:string; selectedChoiceIndex:number; questionOtherActive:boolean; onQuestionAnswerChange:(value:string) => void; onQuestionAnswerSubmit:(value:string) => void; updateBannerActive:boolean; onCtrlShortcut:(shortcut:string) => void; cesarId:string; cesarConfidence:any; cesarContext:any; liveProgress:EngineProgress[] | null; runningJobs:Job[]; chatStartTime:number; streamSnippet:any; statusStats:any; statusCwd:string; statusBranch:string; explorationMode:any; toolOutputExpanded:boolean; fullscreenEnabled:boolean; uiMotion?:'full'|'reduced'|'off'; interactionActive?:boolean }) { +const BottomChromeSection = React.memo(function BottomChromeSection({ updateInfo, updateChecking, questionState, pendingImages, imageVisionNote, inputQueue, steeringCount, liveSpinner, mode, statusDashboardOpen, telemetryVitals, recentFallbacks, termWidth, termHeight, statusDashboardFilter, replState, planModeQueued, autoModeQueued, permissionMode, activePlan, slashPickerOpen, atPickerOpen, atPickerFiles, atPickerPrefix, atPickerQuery, onAtSelect, onAtCancel, inputValue, handleInputChange, handlePasteInput, handleSubmit, allSlashCommands, availableEngines, onSlashSelect, onSlashCancel, questionAnswer, selectedChoiceIndex, questionOtherActive, onQuestionAnswerChange, onQuestionAnswerSubmit, updateBannerActive, onCtrlShortcut, cesarId, cesarConfidence, cesarContext, liveProgress, runningJobs, chatStartTime, streamSnippet, statusStats, statusCwd, statusBranch, explorationMode, toolOutputExpanded, fullscreenEnabled, uiMotion, interactionActive }: { updateInfo:any; updateChecking:boolean; questionState:any; pendingImages:any[]; imageVisionNote?:string|null; inputQueue:string[]; steeringCount:number; liveSpinner:any; mode:'chat'|'campfire'|'brainstorm'|'tribunal'; statusDashboardOpen:boolean; telemetryVitals:Map; recentFallbacks:any[]; termWidth:number; termHeight:number; statusDashboardFilter:any; replState:string; planModeQueued:boolean; autoModeQueued:boolean; permissionMode?:string; activePlan:any; slashPickerOpen:boolean; atPickerOpen:boolean; atPickerFiles:string[]; atPickerPrefix:string; atPickerQuery:string; onAtSelect:(path:string) => void; onAtCancel:(typed:string) => void; inputValue:string; handleInputChange:(value:string) => void; handlePasteInput:(raw:string) => string; handleSubmit:(value:string) => void; allSlashCommands:any[]; availableEngines:string[]; onSlashSelect:(cmd:string) => void; onSlashCancel:() => void; questionAnswer:string; selectedChoiceIndex:number; questionOtherActive:boolean; onQuestionAnswerChange:(value:string) => void; onQuestionAnswerSubmit:(value:string) => void; updateBannerActive:boolean; onCtrlShortcut:(shortcut:string) => void; cesarId:string; cesarConfidence:any; cesarContext:any; liveProgress:EngineProgress[] | null; runningJobs:Job[]; chatStartTime:number; streamSnippet:any; statusStats:any; statusCwd:string; statusBranch:string; explorationMode:any; toolOutputExpanded:boolean; fullscreenEnabled:boolean; uiMotion?:'full'|'reduced'|'off'; interactionActive?:boolean }) { const isActive = replState !== 'idle' || runningJobs.length > 0; return ( @@ -757,7 +757,7 @@ const BottomChromeSection = React.memo(function BottomChromeSection({ updateInfo onCtrlShortcut={onCtrlShortcut} /> )} - {mode === 'chat' && } + {mode === 'chat' && } ); }); diff --git a/packages/cli/src/generated/surfaces/app.entry.tsx b/packages/cli/src/generated/surfaces/app.entry.tsx index f895c78f5..409030b5b 100644 --- a/packages/cli/src/generated/surfaces/app.entry.tsx +++ b/packages/cli/src/generated/surfaces/app.entry.tsx @@ -1,7 +1,7 @@ #!/usr/bin/env node // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/surfaces/app.kern -// @kern-source: app:99 +// @kern-source: app:100 import React from 'react'; import { render } from 'ink'; diff --git a/packages/cli/src/generated/surfaces/app.tsx b/packages/cli/src/generated/surfaces/app.tsx index 5b4c59b1a..a08ba0581 100644 --- a/packages/cli/src/generated/surfaces/app.tsx +++ b/packages/cli/src/generated/surfaces/app.tsx @@ -48,6 +48,8 @@ import type { ReplStateState } from '../signals/app-state.js'; import type { Scoreboard } from '../cesar/scoreboard.js'; +import { resolveAgonPermissionMode } from '../cesar/permission-resolver.js'; + import type { ModeRationale } from '../cesar/mode-rationale.js'; import { processPasteContent, recordPastePlaceholder } from '../signals/paste-handler.js'; @@ -160,7 +162,7 @@ import { runProcessInputQueue, runSendBtwMessage, runHandleSubmit } from './app- export { COMPOSER_HISTORY_LIMIT, isMutatingToolCall, probeEngineVitals, parseToolCallPayload, toolPreviewWindow, toolCallSupportsDetailView, detailViewerSupportsEvent, toolDetailViewportRows, findLatestToolDetailEvent, findLatestToolEvent, buildExecutionRailStats, composerHistoryPath, loadComposerInputHistory, saveComposerInputHistory, findLatestFailedToolEvent, buildFailedToolRetryDraft, buildToolDetailView, createInitialRegistry, drainStdinBuffer, maxScrollOffsetForRowCount, nextWheelAnimationStep, clampNumber, charDisplayWidth, stringDisplayWidth, displayColumnToStringIndex, normalizeRowSelection, normalizeTextSelection, richLineToPlainText, transcriptRowToPlainText, transcriptRowTextStartColumn, resolveTranscriptColumnFromMouse, transcriptRowsToPlainText, resolveTranscriptRowFromMouse, estimateVisibleBlockBudget, estimateWrappedRowCount, estimateQuestionReservedRows, estimateBottomChromeExtraRows, summarizeBtwTranscriptEvent, buildDashboardBlock, estimatePinnedLiveRows, estimateWrappedRows, estimateToolCallRows, estimateOutputEventRows, buildDisplayItems, isToolCallLikeBlock, coalesceToolCallBlocks, effectiveNativeArchiveBlockCount, estimateDisplayItemRows, historyBlocksForTranscript, nativeTranscriptBlocksForStatic, nativeArchiveBlockCount, isDuplicateEngineBlock, appendTranscriptBlock, normalizeTerminalMode, resolveTerminalMode, normalizeTerminalSize, fileRailWidthForTerminal, fileRailMaxRowsForTerminal, buildTerminalReplaySnapshot, parseMarkdownToRows, buildToolCallRows, buildCollapsedToolGroupRows, buildTranscriptRows } from './app-helpers.js'; -// @kern-source: app:99 +// @kern-source: app:100 export function App() { // Ink-safe setter: bridges microtask → macrotask for reliable repaints function __inkSafe(setter: React.Dispatch>): React.Dispatch> { @@ -367,6 +369,7 @@ export function App() { const setTodos = useMemo(() => __inkSafe(_setTodosRaw), [_setTodosRaw]); const [planModeQueued, setPlanModeQueued] = useState(false); const [autoModeQueued, setAutoModeQueued] = useState(() => loadConfig().cesarAutoMode === true); + const [permissionMode, setPermissionMode] = useState(() => resolveAgonPermissionMode(loadConfig())); const [cesarMemory, _setCesarMemoryRaw] = useState(() => createCesarMemory()); const setCesarMemory = useMemo(() => __inkSafe(_setCesarMemoryRaw), [_setCesarMemoryRaw]); const [sessionMcpServers, _setSessionMcpServersRaw] = useState>>([]); @@ -816,6 +819,12 @@ export function App() { setConfigVersion((v: number) => v + 1); }, []); + const applyPermissionMode = useCallback((mode:string) => { + configSet('agonPermissionMode' as any, mode as any); + setPermissionMode(mode); + setPersistentAutoMode(mode === 'auto'); + }, [setPersistentAutoMode]); + const setActivePlanWrapped = useCallback((plan:any) => { if (activePlanClearTimerRef.current) { clearTimeout(activePlanClearTimerRef.current); @@ -1047,13 +1056,13 @@ export function App() { const handleSubmit = useCallback(async (value:string) => { runHandleSubmit({ inputEpochRef, pendingBellRef, awaitingPlanAnnouncedRef, pasteHashesRef, pendingPasteTransformRef, inputValueRef, activePlanRef, activeTurnRef, interruptedTurnRef, chatStartTimeRef, - replState, mode, planModeQueued, autoModeQueued, btwPanel, pendingImages, outputBlocks, allSlashCommands, dynamicSkills, extensionSkills, lastUndoToken, sessionStartTime, explorationMode, neroMode, + replState, mode, planModeQueued, autoModeQueued, permissionMode, btwPanel, pendingImages, outputBlocks, allSlashCommands, dynamicSkills, extensionSkills, lastUndoToken, sessionStartTime, explorationMode, neroMode, jobManager, commandRegistry, eventBus, loadedExtensions, - setInputValue, setInputHistory, setHistoryIndex, setInputQueue, setSteeringCount, setSlashPickerOpen, setStatusDashboardOpen, setPlanModeQueued, setPersistentAutoMode, setMode, setWorkspacePath, setReplState, setJobList, setBtwPanel, + setInputValue, setInputHistory, setHistoryIndex, setInputQueue, setSteeringCount, setSlashPickerOpen, setStatusDashboardOpen, setPlanModeQueued, setPersistentAutoMode, applyPermissionMode, setMode, setWorkspacePath, setReplState, setJobList, setBtwPanel, setPendingImages, setSessionEngines, setEnginePickerOpen, setModelPickerOpen, setModelPickerEntries, setModelPickerLoading, setCesarPickerOpen, setChatSession, setLastUndoToken, setModelPickerTargetEngine, setModelPickerInitialFilter, setModelPickerTitle, setModelPickerCliGroups, setExplorationMode, setNeroMode, dispatch, buildContext, sendBtwMessage, handleSubmit, transition, setActivePlanWrapped, askQuestion, bell, }, value); - }, [replState,dispatch,buildContext,mode,pendingImages,jobManager,loadedExtensions,extensionSkills,commandRegistry,eventBus,planModeQueued,autoModeQueued,setPersistentAutoMode,setActivePlanWrapped,outputBlocks,btwPanel,sendBtwMessage,pendingBellRef,awaitingPlanAnnouncedRef]); + }, [replState,dispatch,buildContext,mode,pendingImages,jobManager,loadedExtensions,extensionSkills,commandRegistry,eventBus,planModeQueued,autoModeQueued,permissionMode,applyPermissionMode,setPersistentAutoMode,setActivePlanWrapped,outputBlocks,btwPanel,sendBtwMessage,pendingBellRef,awaitingPlanAnnouncedRef]); const handleReviewActionCb = useCallback((action:'apply'|'edit'|'reject'|'copy') => { if (!reviewEvent) { @@ -1338,7 +1347,7 @@ export function App() { reviewEvent, toolDetailEvent, btwPanel, statusDashboardOpen, questionState, selectedChoiceIndex, questionOtherActive, replState, jobManager, inputValue, inputHistory, historyIndex, - planModeQueued, autoModeQueued, planApprovalIndex, + planModeQueued, autoModeQueued, permissionMode, applyPermissionMode, planApprovalIndex, outputBlocks, allSlashCommands, availableEngines, updateInfo, terminalMode, newestLiveToolStreamId, fileRailOpen, fileRailExpandedPath, fileRailSelectedIdx, executionRailOpen, currentVisibleRowBudget, startupOnly, @@ -1354,7 +1363,7 @@ export function App() { openLatestToolDetail, openResultsPager, draftLatestFailedToolRetry, triggerUpdatePrompt, dismissUpdateBanner, dispatch, }, input, key); - }, [modelPickerOpen,cesarPickerOpen,slashPickerOpen,atPickerOpen,enginePickerOpen,reviewEvent,toolDetailEvent,btwPanel,questionState,replState,inputValue,inputHistory,historyIndex,planModeQueued,autoModeQueued,outputBlocks,allSlashCommands,availableEngines,handleSubmit,interruptActiveRun,dispatch,openLatestToolDetail,openResultsPager,draftLatestFailedToolRetry,startupOnly,terminalMode,setPersistentAutoMode,statusDashboardOpen,updateInfo,triggerUpdatePrompt,dismissUpdateBanner,selectedChoiceIndex,questionOtherActive,newestLiveToolStreamId]); + }, [modelPickerOpen,cesarPickerOpen,slashPickerOpen,atPickerOpen,enginePickerOpen,reviewEvent,toolDetailEvent,btwPanel,questionState,replState,inputValue,inputHistory,historyIndex,planModeQueued,autoModeQueued,permissionMode,applyPermissionMode,outputBlocks,allSlashCommands,availableEngines,handleSubmit,interruptActiveRun,dispatch,openLatestToolDetail,openResultsPager,draftLatestFailedToolRetry,startupOnly,terminalMode,setPersistentAutoMode,statusDashboardOpen,updateInfo,triggerUpdatePrompt,dismissUpdateBanner,selectedChoiceIndex,questionOtherActive,newestLiveToolStreamId]); useEffect(() => { const activeIds = new Set(Object.keys(liveToolStreams ?? {})); @@ -1964,6 +1973,7 @@ export function App() { replState={replState} planModeQueued={planModeQueued} autoModeQueued={autoModeQueued} + permissionMode={permissionMode} activePlan={activePlan} slashPickerOpen={slashPickerOpen} atPickerOpen={atPickerOpen} @@ -2055,10 +2065,10 @@ export function App() { ); } -// @kern-source: app:97 +// @kern-source: app:98 export const _cesarSessionRef: { session: PersistentSession | null } = { session: null }; -// @kern-source: app:1943 +// @kern-source: app:1953 export async function startRepl(): Promise { ensureAgonHome(); // Session-scoped grounding ONLY — deliberately does NOT call diff --git a/packages/cli/src/generated/surfaces/status-helpers.ts b/packages/cli/src/generated/surfaces/status-helpers.ts index 165346984..f158c0479 100644 --- a/packages/cli/src/generated/surfaces/status-helpers.ts +++ b/packages/cli/src/generated/surfaces/status-helpers.ts @@ -53,10 +53,10 @@ export function appendPriorityStatusSegment(segments: {kind:string,text:string}[ } /** - * Allocate a width-bounded footer into typed semantic segments. The typed result lets Ink preserve colors without giving up context/cost priority or exact truncation. + * Allocate a width-bounded footer into typed semantic segments. The typed result lets Ink preserve colors without giving up context/cost priority or exact truncation. The permission posture renders as one always-present mode segment ('AUTO' when the AUTO harness is queued or the mode is auto, else the mode label + shift+tab hint) so the approval stance is never invisible. */ // @kern-source: status-helpers:52 -export function buildPriorityStatusSegments(parts: {width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,telemetry?:string}): {kind:string,text:string}[] { +export function buildPriorityStatusSegments(parts: {width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,permissionMode?:string,telemetry?:string}): {kind:string,text:string}[] { const width = Math.max(12, Math.floor(Number(parts?.width) || 80)); const context = parts?.context && parts.context.pct > 0 ? `ctx ${parts.context.source === 'estimate' ? '~' : ''}${Math.round(parts.context.pct)}%` @@ -70,6 +70,10 @@ export function buildPriorityStatusSegments(parts: {width?:number,exploration?:b const messages = Math.max(0, Math.floor(Number(parts?.messages) || 0)); const alert = telemetry && telemetry !== '\u25cf idle' ? telemetry : ''; const healthy = telemetry === '\u25cf idle' ? telemetry : ''; + const cleanMode = cleanPriorityStatusText(parts?.permissionMode); + const modeText = parts?.auto || cleanMode === 'auto' ? 'AUTO' + : cleanMode ? `${cleanMode} (shift+tab)` + : ''; const segments: { kind: string; text: string }[] = []; // Context and cost are the two accounting signals the narrow footer must // never silently push behind location/help text. @@ -79,7 +83,7 @@ export function buildPriorityStatusSegments(parts: {width?:number,exploration?:b if (used < 0) return segments; used = appendPriorityStatusSegment(segments, width, used, 'alert', alert, false); if (used < 0) return segments; - used = appendPriorityStatusSegment(segments, width, used, 'auto', parts?.auto ? 'AUTO' : '', false); + used = appendPriorityStatusSegment(segments, width, used, 'auto', modeText, false); if (used < 0) return segments; used = appendPriorityStatusSegment(segments, width, used, 'location', location, true); if (used < 0) return segments; @@ -94,15 +98,15 @@ export function buildPriorityStatusSegments(parts: {width?:number,exploration?:b /** * Build the plain-text form of the priority footer for terminal-frame checks and non-Ink consumers. */ -// @kern-source: status-helpers:89 -export function buildPriorityStatusLine(parts: {width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,telemetry?:string}): string { +// @kern-source: status-helpers:93 +export function buildPriorityStatusLine(parts: {width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,permissionMode?:string,telemetry?:string}): string { return buildPriorityStatusSegments(parts).map((segment) => segment.text).join(' \u00b7 '); } /** * Build the stable metric tail for the one-row footer. It never emits newlines or permanent shortcut help; Ink owns terminal-width truncation. */ -// @kern-source: status-helpers:95 +// @kern-source: status-helpers:99 export function buildCompactStatusTail(parts: {context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,telemetry?:string}): string { const segments: string[] = []; const context = parts?.context; @@ -124,7 +128,7 @@ export function buildCompactStatusTail(parts: {context?:{pct:number,source?:stri /** * Format the status-bar cost without conflating flat-rate plan API usage with CLI usage. */ -// @kern-source: status-helpers:115 +// @kern-source: status-helpers:119 export function formatTokenCostStatus(meteredCostUsd: number, totalCostUsd: number, hasPlanApiUsage: boolean, hasCliUsage: boolean): string { if (meteredCostUsd > 0) { return `$${meteredCostUsd.toFixed(2)}${hasPlanApiUsage ? ' +plan api' : ''}${hasCliUsage ? ' +cli' : ''}`; @@ -139,7 +143,7 @@ export function formatTokenCostStatus(meteredCostUsd: number, totalCostUsd: numb /** * Build a compact phase/progress gauge from a CesarPlan-like object. Pure helper so tests can verify plan progress without rendering Ink. */ -// @kern-source: status-helpers:128 +// @kern-source: status-helpers:132 export function buildPlanPhaseGauge(plan: any, width: number = 12): any { const steps = Array.isArray(plan?.steps) ? plan.steps : []; if (!plan || steps.length === 0) { @@ -183,7 +187,7 @@ export function buildPlanPhaseGauge(plan: any, width: number = 12): any { /** * Render the configurable Cesar one-line status (engine/model · context % · git branch). Pure so tests verify placeholder substitution without rendering Ink. `config`: falsy/'' = off (returns null); true/'default' = standard line; any other string = format with {engine},{model},{context},{branch},{cwd} placeholders. Unknown placeholders pass through literally; a missing/empty value renders empty so a failed git-branch read just leaves {branch} blank (never crashes). */ -// @kern-source: status-helpers:166 +// @kern-source: status-helpers:170 export function formatStatusLine(config: string|boolean|null|undefined, parts: {engine?:string,model?:string,context?:string,branch?:string,cwd?:string}): string|null { if (config === false || config === null || config === undefined || config === '') return null; @@ -218,7 +222,7 @@ export function formatStatusLine(config: string|boolean|null|undefined, parts: { /** * Build compact live rail rows from plan, tool, engine, and fallback state. Kept pure so the rail can evolve without baking logic into JSX. */ -// @kern-source: status-helpers:199 +// @kern-source: status-helpers:203 export function buildExecutionRailTimeline(activePlan: any, lastTool: any, engines: any, recentFallbacks: any, limit: number = 5): any[] { const rows: any[] = []; const maxRows = Math.max(1, Math.min(8, Math.floor(Number(limit) || 5))); @@ -310,19 +314,19 @@ export function buildExecutionRailTimeline(activePlan: any, lastTool: any, engin /** * Below this much dead air since the last meaningful event, the heartbeat suffix stays silent — no flicker on fast turns. */ -// @kern-source: status-helpers:300 +// @kern-source: status-helpers:304 export const HEARTBEAT_GATE_MS: number = 2000; /** * At/after this many seconds the displayed counter stops growing (renders '120s+') and a 'still working — engine not stalled' reassurance is appended (on every tick while capped, not once), so a slow-but-healthy engine never reads as an alarm. Nothing is ever cancelled. */ -// @kern-source: status-helpers:303 +// @kern-source: status-helpers:307 export const HEARTBEAT_CAP_S: number = 120; /** * Derive the heartbeat phase from the brain's existing spinner text — no new event. The brain encodes the live phase in spinner.message: provider-wait states ('Cesar thinking…', 'Cesar responding…', 'Cesar processing tool results…', 'Cesar: retrying …', 'Cesar executing…', 'Cesar grounding…', 'Cesar building plan…', 'Cesar finishing the answer…') vs in-flight tools ('Cesar: …', 'Cesar: awaiting N tool results…'). For a waiting phase the actual verb is preserved in `wait` (thinking/responding/processing/retrying/executing/grounding/finishing/planning) so buildHeartbeatSuffix renders the SAME word the strip already shows — no contradictory 'Cesar responding… · thinking…' strip. Anything unrecognized falls back to waiting/thinking. PURE so the screen stays declarative and the parser is unit-tested. NOTE: the brain's real spinner strings live in cesar/brain.kern (spinner-start/spinner-update messages) — keep this parse aligned with them. */ -// @kern-source: status-helpers:306 +// @kern-source: status-helpers:310 export function parseHeartbeatPhase(spinnerMessage: string|null|undefined): {kind:'waiting'|'tools', names:string[], wait?:'thinking'|'responding'|'processing'|'retrying'|'executing'|'grounding'|'finishing'|'planning'} { const msg = String(spinnerMessage ?? '').trim(); if (!msg) return { kind: 'waiting', names: [], wait: 'thinking' }; @@ -357,7 +361,7 @@ export function parseHeartbeatPhase(spinnerMessage: string|null|undefined): {kin /** * Build the dead-air heartbeat fingerprint from a NORMALIZED phase identity (NOT raw spinner text). Legacy/provider status messages can contain volatile elapsed counters ('Cesar thinking… 4s' → '… 6s', 'timed out after Ns'); fingerprinting the raw message would reset the dead-air anchor on every counter mutation. Instead the phase component uses parseHeartbeatPhase output (kind + wait verb + tool names): genuine phase changes still flip it, but volatile counters do not. Combined with the engine/plan/stream signals the strip already holds. PURE so the fingerprint identity is unit-tested independent of Ink. */ -// @kern-source: status-helpers:339 +// @kern-source: status-helpers:343 export function buildHeartbeatSig(phase: {kind:'waiting'|'tools', names:string[], wait?:string}, engineSig: string, planSig: string, streamSig: string): string { const names = Array.isArray(phase?.names) ? phase.names.join(',') : ''; const wait = (phase && typeof phase.wait === 'string') ? phase.wait : ''; @@ -368,7 +372,7 @@ export function buildHeartbeatSig(phase: {kind:'waiting'|'tools', names:string[] /** * Build the adaptive dead-air suffix for the active-turn status line. UI-ONLY, PURE, no I/O. Returns null when the turn is inactive OR less than HEARTBEAT_GATE_MS (2s) has elapsed since the last meaningful event (silent gate — no flicker on fast turns). At/after the gate it returns a leading '· '-prefixed segment whose label matches the live phase so it never contradicts the activity line the strip already shows: '· … Ns' for a provider-wait (the actual verb the brain emitted — thinking/responding/processing/retrying/… — preserved by parseHeartbeatPhase.wait, defaulting to 'thinking'), '· running … Ns' for one in-flight tool, or '· running N tools… Ns' for several. The counter is capped at HEARTBEAT_CAP_S: from 120s it renders '120s+' and appends a dim reassurance '· still working — engine not stalled' while capped (on every tick at/after the cap, not once). Nothing here cancels or aborts anything — it is display only. Caller resets lastEventAt to now on any meaningful event (text chunk, status, tool running/done) so the gate and elapsed restart per phase. */ -// @kern-source: status-helpers:348 +// @kern-source: status-helpers:352 export function buildHeartbeatSuffix(now: number, lastEventAt: number, phase: {kind:'waiting'|'tools', names:string[], wait?:string}, turnActive: boolean): string|null { if (!turnActive) return null; const elapsedMs = Math.max(0, now - lastEventAt); @@ -397,19 +401,19 @@ export function buildHeartbeatSuffix(now: number, lastEventAt: number, phase: {k /** * Re-read guard-counters.json at most once per 60s for the dashboard. Counters only change when a turn finalizes, so a minute-stale view is fine and keeps the synchronous file read off the hot poll path. */ -// @kern-source: status-helpers:388 +// @kern-source: status-helpers:392 export const GUARD_TELEMETRY_SNAPSHOT_TTL_MS: number = 60 * 1000; /** * Module-level {at, home, value} memo for the dashboard's guard-counters snapshot. `home` keys the entry to the AGON_HOME that produced it so an in-process AGON_HOME change (tests, embedded use) can never serve another home's counters for up to a TTL. Mutated in place by loadGuardTelemetrySnapshot; never frozen at module load. */ -// @kern-source: status-helpers:391 +// @kern-source: status-helpers:395 export const guardTelemetrySnapshotCache = { at: 0, home: '', value: null as (import('@kernlang/agon-core').GuardCounters | null) }; /** * Memoized accessor for the guard-telemetry counters used by the StatusDashboard. Calls the synchronous readGuardCounters() at most once per GUARD_TELEMETRY_SNAPSHOT_TTL_MS; returns the cached value (or null when the counters file is absent) on every call in between. Safe to call from the render path — at most one file read per 60s, never per poll tick. Best-effort: any read failure surfaces as null and the section hides. */ -// @kern-source: status-helpers:394 +// @kern-source: status-helpers:398 export function loadGuardTelemetrySnapshot(): GuardCounters | null { const now = Date.now(); // readGuardCounters resolves AGON_HOME per call — key the memo to the @@ -438,7 +442,7 @@ export function loadGuardTelemetrySnapshot(): GuardCounters | null { /** * Fold the guard-counters snapshot into compact, render-ready rows for the StatusDashboard. PURE (no I/O) so it's fully unit-testable from synthetic counters. Returns { visible, guardRows, engineRows }. visible is false when counters is null OR has no engine-guard cells AND no turn aggregates. codex FIX 3c: guardRows are PER-GUARD so no row shows a permanently-0% column. Each guardRow carries two GENERIC signal cells (sig1Label/sig1Pct, sig2Label/sig2Pct) whose meaning depends on the guard, plus fires, avgOverheadMs (-1 renders as 'n/a'), and the per-guard recommendation. grounded-write -> CER/PRV over resolved=ceremony+prevented, overhead=overheadMsTotal/fires. read-spin -> WHR/STL over resolved=wouldHaveRecovered+stalled+recovered. report-confidence -> HI-HIT (highConfHit/(highConfHit+highConfMiss)) / LO-HIT (lowConfHit/(lowConfHit+lowConfMiss)), avgOverheadMs=-1, recommendation insufficient-data in Phase 0. The P1 GuardPipeline guards evidence + confidence-escalation carry only fires (no ceremony/prevented/whr/calibration split), so both signal cells render as a PLACEHOLDER: sig*Label '—' + sentinel sig*Pct -1 (the dashboard renders -1 as a dim '—', not a fabricated 0% CER/PRV) — FIRES is their only honest signal. Each non-placeholder pct is 0 when its denominator is 0. Per-engine engineRow: { key, engineId, parallelRatePct, avgRoundTripMs, avgAssembleMs } each 0 when its denominator is 0. */ -// @kern-source: status-helpers:421 +// @kern-source: status-helpers:425 export function buildGuardTelemetryView(counters: GuardCounters | null): any { const byEngineGuard: Record> = (counters && counters.byEngineGuard) ? counters.byEngineGuard : {}; const byEngineTurns: Record = (counters && counters.byEngineTurns) ? counters.byEngineTurns : {}; diff --git a/packages/cli/src/generated/surfaces/status.entry.tsx b/packages/cli/src/generated/surfaces/status.entry.tsx index 307e04622..be71f87d0 100644 --- a/packages/cli/src/generated/surfaces/status.entry.tsx +++ b/packages/cli/src/generated/surfaces/status.entry.tsx @@ -1,7 +1,7 @@ #!/usr/bin/env node // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/surfaces/status.kern -// @kern-source: status:571 +// @kern-source: status:574 import React from 'react'; import { render } from 'ink'; diff --git a/packages/cli/src/generated/surfaces/status.tsx b/packages/cli/src/generated/surfaces/status.tsx index fc9da6f85..077914b6a 100644 --- a/packages/cli/src/generated/surfaces/status.tsx +++ b/packages/cli/src/generated/surfaces/status.tsx @@ -56,7 +56,7 @@ export function TokenGauge({ tokens, maxTokens }: { tokens:number; maxTokens:num } // @kern-source: status:63 -const StatusBar = React.memo(function StatusBar({ cesarId, chatMessageCount, totalTokens, totalCostUsd, cwd, branch, explorationMode, toolOutputExpanded, autoModeQueued, isActive, fullscreenEnabled, telemetryVitals, context, meteredCostUsd, hasPlanApiUsage, hasCliUsage, termWidth }: { cesarId:string; chatMessageCount:number; totalTokens:number; totalCostUsd:number; cwd:string; branch?:string; explorationMode?:boolean; toolOutputExpanded?:boolean; autoModeQueued?:boolean; isActive?:boolean; fullscreenEnabled?:boolean; telemetryVitals?:Map; context?:{pct:number,used:number,limit:number,compacted:number,cached:number,source?:string}|null; meteredCostUsd?:number; hasPlanApiUsage?:boolean; hasCliUsage?:boolean; termWidth?:number }) { +const StatusBar = React.memo(function StatusBar({ cesarId, chatMessageCount, totalTokens, totalCostUsd, cwd, branch, explorationMode, toolOutputExpanded, autoModeQueued, permissionMode, isActive, fullscreenEnabled, telemetryVitals, context, meteredCostUsd, hasPlanApiUsage, hasCliUsage, termWidth }: { cesarId:string; chatMessageCount:number; totalTokens:number; totalCostUsd:number; cwd:string; branch?:string; explorationMode?:boolean; toolOutputExpanded?:boolean; autoModeQueued?:boolean; permissionMode?:string; isActive?:boolean; fullscreenEnabled?:boolean; telemetryVitals?:Map; context?:{pct:number,used:number,limit:number,compacted:number,cached:number,source?:string}|null; meteredCostUsd?:number; hasPlanApiUsage?:boolean; hasCliUsage?:boolean; termWidth?:number }) { // Cost honesty: only REAL API token usage is billable per token. A // coding-plan API has real tokens but its cost is included in the plan; // a subscription CLI brain has no countable per-turn cost. Keep those @@ -92,6 +92,7 @@ const StatusBar = React.memo(function StatusBar({ cesarId, chatMessageCount, tot messages: msgs, cost, auto: Boolean(autoModeQueued), + permissionMode: String(permissionMode ?? ''), telemetry, }); return ( @@ -120,7 +121,8 @@ const StatusBar = React.memo(function StatusBar({ cesarId, chatMessageCount, tot ); } const color = segment.kind === 'alert' ? '#fbbf24' - : segment.kind === 'auto' ? '#fb923c' + : segment.kind === 'auto' && segment.text === 'AUTO' ? '#fb923c' + : segment.kind === 'auto' && segment.text.startsWith('auto-edit') ? '#60a5fa' : segment.kind === 'healthy' ? '#4ade80' : undefined; return ( @@ -136,7 +138,7 @@ const StatusBar = React.memo(function StatusBar({ cesarId, chatMessageCount, tot }); export { StatusBar }; -// @kern-source: status:163 +// @kern-source: status:166 const StatusDashboard = React.memo(function StatusDashboard({ telemetryVitals, recentFallbacks, width, height, filter }: { telemetryVitals:Map; recentFallbacks?:{from:string,to:string,reason:string,at:number}[]; width?:number; height?:number; filter?:'all'|'problem' }) { const vitals = Array.from(telemetryVitals?.values?.() ?? []); const severity: Record = { stalled: 5, fallback: 4, offline: 3, busy: 2, idle: 1 }; @@ -275,7 +277,7 @@ const StatusDashboard = React.memo(function StatusDashboard({ telemetryVitals, r }); export { StatusDashboard }; -// @kern-source: status:309 +// @kern-source: status:312 const ExecutionRail = React.memo(function ExecutionRail({ spinner, engines, activePlanState, activePlan, lastTool, stats, recentFallbacks, toolOutputExpanded, startTime, isActive }: { spinner:{ message: string; engineId?: string } | null; engines:EngineProgress[]|null; activePlanState?:string|null; activePlan?:any; lastTool?:any; stats?:any; recentFallbacks?:{from:string,to:string,reason:string,at:number}[]; toolOutputExpanded?:boolean; startTime?:number; isActive?:boolean }) { const planGauge = buildPlanPhaseGauge(activePlan, 10); const now = Date.now(); @@ -379,7 +381,7 @@ const ExecutionRail = React.memo(function ExecutionRail({ spinner, engines, acti }); export { ExecutionRail }; -// @kern-source: status:423 +// @kern-source: status:426 const ExecutionRailPanel = React.memo(function ExecutionRailPanel({ spinner, engines, activePlanState, activePlan, lastTool, stats, recentFallbacks, toolOutputExpanded, startTime, isActive, width, maxRows, focused }: { spinner:{ message: string; engineId?: string } | null; engines:EngineProgress[]|null; activePlanState?:string|null; activePlan?:any; lastTool?:any; stats?:any; recentFallbacks?:{from:string,to:string,reason:string,at:number}[]; toolOutputExpanded?:boolean; startTime?:number; isActive?:boolean; width?:number; maxRows?:number; focused?:boolean }) { const safeWidth = Math.max(32, Math.floor(Number(width ?? 42))); const safeRows = Math.max(8, Math.floor(Number(maxRows ?? 12))); @@ -490,7 +492,7 @@ const ExecutionRailPanel = React.memo(function ExecutionRailPanel({ spinner, eng }); export { ExecutionRailPanel }; -// @kern-source: status:549 +// @kern-source: status:552 const BackgroundJobRail = React.memo(function BackgroundJobRail({ jobs }: { jobs:Job[] }) { return ( @@ -509,7 +511,7 @@ const BackgroundJobRail = React.memo(function BackgroundJobRail({ jobs }: { jobs }); export { BackgroundJobRail }; -// @kern-source: status:571 +// @kern-source: status:574 const CesarStatusStrip = React.memo(function CesarStatusStrip({ cesarId, confidence, context, spinner, engines, jobs, startTime, streamSnippet, isActive, planModeQueued, autoModeQueued, activePlanState, activePlan, scoreboard, rationale, uiMotion, interactionActive }: { cesarId:string; confidence?:number|null; context?:{pct:number,used:number,limit:number,compacted:number,cached:number,source?:string}|null; spinner:{ message: string; engineId?: string } | null; engines:EngineProgress[]|null; jobs?:Job[]; startTime:number; streamSnippet?:{ engineId: string; line: string } | null; isActive:boolean; planModeQueued?:boolean; autoModeQueued?:boolean; activePlanState?:string|null; activePlan?:any; scoreboard?:Scoreboard|null; rationale?:ModeRationale|null; uiMotion?:'full'|'reduced'|'off'; interactionActive?:boolean }) { // Ink-safe setter: bridges microtask → macrotask for reliable repaints function __inkSafe(setter: React.Dispatch>): React.Dispatch> { diff --git a/packages/cli/src/kern/signals/app-input.kern b/packages/cli/src/kern/signals/app-input.kern index def0c7e74..3868c98e8 100644 --- a/packages/cli/src/kern/signals/app-input.kern +++ b/packages/cli/src/kern/signals/app-input.kern @@ -65,6 +65,15 @@ fn name=parseAutoModeCommand params="input:string" returns="'on'|'off'|'toggle'| return value="null" return value="(match.groups[0] as 'on' | 'off' | 'toggle' | 'status' | null | undefined) ?? 'toggle'" +fn name=parsePermissionModeCommand params="input:string" returns="'ask'|'auto-edit'|'auto'|'cycle'|'status'|null" export=true + doc "Parse first-class /mode controls: bare /mode cycles, /mode sets, /mode status shows. Anything else returns null so unrelated input falls through to normal routing." + handler <<< + const trimmed = String(input ?? '').trim().toLowerCase(); + const match = trimmed.match(/^\/mode(?:\s+(ask|auto-edit|auto|status))?$/); + if (!match) return null; + return (match[1] as 'ask' | 'auto-edit' | 'auto' | 'status' | undefined) ?? 'cycle'; + >>> + fn name=hasBtwSideChannelTarget params="opts:{replState:string,activePlanState?:string|null,runningJobCount?:number}" returns=boolean export=true doc "True when /btw should run as a side-channel instead of falling through to normal command dispatch." handler lang="kern" diff --git a/packages/cli/src/kern/signals/keyboard.kern b/packages/cli/src/kern/signals/keyboard.kern index d531d6bc1..ffe76995d 100644 --- a/packages/cli/src/kern/signals/keyboard.kern +++ b/packages/cli/src/kern/signals/keyboard.kern @@ -50,6 +50,7 @@ union name=KeyboardAction discriminant=type field name=ghost type=string variant name=togglePlanQueued variant name=toggleAutoQueued + variant name=cyclePermissionMode variant name=submit field name=value type=string variant name=toggleToolExpand @@ -228,10 +229,12 @@ fn name=resolveKeyboardInput params="ctx:KeyboardCtx" returns="KeyboardAction" && !ctx.questionState ) return { type: 'toggleLiveToolTail' }; - // Shift+Tab: queue auto mode + // Shift+Tab: cycle the permission mode (ask → auto-edit → auto). + // Ctrl+A above stays the direct AUTO toggle for muscle memory and for + // terminals that collapse Shift+Tab into a plain Tab. if (isShiftTab && !ctx.slashPickerOpen && !ctx.enginePickerOpen && !ctx.questionState && !ctx.reviewEventOpen) { if (canQueueMode) { - return { type: 'toggleAutoQueued' }; + return { type: 'cyclePermissionMode' }; } return { type: 'none' }; } diff --git a/packages/cli/src/kern/surfaces/app-keyboard.kern b/packages/cli/src/kern/surfaces/app-keyboard.kern index dae73d2a7..8d2d9d04c 100644 --- a/packages/cli/src/kern/surfaces/app-keyboard.kern +++ b/packages/cli/src/kern/surfaces/app-keyboard.kern @@ -40,6 +40,7 @@ // returns (never any); // Source: breadcrumb per fn; raw `<<<` kept for the // dense decision-tree bodies where structured KERN would distort the logic. import from="../signals/keyboard.js" names="resolveKeyboardInput" +import from="../cesar/permission-resolver.js" names="cycleAgonPermissionMode,describeAgonPermissionMode" import from="../../input-utils.js" names="isTerminalFocusReport" import from="../signals/file-tracker.js" names="listFiles" import from="../../generated/blocks/engine.js" names="OutputBlock" types=true @@ -216,6 +217,8 @@ module name=AppKeyboard // ── Mode + plan queue + plan approval ── field name=planModeQueued type=boolean field name=autoModeQueued type=boolean + field name=permissionMode type=string + field name=applyPermissionMode type="(mode:string) => void" field name=planApprovalIndex type=number // ── Derived snapshots consumed by resolveKeyboardInput ── field name=outputBlocks type="OutputBlock[]" @@ -445,17 +448,26 @@ module name=AppKeyboard return; case 'togglePlanQueued': opts.setPlanModeQueued((prev: boolean) => !prev); return; - case 'toggleAutoQueued': + case 'toggleAutoQueued': { const nextAutoModeQueued = !opts.autoModeQueued; opts.setPlanModeQueued(false); - opts.setPersistentAutoMode(nextAutoModeQueued); + opts.applyPermissionMode(nextAutoModeQueued ? 'auto' : 'auto-edit'); opts.dispatch({ type: 'info', message: nextAutoModeQueued ? 'AUTO ON by default. Plain tasks may self-escalate through Cesar. Ctrl+A toggles it off.' - : 'AUTO OFF by default.', + : 'AUTO OFF — permission mode auto-edit (workspace file edits auto-approved). Shift+Tab cycles modes.', } as any); return; + } + case 'cyclePermissionMode': { + const nextMode = cycleAgonPermissionMode(opts.permissionMode as any); + opts.setPlanModeQueued(false); + opts.applyPermissionMode(nextMode); + const described = describeAgonPermissionMode(nextMode); + opts.dispatch({ type: 'info', message: `Permission mode: ${described.label} — ${described.hint}. Shift+Tab cycles.` } as any); + return; + } case 'submit': opts.handleSubmit(action.value); return; case 'planControl': diff --git a/packages/cli/src/kern/surfaces/app-submit.kern b/packages/cli/src/kern/surfaces/app-submit.kern index 1fb6be5fd..5b64015f1 100644 --- a/packages/cli/src/kern/surfaces/app-submit.kern +++ b/packages/cli/src/kern/surfaces/app-submit.kern @@ -55,7 +55,8 @@ import from="../signals/dispatch.js" names="dispatchIntent,handleModeSwitch,isCe import from="../signals/dispatch.js" names="DispatchCallbacks" types=true import from="../signals/app-state.js" names="startCommandReplState,finishReplState" import from="../signals/app-state.js" names="ReplStateState" types=true -import from="../signals/app-input.js" names="appendInputHistory,cleanSubmitValue,hasBtwSideChannelTarget,parseAutoModeCommand" +import from="../signals/app-input.js" names="appendInputHistory,cleanSubmitValue,hasBtwSideChannelTarget,parseAutoModeCommand,parsePermissionModeCommand" +import from="../cesar/permission-resolver.js" names="cycleAgonPermissionMode,describeAgonPermissionMode" import from="../cesar/steering.js" names="pushSteering,peekSteeringCount,drainLeftoverSteering" import from="../signals/paste-handler.js" names="expandPastePlaceholders" import from="../signals/output.js" names="StreamingEntry" types=true @@ -369,6 +370,7 @@ ${streamCtx ? 'Recent live output from the running task:\n' + streamCtx + '\n\n' field name=mode type=string field name=planModeQueued type=boolean field name=autoModeQueued type=boolean + field name=permissionMode type=string field name=btwPanel type=any field name=pendingImages type="ImageAttachment[]" field name=outputBlocks type="any[]" @@ -394,6 +396,7 @@ ${streamCtx ? 'Recent live output from the running task:\n' + streamCtx + '\n\n' field name=setStatusDashboardOpen type="(val:boolean) => void" field name=setPlanModeQueued type="(val:boolean) => void" field name=setPersistentAutoMode type="(enabled:boolean) => void" + field name=applyPermissionMode type="(mode:string) => void" field name=setMode type="(val:any) => void" field name=setWorkspacePath type="(val:string) => void" field name=setReplState type="(updater:any) => void" @@ -461,6 +464,22 @@ ${streamCtx ? 'Recent live output from the running task:\n' + streamCtx + '\n\n' }); opts.setHistoryIndex(-1); + const modeControl = parsePermissionModeCommand(input); + if (modeControl) { + const currentMode = String(opts.permissionMode || 'auto-edit'); + if (modeControl === 'status') { + const current = describeAgonPermissionMode(currentMode as any); + opts.dispatch({ type: 'info', message: `Permission mode: ${current.label} — ${current.hint}. Shift+Tab cycles ask → auto-edit → auto.` } as any); + return; + } + const nextMode = modeControl === 'cycle' ? cycleAgonPermissionMode(currentMode as any) : modeControl; + opts.setPlanModeQueued(false); + opts.applyPermissionMode(nextMode); + const described = describeAgonPermissionMode(nextMode as any); + opts.dispatch({ type: 'info', message: `Permission mode: ${described.label} — ${described.hint}.` } as any); + return; + } + const autoControl = parseAutoModeCommand(input); if (autoControl) { if (autoControl === 'status') { @@ -469,12 +488,12 @@ ${streamCtx ? 'Recent live output from the running task:\n' + streamCtx + '\n\n' } const nextAutoModeQueued = autoControl === 'toggle' ? !opts.autoModeQueued : autoControl === 'on'; opts.setPlanModeQueued(false); - opts.setPersistentAutoMode(nextAutoModeQueued); + opts.applyPermissionMode(nextAutoModeQueued ? 'auto' : 'auto-edit'); opts.dispatch({ type: 'info', message: nextAutoModeQueued ? 'AUTO ON by default. Plain tasks may self-escalate through Cesar. Use /auto off or Ctrl+A to disable.' - : 'AUTO OFF by default.', + : 'AUTO OFF — permission mode auto-edit. Use /mode or Shift+Tab to change it.', } as any); return; } diff --git a/packages/cli/src/kern/surfaces/app-views.kern b/packages/cli/src/kern/surfaces/app-views.kern index cbb3a4125..9a93c0d6e 100644 --- a/packages/cli/src/kern/surfaces/app-views.kern +++ b/packages/cli/src/kern/surfaces/app-views.kern @@ -854,6 +854,7 @@ screen name=BottomChromeSection target=ink memo=true prop name=replState type="string" prop name=planModeQueued type="boolean" prop name=autoModeQueued type="boolean" + prop name=permissionMode type="string" optional=true prop name=activePlan type="any" prop name=slashPickerOpen type="boolean" prop name=atPickerOpen type="boolean" @@ -962,7 +963,7 @@ screen name=BottomChromeSection target=ink memo=true onCtrlShortcut={onCtrlShortcut} /> )} - {mode === 'chat' && } + {mode === 'chat' && } ); >>> diff --git a/packages/cli/src/kern/surfaces/app.kern b/packages/cli/src/kern/surfaces/app.kern index 607ac5f6c..25360db70 100644 --- a/packages/cli/src/kern/surfaces/app.kern +++ b/packages/cli/src/kern/surfaces/app.kern @@ -25,6 +25,7 @@ import from="../../code-buffer.js" names="codeBlockBuffer" import from="../signals/app-state.js" names="finishReplState" import from="../signals/app-state.js" names="ReplStateState" types=true import from="../cesar/scoreboard.js" names="Scoreboard" types=true +import from="../cesar/permission-resolver.js" names="resolveAgonPermissionMode" import from="../cesar/mode-rationale.js" names="ModeRationale" types=true import from="../signals/paste-handler.js" names="processPasteContent,recordPastePlaceholder" import from="../signals/output.js" names="handleOutputEvent" @@ -176,6 +177,7 @@ screen name=App target=ink state name=todos type="Todo[]" initial="[]" state name=planModeQueued type=boolean initial=false safe=false state name=autoModeQueued type=boolean initial="() => loadConfig().cesarAutoMode === true" safe=false + state name=permissionMode type=string initial="() => resolveAgonPermissionMode(loadConfig())" safe=false state name=cesarMemory type=any initial="() => createCesarMemory()" state name=sessionMcpServers type="Array>" initial="[]" state name=telemetryVitals type="Map" initial="new Map()" throttle=500 @@ -782,6 +784,13 @@ screen name=App target=ink do value="setAutoModeQueued(enabled)" do value="setConfigVersion((v: number) => v + 1)" + callback name=applyPermissionMode params="mode:string" deps="setPersistentAutoMode" + doc "Single writer for the permission posture: persists agonPermissionMode, updates the footer state, and keeps the AUTO toggle coherent (mode auto ⇔ AUTO on) so the lease, harness profile, and footer never disagree." + handler lang="kern" + do value="configSet('agonPermissionMode' as any, mode as any)" + do value="setPermissionMode(mode)" + do value="setPersistentAutoMode(mode === 'auto')" + callback name=setActivePlanWrapped params="plan:any" deps="" handler <<< if (activePlanClearTimerRef.current) { @@ -1187,13 +1196,13 @@ screen name=App target=ink }, question); >>> - callback name=handleSubmit async=true params="value:string" deps="replState,dispatch,buildContext,mode,pendingImages,jobManager,loadedExtensions,extensionSkills,commandRegistry,eventBus,planModeQueued,autoModeQueued,setPersistentAutoMode,setActivePlanWrapped,outputBlocks,btwPanel,sendBtwMessage,pendingBellRef,awaitingPlanAnnouncedRef" + callback name=handleSubmit async=true params="value:string" deps="replState,dispatch,buildContext,mode,pendingImages,jobManager,loadedExtensions,extensionSkills,commandRegistry,eventBus,planModeQueued,autoModeQueued,permissionMode,applyPermissionMode,setPersistentAutoMode,setActivePlanWrapped,outputBlocks,btwPanel,sendBtwMessage,pendingBellRef,awaitingPlanAnnouncedRef" handler <<< runHandleSubmit({ inputEpochRef, pendingBellRef, awaitingPlanAnnouncedRef, pasteHashesRef, pendingPasteTransformRef, inputValueRef, activePlanRef, activeTurnRef, interruptedTurnRef, chatStartTimeRef, - replState, mode, planModeQueued, autoModeQueued, btwPanel, pendingImages, outputBlocks, allSlashCommands, dynamicSkills, extensionSkills, lastUndoToken, sessionStartTime, explorationMode, neroMode, + replState, mode, planModeQueued, autoModeQueued, permissionMode, btwPanel, pendingImages, outputBlocks, allSlashCommands, dynamicSkills, extensionSkills, lastUndoToken, sessionStartTime, explorationMode, neroMode, jobManager, commandRegistry, eventBus, loadedExtensions, - setInputValue, setInputHistory, setHistoryIndex, setInputQueue, setSteeringCount, setSlashPickerOpen, setStatusDashboardOpen, setPlanModeQueued, setPersistentAutoMode, setMode, setWorkspacePath, setReplState, setJobList, setBtwPanel, + setInputValue, setInputHistory, setHistoryIndex, setInputQueue, setSteeringCount, setSlashPickerOpen, setStatusDashboardOpen, setPlanModeQueued, setPersistentAutoMode, applyPermissionMode, setMode, setWorkspacePath, setReplState, setJobList, setBtwPanel, setPendingImages, setSessionEngines, setEnginePickerOpen, setModelPickerOpen, setModelPickerEntries, setModelPickerLoading, setCesarPickerOpen, setChatSession, setLastUndoToken, setModelPickerTargetEngine, setModelPickerInitialFilter, setModelPickerTitle, setModelPickerCliGroups, setExplorationMode, setNeroMode, dispatch, buildContext, sendBtwMessage, handleSubmit, transition, setActivePlanWrapped, askQuestion, bell, }, value); @@ -1532,14 +1541,14 @@ screen name=App target=ink >>> // ── Keyboard handling — pure decision tree + thin dispatcher ── - callback name=handleKeyboardInput params="input:string,key:any" deps="modelPickerOpen,cesarPickerOpen,slashPickerOpen,atPickerOpen,enginePickerOpen,reviewEvent,toolDetailEvent,btwPanel,questionState,replState,inputValue,inputHistory,historyIndex,planModeQueued,autoModeQueued,outputBlocks,allSlashCommands,availableEngines,handleSubmit,interruptActiveRun,dispatch,openLatestToolDetail,openResultsPager,draftLatestFailedToolRetry,startupOnly,terminalMode,setPersistentAutoMode,statusDashboardOpen,updateInfo,triggerUpdatePrompt,dismissUpdateBanner,selectedChoiceIndex,questionOtherActive,newestLiveToolStreamId" + callback name=handleKeyboardInput params="input:string,key:any" deps="modelPickerOpen,cesarPickerOpen,slashPickerOpen,atPickerOpen,enginePickerOpen,reviewEvent,toolDetailEvent,btwPanel,questionState,replState,inputValue,inputHistory,historyIndex,planModeQueued,autoModeQueued,permissionMode,applyPermissionMode,outputBlocks,allSlashCommands,availableEngines,handleSubmit,interruptActiveRun,dispatch,openLatestToolDetail,openResultsPager,draftLatestFailedToolRetry,startupOnly,terminalMode,setPersistentAutoMode,statusDashboardOpen,updateInfo,triggerUpdatePrompt,dismissUpdateBanner,selectedChoiceIndex,questionOtherActive,newestLiveToolStreamId" handler <<< runHandleKeyboardInput({ modelPickerOpen, cesarPickerOpen, slashPickerOpen, atPickerOpen, enginePickerOpen, reviewEvent, toolDetailEvent, btwPanel, statusDashboardOpen, questionState, selectedChoiceIndex, questionOtherActive, replState, jobManager, inputValue, inputHistory, historyIndex, - planModeQueued, autoModeQueued, planApprovalIndex, + planModeQueued, autoModeQueued, permissionMode, applyPermissionMode, planApprovalIndex, outputBlocks, allSlashCommands, availableEngines, updateInfo, terminalMode, newestLiveToolStreamId, fileRailOpen, fileRailExpandedPath, fileRailSelectedIdx, executionRailOpen, currentVisibleRowBudget, startupOnly, @@ -1848,6 +1857,7 @@ screen name=App target=ink replState={replState} planModeQueued={planModeQueued} autoModeQueued={autoModeQueued} + permissionMode={permissionMode} activePlan={activePlan} slashPickerOpen={slashPickerOpen} atPickerOpen={atPickerOpen} diff --git a/packages/cli/src/kern/surfaces/status-helpers.kern b/packages/cli/src/kern/surfaces/status-helpers.kern index e23b2ff79..85ae63f51 100644 --- a/packages/cli/src/kern/surfaces/status-helpers.kern +++ b/packages/cli/src/kern/surfaces/status-helpers.kern @@ -49,8 +49,8 @@ fn name=appendPriorityStatusSegment params="segments:{kind:string,text:string}[] return -1; >>> -fn name=buildPriorityStatusSegments params="parts:{width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,telemetry?:string}" returns="{kind:string,text:string}[]" export=true - doc "Allocate a width-bounded footer into typed semantic segments. The typed result lets Ink preserve colors without giving up context/cost priority or exact truncation." +fn name=buildPriorityStatusSegments params="parts:{width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,permissionMode?:string,telemetry?:string}" returns="{kind:string,text:string}[]" export=true + doc "Allocate a width-bounded footer into typed semantic segments. The typed result lets Ink preserve colors without giving up context/cost priority or exact truncation. The permission posture renders as one always-present mode segment ('AUTO' when the AUTO harness is queued or the mode is auto, else the mode label + shift+tab hint) so the approval stance is never invisible." handler <<< const width = Math.max(12, Math.floor(Number(parts?.width) || 80)); const context = parts?.context && parts.context.pct > 0 @@ -65,6 +65,10 @@ fn name=buildPriorityStatusSegments params="parts:{width?:number,exploration?:bo const messages = Math.max(0, Math.floor(Number(parts?.messages) || 0)); const alert = telemetry && telemetry !== '\u25cf idle' ? telemetry : ''; const healthy = telemetry === '\u25cf idle' ? telemetry : ''; + const cleanMode = cleanPriorityStatusText(parts?.permissionMode); + const modeText = parts?.auto || cleanMode === 'auto' ? 'AUTO' + : cleanMode ? `${cleanMode} (shift+tab)` + : ''; const segments: { kind: string; text: string }[] = []; // Context and cost are the two accounting signals the narrow footer must // never silently push behind location/help text. @@ -74,7 +78,7 @@ fn name=buildPriorityStatusSegments params="parts:{width?:number,exploration?:bo if (used < 0) return segments; used = appendPriorityStatusSegment(segments, width, used, 'alert', alert, false); if (used < 0) return segments; - used = appendPriorityStatusSegment(segments, width, used, 'auto', parts?.auto ? 'AUTO' : '', false); + used = appendPriorityStatusSegment(segments, width, used, 'auto', modeText, false); if (used < 0) return segments; used = appendPriorityStatusSegment(segments, width, used, 'location', location, true); if (used < 0) return segments; @@ -86,7 +90,7 @@ fn name=buildPriorityStatusSegments params="parts:{width?:number,exploration?:bo return segments; >>> -fn name=buildPriorityStatusLine params="parts:{width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,telemetry?:string}" returns=string export=true +fn name=buildPriorityStatusLine params="parts:{width?:number,exploration?:boolean,cwd?:string,branch?:string,context?:{pct:number,source?:string}|null,tokens?:number,messages?:number,cost?:string,auto?:boolean,permissionMode?:string,telemetry?:string}" returns=string export=true doc "Build the plain-text form of the priority footer for terminal-frame checks and non-Ink consumers." handler <<< return buildPriorityStatusSegments(parts).map((segment) => segment.text).join(' \u00b7 '); diff --git a/packages/cli/src/kern/surfaces/status.kern b/packages/cli/src/kern/surfaces/status.kern index 0d1889247..cba2f35cc 100644 --- a/packages/cli/src/kern/surfaces/status.kern +++ b/packages/cli/src/kern/surfaces/status.kern @@ -70,6 +70,7 @@ screen name=StatusBar target=ink memo=true prop name=explorationMode type=boolean optional=true prop name=toolOutputExpanded type=boolean optional=true prop name=autoModeQueued type=boolean optional=true + prop name=permissionMode type=string optional=true prop name=isActive type=boolean optional=true prop name=fullscreenEnabled type=boolean optional=true prop name=telemetryVitals type="Map" optional=true @@ -115,6 +116,7 @@ screen name=StatusBar target=ink memo=true messages: msgs, cost, auto: Boolean(autoModeQueued), + permissionMode: String(permissionMode ?? ''), telemetry, }); return ( @@ -143,7 +145,8 @@ screen name=StatusBar target=ink memo=true ); } const color = segment.kind === 'alert' ? '#fbbf24' - : segment.kind === 'auto' ? '#fb923c' + : segment.kind === 'auto' && segment.text === 'AUTO' ? '#fb923c' + : segment.kind === 'auto' && segment.text.startsWith('auto-edit') ? '#60a5fa' : segment.kind === 'healthy' ? '#4ade80' : undefined; return ( diff --git a/tests/unit/app-input.test.ts b/tests/unit/app-input.test.ts index 2a824bd47..b52ed4ca4 100644 --- a/tests/unit/app-input.test.ts +++ b/tests/unit/app-input.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { appendInputHistory, cleanInputValue, cleanSubmitValue, findInputChange, getSlashMatches, hasBtwSideChannelTarget, movePickerCursor, parseAutoModeCommand, resolveEscapeAction, shouldQueuePlanModeOnTab, tryGhostComplete } from '../../packages/cli/src/generated/signals/app-input.js'; +import { appendInputHistory, cleanInputValue, cleanSubmitValue, findInputChange, getSlashMatches, hasBtwSideChannelTarget, movePickerCursor, parseAutoModeCommand, parsePermissionModeCommand, resolveEscapeAction, shouldQueuePlanModeOnTab, tryGhostComplete } from '../../packages/cli/src/generated/signals/app-input.js'; import { expandPastePlaceholders, processPasteContent, recordPastePlaceholder } from '../../packages/cli/src/generated/signals/paste-handler.js'; import { pasteStore } from '@kernlang/agon-core'; @@ -62,6 +62,16 @@ describe('app input helpers', () => { expect(parseAutoModeCommand('/auto fix login')).toBeNull(); }); + it('parses first-class /mode controls without stealing unrelated input', () => { + expect(parsePermissionModeCommand('/mode')).toBe('cycle'); + expect(parsePermissionModeCommand('/mode ask')).toBe('ask'); + expect(parsePermissionModeCommand('/mode auto-edit')).toBe('auto-edit'); + expect(parsePermissionModeCommand('/mode auto')).toBe('auto'); + expect(parsePermissionModeCommand('/mode status')).toBe('status'); + expect(parsePermissionModeCommand('/mode yolo')).toBeNull(); + expect(parsePermissionModeCommand('/models')).toBeNull(); + }); + it('allows /btw while a plan or background job is active even if the composer is idle', () => { expect(hasBtwSideChannelTarget({ replState: 'streaming', activePlanState: null, runningJobCount: 0 })).toBe(true); expect(hasBtwSideChannelTarget({ replState: 'idle', activePlanState: 'running', runningJobCount: 0 })).toBe(true); diff --git a/tests/unit/keyboard.test.ts b/tests/unit/keyboard.test.ts index 6371d73cc..8dc561648 100644 --- a/tests/unit/keyboard.test.ts +++ b/tests/unit/keyboard.test.ts @@ -181,18 +181,18 @@ describe('resolveKeyboardInput', () => { }))).toEqual({ type: 'togglePlanQueued' }); }); - it('routes shift+tab to queued auto mode before the plain tab handler', () => { + it('routes shift+tab to the permission-mode cycle before the plain tab handler', () => { expect(resolveKeyboardInput(baseCtx({ input: '\t', key: { tab: true, shift: true }, - }))).toEqual({ type: 'toggleAutoQueued' }); + }))).toEqual({ type: 'cyclePermissionMode' }); }); - it('routes raw terminal shift+tab escape sequence to queued auto mode', () => { + it('routes raw terminal shift+tab escape sequence to the permission-mode cycle', () => { expect(resolveKeyboardInput(baseCtx({ input: '\x1b[Z', key: {}, - }))).toEqual({ type: 'toggleAutoQueued' }); + }))).toEqual({ type: 'cyclePermissionMode' }); }); it('routes ctrl+a on an idle empty composer to queued auto mode', () => { From de0db27f723a20ba668dc6b3641762ac32d41e9b Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:24:28 +0200 Subject: [PATCH 3/4] feat: rule-persisting permission prompt and /permissions editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission prompt grows to Yes / Yes-for-this-session / Always / No / Never. Always persists a validated scoped rule (two-token Bash scope like Bash(git push:*), exact resolved path for file tools) into permissions.allow — never a bare base token; when no safe rule can be synthesized (bare verbs, compounds, substitution, rendered previews) the Always/Never choices simply don't appear. Never persists a deny rule. Yes-for-session stores the rule in a session-scoped store so one answer drains coalesced sibling prompts across a multi-engine run; the queue drain now also honors persisted and session rules, not just allowedCommands. /permissions lists persisted + session rules, the legacy allowedCommands compat list, and the active mode, and gains add allow|deny / remove subcommands. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- README.md | 14 +- packages/cli/src/generated/handlers/info.ts | 73 +++++++--- .../signals/dispatch/intent-session.ts | 2 +- packages/cli/src/generated/signals/intent.ts | 16 ++- packages/cli/src/generated/signals/output.ts | 134 +++++++++++------- packages/cli/src/kern/handlers/info.kern | 39 ++++- .../kern/signals/dispatch/intent-session.kern | 2 +- packages/cli/src/kern/signals/intent.kern | 14 +- packages/cli/src/kern/signals/output.kern | 101 ++++++++----- tests/unit/intent.test.ts | 27 ++++ tests/unit/permission-queue.test.ts | 51 ++++++- 11 files changed, 342 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 7d102fb7d..485dd1e13 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,19 @@ Also available as interactive `/conquer` in the REPL and `agon call conquer` for Launching `agon` starts a powerful terminal REPL equipped with native scrollback, command history, and a file rail. -Available commands include: `/forge`, `/synthesis`, `/brainstorm`, `/tribunal`, `/campfire`, `/pipeline`, `/review`, `/agent`, `/speculate`, `/team-forge`, `/status`, `/leaderboard`, `/history`, `/config`, `/plan`, `/models`, `/engines`, `/doctor`, `/help`, and `/exit`. +Available commands include: `/forge`, `/synthesis`, `/brainstorm`, `/tribunal`, `/campfire`, `/pipeline`, `/review`, `/agent`, `/speculate`, `/team-forge`, `/status`, `/leaderboard`, `/history`, `/config`, `/plan`, `/mode`, `/permissions`, `/models`, `/engines`, `/doctor`, `/help`, and `/exit`. + +### Permission modes + +Agon uses a Claude-Code-style permission mode, cycled with **Shift+Tab** (or `/mode`) and always visible in the footer: + +| Mode | Behavior | +|---|---| +| `ask` | Prompts before file edits and mutating commands. Read-only tools and commands run freely. | +| `auto-edit` | Workspace file edits are auto-approved; Bash mutations still prompt. (Default — migrated from the legacy `smart` mode.) | +| `auto` (AUTO) | Workspace autonomy. Boundaries still prompt: pushes, publishing, deployments, workspace escapes, and deny rules always win. | + +Permission prompts offer **Yes / Yes for this session / Always / No / Never**. *Always* persists a scoped rule such as `Bash(git push:*)` into `permissions.allow` (never a bare verb); *Never* persists a deny rule. Inspect and edit rules with `/permissions`, `/permissions add allow|deny `, and `/permissions remove `. Delegated engine runs (forge, tribunal, agent teams) execute at least at `auto-edit` so multi-engine dispatches don't queue dozens of file-edit prompts — their Bash mutations still prompt once per pattern. **Example Session:** ```text diff --git a/packages/cli/src/generated/handlers/info.ts b/packages/cli/src/generated/handlers/info.ts index 3e672348a..238e44110 100644 --- a/packages/cli/src/generated/handlers/info.ts +++ b/packages/cli/src/generated/handlers/info.ts @@ -18,13 +18,17 @@ import { EngineRegistry } from '@kernlang/agon-core'; import { deriveRoutingHints, buildRoutingContext } from '../cesar/routing.js'; +import { persistPermissionRule, removePermissionRule, getSessionPermissionRules, resolveAgonPermissionMode, describeAgonPermissionMode } from '../cesar/permission-resolver.js'; + +import { parsePermissionRule } from '@kernlang/agon-core'; + import { summarizeAllCesarToolReliability, readCesarDecisionRecords, summarizeCesarLatency } from '../cesar/reliability.js'; import { getLastFoldedRaw, getFoldedRaw, getFoldedRawCount } from '../blocks/narration-fold.js'; // ── Module: InfoHandlers ── -// @kern-source: info:14 +// @kern-source: info:16 export function handleLeaderboard(dispatch: Dispatch): void { const ratings = getRatings(); dispatch({ type: 'header', title: 'Global Leaderboard' }); @@ -58,7 +62,7 @@ export function handleLeaderboard(dispatch: Dispatch): void { } } -// @kern-source: info:48 +// @kern-source: info:50 export function handleCesarReport(dispatch: Dispatch): void { // The report renders only the latest 200 decisions. Bound both JSONL // tails so a long-lived installation never reparses its entire history. @@ -227,7 +231,7 @@ export function handleCesarReport(dispatch: Dispatch): void { dispatch({ type: 'table', headers: ['Recent Mode', 'Intake', 'Flow', 'Tools', 'Stalls', 'Family', 'Hint', 'Breadth', 'Forge Scope'], rows: recentTail }); } -// @kern-source: info:217 +// @kern-source: info:219 export function handleCesarHints(input: string, dispatch: Dispatch, ctx: HandlerContext): void { const trimmed = input.trim(); if (!trimmed) { @@ -245,7 +249,7 @@ export function handleCesarHints(input: string, dispatch: Dispatch, ctx: Handler dispatch({ type: 'text', content: routingCtx }); } -// @kern-source: info:233 +// @kern-source: info:235 function showRunDetail(dispatch: Dispatch, id: string): void { let files: string[]; try { @@ -278,7 +282,7 @@ function showRunDetail(dispatch: Dispatch, id: string): void { } } -// @kern-source: info:266 +// @kern-source: info:268 export function handleHistory(dispatch: Dispatch, id?: string): void { ensureAgonHome(); @@ -321,7 +325,7 @@ export function handleHistory(dispatch: Dispatch, id?: string): void { dispatch({ type: 'info', message: 'Use /history for details' }); } -// @kern-source: info:309 +// @kern-source: info:311 export async function handleEngines(dispatch: Dispatch, ctx: HandlerContext, intent?: Intent&{type:'engines'}): Promise { const action = String((intent as any)?.action ?? '').toLowerCase(); if (action && action !== 'list') { @@ -469,7 +473,7 @@ export async function handleEngines(dispatch: Dispatch, ctx: HandlerContext, int } } -// @kern-source: info:457 +// @kern-source: info:459 export async function handleDiscover(dispatch: Dispatch, ctx: HandlerContext): Promise { dispatch({ type: 'header', title: 'Engine Discovery' }); dispatch({ type: 'spinner-start', message: 'Scanning installed engines...' }); @@ -498,7 +502,7 @@ export async function handleDiscover(dispatch: Dispatch, ctx: HandlerContext): P } } -// @kern-source: info:486 +// @kern-source: info:488 export function handleConfig(intent: Intent&{type:'config'}, dispatch: Dispatch, ctx?: HandlerContext): void { ensureAgonHome(); const action = (intent as any).action ?? 'list'; @@ -561,11 +565,31 @@ export function handleConfig(intent: Intent&{type:'config'}, dispatch: Dispatch, } /** - * Render the loaded CC-parity allow/deny permission rules, their source scope file, and which scope wins. Mirrors Claude Code's /permissions inspection. deny ALWAYS wins over allow. + * Render the loaded CC-parity allow/deny permission rules, their source scope file, and which scope wins — plus session-scoped rules, the legacy allowedCommands compat list, and the active permission mode. /permissions add allow|deny persists a validated rule; /permissions remove revokes from every bucket. deny ALWAYS wins over allow. */ -// @kern-source: info:548 -export function handlePermissions(dispatch: Dispatch): void { +// @kern-source: info:550 +export function handlePermissions(dispatch: Dispatch, intent?: any): void { ensureAgonHome(); + const permAction = String(intent?.action ?? ''); + if (permAction === 'add') { + const rule = String(intent?.value ?? '').trim(); + const kind = String(intent?.key ?? 'allow') === 'deny' ? 'deny' : 'allow'; + if (!parsePermissionRule(rule)) { + dispatch({ type: 'error', message: `Malformed rule: ${rule}. Expected e.g. Bash(git push:*) or Edit(/path/to/file).` }); + return; + } + dispatch(persistPermissionRule(kind as any, rule) + ? { type: 'success', message: `Persisted ${kind} rule: ${rule}` } + : { type: 'info', message: `${kind} rule already present: ${rule}` }); + return; + } + if (permAction === 'remove') { + const rule = String(intent?.value ?? '').trim(); + dispatch(removePermissionRule(rule) + ? { type: 'success', message: `Removed rule: ${rule}` } + : { type: 'warning', message: `Rule not found in any bucket: ${rule}` }); + return; + } // Use the resolved active workspace dir, not the raw process cwd, so // /permissions inspects the same .agon.json/.agon.local.json scope the // approval gate actually loads (workspace switch / subdir invocation safe). @@ -605,13 +629,24 @@ export function handlePermissions(dispatch: Dispatch): void { const effAllow: string[] = Array.isArray(eff.allow) ? eff.allow : []; const effDeny: string[] = Array.isArray(eff.deny) ? eff.deny : []; if (effAllow.length === 0 && effDeny.length === 0) { - dispatch({ type: 'info', message: 'No permission rules configured. Add a "permissions" block with allow/deny arrays to .agon.json, e.g. { "permissions": { "allow": ["Bash(npm test:*)"], "deny": ["Bash(rm:*)"] } }' }); + dispatch({ type: 'info', message: 'No permission rules configured. Add one with /permissions add allow "Bash(npm test:*)" or a "permissions" block in .agon.json.' }); } else { dispatch({ type: 'text', content: `Effective (all scopes merged): ${effDeny.length} deny, ${effAllow.length} allow. Deny rules take precedence.` }); } + const sessionRules = getSessionPermissionRules(); + if (sessionRules.length > 0) { + dispatch({ type: 'text', content: `Session allow rules (this run only): ${sessionRules.join(', ')}` }); + } + const legacyAllowed: string[] = Array.isArray((cfg as any).allowedCommands) ? (cfg as any).allowedCommands : []; + if (legacyAllowed.length > 0) { + dispatch({ type: 'text', content: `Legacy allowedCommands (base-token prefixes, still honored): ${legacyAllowed.join(', ')} — upgrade with /permissions add allow "Bash( :*)" then /config set allowedCommands [].` }); + } + const modeInfo = describeAgonPermissionMode(resolveAgonPermissionMode(cfg)); + dispatch({ type: 'text', content: `Permission mode: ${modeInfo.label} — ${modeInfo.hint}. Shift+Tab or /mode cycles ask → auto-edit → auto.` }); + dispatch({ type: 'text', content: 'Edit rules with /permissions add allow|deny and /permissions remove .' }); } -// @kern-source: info:597 +// @kern-source: info:630 export function handleUse(engineIds: string[], dispatch: Dispatch, ctx: HandlerContext, setSessionEngines: (engines:string[]|null)=>void): void { if (engineIds.length === 0 || engineIds.length === 1 && engineIds[0] === 'all') { setSessionEngines(null); @@ -646,7 +681,7 @@ export function handleUse(engineIds: string[], dispatch: Dispatch, ctx: HandlerC dispatch({ type: 'info', message: 'Saved — persists across sessions. Use /cesar to change Cesar brain separately.' }); } -// @kern-source: info:626 +// @kern-source: info:659 export function handleCesar(engineId: string, dispatch: Dispatch, ctx: HandlerContext): void { // Parse: "/cesar claude api" or "/cesar claude cli" or "/cesar claude" or "/cesar" const parts = engineId.trim().split(/\s+/); @@ -723,7 +758,7 @@ export function handleCesar(engineId: string, dispatch: Dispatch, ctx: HandlerCo /** * Reprint the untouched raw text of a recently narration-folded engine-block. The compact '▸ N reasoning steps folded · /raw' placeholder points here; native scrollback rows cannot expand in place, so /raw re-emits the full original as a fresh block. Bare /raw shows the most recent fold; /raw pages back (1 = most recent) through the last 20. foldedSteps:0 marks it pre-processed so output.kern does not re-fold it. */ -// @kern-source: info:700 +// @kern-source: info:733 export function handleRaw(dispatch: Dispatch, index?: number): void { const count = getFoldedRawCount(); if (count === 0) { @@ -740,7 +775,7 @@ export function handleRaw(dispatch: Dispatch, index?: number): void { dispatch({ type: 'engine-block', engineId: 'raw', color: 244, content: raw, foldedSteps: 0 }); } -// @kern-source: info:718 +// @kern-source: info:751 export function handleTokens(dispatch: Dispatch): void { const stats = tracker.getStats(); dispatch({ type: 'header', title: 'Token Usage — This Session' }); @@ -809,7 +844,7 @@ export function handleTokens(dispatch: Dispatch): void { dispatch({ type: 'info', message: `${stats.dispatches} dispatches across ${Object.keys(stats.byEngine).length} engines` }); } -// @kern-source: info:787 +// @kern-source: info:820 export function handleWorkspace(action: string, dispatch: Dispatch, ctx: HandlerContext, path?: string): void { switch (action) { case 'add': { @@ -866,7 +901,7 @@ export function handleWorkspace(action: string, dispatch: Dispatch, ctx: Handler } } -// @kern-source: info:844 +// @kern-source: info:877 export function handleChats(dispatch: Dispatch, sessionId?: string): void { if (sessionId) { const session = loadChatSession(sessionId); @@ -900,7 +935,7 @@ export function handleChats(dispatch: Dispatch, sessionId?: string): void { dispatch({ type: 'table', headers: ['Session', 'Msgs', 'Date', 'First Message'], rows }); } -// @kern-source: info:878 +// @kern-source: info:911 export function handleModels(dispatch: Dispatch, ctx: HandlerContext): void { dispatch({ type: 'header', title: 'Models & Engines' }); const config = ctx.config; diff --git a/packages/cli/src/generated/signals/dispatch/intent-session.ts b/packages/cli/src/generated/signals/dispatch/intent-session.ts index ae7c97a8d..92f42cfe9 100644 --- a/packages/cli/src/generated/signals/dispatch/intent-session.ts +++ b/packages/cli/src/generated/signals/dispatch/intent-session.ts @@ -146,7 +146,7 @@ export async function dispatchSessionInfoIntent(intent: any, input: string, cb: case 'discover': await handleDiscover(cb.dispatch, cb.ctx); break; case 'provider': await handleProvider(intent.action, intent.args, cb.dispatch, cb.ctx); break; case 'config': handleConfig(intent, cb.dispatch, cb.ctx); break; - case 'permissions': handlePermissions(cb.dispatch); break; + case 'permissions': handlePermissions(cb.dispatch, intent); break; case 'use': handleUse(intent.engineIds, cb.dispatch, cb.ctx, cb.setSessionEngines); break; case 'cesar': { const cesarTarget = (intent.engineIds ?? []).join(' ').trim(); diff --git a/packages/cli/src/generated/signals/intent.ts b/packages/cli/src/generated/signals/intent.ts index b49386394..640150b58 100644 --- a/packages/cli/src/generated/signals/intent.ts +++ b/packages/cli/src/generated/signals/intent.ts @@ -54,7 +54,7 @@ export interface Intent { } // @kern-source: intent:49 -export const SLASH_COMMANDS: SlashCommand[] = [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: ' — show allow/deny permission rules (.agon.json)' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }]; +export const SLASH_COMMANDS: SlashCommand[] = [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/mode', desc: '[ask|auto-edit|auto|status] — permission mode (Shift+Tab cycles)' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: '[add allow|deny |remove ] — list/edit permission rules' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }]; // @kern-source: intent:51 export const FITNESS_PATTERN: RegExp = /\b(?:test with|test:|--test|fitness:)\s+(.+)/i; @@ -691,8 +691,18 @@ function parseSlashCommand(input: string, commandRegistry?: any): Intent { case 'readonly': return { type: 'explore' } as Intent; case 'permissions': - case 'perms': + case 'perms': { + // /permissions — list; /permissions add allow|deny — persist a + // rule; /permissions remove — revoke from every bucket. + const permMatch = rest.match(/^(add)\s+(allow|deny)\s+(.+)$/i) ?? rest.match(/^(remove)\s+(.+)$/i); + if (permMatch && permMatch[1].toLowerCase() === 'add') { + return { type: 'permissions', action: 'add', key: permMatch[2].toLowerCase(), value: permMatch[3].trim() } as Intent; + } + if (permMatch && permMatch[1].toLowerCase() === 'remove') { + return { type: 'permissions', action: 'remove', value: permMatch[2].trim() } as Intent; + } return { type: 'permissions' } as Intent; + } case 'nogate': case 'no-gate': return { type: 'nogate' } as Intent; @@ -750,7 +760,7 @@ function parseSlashCommand(input: string, commandRegistry?: any): Intent { } } -// @kern-source: intent:691 +// @kern-source: intent:701 export function detectIntent(raw: string, commandRegistry?: any): Intent { const input = raw.trim(); if (!input) { diff --git a/packages/cli/src/generated/signals/output.ts b/packages/cli/src/generated/signals/output.ts index 4ee32bf85..1ec3c9e83 100644 --- a/packages/cli/src/generated/signals/output.ts +++ b/packages/cli/src/generated/signals/output.ts @@ -8,7 +8,9 @@ import { foldNarration, setLastFoldedRaw } from '../blocks/narration-fold.js'; import { codeBlockBuffer } from '../../code-buffer.js'; -import { loadConfig, configSet } from '@kernlang/agon-core'; +import { loadConfig, configSet, resolveWorkingDir, evaluateToolRules } from '@kernlang/agon-core'; + +import { synthesizePermissionRule, validateSynthesizedRule, persistPermissionRule, addSessionPermissionRule, buildEffectivePermissionRuleSet } from '../cesar/permission-resolver.js'; import type { Todo } from './todos.js'; @@ -17,7 +19,7 @@ import { setTodos, updateTodoState, clearTodos, clearLiveTodos } from './todos.j /** * Live state for a running autonomous agent session. Fed by agent-* OutputEvents, rendered by surfaces/agent.kern::AgentProgressView. teamId groups members of a single AgentTeam run for explicit clear-by-team. completedAt is set by agent-step-end and consumed by the TTL pruner in app.kern. */ -// @kern-source: output:13 +// @kern-source: output:14 export interface AgentProgressSnapshot { engineId: string; turnIndex: number; @@ -43,7 +45,7 @@ export interface AgentProgressSnapshot { * * True when this entry holds ONLY a SPECULATIVE preview draft (from a PTY 'preview' chunk), never authoritative engine text. A draft-only entry is NEVER committed to the transcript at streaming-end (it is dropped), and the first authoritative streaming-chunk REPLACES the draft content rather than appending to it. This is the contamination firewall: a speculative preview can update the live pane but can never become — or prefix — the committed answer. */ -// @kern-source: output:33 +// @kern-source: output:34 export interface StreamingEntry { engineId: string; content: string; @@ -54,7 +56,7 @@ export interface StreamingEntry { /** * Typed in-flight tool stream shared by output state, the frame-limited bridge, and the live renderer. */ -// @kern-source: output:41 +// @kern-source: output:42 export interface LiveToolStreamEntry { streamId: string; engineId: string; @@ -65,7 +67,7 @@ export interface LiveToolStreamEntry { startedAt: number; } -// @kern-source: output:51 +// @kern-source: output:52 export interface OutputState { liveSpinner: {message:string,color?:number,engineId?:string}|null; liveProgress: EngineProgress[]|null; @@ -75,7 +77,7 @@ export interface OutputState { todos: Todo[]; } -// @kern-source: output:59 +// @kern-source: output:60 export interface OutputActions { setLiveSpinner: (val:any) => void; setLiveProgress: (val:EngineProgress[]|null) => void; @@ -99,16 +101,16 @@ export interface OutputActions { setTodos: (updater:Todo[] | ((prev:Todo[]) => Todo[])) => void; } -// @kern-source: output:82 +// @kern-source: output:83 export const _thinkingBuffer: {engineId:string,content:string} = { engineId: '', content: '' }; -// @kern-source: output:85 +// @kern-source: output:86 export const _permissionQueue: Array<{tool:string,command:string,description?:string,reason:string,diffPreview?:any,fallbackNote?:string,resolve:(approved:boolean)=>void}> = [] as Array<{tool:string,command:string,description?:string,reason:string,diffPreview?:any,fallbackNote?:string,resolve:(approved:boolean)=>void}>; -// @kern-source: output:87 +// @kern-source: output:88 export const _sessionAllowList: string[] = [] as string[]; -// @kern-source: output:89 +// @kern-source: output:90 export function getSessionAllowList(): string[] { return _sessionAllowList; } @@ -116,7 +118,7 @@ export function getSessionAllowList(): string[] { /** * Reject all queued permissions and clear the queue. Called on interrupt/cancel. */ -// @kern-source: output:91 +// @kern-source: output:92 export function clearPermissionQueue(): void { while (_permissionQueue.length > 0) { const entry = _permissionQueue.shift()!; @@ -127,31 +129,31 @@ export function clearPermissionQueue(): void { /** * Drop any buffered thinking-chunk content. Called on interrupt / clear / SIGINT so the next turn doesn't emit stale content as a fresh block. */ -// @kern-source: output:98 +// @kern-source: output:99 export function clearThinkingBuffer(): void { _thinkingBuffer.engineId = ''; _thinkingBuffer.content = ''; } -// @kern-source: output:110 +// @kern-source: output:111 export const TOOL_CALL_GROUP_FLUSH_MS: number = 500; -// @kern-source: output:112 +// @kern-source: output:113 export const _pendingToolCalls: any[] = [] as any[]; -// @kern-source: output:114 +// @kern-source: output:115 export const _pendingFlushTimer: { timer: any, actions: any } = ({ timer: null, actions: null }) as any; -// @kern-source: output:117 +// @kern-source: output:118 export const _liveToolStreams: Record = {}; -// @kern-source: output:122 +// @kern-source: output:123 export const _pinnedPlan: { event: any } = ({ event: null }) as { event: any }; -// @kern-source: output:130 +// @kern-source: output:131 export const _planStepActive: { on: boolean } = ({ on: false }) as { on: boolean }; -// @kern-source: output:132 +// @kern-source: output:133 function toolCallKey(event: any): string { return [String(event?.engineId ?? ''), String(event?.tool ?? ''), String(event?.input ?? '')].join('\x00'); } @@ -159,7 +161,7 @@ function toolCallKey(event: any): string { /** * Emit any buffered tool-call events as a single tool-call-group block. */ -// @kern-source: output:136 +// @kern-source: output:137 export function flushPendingToolCalls(actions: OutputActions): void { if (_pendingFlushTimer.timer) { clearTimeout(_pendingFlushTimer.timer); @@ -174,7 +176,7 @@ export function flushPendingToolCalls(actions: OutputActions): void { /** * Debounce-flush pending tool-calls after a quiet period — covers turns that end on a tool-call without any trailing event. */ -// @kern-source: output:149 +// @kern-source: output:150 export function schedulePendingFlush(actions: OutputActions): void { _pendingFlushTimer.actions = actions; if (_pendingFlushTimer.timer) clearTimeout(_pendingFlushTimer.timer); @@ -185,20 +187,32 @@ export function schedulePendingFlush(actions: OutputActions): void { } /** - * Auto-approve queued permissions whose base command is already in allowedCommands. + * Best-effort raw target for rule synthesis/evaluation. Bash prompts carry the real command; file-tool prompts carry a rendered preview string, so the structured diff preview's file path is the truthful target when present. + */ +// @kern-source: output:161 +function _permissionRuleTarget(entry: {tool:string,command:string,diffPreview?:any}): string { + if (entry.tool === 'Bash') return String(entry.command ?? ''); + const previewPath = entry.diffPreview?.files?.[0]?.path; + return typeof previewPath === 'string' && previewPath.trim() ? previewPath : String(entry.command ?? ''); +} + +/** + * Auto-approve queued permissions already covered by an allow source: the legacy allowedCommands base-token list, or a persisted/session allow rule (so one Always/Yes-for-session answer drains coalesced sibling prompts for the same pattern). */ -// @kern-source: output:160 +// @kern-source: output:169 function _drainAutoApproved(actions: OutputActions): void { const cfg = loadConfig(); const allowed: string[] = (cfg as any).allowedCommands ?? []; - if (allowed.length === 0) { - return; - } - // Drain from the front: auto-approve entries matching the allow-list. + const rules = buildEffectivePermissionRuleSet(cfg); + if (allowed.length === 0 && rules.allow.length === 0) return; + const cwd = resolveWorkingDir(); while (_permissionQueue.length > 0) { const head = _permissionQueue[0]; - const base = head.command.trim().split(/[ \t\n\r\f\v]+/)[0]; - if (base && allowed.some((a: string) => base.toLowerCase().startsWith(a.toLowerCase()))) { + const base = head.command.trim().split(/\s+/)[0]; + const legacyHit = head.tool === 'Bash' && base && allowed.some((a: string) => base.toLowerCase().startsWith(a.toLowerCase())); + const target = _permissionRuleTarget(head); + const ruleHit = target && evaluateToolRules(head.tool, target, cwd, rules) === 'allow'; + if (legacyHit || ruleHit) { _permissionQueue.shift(); head.resolve(true); } else { @@ -207,7 +221,10 @@ function _drainAutoApproved(actions: OutputActions): void { } } -// @kern-source: output:177 +/** + * Claude-Code-parity prompt: Yes / Yes-for-session / Always (persists a validated scoped rule like Bash(git push:*)) / No / Never (persists a deny rule) / tell-Cesar. Always/Never only appear when a rule can be synthesized AND re-validates against the originating action — bare-verb rules are never persisted. Yes approves exactly once. + */ +// @kern-source: output:192 function _showNextPermission(actions: OutputActions): void { // First drain any that are now auto-approved (e.g. after "Always") _drainAutoApproved(actions); @@ -216,6 +233,20 @@ function _showNextPermission(actions: OutputActions): void { actions.addBlock({ type: 'permission-ask', tool: next.tool, command: next.command, description: next.description, reason: next.reason, diffPreview: next.diffPreview, fallbackNote: next.fallbackNote, resolve: next.resolve } as any); const permResolve = next.resolve; const permCommand = next.command; + const permTool = next.tool; + const ruleCwd = resolveWorkingDir(); + const ruleTarget = _permissionRuleTarget(next); + const candidateRule = synthesizePermissionRule(permTool, ruleTarget, ruleCwd); + const rule = candidateRule && validateSynthesizedRule(candidateRule, permTool, ruleTarget, ruleCwd) ? candidateRule : null; + const sessionBase = permTool === 'Bash' ? (permCommand.trim().split(/\s+/)[0] ?? '') : ''; + const choices: Array<{ key: string; label: string; color: string }> = [ + { key: 'y', label: 'Yes', color: '#4ade80' }, + ]; + if (rule || sessionBase) choices.push({ key: 's', label: 'Yes for this session', color: '#4ade80' }); + if (rule) choices.push({ key: 'a', label: `Always allow ${rule}`, color: '#60a5fa' }); + choices.push({ key: 'n', label: 'No', color: '#ef4444' }); + if (rule) choices.push({ key: 'x', label: `Never (deny ${rule})`, color: '#ef4444' }); + choices.push({ key: '__other', label: 'No, tell Cesar what to do instead', color: '#9ca3af' }); actions.setQuestionState({ kind: 'permission', prompt: `Approve ${next.tool}`, @@ -225,36 +256,29 @@ function _showNextPermission(actions: OutputActions): void { reason: next.reason, diffPreview: next.diffPreview, fallbackNote: next.fallbackNote, - choices: [ - { key: 'y', label: 'Yes', color: '#4ade80' }, - { key: 'n', label: 'No', color: '#ef4444' }, - { key: 'a', label: 'Always', color: '#60a5fa' }, - { key: '__other', label: 'No, tell Cesar what to do instead', color: '#9ca3af' }, - ], + choices, resolve: (answer: string) => { _permissionQueue.shift(); const lower = answer.toLowerCase().trim(); - if (lower === 'a') { - const cfg = loadConfig(); - const allowed = (cfg as any).allowedCommands ?? []; - const base = permCommand.trim().split(/\s+/)[0]; - if (base && !allowed.includes(base)) { - allowed.push(base); - configSet('allowedCommands' as any, allowed); + if (lower === 'a' && rule) { + persistPermissionRule('allow', rule); + permResolve(true); + actions.addBlock({ type: 'success', message: `Always allowed: ${rule}` } as any); + } else if (lower === 's') { + if (rule) { + addSessionPermissionRule(rule); + actions.addBlock({ type: 'success', message: `Allowed for this session: ${rule}` } as any); + } else if (sessionBase) { + if (!_sessionAllowList.includes(sessionBase)) _sessionAllowList.push(sessionBase); + actions.addBlock({ type: 'success', message: `Allowed for this session: ${sessionBase}` } as any); } permResolve(true); - actions.addBlock({ type: 'success', message: `Always allowed: ${base}` } as any); } else if (lower === 'y') { - // Smart mode: add to session allowlist (memory-only, not persisted) - const cfg = loadConfig(); - const mode = (cfg as any).permissionMode ?? 'smart'; - if (mode === 'smart') { - const base = permCommand.trim().split(/\s+/)[0]; - if (base && !_sessionAllowList.includes(base)) { - _sessionAllowList.push(base); - } - } permResolve(true); + } else if (lower === 'x' && rule) { + persistPermissionRule('deny', rule); + permResolve(false); + actions.addBlock({ type: 'warning', message: `Never allowed (deny rule persisted): ${rule}` } as any); } else { permResolve(false); actions.addBlock({ type: 'warning', message: 'Denied' } as any); @@ -270,7 +294,7 @@ function _showNextPermission(actions: OutputActions): void { /** * Apply engine-agnostic narration folding to engine-block content per the narrationFold config (off|safe|aggressive, default safe). On a fold, records the raw in the bounded ring (for /raw) and returns { content, foldedSteps } to spread onto the engine-block; clean text folds nothing and returns just { content }. Pure backstop — runs on every engine-block, structured engines simply fold nothing. The raw is NOT returned per-event; it lives in the ring to avoid unbounded per-block memory growth. */ -// @kern-source: output:237 +// @kern-source: output:260 export function foldEngineContent(content: string): { content: string, foldedSteps?: number } { const cfg = loadConfig(); const policy = String((cfg as any).narrationFold ?? 'safe'); @@ -283,7 +307,7 @@ export function foldEngineContent(content: string): { content: string, foldedSte /** * Process a single OutputEvent — updates spinner, streaming, and block state. */ -// @kern-source: output:248 +// @kern-source: output:271 export function handleOutputEvent(event: OutputEvent, state: OutputState, actions: OutputActions, mode: string, chatStartTime: number): void { // Flush accumulated thinking buffer when any non-thinking event arrives if (event.type !== 'thinking-chunk' && _thinkingBuffer.content) { diff --git a/packages/cli/src/kern/handlers/info.kern b/packages/cli/src/kern/handlers/info.kern index f0e5b616d..31a806ff1 100644 --- a/packages/cli/src/kern/handlers/info.kern +++ b/packages/cli/src/kern/handlers/info.kern @@ -7,6 +7,8 @@ import from="../signals/icons.js" names="icons" import from="../../handlers/types.js" names="Dispatch,HandlerContext" types=true import from="@kernlang/agon-core" names="EngineRegistry" import from="../cesar/routing.js" names="deriveRoutingHints,buildRoutingContext" +import from="../cesar/permission-resolver.js" names="persistPermissionRule,removePermissionRule,getSessionPermissionRules,resolveAgonPermissionMode,describeAgonPermissionMode" +import from="@kernlang/agon-core" names="parsePermissionRule" import from="../cesar/reliability.js" names="summarizeAllCesarToolReliability,readCesarDecisionRecords,summarizeCesarLatency" import from="../blocks/narration-fold.js" names="getLastFoldedRaw,getFoldedRaw,getFoldedRawCount" @@ -545,10 +547,30 @@ module name=InfoHandlers } >>> - fn name=handlePermissions params="dispatch:Dispatch" returns=void - doc "Render the loaded CC-parity allow/deny permission rules, their source scope file, and which scope wins. Mirrors Claude Code's /permissions inspection. deny ALWAYS wins over allow." + fn name=handlePermissions params="dispatch:Dispatch,intent?:any" returns=void + doc "Render the loaded CC-parity allow/deny permission rules, their source scope file, and which scope wins — plus session-scoped rules, the legacy allowedCommands compat list, and the active permission mode. /permissions add allow|deny persists a validated rule; /permissions remove revokes from every bucket. deny ALWAYS wins over allow." handler <<< ensureAgonHome(); + const permAction = String(intent?.action ?? ''); + if (permAction === 'add') { + const rule = String(intent?.value ?? '').trim(); + const kind = String(intent?.key ?? 'allow') === 'deny' ? 'deny' : 'allow'; + if (!parsePermissionRule(rule)) { + dispatch({ type: 'error', message: `Malformed rule: ${rule}. Expected e.g. Bash(git push:*) or Edit(/path/to/file).` }); + return; + } + dispatch(persistPermissionRule(kind as any, rule) + ? { type: 'success', message: `Persisted ${kind} rule: ${rule}` } + : { type: 'info', message: `${kind} rule already present: ${rule}` }); + return; + } + if (permAction === 'remove') { + const rule = String(intent?.value ?? '').trim(); + dispatch(removePermissionRule(rule) + ? { type: 'success', message: `Removed rule: ${rule}` } + : { type: 'warning', message: `Rule not found in any bucket: ${rule}` }); + return; + } // Use the resolved active workspace dir, not the raw process cwd, so // /permissions inspects the same .agon.json/.agon.local.json scope the // approval gate actually loads (workspace switch / subdir invocation safe). @@ -588,10 +610,21 @@ module name=InfoHandlers const effAllow: string[] = Array.isArray(eff.allow) ? eff.allow : []; const effDeny: string[] = Array.isArray(eff.deny) ? eff.deny : []; if (effAllow.length === 0 && effDeny.length === 0) { - dispatch({ type: 'info', message: 'No permission rules configured. Add a "permissions" block with allow/deny arrays to .agon.json, e.g. { "permissions": { "allow": ["Bash(npm test:*)"], "deny": ["Bash(rm:*)"] } }' }); + dispatch({ type: 'info', message: 'No permission rules configured. Add one with /permissions add allow "Bash(npm test:*)" or a "permissions" block in .agon.json.' }); } else { dispatch({ type: 'text', content: `Effective (all scopes merged): ${effDeny.length} deny, ${effAllow.length} allow. Deny rules take precedence.` }); } + const sessionRules = getSessionPermissionRules(); + if (sessionRules.length > 0) { + dispatch({ type: 'text', content: `Session allow rules (this run only): ${sessionRules.join(', ')}` }); + } + const legacyAllowed: string[] = Array.isArray((cfg as any).allowedCommands) ? (cfg as any).allowedCommands : []; + if (legacyAllowed.length > 0) { + dispatch({ type: 'text', content: `Legacy allowedCommands (base-token prefixes, still honored): ${legacyAllowed.join(', ')} — upgrade with /permissions add allow "Bash( :*)" then /config set allowedCommands [].` }); + } + const modeInfo = describeAgonPermissionMode(resolveAgonPermissionMode(cfg)); + dispatch({ type: 'text', content: `Permission mode: ${modeInfo.label} — ${modeInfo.hint}. Shift+Tab or /mode cycles ask → auto-edit → auto.` }); + dispatch({ type: 'text', content: 'Edit rules with /permissions add allow|deny and /permissions remove .' }); >>> fn name=handleUse params="engineIds:string[], dispatch:Dispatch, ctx:HandlerContext, setSessionEngines:(engines:string[]|null)=>void" returns=void diff --git a/packages/cli/src/kern/signals/dispatch/intent-session.kern b/packages/cli/src/kern/signals/dispatch/intent-session.kern index 6eb292d09..853351360 100644 --- a/packages/cli/src/kern/signals/dispatch/intent-session.kern +++ b/packages/cli/src/kern/signals/dispatch/intent-session.kern @@ -127,7 +127,7 @@ fn name=dispatchSessionInfoIntent async=true params="intent:any, input:string, c case 'discover': await handleDiscover(cb.dispatch, cb.ctx); break; case 'provider': await handleProvider(intent.action, intent.args, cb.dispatch, cb.ctx); break; case 'config': handleConfig(intent, cb.dispatch, cb.ctx); break; - case 'permissions': handlePermissions(cb.dispatch); break; + case 'permissions': handlePermissions(cb.dispatch, intent); break; case 'use': handleUse(intent.engineIds, cb.dispatch, cb.ctx, cb.setSessionEngines); break; case 'cesar': { const cesarTarget = (intent.engineIds ?? []).join(' ').trim(); diff --git a/packages/cli/src/kern/signals/intent.kern b/packages/cli/src/kern/signals/intent.kern index 57d62ccde..f64d5a6db 100644 --- a/packages/cli/src/kern/signals/intent.kern +++ b/packages/cli/src/kern/signals/intent.kern @@ -46,7 +46,7 @@ module name=IntentParsing field name=count type="number|undefined" field name=last type="boolean|undefined" - const name=SLASH_COMMANDS type="SlashCommand[]" value={{ [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: ' — show allow/deny permission rules (.agon.json)' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }] }} + const name=SLASH_COMMANDS type="SlashCommand[]" value={{ [{ cmd: '/forge', desc: ' test with [--hardened] — competitive code generation' }, { cmd: '/brainstorm', desc: ' — confidence-bidding answers' }, { cmd: '/tribunal', desc: '[mode] — debate (adversarial|socratic|red-team|steelman|synthesis|postmortem)' }, { cmd: '/campfire', desc: ' — think together, no competition' }, { cmd: '/think', desc: ' [--strategy reflexion] [--steps 8] — sequential thinking, one engine' }, { cmd: '/council', desc: ' — roundtable: every engine a role, top-rated chairs' }, { cmd: '/research', desc: ' [--count N] [--engine X] — keyless web-grounded cited research (npm/GitHub/MDN/IETF/SO/Wikipedia)' }, { cmd: '/chrome', desc: ' — drive your browser (read/navigate/screenshot/click), result feeds Cesar' }, { cmd: '/synthesis', desc: ' [--swaps 2] — engines draft, swap, improve; judge picks winner' }, { cmd: '/workspace', desc: 'add|remove|list|switch — manage project repos' }, { cmd: '/ws', desc: ' — list workspaces (shortcut)' }, { cmd: '/cesar', desc: ' — set Cesar brain engine (e.g. /cesar codex)' }, { cmd: '/models', desc: ' — browse & add provider models + CLI models' }, { cmd: '/tokens', desc: ' — show token usage & costs' }, { cmd: '/raw', desc: ' — reprint last folded engine output (unfolded)' }, { cmd: '/engines', desc: ' — select active engines' }, { cmd: '/leaderboard', desc: ' — Glicko rankings' }, { cmd: '/cesar-report', desc: ' — Cesar routing calibration report' }, { cmd: '/cesar-hints', desc: ' — inspect Cesar routing hints for a prompt' }, { cmd: '/history', desc: '[id] — past forge runs' }, { cmd: '/config', desc: '[list|get|set] — settings' }, { cmd: '/plan', desc: ' or no args — plan mode or show plan' }, { cmd: '/auto', desc: '[on|off|toggle|status] or — autonomous mode control' }, { cmd: '/mode', desc: '[ask|auto-edit|auto|status] — permission mode (Shift+Tab cycles)' }, { cmd: '/plans', desc: ' — list recent plans' }, { cmd: '/approve', desc: ' — approve current plan' }, { cmd: '/retry', desc: ' — retry failed plan step' }, { cmd: '/cancel', desc: ' — cancel current plan' }, { cmd: '/apply', desc: '[path] [--force] — apply winning forge patch' }, { cmd: '/cp', desc: '[N|last] — copy code block N, or last response, to clipboard' }, { cmd: '/img', desc: ' — attach image to next prompt' }, { cmd: '/flow', desc: ' — log this session' }, { cmd: '/flows', desc: ' — flow analytics dashboard' }, { cmd: '/chats', desc: '[id|resume ] — chat history or resume session' }, { cmd: '/build', desc: ' — agent builds in cwd (reads/edits/tests)' }, { cmd: '/goal', desc: ' --queue --gate "" [--push] — autonomous queue: build→review(all)→judge→fix→commit→push per task (background job)' }, { cmd: '/conquer', desc: ' --gate "" [--builder X] [-e a,b] — supervised-autonomous build: Cesar drives a builder CLI, convenes nero/tribunal/council on forks, stops at a human merge gate (background job)' },{ cmd: '/agent', desc: ' — autonomous agent loop (solo or shadow, auto-routed)' }, { cmd: '/agent-solo', desc: ' — force solo agent mode, no shadow worker' }, { cmd: '/speculate', desc: ' — parallel speculation: N engines race in worktrees, winner applied' }, { cmd: '/team-forge', desc: '[2v2|3v3] test with — team code competition' }, { cmd: '/team-tribunal', desc: '[2v2|3v3] [mode] — team debate' }, { cmd: '/team-brainstorm', desc: '[2v2|3v3] — team ideation' }, { cmd: '/pipeline', desc: ' [test with ] — build→review→fix loop' }, { cmd: '/review', desc: '[with ] [] — code review (uncommitted|branch:NAME|commit:SHA)' }, { cmd: '/provider', desc: 'add|remove|list|key — providers & keys (key set/clear/list)' }, { cmd: '/run', desc: ' — run shell command inline' }, { cmd: '/commit', desc: '[message] — stage & commit with auto-generated message' }, { cmd: '/status', desc: ' — live engine telemetry snapshot' }, { cmd: '/doctor', desc: '[engines|harness] — diagnose engines, worktree, or Cesar harness' }, { cmd: '/harness-replay', desc: '[turnId] — replay Cesar tool timeline + approval ledger' }, { cmd: '/undo', desc: ' — revert last patch or Cesar checkpoint' }, { cmd: '/checkpoints', desc: ' — list recent file checkpoints' }, { cmd: '/jobs', desc: ' — list running/completed jobs' }, { cmd: '/focus', desc: ' — switch to background job output' }, { cmd: '/explore', desc: ' — toggle exploration mode (read-only)' }, { cmd: '/permissions', desc: '[add allow|deny |remove ] — list/edit permission rules' }, { cmd: '/nogate', desc: ' — toggle the verify-before-done gate nudge for this session' }, { cmd: '/nero', desc: '[] — toggle Nero mode, or challenge a decision (top-rated critic)' }, { cmd: '/btw', desc: ' — ask something while engines work (side-channel)' }, { cmd: '/compact', desc: ' — shrink Cesar context without clearing transcript' }, { cmd: '/mcp', desc: 'connect | disconnect | list — manage session MCP servers' }, { cmd: '/init', desc: ' — create AGON.md config wizard' }, { cmd: '/create-skill', desc: ' — scaffold a new skill (.agon/skills/)' }, { cmd: '/clear', desc: ' — reset session (saves chat, clears brain)' }, { cmd: '/clean', desc: ' — alias for /clear' }, { cmd: '/extensions', desc: ' — list installed extensions' }, { cmd: '/help', desc: ' — show this help' }, { cmd: '/exit', desc: ' — quit' }] }} const name=FITNESS_PATTERN type=RegExp value={{ /\b(?:test with|test:|--test|fitness:)\s+(.+)/i }} @@ -629,8 +629,18 @@ module name=IntentParsing case 'readonly': return { type: 'explore' } as Intent; case 'permissions': - case 'perms': + case 'perms': { + // /permissions — list; /permissions add allow|deny — persist a + // rule; /permissions remove — revoke from every bucket. + const permMatch = rest.match(/^(add)\s+(allow|deny)\s+(.+)$/i) ?? rest.match(/^(remove)\s+(.+)$/i); + if (permMatch && permMatch[1].toLowerCase() === 'add') { + return { type: 'permissions', action: 'add', key: permMatch[2].toLowerCase(), value: permMatch[3].trim() } as Intent; + } + if (permMatch && permMatch[1].toLowerCase() === 'remove') { + return { type: 'permissions', action: 'remove', value: permMatch[2].trim() } as Intent; + } return { type: 'permissions' } as Intent; + } case 'nogate': case 'no-gate': return { type: 'nogate' } as Intent; diff --git a/packages/cli/src/kern/signals/output.kern b/packages/cli/src/kern/signals/output.kern index 8fe629de5..268ea3c98 100644 --- a/packages/cli/src/kern/signals/output.kern +++ b/packages/cli/src/kern/signals/output.kern @@ -6,7 +6,8 @@ import from="../../handlers/types.js" names="OutputEvent,EngineProgress" types=t import from="../blocks/markdown.js" names="parseMarkdownBlocks,cleanEngineOutput" import from="../blocks/narration-fold.js" names="foldNarration,setLastFoldedRaw" import from="../../code-buffer.js" names="codeBlockBuffer" -import from="@kernlang/agon-core" names="loadConfig,configSet" +import from="@kernlang/agon-core" names="loadConfig,configSet,resolveWorkingDir,evaluateToolRules" +import from="../cesar/permission-resolver.js" names="synthesizePermissionRule,validateSynthesizedRule,persistPermissionRule,addSessionPermissionRule,buildEffectivePermissionRuleSet" import from="./todos.js" names="Todo" types=true import from="./todos.js" names="setTodos,updateTodoState,clearTodos,clearLiveTodos" @@ -157,24 +158,39 @@ fn name=schedulePendingFlush params="actions:OutputActions" returns=void }, TOOL_CALL_GROUP_FLUSH_MS); >>> +fn name=_permissionRuleTarget params="entry:{tool:string,command:string,diffPreview?:any}" returns=string export=false + doc "Best-effort raw target for rule synthesis/evaluation. Bash prompts carry the real command; file-tool prompts carry a rendered preview string, so the structured diff preview's file path is the truthful target when present." + handler <<< + if (entry.tool === 'Bash') return String(entry.command ?? ''); + const previewPath = entry.diffPreview?.files?.[0]?.path; + return typeof previewPath === 'string' && previewPath.trim() ? previewPath : String(entry.command ?? ''); + >>> + fn name=_drainAutoApproved params="actions:OutputActions" returns=void export=false - doc "Auto-approve queued permissions whose base command is already in allowedCommands." - handler lang="kern" - let name=cfg value="loadConfig()" - let name=allowed type="string[]" value="(cfg as any).allowedCommands ?? []" - if cond="allowed.length === 0" - return - comment raw="// Drain from the front: auto-approve entries matching the allow-list." - while cond="_permissionQueue.length > 0" - let name=head value="_permissionQueue[0]" - let name=base value="head.command.trim().split(/\\s+/)[0]" - if cond="base && allowed.some((a: string) => base.toLowerCase().startsWith(a.toLowerCase()))" - do value="_permissionQueue.shift()" - do value="head.resolve(true)" - else - break + doc "Auto-approve queued permissions already covered by an allow source: the legacy allowedCommands base-token list, or a persisted/session allow rule (so one Always/Yes-for-session answer drains coalesced sibling prompts for the same pattern)." + handler <<< + const cfg = loadConfig(); + const allowed: string[] = (cfg as any).allowedCommands ?? []; + const rules = buildEffectivePermissionRuleSet(cfg); + if (allowed.length === 0 && rules.allow.length === 0) return; + const cwd = resolveWorkingDir(); + while (_permissionQueue.length > 0) { + const head = _permissionQueue[0]; + const base = head.command.trim().split(/\s+/)[0]; + const legacyHit = head.tool === 'Bash' && base && allowed.some((a: string) => base.toLowerCase().startsWith(a.toLowerCase())); + const target = _permissionRuleTarget(head); + const ruleHit = target && evaluateToolRules(head.tool, target, cwd, rules) === 'allow'; + if (legacyHit || ruleHit) { + _permissionQueue.shift(); + head.resolve(true); + } else { + break; + } + } + >>> fn name=_showNextPermission params="actions:OutputActions" returns=void export=false + doc "Claude-Code-parity prompt: Yes / Yes-for-session / Always (persists a validated scoped rule like Bash(git push:*)) / No / Never (persists a deny rule) / tell-Cesar. Always/Never only appear when a rule can be synthesized AND re-validates against the originating action — bare-verb rules are never persisted. Yes approves exactly once." handler <<< // First drain any that are now auto-approved (e.g. after "Always") _drainAutoApproved(actions); @@ -183,6 +199,20 @@ fn name=_showNextPermission params="actions:OutputActions" returns=void export=f actions.addBlock({ type: 'permission-ask', tool: next.tool, command: next.command, description: next.description, reason: next.reason, diffPreview: next.diffPreview, fallbackNote: next.fallbackNote, resolve: next.resolve } as any); const permResolve = next.resolve; const permCommand = next.command; + const permTool = next.tool; + const ruleCwd = resolveWorkingDir(); + const ruleTarget = _permissionRuleTarget(next); + const candidateRule = synthesizePermissionRule(permTool, ruleTarget, ruleCwd); + const rule = candidateRule && validateSynthesizedRule(candidateRule, permTool, ruleTarget, ruleCwd) ? candidateRule : null; + const sessionBase = permTool === 'Bash' ? (permCommand.trim().split(/\s+/)[0] ?? '') : ''; + const choices: Array<{ key: string; label: string; color: string }> = [ + { key: 'y', label: 'Yes', color: '#4ade80' }, + ]; + if (rule || sessionBase) choices.push({ key: 's', label: 'Yes for this session', color: '#4ade80' }); + if (rule) choices.push({ key: 'a', label: `Always allow ${rule}`, color: '#60a5fa' }); + choices.push({ key: 'n', label: 'No', color: '#ef4444' }); + if (rule) choices.push({ key: 'x', label: `Never (deny ${rule})`, color: '#ef4444' }); + choices.push({ key: '__other', label: 'No, tell Cesar what to do instead', color: '#9ca3af' }); actions.setQuestionState({ kind: 'permission', prompt: `Approve ${next.tool}`, @@ -192,36 +222,29 @@ fn name=_showNextPermission params="actions:OutputActions" returns=void export=f reason: next.reason, diffPreview: next.diffPreview, fallbackNote: next.fallbackNote, - choices: [ - { key: 'y', label: 'Yes', color: '#4ade80' }, - { key: 'n', label: 'No', color: '#ef4444' }, - { key: 'a', label: 'Always', color: '#60a5fa' }, - { key: '__other', label: 'No, tell Cesar what to do instead', color: '#9ca3af' }, - ], + choices, resolve: (answer: string) => { _permissionQueue.shift(); const lower = answer.toLowerCase().trim(); - if (lower === 'a') { - const cfg = loadConfig(); - const allowed = (cfg as any).allowedCommands ?? []; - const base = permCommand.trim().split(/\s+/)[0]; - if (base && !allowed.includes(base)) { - allowed.push(base); - configSet('allowedCommands' as any, allowed); + if (lower === 'a' && rule) { + persistPermissionRule('allow', rule); + permResolve(true); + actions.addBlock({ type: 'success', message: `Always allowed: ${rule}` } as any); + } else if (lower === 's') { + if (rule) { + addSessionPermissionRule(rule); + actions.addBlock({ type: 'success', message: `Allowed for this session: ${rule}` } as any); + } else if (sessionBase) { + if (!_sessionAllowList.includes(sessionBase)) _sessionAllowList.push(sessionBase); + actions.addBlock({ type: 'success', message: `Allowed for this session: ${sessionBase}` } as any); } permResolve(true); - actions.addBlock({ type: 'success', message: `Always allowed: ${base}` } as any); } else if (lower === 'y') { - // Smart mode: add to session allowlist (memory-only, not persisted) - const cfg = loadConfig(); - const mode = (cfg as any).permissionMode ?? 'smart'; - if (mode === 'smart') { - const base = permCommand.trim().split(/\s+/)[0]; - if (base && !_sessionAllowList.includes(base)) { - _sessionAllowList.push(base); - } - } permResolve(true); + } else if (lower === 'x' && rule) { + persistPermissionRule('deny', rule); + permResolve(false); + actions.addBlock({ type: 'warning', message: `Never allowed (deny rule persisted): ${rule}` } as any); } else { permResolve(false); actions.addBlock({ type: 'warning', message: 'Denied' } as any); diff --git a/tests/unit/intent.test.ts b/tests/unit/intent.test.ts index 8169d244a..f6e3b7d6b 100644 --- a/tests/unit/intent.test.ts +++ b/tests/unit/intent.test.ts @@ -713,3 +713,30 @@ describe('Intent Detection — natural-language review dispatch (no slash)', () expect(detectIntent('fix the bug then review with codex').type).not.toBe('review'); }); }); + +describe('/permissions rule editing', () => { + it('parses the bare listing form', () => { + expect(detectIntent('/permissions')).toMatchObject({ type: 'permissions' }); + expect(detectIntent('/perms')).toMatchObject({ type: 'permissions' }); + }); + + it('parses add allow/deny with the rule preserved verbatim', () => { + expect(detectIntent('/permissions add allow Bash(git push:*)')).toMatchObject({ + type: 'permissions', action: 'add', key: 'allow', value: 'Bash(git push:*)', + }); + expect(detectIntent('/permissions add deny Bash(rm:*)')).toMatchObject({ + type: 'permissions', action: 'add', key: 'deny', value: 'Bash(rm:*)', + }); + }); + + it('parses remove', () => { + expect(detectIntent('/permissions remove Bash(git push:*)')).toMatchObject({ + type: 'permissions', action: 'remove', value: 'Bash(git push:*)', + }); + }); + + it('falls back to listing on malformed subcommands', () => { + expect(detectIntent('/permissions add Bash(git push:*)')).toMatchObject({ type: 'permissions' }); + expect((detectIntent('/permissions add Bash(git push:*)') as any).action).toBeUndefined(); + }); +}); diff --git a/tests/unit/permission-queue.test.ts b/tests/unit/permission-queue.test.ts index 3ee3dcecf..822265dd0 100644 --- a/tests/unit/permission-queue.test.ts +++ b/tests/unit/permission-queue.test.ts @@ -28,6 +28,7 @@ import { _permissionQueue, } from '../../packages/cli/src/generated/signals/output.js'; import type { OutputActions, OutputState } from '../../packages/cli/src/generated/signals/output.js'; +import { clearSessionPermissionRules } from '../../packages/cli/src/generated/cesar/permission-resolver.js'; function createMockActions(): OutputActions & { calls: Record } { const calls: Record = { @@ -85,6 +86,7 @@ describe('permission queue', () => { beforeEach(() => { // Drain any residual queue state between tests _permissionQueue.length = 0; + clearSessionPermissionRules(); loadConfigMock.mockReturnValue({}); configSetMock.mockReset(); delete (handleOutputEvent as any)._lastSpinnerUpdate; @@ -175,7 +177,7 @@ describe('permission queue', () => { }); }); - it('"Always" auto-resolves next queued permission with same base command', async () => { + it('"Always" persists a scoped two-token rule and drains queued siblings it covers', async () => { loadConfigMock.mockReturnValue({ allowedCommands: [] }); const actions = createMockActions(); @@ -183,18 +185,17 @@ describe('permission queue', () => { const resolve2 = vi.fn(); firePermission(actions, 'Bash', 'npm test --watch', resolve1); - firePermission(actions, 'Bash', 'npm install express', resolve2); + firePermission(actions, 'Bash', 'npm test --coverage', resolve2); - // Answer first with "Always" — this should add "npm" to allowedCommands + // Answer first with "Always" — persists Bash(npm test:*), never a bare token const firstQuestion = actions.calls.setQuestionState[0][0] as { resolve: (answer: string) => void }; firstQuestion.resolve('a'); expect(resolve1).toHaveBeenCalledWith(true); - expect(configSetMock).toHaveBeenCalledWith('allowedCommands', ['npm']); + expect(configSetMock).toHaveBeenCalledWith('permissions', { allow: ['Bash(npm test:*)'], deny: [] }); - // After "Always", the drain function should auto-approve the next queued "npm ..." command - // loadConfig needs to return the updated allowedCommands for the drain - loadConfigMock.mockReturnValue({ allowedCommands: ['npm'] }); + // After "Always", the drain auto-approves the next queued command the rule covers + loadConfigMock.mockReturnValue({ permissions: { allow: ['Bash(npm test:*)'] } }); await new Promise(r => setTimeout(r, 100)); @@ -202,6 +203,42 @@ describe('permission queue', () => { expect(resolve2).toHaveBeenCalledWith(true); }); + it('"Always" never persists a bare-verb rule — flags-only commands fall back to a one-time Yes', () => { + loadConfigMock.mockReturnValue({ allowedCommands: [] }); + + const actions = createMockActions(); + const resolve1 = vi.fn(); + firePermission(actions, 'Bash', 'ls -la', resolve1); + + const question = actions.calls.setQuestionState[0][0] as { choices: Array<{ key: string }>; resolve: (answer: string) => void }; + expect(question.choices.some((c) => c.key === 'a')).toBe(false); + expect(question.choices.some((c) => c.key === 'x')).toBe(false); + + question.resolve('y'); + expect(resolve1).toHaveBeenCalledWith(true); + expect(configSetMock).not.toHaveBeenCalledWith('permissions', expect.anything()); + }); + + it('"Yes for session" covers coalesced sibling prompts without persisting to disk', async () => { + loadConfigMock.mockReturnValue({ allowedCommands: [] }); + + const actions = createMockActions(); + const resolve1 = vi.fn(); + const resolve2 = vi.fn(); + + firePermission(actions, 'Bash', 'cargo fmt --all', resolve1); + firePermission(actions, 'Bash', 'cargo fmt --check', resolve2); + + const firstQuestion = actions.calls.setQuestionState[0][0] as { resolve: (answer: string) => void }; + firstQuestion.resolve('s'); + + expect(resolve1).toHaveBeenCalledWith(true); + expect(configSetMock).not.toHaveBeenCalledWith('permissions', expect.anything()); + + await new Promise(r => setTimeout(r, 100)); + expect(resolve2).toHaveBeenCalledWith(true); + }); + it('clearPermissionQueue resolves all queued permissions with false', () => { const actions = createMockActions(); const resolve1 = vi.fn(); From ae3d14bcc362c67866f8cc4494b35233df066aa3 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:45:05 +0200 Subject: [PATCH 4/4] fix: close review-found resolver ordering and containment holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-roster review (4/6 engines) confirmed two blockers, both fixed: legacy bare allow sources (allowedCommands base tokens, tool-level toolPermissions allow, session base tokens) could cover a dangerous lease boundary — they now rank BELOW the boundary ask, and only scoped permission rules (the deliberate Always artifact) remain strong enough to auto-approve a push. And fileTargetInsideWorkspace treated an empty cwd as universal containment, so delegated agent runs floor- clamped to auto-edit could silently approve edits anywhere on disk; empty-cwd now passes only relative non-escaping paths, the delegated callback passes the real workspace root (team worktrees live under .agon/agent-worktrees inside it), and auto mode without a lease fences file mutations to the workspace itself. Also from the review: subcommand synthesis skips key=value option tokens (git -c user.name=x push -> Bash(git push:*)), persisting a rule evicts it from the opposite bucket so Always-after-Never actually wins, the remaining inline approval-string checks use isApprovedPermissionResponse, ask-mode prompts carry the resolver reason, and dead rule-engine imports are dropped. Refuted (no change): 'auto_off' in boundaryReasons is reachable via the lease reason carry-through; parsePermissionRule strips ':*' so synthesized rules validate (covered by tests). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/cesar/brain.ts | 2 +- .../generated/cesar/permission-resolver.ts | 71 ++++++++++------ packages/cli/src/generated/cesar/session.ts | 8 +- packages/cli/src/generated/cesar/tools.ts | 4 +- packages/cli/src/generated/handlers/agent.ts | 24 +++--- packages/cli/src/kern/cesar/brain.kern | 2 +- .../src/kern/cesar/permission-resolver.kern | 57 ++++++++----- packages/cli/src/kern/cesar/session.kern | 8 +- packages/cli/src/kern/cesar/tools.kern | 4 +- packages/cli/src/kern/handlers/agent.kern | 9 ++- tests/unit/permission-resolver.test.ts | 81 ++++++++++++++++++- 11 files changed, 198 insertions(+), 72 deletions(-) diff --git a/packages/cli/src/generated/cesar/brain.ts b/packages/cli/src/generated/cesar/brain.ts index 3fac609f4..52720efa9 100644 --- a/packages/cli/src/generated/cesar/brain.ts +++ b/packages/cli/src/generated/cesar/brain.ts @@ -1740,7 +1740,7 @@ export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: H reason: taskActionApprovalMessage(evaluation), diffPreview: diffPreview && Array.isArray(diffPreview.files) ? diffPreview : undefined, fallbackNote: diffPreview && typeof diffPreview.fallback === 'string' ? diffPreview.fallback : undefined, - resolve: (approved: boolean | string) => resolve(typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved), + resolve: (approved: boolean | string) => resolve(isApprovedPermissionResponse(approved)), } as any); }); const authorizeXmlTaskAction = async (tool: string, args: Record): Promise => { diff --git a/packages/cli/src/generated/cesar/permission-resolver.ts b/packages/cli/src/generated/cesar/permission-resolver.ts index c7a87fc95..717691afc 100644 --- a/packages/cli/src/generated/cesar/permission-resolver.ts +++ b/packages/cli/src/generated/cesar/permission-resolver.ts @@ -119,13 +119,17 @@ export function buildEffectivePermissionRuleSet(config: any): PermissionRuleSet } /** - * Containment check for the auto-edit mode policy. An empty cwd means the executing layer owns containment (delegated agents run in their own worktrees whose path this seam cannot know) and passes. Missing targets fail closed. + * Containment check for the auto-edit/auto mode policy. Missing targets fail closed. With an empty cwd only a relative, non-escaping path passes (it resolves under the executing agent's own cwd by construction); absolute and ~ paths fail closed — an unknown workspace must never approve edits anywhere on disk (review blocking finding). */ // @kern-source: permission-resolver:101 export function fileTargetInsideWorkspace(cwd: string, target: string): boolean { - if (!cwd) return true; const candidate = String(target ?? '').trim(); if (!candidate) return false; + if (!cwd) { + if (isAbsolute(candidate) || /^~/.test(candidate)) return false; + const probe = resolve('/agon-probe'); + return !relativePathEscapesWorkspace(relative(probe, resolve(probe, candidate))); + } const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(cwd, candidate); return !relativePathEscapesWorkspace(relative(resolve(cwd), absolute)); } @@ -133,7 +137,7 @@ export function fileTargetInsideWorkspace(cwd: string, target: string): boolean /** * Boundary backstop for approval seams that carry no task lease (delegated agent callbacks). Auto mode must not blanket-approve pushes, publishing, or other external side effects just because the lease classifier is out of reach. */ -// @kern-source: permission-resolver:111 +// @kern-source: permission-resolver:115 export function isLeaselessBashBoundary(command: string): boolean { const cmd = String(command ?? ''); if (isExternalSideEffectCommand(cmd)) return true; @@ -144,7 +148,7 @@ export function isLeaselessBashBoundary(command: string): boolean { /** * The ONE seam where mode, rules, allowlists, and the task lease combine. Ordered: hard-deny → toolPermissions deny → deny rules → lease deny/allow → allow sources (toolPermissions allow, allow rules incl. session, allowedCommands compat, session base tokens) → lease boundary asks (dangerous/important — never absorbed by any mode) → mode policy with the delegated floor clamp. Callers map allow/deny directly and route ask into their prompt surface. */ -// @kern-source: permission-resolver:120 +// @kern-source: permission-resolver:124 export function resolvePermissionDecision(request: PermissionResolutionRequest): PermissionResolution { const tool = String(request.tool ?? '').trim(); const target = String(request.target ?? ''); @@ -177,12 +181,27 @@ export function resolvePermissionDecision(request: PermissionResolutionRequest): } } - if (toolPermissions[tool] === 'allow') { - return { decision: 'allow', reason: `${tool} allowed in settings`, stage: 'tool-permissions', signature }; - } + // Scoped rules (user-authored via Always / .agon.json, incl. session + // rules) are the ONE allow source deliberately strong enough to cover a + // lease boundary — that is the whole point of persisting Bash(git push:*). if (ruleDecision === 'allow') { return { decision: 'allow', reason: `${tool} allowed by permissions rule`, stage: 'allow-rule', signature }; } + + // Dangerous/important boundaries ask BEFORE the blunt allow sources: + // a legacy bare base token ('git' in allowedCommands) or a tool-level + // toolPermissions allow must never auto-approve a push/publish boundary + // (review blocking finding — the old gates never let them, either). + if (leaseEvaluation && (leaseEvaluation.reason === 'dangerous_boundary' || leaseEvaluation.reason === 'important_task')) { + return { decision: 'ask', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + if (tool === 'Bash' && !request.lease && isLeaselessBashBoundary(target)) { + return { decision: 'ask', reason: 'dangerous_boundary', stage: 'mode', signature }; + } + + if (toolPermissions[tool] === 'allow') { + return { decision: 'allow', reason: `${tool} allowed in settings`, stage: 'tool-permissions', signature }; + } if (tool === 'Bash') { const commandLower = target.trim().toLowerCase(); const base = target.trim().split(/\s+/)[0] ?? ''; @@ -196,15 +215,14 @@ export function resolvePermissionDecision(request: PermissionResolutionRequest): } } - if (leaseEvaluation && (leaseEvaluation.reason === 'dangerous_boundary' || leaseEvaluation.reason === 'important_task')) { - return { decision: 'ask', reason: leaseEvaluation.reason, stage: 'lease', signature }; - } - const baseMode = resolveAgonPermissionMode(cfg); const mode = request.source === 'delegated' ? clampDelegatedPermissionMode(baseMode) : baseMode; + const containedFileMutation = isTaskFileMutationAction(tool) && fileTargetInsideWorkspace(cwd, target); if (mode === 'auto') { - if (tool === 'Bash' && !request.lease && isLeaselessBashBoundary(target)) { - return { decision: 'ask', reason: 'dangerous_boundary', stage: 'mode', signature }; + // Without a lease the resolver owns workspace containment itself: a + // file mutation escaping the workspace must not ride auto mode. + if (!request.lease && isTaskFileMutationAction(tool) && !containedFileMutation) { + return { decision: 'ask', reason: 'workspace_boundary', stage: 'mode', signature }; } return { decision: 'allow', reason: 'auto mode', stage: 'mode', signature }; } @@ -215,7 +233,7 @@ export function resolvePermissionDecision(request: PermissionResolutionRequest): if (tool === 'Bash' && isReadOnlyCommand(target)) { return { decision: 'allow', reason: 'read-only command', stage: 'mode', signature }; } - if (mode === 'auto-edit' && isTaskFileMutationAction(tool) && fileTargetInsideWorkspace(cwd, target)) { + if (mode === 'auto-edit' && containedFileMutation) { return { decision: 'allow', reason: 'auto-edit mode approves workspace file edits', stage: 'mode', signature }; } return { decision: 'ask', reason: leaseEvaluation ? leaseEvaluation.reason : `${mode} mode requires approval`, stage: 'mode', signature }; @@ -224,7 +242,7 @@ export function resolvePermissionDecision(request: PermissionResolutionRequest): /** * Resolver-first replacement for raw authorizeTaskAction at the native/XML/eager preflights. allow/deny map straight through (an Always rule now suppresses the lease prompt instead of double-gating); ask routes into the lease's join/claim machinery when a lease exists so concurrent identical boundaries share ONE prompt, and falls back to a single direct approval otherwise. */ -// @kern-source: permission-resolver:198 +// @kern-source: permission-resolver:216 export async function authorizeResolvedTaskAction(request: PermissionResolutionRequest, requestApproval: (evaluation:TaskActionEvaluation)=>Promise): Promise<{decision:'allow'|'deny',reason:string}> { const resolution = resolvePermissionDecision(request); if (resolution.decision === 'allow') return { decision: 'allow', reason: resolution.reason }; @@ -242,7 +260,7 @@ export async function authorizeResolvedTaskAction(request: PermissionResolutionR /** * Synthesize a Claude-Code-parity rule string from an approved action. Bash uses the two-token scope (binary + first non-flag argument → 'Bash(git push:*)'); compound commands, substitution, globs, and bare single verbs synthesize nothing (fall back to a one-time approval). File tools synthesize an exact resolved-path rule so 'Always' never silently widens to a directory. */ -// @kern-source: permission-resolver:216 +// @kern-source: permission-resolver:234 export function synthesizePermissionRule(tool: string, target: string, cwd: string): string|null { const t = String(tool ?? '').trim(); const raw = String(target ?? '').trim(); @@ -251,7 +269,7 @@ export function synthesizePermissionRule(tool: string, target: string, cwd: stri if (hasShellControl(raw)) return null; const tokens = raw.split(/\s+/).map((token) => token.replace(/^['"]|['"]$/g, '')); const base = tokens[0] ?? ''; - const sub = tokens.slice(1).find((token) => !token.startsWith('-')) ?? ''; + const sub = tokens.slice(1).find((token) => !token.startsWith('-') && !token.includes('=')) ?? ''; if (!base || !sub) return null; if (/[*?`$(){}\[\]\\]/.test(base) || /[*?`$(){}\[\]\\]/.test(sub)) return null; return `Bash(${base} ${sub}:*)`; @@ -267,7 +285,7 @@ export function synthesizePermissionRule(tool: string, target: string, cwd: stri /** * Hard guard before persisting a synthesized rule: it must parse, a Bash rule must carry a two-token scope (never a bare verb like 'Bash(git)' or a star), and re-running the rule engine against the originating action must yield allow. */ -// @kern-source: permission-resolver:239 +// @kern-source: permission-resolver:257 export function validateSynthesizedRule(rule: string, tool: string, target: string, cwd: string): boolean { const parsed = parsePermissionRule(rule); if (!parsed || !parsed.command) return false; @@ -278,17 +296,20 @@ export function validateSynthesizedRule(rule: string, tool: string, target: stri } /** - * Append one rule string to the persisted permissions object (config scope resolution is configSet's job). Returns false without writing when the rule is already present. + * Append one rule string to the persisted permissions object (config scope resolution is configSet's job). The same rule is removed from the opposite bucket first — an Always after a Never must not leave a deny that silently keeps winning. Returns false without writing when nothing changes. */ -// @kern-source: permission-resolver:250 +// @kern-source: permission-resolver:268 export function persistPermissionRule(kind: 'allow'|'deny', rule: string): boolean { const cfg = loadConfig() as any; const current = (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {}; - const allow: string[] = Array.isArray(current.allow) ? current.allow.slice() : []; - const deny: string[] = Array.isArray(current.deny) ? current.deny.slice() : []; + let allow: string[] = Array.isArray(current.allow) ? current.allow.slice() : []; + let deny: string[] = Array.isArray(current.deny) ? current.deny.slice() : []; + const hadOpposite = (kind === 'deny' ? allow : deny).includes(rule); + if (kind === 'deny') allow = allow.filter((entry: string) => entry !== rule); + else deny = deny.filter((entry: string) => entry !== rule); const bucket = kind === 'deny' ? deny : allow; - if (bucket.includes(rule)) return false; - bucket.push(rule); + if (bucket.includes(rule) && !hadOpposite) return false; + if (!bucket.includes(rule)) bucket.push(rule); configSet('permissions' as any, { allow, deny } as any); return true; } @@ -296,7 +317,7 @@ export function persistPermissionRule(kind: 'allow'|'deny', rule: string): boole /** * Remove a rule string from BOTH persisted buckets and the session store. Returns true when anything was actually removed. */ -// @kern-source: permission-resolver:264 +// @kern-source: permission-resolver:285 export function removePermissionRule(rule: string): boolean { let removed = false; const cfg = loadConfig() as any; diff --git a/packages/cli/src/generated/cesar/session.ts b/packages/cli/src/generated/cesar/session.ts index 6ec0eddea..42c48351f 100644 --- a/packages/cli/src/generated/cesar/session.ts +++ b/packages/cli/src/generated/cesar/session.ts @@ -14,7 +14,7 @@ import { createRequire } from 'node:module'; import type { PersistentSession, PersistentSessionConfig } from '@kernlang/agon-core'; -import { EngineRegistry, loadConfig, ensureAgonHome, getAgonHome, resolveWorkingDir, scanProjectContext, buildCodebaseMap, buildKernContextSpine, buildProjectMemoryBlock, createPersistentSession, ToolRegistry, getProjectFileStateCache, buildToolSystemPrompt, toolsToOpenAIFormat, executeToolCall, RUNS_DIR, tracker, discoverMcpServers, mcpDiscoveryFingerprint, mcpServersToWireFormat, listCesarPlans, saveConversation, formatChatContextForPrompt, isReadOnlyCommand, AGON_MODE_NAMES, parsePermissionRuleSet, parseToolHooks, evaluatePermissionRules, evaluateToolRules, PERMISSION_DENIED_MESSAGE, claudeBrainUsesPty } from '@kernlang/agon-core'; +import { EngineRegistry, loadConfig, ensureAgonHome, getAgonHome, resolveWorkingDir, scanProjectContext, buildCodebaseMap, buildKernContextSpine, buildProjectMemoryBlock, createPersistentSession, ToolRegistry, getProjectFileStateCache, buildToolSystemPrompt, toolsToOpenAIFormat, executeToolCall, RUNS_DIR, tracker, discoverMcpServers, mcpDiscoveryFingerprint, mcpServersToWireFormat, listCesarPlans, saveConversation, formatChatContextForPrompt, isReadOnlyCommand, AGON_MODE_NAMES, parsePermissionRuleSet, parseToolHooks, PERMISSION_DENIED_MESSAGE, claudeBrainUsesPty } from '@kernlang/agon-core'; import type { ToolContext, ToolCallResult } from '@kernlang/agon-core'; @@ -38,7 +38,7 @@ import { approvalToolIsFileMutating, buildApprovalDiffPreview } from './approval import { isActiveCesarTurn, runTurnPermissionOnce, runTurnToolOnce } from './turn-runtime.js'; -import { approveTaskAction, claimTaskActionPrompt, isTaskFileMutationAction, taskActionApprovalMessage } from './task-execution-lease.js'; +import { approveTaskAction, claimTaskActionPrompt, isTaskFileMutationAction, taskActionApprovalMessage, isApprovedPermissionResponse } from './task-execution-lease.js'; import { resolvePermissionDecision, authorizeResolvedTaskAction } from './permission-resolver.js'; @@ -1227,7 +1227,7 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st const boundaryReasons = ['auto_off', 'dangerous_boundary', 'important_task']; const promptReason = boundaryReasons.includes(resolution.reason) ? taskActionApprovalMessage({ decision: resolution.reason === 'important_task' ? 'ask_task_once' : 'ask_boundary_once', signature: resolution.signature, reason: resolution.reason }) - : `Cesar (${engineId}) wants to execute`; + : `Cesar (${engineId}) wants to execute (${resolution.reason})`; return new Promise((resolve) => { const dispatch = ctx.cesar!.lastDispatch; if (dispatch) { @@ -1240,7 +1240,7 @@ export function buildOnApproval(ctx: HandlerContext, engineId: string): (tool:st catch { /* diff is best-effort — fall back to command preview */ } } dispatch({ type: 'permission-ask', tool: agonTool, command, reason: promptReason, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' || approved === 's' : !!approved; + const wasApproved = isApprovedPermissionResponse(approved); if (wasApproved && taskLease) approveTaskAction(taskLease, agonTool, taskTarget); logApproval(wasApproved ? 'approved' : 'denied', 'user-prompt', wasApproved ? 'user approved' : 'user denied'); resolve(wasApproved); diff --git a/packages/cli/src/generated/cesar/tools.ts b/packages/cli/src/generated/cesar/tools.ts index eae499937..4d8d24586 100644 --- a/packages/cli/src/generated/cesar/tools.ts +++ b/packages/cli/src/generated/cesar/tools.ts @@ -8,7 +8,7 @@ import type { Dispatch, HandlerContext } from '../../handlers/types.js'; import { createCouncilTool } from './council-tool.js'; -import { isTaskFileMutationAction, taskActionApprovalMessage } from './task-execution-lease.js'; +import { isTaskFileMutationAction, taskActionApprovalMessage, isApprovedPermissionResponse } from './task-execution-lease.js'; import { authorizeResolvedTaskAction } from './permission-resolver.js'; @@ -138,7 +138,7 @@ export async function executeEagerTool(toolName: string, meta: Record new Promise((resolve) => { const command = (parsedInput as any).command ?? (parsedInput as any).file_path ?? toolInput; dispatch({ type: 'permission-ask', tool, command, reason: message, resolve: (approved: boolean | string) => { - resolve(typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved); + resolve(isApprovedPermissionResponse(approved)); } } as any); }); const authorizeEagerTaskAction = async (tool: string, input: Record): Promise => { diff --git a/packages/cli/src/generated/handlers/agent.ts b/packages/cli/src/generated/handlers/agent.ts index ecaa737e9..c4f498d3d 100644 --- a/packages/cli/src/generated/handlers/agent.ts +++ b/packages/cli/src/generated/handlers/agent.ts @@ -14,7 +14,9 @@ import { getSessionAllowList } from '../signals/output.js'; import { resolvePermissionDecision } from '../cesar/permission-resolver.js'; -// @kern-source: agent:25 +import { approvalArgsFromCommand } from '../cesar/self-turn-approval.js'; + +// @kern-source: agent:26 export interface RunAgentOptions { engineId?: string; maxTurns?: number; @@ -24,7 +26,7 @@ export interface RunAgentOptions { parentSignal?: AbortSignal; } -// @kern-source: agent:33 +// @kern-source: agent:34 export interface AgentContinuationResult { kind: string; status: string; @@ -39,7 +41,7 @@ export interface AgentContinuationResult { workspaceChangedInPlace: boolean; } -// @kern-source: agent:46 +// @kern-source: agent:47 export function clipAgentText(text: string|null|undefined, limit: number): string { const raw = String(text ?? '').trim(); if (!raw) { @@ -51,7 +53,7 @@ export function clipAgentText(text: string|null|undefined, limit: number): strin /** * Shared approval callback for delegated agent runs, routed through the unified permission resolver with source='delegated' (floor-clamped to auto-edit: file edits run free, Bash mutations still prompt — the old smart-mode blanket auto-approve is retired). Exploration and plan-mode read-only blocks stay in front with instructive messages. cwd is intentionally empty: delegated agents execute in their own worktrees whose containment the executing tool layer owns. */ -// @kern-source: agent:53 +// @kern-source: agent:54 export function buildAgentApprovalCallback(dispatch: Dispatch, ctx: HandlerContext, engineId: string): (tool:string, command:string, reason?:string)=>Promise { return async (tool: string, command: string, reason?: string): Promise => { const cfg = ctx.config; @@ -77,10 +79,14 @@ export function buildAgentApprovalCallback(dispatch: Dispatch, ctx: HandlerConte } } + // cwd is the real workspace root: solo agents run in it directly and + // team worktrees live under /.agon/agent-worktrees/, so the + // containment check holds for both. Relative paths resolve inside the + // executing agent's own cwd; absolute escapes fall back to the prompt. const resolution = resolvePermissionDecision({ tool: agonTool, - target: command, - cwd: '', + target: agonTool === 'Bash' ? command : String((approvalArgsFromCommand(agonTool, command) as any)?.file_path ?? command), + cwd: resolveWorkingDir(), source: 'delegated', config: cfg, sessionAllowList: getSessionAllowList(), @@ -103,7 +109,7 @@ export function buildAgentApprovalCallback(dispatch: Dispatch, ctx: HandlerConte /** * Run one autonomous agent invocation. Creates a session, calls session.step() once (which internally loops up to maxInnerSteps tool calls), emits OutputEvents throughout, handles Ctrl+C via the KERN-generated abort signal bridged to session.cancel(). */ -// @kern-source: agent:103 +// @kern-source: agent:108 export async function runAgentMode(input: string, dispatch: Dispatch, ctx: HandlerContext, opts?: RunAgentOptions): Promise { const abort = new AbortController(); // ── Resolve engine ───────────────────────────────────────── @@ -500,7 +506,7 @@ export async function runAgentMode(input: string, dispatch: Dispatch, ctx: Handl return followUp; } -// @kern-source: agent:508 +// @kern-source: agent:513 export interface RunAgentTeamOptions { engines?: string[]; taskKind?: 'edit'|'investigate'; @@ -519,7 +525,7 @@ export interface RunAgentTeamOptions { /** * Run an autonomous agent team: N AgentSession instances in N worktrees with shared budget, synthesis, and explicit transcript events. Used by Cesar-driven team mode and by /agent-team slash command. Wraps AgentTeam from core/cesar/agent-team.kern. */ -// @kern-source: agent:522 +// @kern-source: agent:527 export async function runAgentTeam(input: string, dispatch: Dispatch, ctx: HandlerContext, opts?: RunAgentTeamOptions): Promise { const abort = new AbortController(); // ── Resolve members ─────────────────────────────────────── diff --git a/packages/cli/src/kern/cesar/brain.kern b/packages/cli/src/kern/cesar/brain.kern index 3cad42c8a..090158b1e 100644 --- a/packages/cli/src/kern/cesar/brain.kern +++ b/packages/cli/src/kern/cesar/brain.kern @@ -1695,7 +1695,7 @@ ${reviewFollowup.prompt}`; reason: taskActionApprovalMessage(evaluation), diffPreview: diffPreview && Array.isArray(diffPreview.files) ? diffPreview : undefined, fallbackNote: diffPreview && typeof diffPreview.fallback === 'string' ? diffPreview.fallback : undefined, - resolve: (approved: boolean | string) => resolve(typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved), + resolve: (approved: boolean | string) => resolve(isApprovedPermissionResponse(approved)), } as any); }); const authorizeXmlTaskAction = async (tool: string, args: Record): Promise => { diff --git a/packages/cli/src/kern/cesar/permission-resolver.kern b/packages/cli/src/kern/cesar/permission-resolver.kern index ed0ede40b..737173634 100644 --- a/packages/cli/src/kern/cesar/permission-resolver.kern +++ b/packages/cli/src/kern/cesar/permission-resolver.kern @@ -99,11 +99,15 @@ fn name=buildEffectivePermissionRuleSet params="config:any" returns=PermissionRu >>> fn name=fileTargetInsideWorkspace params="cwd:string,target:string" returns=boolean export=true - doc "Containment check for the auto-edit mode policy. An empty cwd means the executing layer owns containment (delegated agents run in their own worktrees whose path this seam cannot know) and passes. Missing targets fail closed." + doc "Containment check for the auto-edit/auto mode policy. Missing targets fail closed. With an empty cwd only a relative, non-escaping path passes (it resolves under the executing agent's own cwd by construction); absolute and ~ paths fail closed — an unknown workspace must never approve edits anywhere on disk (review blocking finding)." handler <<< - if (!cwd) return true; const candidate = String(target ?? '').trim(); if (!candidate) return false; + if (!cwd) { + if (isAbsolute(candidate) || /^~/.test(candidate)) return false; + const probe = resolve('/agon-probe'); + return !relativePathEscapesWorkspace(relative(probe, resolve(probe, candidate))); + } const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(cwd, candidate); return !relativePathEscapesWorkspace(relative(resolve(cwd), absolute)); >>> @@ -151,12 +155,27 @@ fn name=resolvePermissionDecision params="request:PermissionResolutionRequest" r } } - if (toolPermissions[tool] === 'allow') { - return { decision: 'allow', reason: `${tool} allowed in settings`, stage: 'tool-permissions', signature }; - } + // Scoped rules (user-authored via Always / .agon.json, incl. session + // rules) are the ONE allow source deliberately strong enough to cover a + // lease boundary — that is the whole point of persisting Bash(git push:*). if (ruleDecision === 'allow') { return { decision: 'allow', reason: `${tool} allowed by permissions rule`, stage: 'allow-rule', signature }; } + + // Dangerous/important boundaries ask BEFORE the blunt allow sources: + // a legacy bare base token ('git' in allowedCommands) or a tool-level + // toolPermissions allow must never auto-approve a push/publish boundary + // (review blocking finding — the old gates never let them, either). + if (leaseEvaluation && (leaseEvaluation.reason === 'dangerous_boundary' || leaseEvaluation.reason === 'important_task')) { + return { decision: 'ask', reason: leaseEvaluation.reason, stage: 'lease', signature }; + } + if (tool === 'Bash' && !request.lease && isLeaselessBashBoundary(target)) { + return { decision: 'ask', reason: 'dangerous_boundary', stage: 'mode', signature }; + } + + if (toolPermissions[tool] === 'allow') { + return { decision: 'allow', reason: `${tool} allowed in settings`, stage: 'tool-permissions', signature }; + } if (tool === 'Bash') { const commandLower = target.trim().toLowerCase(); const base = target.trim().split(/\s+/)[0] ?? ''; @@ -170,15 +189,14 @@ fn name=resolvePermissionDecision params="request:PermissionResolutionRequest" r } } - if (leaseEvaluation && (leaseEvaluation.reason === 'dangerous_boundary' || leaseEvaluation.reason === 'important_task')) { - return { decision: 'ask', reason: leaseEvaluation.reason, stage: 'lease', signature }; - } - const baseMode = resolveAgonPermissionMode(cfg); const mode = request.source === 'delegated' ? clampDelegatedPermissionMode(baseMode) : baseMode; + const containedFileMutation = isTaskFileMutationAction(tool) && fileTargetInsideWorkspace(cwd, target); if (mode === 'auto') { - if (tool === 'Bash' && !request.lease && isLeaselessBashBoundary(target)) { - return { decision: 'ask', reason: 'dangerous_boundary', stage: 'mode', signature }; + // Without a lease the resolver owns workspace containment itself: a + // file mutation escaping the workspace must not ride auto mode. + if (!request.lease && isTaskFileMutationAction(tool) && !containedFileMutation) { + return { decision: 'ask', reason: 'workspace_boundary', stage: 'mode', signature }; } return { decision: 'allow', reason: 'auto mode', stage: 'mode', signature }; } @@ -189,7 +207,7 @@ fn name=resolvePermissionDecision params="request:PermissionResolutionRequest" r if (tool === 'Bash' && isReadOnlyCommand(target)) { return { decision: 'allow', reason: 'read-only command', stage: 'mode', signature }; } - if (mode === 'auto-edit' && isTaskFileMutationAction(tool) && fileTargetInsideWorkspace(cwd, target)) { + if (mode === 'auto-edit' && containedFileMutation) { return { decision: 'allow', reason: 'auto-edit mode approves workspace file edits', stage: 'mode', signature }; } return { decision: 'ask', reason: leaseEvaluation ? leaseEvaluation.reason : `${mode} mode requires approval`, stage: 'mode', signature }; @@ -223,7 +241,7 @@ fn name=synthesizePermissionRule params="tool:string,target:string,cwd:string" r if (hasShellControl(raw)) return null; const tokens = raw.split(/\s+/).map((token) => token.replace(/^['"]|['"]$/g, '')); const base = tokens[0] ?? ''; - const sub = tokens.slice(1).find((token) => !token.startsWith('-')) ?? ''; + const sub = tokens.slice(1).find((token) => !token.startsWith('-') && !token.includes('=')) ?? ''; if (!base || !sub) return null; if (/[*?`$(){}\[\]\\]/.test(base) || /[*?`$(){}\[\]\\]/.test(sub)) return null; return `Bash(${base} ${sub}:*)`; @@ -248,15 +266,18 @@ fn name=validateSynthesizedRule params="rule:string,tool:string,target:string,cw >>> fn name=persistPermissionRule params="kind:'allow'|'deny',rule:string" returns=boolean export=true - doc "Append one rule string to the persisted permissions object (config scope resolution is configSet's job). Returns false without writing when the rule is already present." + doc "Append one rule string to the persisted permissions object (config scope resolution is configSet's job). The same rule is removed from the opposite bucket first — an Always after a Never must not leave a deny that silently keeps winning. Returns false without writing when nothing changes." handler <<< const cfg = loadConfig() as any; const current = (cfg.permissions && typeof cfg.permissions === 'object') ? cfg.permissions : {}; - const allow: string[] = Array.isArray(current.allow) ? current.allow.slice() : []; - const deny: string[] = Array.isArray(current.deny) ? current.deny.slice() : []; + let allow: string[] = Array.isArray(current.allow) ? current.allow.slice() : []; + let deny: string[] = Array.isArray(current.deny) ? current.deny.slice() : []; + const hadOpposite = (kind === 'deny' ? allow : deny).includes(rule); + if (kind === 'deny') allow = allow.filter((entry: string) => entry !== rule); + else deny = deny.filter((entry: string) => entry !== rule); const bucket = kind === 'deny' ? deny : allow; - if (bucket.includes(rule)) return false; - bucket.push(rule); + if (bucket.includes(rule) && !hadOpposite) return false; + if (!bucket.includes(rule)) bucket.push(rule); configSet('permissions' as any, { allow, deny } as any); return true; >>> diff --git a/packages/cli/src/kern/cesar/session.kern b/packages/cli/src/kern/cesar/session.kern index bd2c46c04..6dc1d8ac3 100644 --- a/packages/cli/src/kern/cesar/session.kern +++ b/packages/cli/src/kern/cesar/session.kern @@ -5,7 +5,7 @@ import from="node:fs" names="mkdirSync" import from="node:url" names="fileURLToPath,pathToFileURL" import from="node:module" names="createRequire" import from="@kernlang/agon-core" names="PersistentSession,PersistentSessionConfig" types=true -import from="@kernlang/agon-core" names="EngineRegistry,loadConfig,ensureAgonHome,getAgonHome,resolveWorkingDir,scanProjectContext,buildCodebaseMap,buildKernContextSpine,buildProjectMemoryBlock,createPersistentSession,ToolRegistry,getProjectFileStateCache,buildToolSystemPrompt,toolsToOpenAIFormat,executeToolCall,RUNS_DIR,tracker,discoverMcpServers,mcpDiscoveryFingerprint,mcpServersToWireFormat,listCesarPlans,saveConversation,formatChatContextForPrompt,isReadOnlyCommand,AGON_MODE_NAMES,parsePermissionRuleSet,parseToolHooks,evaluatePermissionRules,evaluateToolRules,PERMISSION_DENIED_MESSAGE,claudeBrainUsesPty" +import from="@kernlang/agon-core" names="EngineRegistry,loadConfig,ensureAgonHome,getAgonHome,resolveWorkingDir,scanProjectContext,buildCodebaseMap,buildKernContextSpine,buildProjectMemoryBlock,createPersistentSession,ToolRegistry,getProjectFileStateCache,buildToolSystemPrompt,toolsToOpenAIFormat,executeToolCall,RUNS_DIR,tracker,discoverMcpServers,mcpDiscoveryFingerprint,mcpServersToWireFormat,listCesarPlans,saveConversation,formatChatContextForPrompt,isReadOnlyCommand,AGON_MODE_NAMES,parsePermissionRuleSet,parseToolHooks,PERMISSION_DENIED_MESSAGE,claudeBrainUsesPty" import from="@kernlang/agon-core" names="ToolContext,ToolCallResult" types=true import from="@kernlang/agon-core" names="resolveGuardMode,readGuardModesFromConfig" import from="@kernlang/agon-core" names="GuardMode" types=true @@ -17,7 +17,7 @@ import from="./brain-helpers.js" names="extractDelegation" import from="./self-turn-approval.js" names="applyCesarSelfTurnApproval,approvalArgsFromCommand" import from="./approval-diff.js" names="approvalToolIsFileMutating,buildApprovalDiffPreview" import from="./turn-runtime.js" names="isActiveCesarTurn,runTurnPermissionOnce,runTurnToolOnce" -import from="./task-execution-lease.js" names="approveTaskAction,claimTaskActionPrompt,isTaskFileMutationAction,taskActionApprovalMessage" +import from="./task-execution-lease.js" names="approveTaskAction,claimTaskActionPrompt,isTaskFileMutationAction,taskActionApprovalMessage,isApprovedPermissionResponse" import from="./permission-resolver.js" names="resolvePermissionDecision,authorizeResolvedTaskAction" import from="./tool-observability.js" names="recordCesarApprovalDecision,recordCesarToolTimeline,recordCesarConfidence,buildToolErrorDiagnostic" import from="./task-controller.js" names="resolveCesarHarnessProfile,isAgenticAutoMode" @@ -1160,7 +1160,7 @@ fn name=buildOnApproval params="ctx:HandlerContext, engineId:string" returns="(t const boundaryReasons = ['auto_off', 'dangerous_boundary', 'important_task']; const promptReason = boundaryReasons.includes(resolution.reason) ? taskActionApprovalMessage({ decision: resolution.reason === 'important_task' ? 'ask_task_once' : 'ask_boundary_once', signature: resolution.signature, reason: resolution.reason }) - : `Cesar (${engineId}) wants to execute`; + : `Cesar (${engineId}) wants to execute (${resolution.reason})`; return new Promise((resolve) => { const dispatch = ctx.cesar!.lastDispatch; if (dispatch) { @@ -1173,7 +1173,7 @@ fn name=buildOnApproval params="ctx:HandlerContext, engineId:string" returns="(t catch { /* diff is best-effort — fall back to command preview */ } } dispatch({ type: 'permission-ask', tool: agonTool, command, reason: promptReason, diffPreview: permDiffPreview && Array.isArray(permDiffPreview.files) ? permDiffPreview : undefined, fallbackNote: permDiffPreview && typeof permDiffPreview.fallback === 'string' ? permDiffPreview.fallback : undefined, resolve: (approved: boolean | string) => { - const wasApproved = typeof approved === 'string' ? approved === 'y' || approved === 'a' || approved === 's' : !!approved; + const wasApproved = isApprovedPermissionResponse(approved); if (wasApproved && taskLease) approveTaskAction(taskLease, agonTool, taskTarget); logApproval(wasApproved ? 'approved' : 'denied', 'user-prompt', wasApproved ? 'user approved' : 'user denied'); resolve(wasApproved); diff --git a/packages/cli/src/kern/cesar/tools.kern b/packages/cli/src/kern/cesar/tools.kern index bf4436042..b150feed5 100644 --- a/packages/cli/src/kern/cesar/tools.kern +++ b/packages/cli/src/kern/cesar/tools.kern @@ -2,7 +2,7 @@ import from="@kernlang/agon-core" names="ToolRegistry,getProjectFileStateCache,c import from="@kernlang/agon-core" names="ToolContext,ToolCallResult" types=true import from="../../handlers/types.js" names="Dispatch,HandlerContext" types=true import from="./council-tool.js" names="createCouncilTool" -import from="./task-execution-lease.js" names="isTaskFileMutationAction,taskActionApprovalMessage" +import from="./task-execution-lease.js" names="isTaskFileMutationAction,taskActionApprovalMessage,isApprovedPermissionResponse" import from="./permission-resolver.js" names="authorizeResolvedTaskAction" import from="../signals/output.js" names="getSessionAllowList" import from="./brain-helpers.js" names="isBashToolName" @@ -119,7 +119,7 @@ fn name=executeEagerTool async=true params="toolName:string, meta:Record new Promise((resolve) => { const command = (parsedInput as any).command ?? (parsedInput as any).file_path ?? toolInput; dispatch({ type: 'permission-ask', tool, command, reason: message, resolve: (approved: boolean | string) => { - resolve(typeof approved === 'string' ? approved === 'y' || approved === 'a' : !!approved); + resolve(isApprovedPermissionResponse(approved)); } } as any); }); const authorizeEagerTaskAction = async (tool: string, input: Record): Promise => { diff --git a/packages/cli/src/kern/handlers/agent.kern b/packages/cli/src/kern/handlers/agent.kern index 0aea56935..a67ae11b6 100644 --- a/packages/cli/src/kern/handlers/agent.kern +++ b/packages/cli/src/kern/handlers/agent.kern @@ -21,6 +21,7 @@ import from="node:fs" names="writeFileSync,mkdirSync,existsSync" import from="node:path" names="join" import from="../signals/output.js" names="getSessionAllowList" import from="../cesar/permission-resolver.js" names="resolvePermissionDecision" +import from="../cesar/self-turn-approval.js" names="approvalArgsFromCommand" interface name=RunAgentOptions field name=engineId type=string optional=true @@ -77,10 +78,14 @@ fn name=buildAgentApprovalCallback params="dispatch:Dispatch, ctx:HandlerContext } } + // cwd is the real workspace root: solo agents run in it directly and + // team worktrees live under /.agon/agent-worktrees/, so the + // containment check holds for both. Relative paths resolve inside the + // executing agent's own cwd; absolute escapes fall back to the prompt. const resolution = resolvePermissionDecision({ tool: agonTool, - target: command, - cwd: '', + target: agonTool === 'Bash' ? command : String((approvalArgsFromCommand(agonTool, command) as any)?.file_path ?? command), + cwd: resolveWorkingDir(), source: 'delegated', config: cfg, sessionAllowList: getSessionAllowList(), diff --git a/tests/unit/permission-resolver.test.ts b/tests/unit/permission-resolver.test.ts index 88d1fc059..4c8aca015 100644 --- a/tests/unit/permission-resolver.test.ts +++ b/tests/unit/permission-resolver.test.ts @@ -1,4 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock ONLY the config I/O — rule evaluation, path resolution, and command +// classification stay real. persistPermissionRule must never touch the +// developer's actual ~/.agon/config.json from a unit test. +const { loadConfigMock, configSetMock } = vi.hoisted(() => ({ + loadConfigMock: vi.fn().mockReturnValue({}), + configSetMock: vi.fn(), +})); +vi.mock('@kernlang/agon-core', async () => { + const actual = await vi.importActual>('@kernlang/agon-core'); + return { ...actual, loadConfig: loadConfigMock, configSet: configSetMock }; +}); + import { addSessionPermissionRule, authorizeResolvedTaskAction, @@ -16,6 +29,7 @@ import { synthesizePermissionRule, validateSynthesizedRule, } from '../../packages/cli/src/generated/cesar/permission-resolver.js'; +import { persistPermissionRule } from '../../packages/cli/src/generated/cesar/permission-resolver.js'; import { createTaskExecutionLease } from '../../packages/cli/src/generated/cesar/task-execution-lease.js'; const WS = process.cwd(); @@ -37,7 +51,11 @@ const request = (overrides: Record = {}) => ({ ...overrides, }); -beforeEach(() => clearSessionPermissionRules()); +beforeEach(() => { + clearSessionPermissionRules(); + loadConfigMock.mockReturnValue({}); + configSetMock.mockReset(); +}); describe('resolveAgonPermissionMode', () => { it('honors an explicit agonPermissionMode', () => { @@ -114,13 +132,33 @@ describe('resolvePermissionDecision — allow sources', () => { })); expect(r).toMatchObject({ decision: 'allow', stage: 'allow-rule' }); }); - it('legacy allowedCommands base-prefix still auto-approves', () => { + it('legacy allowedCommands base-prefix still auto-approves routine commands', () => { const r = resolvePermissionDecision(request({ target: 'npm run build', config: cfg({ permissionMode: 'ask', allowedCommands: ['npm run'] }), })); expect(r).toMatchObject({ decision: 'allow', stage: 'allowed-commands' }); }); + it('legacy bare tokens and tool-level allows never cover a dangerous boundary', () => { + const lease = createTaskExecutionLease('build the feature', true, WS); + const viaToken = resolvePermissionDecision(request({ + target: 'git push origin main', + lease, + config: cfg({ allowedCommands: ['git'] }), + })); + expect(viaToken).toMatchObject({ decision: 'ask', reason: 'dangerous_boundary' }); + const viaToolAllow = resolvePermissionDecision(request({ + target: 'git push origin main', + lease, + config: cfg({ toolPermissions: { Bash: 'allow' } }), + })); + expect(viaToolAllow).toMatchObject({ decision: 'ask', reason: 'dangerous_boundary' }); + const leaseless = resolvePermissionDecision(request({ + target: 'npm publish', + config: cfg({ allowedCommands: ['npm'] }), + })); + expect(leaseless).toMatchObject({ decision: 'ask', reason: 'dangerous_boundary' }); + }); it('the session allowlist auto-approves Bash', () => { const r = resolvePermissionDecision(request({ target: 'cargo fmt --all', @@ -199,6 +237,17 @@ describe('resolvePermissionDecision — mode policy', () => { expect(resolvePermissionDecision(request({ target: 'npm run build', cwd: '', source: 'delegated', config })).decision).toBe('ask'); expect(resolvePermissionDecision(request({ target: 'git status', cwd: '', source: 'delegated', config })).decision).toBe('allow'); }); + it('delegated file mutations never auto-approve absolute paths outside the workspace', () => { + const config = cfg({ agonPermissionMode: 'ask' }); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: '/etc/passwd', cwd: '', source: 'delegated', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ tool: 'Write', target: '../outside.ts', cwd: '', source: 'delegated', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: '/etc/passwd', cwd: WS, source: 'delegated', config })).decision).toBe('ask'); + }); + it('auto mode without a lease still fences file mutations to the workspace', () => { + const config = cfg({ agonPermissionMode: 'auto' }); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: '/etc/passwd', config })).decision).toBe('ask'); + expect(resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config })).decision).toBe('allow'); + }); it('same action resolves identically from native and self-turn sources', () => { const config = cfg({ agonPermissionMode: 'ask' }); const native = resolvePermissionDecision(request({ tool: 'Edit', target: 'src/index.ts', config, source: 'native' })); @@ -208,8 +257,11 @@ describe('resolvePermissionDecision — mode policy', () => { }); describe('workspace containment helper', () => { - it('passes with an empty cwd (delegated worktrees own containment)', () => { - expect(fileTargetInsideWorkspace('', '/anywhere/file.ts')).toBe(true); + it('with an empty cwd only relative, non-escaping paths pass', () => { + expect(fileTargetInsideWorkspace('', 'src/file.ts')).toBe(true); + expect(fileTargetInsideWorkspace('', '/anywhere/file.ts')).toBe(false); + expect(fileTargetInsideWorkspace('', '../escape.ts')).toBe(false); + expect(fileTargetInsideWorkspace('', '~/notes.md')).toBe(false); }); it('fails closed on empty targets and escapes', () => { expect(fileTargetInsideWorkspace(WS, '')).toBe(false); @@ -223,6 +275,9 @@ describe('rule synthesis', () => { expect(synthesizePermissionRule('Bash', 'git push origin main', WS)).toBe('Bash(git push:*)'); expect(synthesizePermissionRule('Bash', 'npm run build', WS)).toBe('Bash(npm run:*)'); }); + it('skips key=value option tokens when picking the subcommand', () => { + expect(synthesizePermissionRule('Bash', 'git -c user.name=Agon push origin', WS)).toBe('Bash(git push:*)'); + }); it('refuses bare verbs, flags-only, compounds, and substitution', () => { expect(synthesizePermissionRule('Bash', 'ls', WS)).toBeNull(); expect(synthesizePermissionRule('Bash', 'ls -la', WS)).toBeNull(); @@ -242,6 +297,24 @@ describe('rule synthesis', () => { }); }); +describe('persistPermissionRule', () => { + it('appends to the requested bucket and dedupes', () => { + loadConfigMock.mockReturnValue({ permissions: { allow: [], deny: [] } }); + expect(persistPermissionRule('allow', 'Bash(git push:*)')).toBe(true); + expect(configSetMock).toHaveBeenCalledWith('permissions', { allow: ['Bash(git push:*)'], deny: [] }); + loadConfigMock.mockReturnValue({ permissions: { allow: ['Bash(git push:*)'], deny: [] } }); + expect(persistPermissionRule('allow', 'Bash(git push:*)')).toBe(false); + }); + it('removes the rule from the opposite bucket so Always after Never actually wins', () => { + loadConfigMock.mockReturnValue({ permissions: { allow: [], deny: ['Bash(git push:*)'] } }); + expect(persistPermissionRule('allow', 'Bash(git push:*)')).toBe(true); + expect(configSetMock).toHaveBeenCalledWith('permissions', { allow: ['Bash(git push:*)'], deny: [] }); + loadConfigMock.mockReturnValue({ permissions: { allow: ['Bash(npm test:*)'], deny: [] } }); + expect(persistPermissionRule('deny', 'Bash(npm test:*)')).toBe(true); + expect(configSetMock).toHaveBeenCalledWith('permissions', { allow: [], deny: ['Bash(npm test:*)'] }); + }); +}); + describe('buildEffectivePermissionRuleSet', () => { it('merges persisted and session allow rules; deny stays persisted-only', () => { addSessionPermissionRule('Bash(cargo fmt:*)');