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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Finder (MacOS) folder config
.DS_Store

# GSD local planning (not synced to GitHub)
.planning/

doc/
41 changes: 41 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions docs/agent-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Agent permissions (Build mode)

MoCode separates **who enforces permissions** from **who invokes shell commands**, aligned with Claude Code harness behavior.

## CLI TUI is the permission gate

Dangerous bash commands are blocked by a **blocklist** in the CLI (`packages/cli/src/lib/bash-approval.ts`, via `requiresApproval()` called from `use-chat.ts`), not by the model asking in chat. When a command matches the blocklist:

1. Execution pauses before the command runs.
2. A TUI modal appears: **Approve once** / **Reject** / **Allow for this session**.
3. Keyboard navigation: **↑ / ↓** to move, **Enter** to confirm, **Esc** to reject.

Non-blocklisted commands (e.g. `npm test`, `git status`) run immediately with no dialog.

## Model behavior

In Build mode the model should:

- **Invoke bash directly** for shell work — do not ask in chat whether to run a command first.
- Treat the **TUI approval dialog as the sole confirmation** for dangerous commands; chat messages are not a permission gate.
- **Not retry** a rejected command unless the user explicitly asks again. On rejection, acknowledge and suggest alternatives.
- Use the optional **`description`** field on bash tool calls when the command string alone does not convey intent (shown as a dim line in the transcript).

## Plan mode

Plan mode has no bash tool. Git inspection uses read-only `gitStatus` and `gitDiff` tools instead.
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"module": "src/index.tsx",
"type": "module",
"private": true,
"bin":{
"bin": {
"mocode": "./bin/mocode"
},
"scripts": {
Expand All @@ -24,6 +24,7 @@
"@mocode/shared": "workspace:*",
"@opentui/core": "^0.4.1",
"@opentui/react": "^0.4.1",
"@vscode/ripgrep": "^1.18.0",
"ai": "^6.0.208",
"date-fns": "^4.4.0",
"hono": "^4.12.25",
Expand All @@ -32,6 +33,7 @@
"pretty-ms": "^9.3.0",
"react": "^19.2.6",
"react-router": "^7.17.0",
"simple-git": "^3.36.0",
"zod": "^4.4.3"
}
}
109 changes: 109 additions & 0 deletions packages/cli/src/components/dialogs/bash-approval-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { TextAttributes } from "@opentui/core";
import { useKeyboard } from "@opentui/react";
import { useState } from "react";
import {
BASH_APPROVAL_ACTION_COUNT,
BASH_APPROVAL_DEFAULT_INDEX,
moveDialogSelection,
} from "../../lib/dialog-action-nav";
import { useKeyboardLayer } from "../../providers/keyboard-layer";
import { useTheme } from "../../providers/theme";

type BashApprovalDialogProps = {
command: string;
onApproveOnce: () => void;
onReject: () => void;
onAllowSession: () => void;
};

type ActionButtonProps = {
label: string;
hint?: string;
selected?: boolean;
onSelect: () => void;
onMouseMove?: () => void;
};

function ActionButton({ label, hint, selected, onSelect, onMouseMove }: ActionButtonProps) {
const { colors } = useTheme();

return (
<box
flexDirection="row"
paddingX={1}
height={1}
backgroundColor={selected ? colors.selection : undefined}
onMouseMove={onMouseMove}
onMouseDown={onSelect}
>
<text selectable={false} fg={selected ? "black" : "white"} attributes={TextAttributes.BOLD}>
{label}
</text>
{hint ? (
<text selectable={false} fg={selected ? "black" : "gray"}>
{" "}
{hint}
</text>
) : null}
</box>
);
}

/** Three-action modal body for dangerous bash approval (D-12, D-14). Phase 01 plan 02 UI; plan 03 keyboard. */
export function BashApprovalDialog({
command,
onApproveOnce,
onReject,
onAllowSession,
}: BashApprovalDialogProps) {
// Default highlight = Reject (index 1). Enter without arrow keys denies the command (D-20).
const [selectedIndex, setSelectedIndex] = useState(BASH_APPROVAL_DEFAULT_INDEX);
const { isTopLayer } = useKeyboardLayer();

const actions = [
{ label: "Approve once", onSelect: onApproveOnce },
{ label: "Reject", onSelect: onReject },
{
label: "Allow for this session",
hint: "(skip future prompts for this command)",
onSelect: onAllowSession,
},
] as const;

// Keyboard layer "dialog" must be topmost — same pattern as DialogSearchList (D-21).
// preventDefault on arrows stops them from scrolling the chat behind the modal.
useKeyboard((key) => {
if (!isTopLayer("dialog")) return;

if (key.name === "return" || key.name === "enter") {
actions[selectedIndex]?.onSelect();
} else if (key.name === "up") {
key.preventDefault();
setSelectedIndex((i) => moveDialogSelection(i, "up", BASH_APPROVAL_ACTION_COUNT));
} else if (key.name === "down") {
key.preventDefault();
setSelectedIndex((i) => moveDialogSelection(i, "down", BASH_APPROVAL_ACTION_COUNT));
}
});

return (
<box flexDirection="column" gap={1}>
<text attributes={TextAttributes.DIM}>The model wants to run:</text>
<box paddingX={1} paddingY={1}>
<text selectable={false}>{command}</text>
</box>
<box flexDirection="column" gap={0}>
{actions.map((action, i) => (
<ActionButton
key={action.label}
label={action.label}
hint={"hint" in action ? action.hint : undefined}
selected={i === selectedIndex}
onSelect={action.onSelect}
onMouseMove={() => setSelectedIndex(i)}
/>
))}
</box>
</box>
);
}
3 changes: 2 additions & 1 deletion packages/cli/src/components/dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
export { ThemeDialogContent } from "./theme-dialog";
export { SessionDialogContent } from "./sessions-dialog";
export { AgentsDialogContent } from "./agents-dialog";
export { ModelsDialogContent } from "./models-dialog";
export { ModelsDialogContent } from "./models-dialog";
export { BashApprovalDialog } from "./bash-approval-dialog";
77 changes: 71 additions & 6 deletions packages/cli/src/components/messages/bot-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@ function formatToolArgs(tc: ToolPart): string {
return Object.values(tc.input).map(String).join(" ");
}

type BashToolDisplay = {
command: string;
description?: string;
};

/** TUI reject tool errors are long model hints — show a short label in the transcript. */
function formatToolErrorForDisplay(toolName: string, errorText: string): string {
// BASH_REJECT_ERROR_TEXT from use-chat.ts is ~500 chars of model guidance;
// users only need a one-line status in the message stream (Phase 01, plan 04).
if (
toolName === "bash" &&
errorText.startsWith("User rejected this command in the TUI approval dialog")
) {
return "— rejected in approval dialog";
}
return errorText.length > 160 ? `${errorText.slice(0, 160)}…` : errorText;
}

/**
* Bash-specific transcript layout (Phase 01, D-24/D-26).
* Primary line: command string. Secondary line (dim): optional description field
* from the bash tool input — helps users understand intent when the command alone
* is opaque (e.g. long piped one-liners).
*/
function formatBashToolDisplay(input: unknown): BashToolDisplay | null {
if (input == null || typeof input !== "object") return null;

const record = input as Record<string, unknown>;
if (typeof record.command !== "string") return null;

const description =
typeof record.description === "string" && record.description.trim().length > 0
? record.description.trim()
: undefined;

return { command: record.command, description };
}

type PartGroup = {
type: ClientMessagePart["type"];
parts: ClientMessagePart[];
Expand Down Expand Up @@ -76,6 +114,14 @@ export function BotMessage({
streaming = false,
}: Props) {
const { colors } = useTheme();
const hasTextPart = parts.some((part) => part.type === "text" && part.text.length > 0);
const toolsPending = parts.some(
(part) =>
isToolPart(part) &&
part.state !== "output-available" &&
part.state !== "output-error",
);

return (
<box width="100%" alignItems="center">
{groupConsecutiveParts(parts).map((group, i) => (
Expand Down Expand Up @@ -104,6 +150,19 @@ export function BotMessage({
if (isToolPart(part)) {
const toolName =
part.type === "dynamic-tool" ? part.toolName : part.type.slice("tool-".length);
const bashDisplay =
toolName === "bash" && "input" in part
? formatBashToolDisplay(part.input)
: null;
const argsText = bashDisplay?.command ?? formatToolArgs(part);
const statusSuffix =
part.state !== "output-available" && part.state !== "output-error"
? " …"
: "";
const errorSuffix =
part.state === "output-error" && part.errorText
? ` ${formatToolErrorForDisplay(toolName, part.errorText)}`
: "";

return (
<box
Expand All @@ -118,13 +177,13 @@ export function BotMessage({
paddingX={2}
>
<text attributes={TextAttributes.DIM}>
<em fg={colors.info}>{formatToolName(toolName)}:</em> {formatToolArgs(part)}
{part.state !== "output-available" && part.state !== "output-error"
? " …"
: ""
}
{part.state === "output-error" ? ` ${part.errorText}` : ""}
<em fg={colors.info}>{formatToolName(toolName)}:</em> {argsText}
{statusSuffix}
{errorSuffix}
</text>
{bashDisplay?.description && (
<text attributes={TextAttributes.DIM}> {bashDisplay.description}</text>
)}
</box>
);
}
Expand All @@ -142,6 +201,12 @@ export function BotMessage({
</box>
))}

{streaming && !hasTextPart && !toolsPending && (
<box paddingX={3} width="100%">
<text attributes={TextAttributes.DIM}>Generating response…</text>
</box>
)}

<box paddingX={3} paddingY={1} gap={1} width="100%">
<box flexDirection="row" gap={2}>
<text fg={mode === Mode.PLAN ? colors.planMode : colors.primary}>◉</text>
Expand Down
Loading