Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,19 @@ Also available as interactive `/conquer` in the REPL (with full CLI flag parity:

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 <rule>`, and `/permissions remove <rule>`. 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
Expand Down
87 changes: 38 additions & 49 deletions packages/cli/src/generated/cesar/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,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';

Expand All @@ -64,7 +68,7 @@ import { consumeCesarPlanControlSignals } from './plan-control-signals.js';

import { hostNowIso, hostWaitForInteractiveChoice } from '../lib/kern-host.js';

// @kern-source: brain:34
// @kern-source: brain:36
export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input: string, response: string, cesarEngineId: string, streaming: boolean, dispatch: Dispatch, ctx: HandlerContext, telemetry?: Record<string,unknown>, turnAlreadyCommitted?: boolean): Promise<CesarTurnOutcome> {
// 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
Expand Down Expand Up @@ -97,7 +101,7 @@ export async function commitTurnAndDelegate(pendingDel: PendingDelegation, input
return { delegated: false, responded: true, decisionReason: 'delegation-cancelled', ...telemetry ?? {} };
}

// @kern-source: brain:61
// @kern-source: brain:63
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<string,unknown>): Promise<CesarTurnOutcome> {
// 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.
Expand Down Expand Up @@ -128,10 +132,10 @@ export async function commitTurnAndSuggest(suggestion: {action:string, rest?:str
return { delegated: false, responded: true, decisionReason: 'suggestion-cancelled', ...telemetry ?? {} };
}

// @kern-source: brain:86
// @kern-source: brain:88
export const _noBriefNudged: WeakMap<object, boolean> = new WeakMap<object, boolean>();

// @kern-source: brain:88
// @kern-source: brain:90
export async function handleCesarBrain(input: string, dispatch: Dispatch, ctx: HandlerContext, images?: ImageAttachment[]): Promise<CesarTurnOutcome> {
const abort = new AbortController();
const _turnStart = Date.now();
Expand Down Expand Up @@ -522,7 +526,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,
Expand Down Expand Up @@ -990,10 +994,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<string, unknown>;
const logMcpApproval = (decision: 'approved'|'denied'|'prompted'|'blocked', source: string, reason?: string) => {
Expand Down Expand Up @@ -1023,35 +1024,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;
}
Expand All @@ -1064,7 +1059,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,
Expand Down Expand Up @@ -1093,17 +1088,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);
Expand Down Expand Up @@ -1784,16 +1775,14 @@ 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<string, unknown>): Promise<boolean | string> => {
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'
Expand Down
Loading