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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

### Changed

- Replace permission tiers with Ask, Approve for Me (default), and Full Access. Full Access runs dangerous and unanalyzable commands after a session-only or remembered risk acknowledgment. Saved legacy permission modes reset to Ask for users to choose again; `--approval-mode strict` is no longer accepted. Both automatic tiers enable bounded model-error continuation by default; Ask does not.

### Fixed

- Recognize `/exit` as an alias for `/quit`, including while the agent is running or compacting.
- Recognize `search_files`, `find_files`, and `list_directory` when selecting default system-prompt file-exploration guidance.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ When running from the source tree, replace `step` with `pnpm step`\.

## Security

- Four permission modes \(Ask / Read Only / Bypass / Autopilot\), cycled with `Shift+Tab`; dangerous commands require a separate confirmation dialog in every mode\.
- Three permission tiers \(Ask / Approve for Me / Full Access\), cycled with `Shift+Tab`; dangerous commands require a separate confirmation dialog in Ask and Approve for Me, and run without one only in Full Access after its risk acknowledgment\.

## Uninstall

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ pnpm step

## 安全

- 权限四档(Ask / Read Only / Bypass / Autopilot),`Shift+Tab` 循环切换;危险命令在任何模式下都会单独弹窗确认。
- 权限三档(Ask / Approve for Me / Full Access),`Shift+Tab` 循环切换;危险命令在 Ask 和 Approve for Me 下都会单独弹窗确认,只有 Full Access 在风险确认后不再拦截。

## 卸载

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/ui/runtime/input-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ export function wireSubmitHandler(ctx: RuntimeContext): void {
ctx.editor.setText("");
return;
}
if (text === "/quit") {
if (text === "/quit" || text === "/exit") {
ctx.editor.setText("");
await ctx.shutdown();
return;
Expand Down
24 changes: 10 additions & 14 deletions apps/cli/src/ui/view/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,10 @@ export class FooterComponent implements Component {
const extensionStatuses = this.footerData.getExtensionStatuses();
const permissionStatus = extensionStatuses.get("step-permission");
const statusPreset = permissionStatus?.match(/^Mode:\s*([^()]+?)(?:\s*\(auto-resume\))?$/u)?.[1]?.trim();
// Extension statuses use human-readable labels (for example "Read Only"),
// while embedded hosts may expose the corresponding id ("read-only" or
// "readOnly"). Normalize the vocabulary before selecting the footer color.
// Extension statuses use human-readable labels (for example "Approve for
// Me"), while embedded hosts may expose the corresponding id
// ("approve-for-me"). Normalize the vocabulary before selecting the footer
// color.
const normalizedStatusPreset = statusPreset?.toLowerCase().replace(/[\s_-]+/gu, "");
const rawMode = String(
(this.session as unknown as { approvalMode?: string }).approvalMode ??
Expand All @@ -324,18 +325,13 @@ export class FooterComponent implements Component {
.trim()
.toLowerCase()
.replace(/[\s_-]+/gu, "");
const effectiveMode = normalizedStatusPreset ?? normalizedRawMode;
const mode =
normalizedStatusPreset === "autopilot"
? { label: "Autopilot", paint: warning }
: normalizedStatusPreset === "bypass"
? { label: "Bypass", paint: warning }
: normalizedRawMode === "auto" || normalizedRawMode === "bypasspermissions"
? { label: "Bypass", paint: warning }
: normalizedStatusPreset === "readonly" ||
normalizedRawMode === "strict" ||
normalizedRawMode === "readonly"
? { label: "Read-only", paint: muted }
: { label: "Ask", paint: accent };
effectiveMode === "fullaccess"
? { label: "Full Access", paint: error }
: effectiveMode === "approveforme" || effectiveMode === "auto" || effectiveMode === "bypasspermissions"
? { label: "Approve for Me", paint: warning }
: { label: "Ask", paint: accent };
const displayMode = this.bashMode ? { label: "Shell", paint: (text: string) => theme.fg("error", text) } : mode;
// The permission cycle was reachable but invisible: the footer named the
// mode and nothing said how to change it. Feedback issue-b39a464025061aa5.
Expand Down
38 changes: 27 additions & 11 deletions apps/cli/test/footer-width.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ describe("FooterComponent width handling", () => {
});

const plain = stripAnsi(footer.render(120)[0] ?? "");
expect(plain.startsWith("⏵ Bypass (shift+tab)")).toBe(true);
expect(plain.startsWith("⏵ Approve for Me (shift+tab)")).toBe(true);
});

it("omits the permission cycle key on a narrow terminal", () => {
Expand All @@ -308,7 +308,7 @@ describe("FooterComponent width handling", () => {

const plain = stripAnsi(footer.render(50)[0] ?? "");
expect(plain).not.toContain("shift+tab");
expect(plain).toContain("⏵ Bypass");
expect(plain).toContain("⏵ Approve for Me");
});

it("omits the permission cycle key when the session has no cycle", () => {
Expand All @@ -318,19 +318,35 @@ describe("FooterComponent width handling", () => {
expect(stripAnsi(footer.render(120)[0] ?? "")).not.toContain("shift+tab");
});

it("renders the read-only permission status instead of falling back to ask", () => {
const session = createSession({ sessionName: "", approvalMode: "strict" });
it("renders the full access permission status", () => {
const session = createSession({ sessionName: "", approvalMode: "auto" });
const footer = new FooterComponent(
session,
createFooterData(1, new Map([["step-permission", "Mode: Read Only"]])),
createFooterData(1, new Map([["step-permission", "Mode: Full Access"]])),
{ presentation: "step" },
);

const [line = ""] = footer.render(120);
const plain = stripAnsi(line);
expect(plain.startsWith("⏵ Read-only")).toBe(true);
expect(plain.startsWith("⏵ Ask")).toBe(false);
expect(plain).not.toContain("Mode: Read Only");
expect(plain.startsWith("⏵ Full Access")).toBe(true);
expect(plain.startsWith("⏵ Approve for Me")).toBe(false);
expect(plain).not.toContain("Mode: Full Access");
});

it("prefers the active Ask status over a stale automatic raw mode", () => {
const session = createSession({ sessionName: "", approvalMode: "auto" });
const footer = new FooterComponent(
session,
createFooterData(1, new Map([["step-permission", "Mode: Ask"]])),
{ presentation: "step" },
);
expect(stripAnsi(footer.render(120)[0] ?? "").startsWith("⏵ Ask")).toBe(true);
});

it.each(["approve-for-me", "full-access"])("renders the raw %s tier when no status is present", (preset) => {
const session = createSession({ sessionName: "", approvalMode: preset });
const footer = new FooterComponent(session, createFooterData(1), { presentation: "step" });
expect(stripAnsi(footer.render(120)[0] ?? "")).toContain(preset === "full-access" ? "Full Access" : "Approve for Me");
});

it("does not duplicate the permission status while retaining other statuses", () => {
Expand All @@ -340,7 +356,7 @@ describe("FooterComponent width handling", () => {
createFooterData(
1,
new Map([
["step-permission", "Mode: Autopilot (auto-resume)"],
["step-permission", "Mode: Approve for Me (auto-resume)"],
["plan-mode", "plan 1/2"],
]),
),
Expand All @@ -349,8 +365,8 @@ describe("FooterComponent width handling", () => {

const [line = ""] = footer.render(120);
const plain = stripAnsi(line);
expect(plain.startsWith("⏵ Autopilot")).toBe(true);
expect(plain).not.toContain("Mode: Autopilot");
expect(plain.startsWith("⏵ Approve for Me")).toBe(true);
expect(plain).not.toContain("Mode: Approve for Me");
expect(plain).toContain("plan 1/2");
});

Expand Down
38 changes: 38 additions & 0 deletions apps/cli/test/interactive-exit-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_SLASH_COMMANDS } from "../../../packages/coding-agent/src/core/slash-commands.ts";
import type { RuntimeContext } from "../src/ui/runtime/context.ts";
import { wireSubmitHandler } from "../src/ui/runtime/input-dispatch.ts";

describe("interactive exit commands", () => {
it("advertises /exit in slash-command discovery", () => {
expect(BUILTIN_SLASH_COMMANDS.some((command) => command.name === "exit")).toBe(true);
});

it.each([
{ name: "idle", isStreaming: false, isCompacting: false },
{ name: "streaming", isStreaming: true, isCompacting: false },
{ name: "compacting", isStreaming: false, isCompacting: true },
])("exits immediately while $name instead of queuing a prompt", async (state) => {
for (const command of ["/exit", "/quit", " /exit "]) {
const editor = { setText: vi.fn(), onSubmit: undefined as ((text: string) => Promise<void>) | undefined };
const shutdown = vi.fn(async () => {});
const prompt = vi.fn();
const unknown = vi.fn(() => "exit");
const showError = vi.fn();
const queueCompactionMessage = vi.fn();
const context = {
defaultEditor: editor, editor, shutdown,
session: { ...state, prompt },
getUnknownSlashCommandName: unknown, showError, queueCompactionMessage,
} as unknown as RuntimeContext;
wireSubmitHandler(context);
await editor.onSubmit!(command);
expect(shutdown).toHaveBeenCalledOnce();
expect(editor.setText).toHaveBeenCalledWith("");
expect(unknown).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
expect(prompt).not.toHaveBeenCalled();
expect(queueCompactionMessage).not.toHaveBeenCalled();
}
});
});
22 changes: 13 additions & 9 deletions docs/command-permissions.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
# Command permissions

Explicit per-tool denial takes precedence. Otherwise, shared command analysis
Explicit per-tool overrides remain effective, including `deny` and `confirm`
under Full Access. Otherwise, shared command analysis
returns one of three outcomes:

| Analysis | Ask / Bypass / Autopilot | Read-only | Without an approval channel |
| Analysis | Ask / Approve for Me | Full Access (acknowledged) | Ask / Approve for Me without an approval channel |
| --- | --- | --- | --- |
| A built-in dangerous rule matched | Confirm each call | Deny | Deny |
| Syntax or executable input could not be fully analyzed | Confirm each call, explaining the uncertainty | Deny | Deny |
| Analysis completed without a rule match | Ordinary preset/tool policy | Ordinary read-only policy | Ordinary unattended policy |

Unresolved analysis is not reported as a detected dangerous command.
| A built-in dangerous rule matched | Confirm each call | Run without confirmation | Deny |
| Syntax or executable input could not be fully analyzed | Confirm each call, explaining the uncertainty | Run without confirmation | Deny |
| Analysis completed without a rule match | Ordinary tier/tool policy | Run without confirmation | Ordinary unattended policy |

Full Access is the only tier that runs a detected dangerous command or an
unanalyzable one; it requires the one-time risk acknowledgment and is the sole
behavioral difference from Approve for Me. Unresolved analysis is not reported
as a detected dangerous command.
`StepToolDecision.analysisIncomplete` distinguishes it from `hazardous`.
Neither outcome can use an automatic tool override or unattended `allow`.
Outside Full Access, neither outcome can use an automatic tool override or unattended `allow`.
Explicit user approval applies only to that call.

Without an approval channel, a deny ends the run by default. The opt-in
Expand All @@ -20,7 +24,7 @@ env `STEP_NON_INTERACTIVE_DENIAL=continue`) keeps a recoverable confirmation
blocked but returns it as a failed tool result so the agent can continue.
Unsupported shells and shell-configuration failures still terminate because a
model retry cannot repair the execution environment. Explicit per-tool denial
and read-only mode still terminate, and dangerous commands remain blocked.
still terminates, and dangerous commands remain blocked outside Full Access.

The product policy in `packages/coding-agent/src/step/permissions.ts` runs through
the existing `tool_call` hook, before foreground/background execution. Clients
Expand Down
45 changes: 37 additions & 8 deletions docs/step-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,54 @@ by an older release is still repaired in place at startup.

## Mandatory command approval

Permission presets and tool overrides cannot automatically approve commands
matched by the built-in command rules. In particular, `rm` with both recursive
and force options requires confirmation for every target, including `./build`
and `/tmp/cache`. Bypass, auto, and autopilot still ask for each call. Read-only
mode blocks it, and runs without an approval channel cannot execute it even
with `nonInteractiveApproval = "allow"`.
Ask and Approve for Me cannot automatically approve commands matched by the
built-in command rules. In particular, `rm` with both recursive and force
options requires confirmation for every target, including `./build` and
`/tmp/cache`. Full Access is the only tier that runs them without confirmation,
and it requires the one-time risk acknowledgment; a run without an approval
channel cannot execute a dangerous command under the other tiers even with
`nonInteractiveApproval = "allow"`.

By default, a run without an approval channel terminates after such a block.
`--non-interactive-denial continue` (or `STEP_NON_INTERACTIVE_DENIAL=continue`)
keeps a recoverable confirmation blocked but reports it as a failed tool result,
so an unattended agent can take a safer route instead of ending the run.
Unsupported shells and shell-configuration failures still terminate because the
agent cannot repair the execution environment by retrying. Explicit denials —
read-only mode and `deny` tool overrides — still terminate, and dangerous
commands remain blocked.
`deny` tool overrides — still terminate, and dangerous commands remain blocked
outside Full Access.

See [command permissions](command-permissions.md) for matching behavior and
how to extend the built-in rules.

### Full Access confirmation

Select Full Access using `/permissions full-access` or `Shift+Tab`:

- **Yes, continue anyway** enables Full Access for this session without saving the tier.
- **Yes, and don't ask again** saves Full Access and its risk acknowledgment for future launches.
- **Cancel** keeps the current tier.

A session-only acknowledgment expires when the session is replaced or closed.
Without an interactive UI, an unacknowledged Full Access selection falls back to
Approve for Me. An acknowledged Full Access selection also runs dangerous
commands unattended. Explicit per-tool `deny` and `confirm` overrides still apply.

### Upgrading permission settings and automatic continuation

Saved Bypass, Autopilot, and Read Only policies reset to Ask on upgrade, including
their old `mode`, unattended-approval and auto-resume fields. Saved policies with
only the old low-level approval mode also reset to Ask. Select a new tier through
`/permissions` or `Shift+Tab`; Full Access requires the risk acknowledgment above.
Once a new tier is saved, later launches retain it.

Both Approve for Me and Full Access enable the bounded model-error continuation
by default; Ask does not resume automatically. Selecting either automatic tier
enables continuation even if the old tier had saved `autoResume = false`.
The existing retry limits and backoff remain unchanged. `STEP_AUTO_RESUME` or an
explicit `autoResume` config value can override continuation at startup without
granting extra permissions.

## Environment and shell commands

`STEP_CODING_AGENT_DIR` selects the agent directory for CLI and SDK callers.
Expand Down
4 changes: 3 additions & 1 deletion docs/step-unified-config-and-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,9 @@ agent 目录本身,而不是把凭据写到宿主指定的命名空间之外。
theme = "step-dark"
defaultProvider = "step"
defaultModel = "step-2"
permissionPreset = "standard"

[tools.approval]
preset = "approve-for-me"
autoResume = true

[telemetry]
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Step reads the variables below. Configuration shared across launches belongs in
| `STEP_CODING_AGENT_DIR` | Override the agent directory; default is `~/.stepcode/agent` |
| `STEP_CODING_AGENT_SESSION_DIR` | Override session storage; `--session-dir` takes precedence |
| `STEP_CODING_AGENT_PLAN_DIR` | Directory for newly selected native Markdown plan paths; defaults to `<cwd>/.stepcode/plans` |
| `STEP_PERMISSION_PRESET` | Permission tier: `ask`, `approve-for-me`, or `full-access`. Retired `bypass`/`autopilot`/`read-only` tiers reset to `ask` so the user can choose again; `full-access` requires risk acknowledgment |
| `STEP_APPROVAL_MODE` | Low-level approval mode: `confirm` or `auto` (retired `strict` maps to `confirm`) |
| `STEP_AUTO_RESUME` | Model-error continuation override (`1` enables, `0` disables). Enabled by default in Approve for Me and Full Access, inactive in Ask. Does not grant permissions |
| `STEP_AUTOPILOT` | Retired tier selector; resets to Ask. Select a new tier instead |
| `STEP_API_KEY` | StepFun API credential |
| `STEP_BASE_URL` | Override the StepFun API endpoint |
| `STEP_PROVIDER`, `STEP_MODEL` | Default provider and model selection |
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/docs/step-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,8 @@ Pi before loading the Step product adapter.

This layer does not reimplement editor key handling, UTF-8 buffering, render
throttling, selectors, or agent scheduling. Step's permission extension only
defines the product presets and dangerous-command policy; it invokes Pi's
native `tool_call` confirmation and `ui.select` flows. Autopilot likewise
defines the product tiers and dangerous-command policy; it invokes Pi's
native `tool_call` confirmation and `ui.select` flows. Auto-resume likewise
toggles Pi's native retry setting and adds only a bounded continuation after a
settled retryable failure. Changes to the underlying interaction or scheduling
behavior belong in Pi's shared packages and should be consumed here through
Expand Down
Loading
Loading