diff --git a/AGENTS.md b/AGENTS.md index 38638ccb..6e2080b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ Step Realtime CLI 采用分层 monorepo 架构,默认依赖方向如下: - `extensions/realtime-aec/`:基于 Chrome 的 AEC 适配 - 其他第三方系统集成(IM / Email / storage / vector db / search backend 等) -扩展目录只负责外部系统对接,不负责核心编排语义。realtime-* 系列适配器统一依赖 `packages/realtime` 暴露的协议层。 +扩展目录只负责外部系统对接,不负责核心编排语义。realtime-\* 系列适配器统一依赖 `packages/realtime` 暴露的协议层。 ### `src/gateway/` @@ -311,7 +311,7 @@ TUI / UI / CLI / Desktop 默认通过 `packages/sdk` 调用 gateway: - 新增 CLI 子命令注册:`src/commands/` - 新增本地 CLI / TUI 运行时装配:`src/runtime/` - 新增内置工具或技能:`skills/` -- 新增第三方集成或通道(含 LLM / MCP / realtime-* 适配):`extensions/` +- 新增第三方集成或通道(含 LLM / MCP / realtime-\* 适配):`extensions/` - 新增服务端宿主能力:`src/gateway/` - 新增默认 CLI 客户端实现:`src/cli/` - 新增界面与交互:`src/tui/`、`ui/`、`apps/*` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9b6f2e4..5c27843e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ git clone cd step-realtime-cli pnpm install pnpm step --help # run locally without building +pnpm test # run the automated test suite pnpm check # required before opening a PR ``` @@ -36,6 +37,10 @@ platform-specific code (audio drivers, Chrome finder, etc.), use `vi.skipIf` / `describe.runIf` rather than hardcoded platform skips so that every test file still loads on every platform. +For UI, voice, or service-facing changes, also verify behavior manually with +`pnpm step` / `pnpm gateway:watch` / `pnpm tui:dev` / `pnpm ui:dev` as +appropriate for the change. + ## Architecture Read [`AGENTS.md`](./AGENTS.md) first — it lays out the layered monorepo diff --git a/README.md b/README.md index fa35a853..45c52f6c 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ StepFun operates two independent sites; pick the one that matches where your API key was issued. The two sites do **not** share accounts or keys. -| Region | Console | API endpoint | Installer | -| --- | --- | --- | --- | -| Mainland China (default) | https://platform.stepfun.com/ | `https://api.stepfun.com` | `bash scripts/setup.sh` | -| Overseas | https://platform.stepfun.ai/ | `https://api.stepfun.ai` | `bash scripts/setup-overseas.sh` | +| Region | Console | API endpoint | Installer | +| ------------------------ | ----------------------------- | ------------------------- | -------------------------------- | +| Mainland China (default) | https://platform.stepfun.com/ | `https://api.stepfun.com` | `bash scripts/setup.sh` | +| Overseas | https://platform.stepfun.ai/ | `https://api.stepfun.ai` | `bash scripts/setup-overseas.sh` | `scripts/setup-overseas.sh` runs the same flow as `scripts/setup.sh` and then rewrites `~/.step-cli/config.json` so both the realtime WebSocket and the models-proxy base URL point at `api.stepfun.ai`. All other flags (`--skip-build`, `--force-config`, `--uninstall`, …) are forwarded verbatim. diff --git a/README_CN.md b/README_CN.md index 846f05f2..e79f9a16 100644 --- a/README_CN.md +++ b/README_CN.md @@ -30,10 +30,10 @@ StepFun 提供两个相互独立的站点,请按 API Key 的发放来源选择对应安装方式。两个站点的账号与密钥**不互通**。 -| 站点 | 控制台 | API 域名 | 安装脚本 | -| --- | --- | --- | --- | -| 国内(默认) | https://platform.stepfun.com/ | `https://api.stepfun.com` | `bash scripts/setup.sh` | -| 海外 | https://platform.stepfun.ai/ | `https://api.stepfun.ai` | `bash scripts/setup-overseas.sh` | +| 站点 | 控制台 | API 域名 | 安装脚本 | +| ------------ | ----------------------------- | ------------------------- | -------------------------------- | +| 国内(默认) | https://platform.stepfun.com/ | `https://api.stepfun.com` | `bash scripts/setup.sh` | +| 海外 | https://platform.stepfun.ai/ | `https://api.stepfun.ai` | `bash scripts/setup-overseas.sh` | `scripts/setup-overseas.sh` 会先复用 `scripts/setup.sh` 的全部流程,再把 `~/.step-cli/config.json` 中实时语音的 WebSocket 端点与 models-proxy 基础地址改写为 `api.stepfun.ai`。所有其他参数(`--skip-build`、`--force-config`、`--uninstall` 等)均会原样转发。 diff --git a/extensions/llm/src/factory.test.ts b/extensions/llm/src/factory.test.ts index 344b7481..603d71d7 100644 --- a/extensions/llm/src/factory.test.ts +++ b/extensions/llm/src/factory.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { - CompletionRequest, -} from "@step-cli/protocol"; +import type { CompletionRequest } from "@step-cli/protocol"; import { createChatCompletionClient } from "./factory.js"; import { AnthropicMessagesClient } from "./anthropic-client.js"; import { OpenAICompatibleClient } from "./openai-client.js"; diff --git a/packages/core/src/policy/tool-policy.test.ts b/packages/core/src/policy/tool-policy.test.ts index 77595d65..6885ebbe 100644 --- a/packages/core/src/policy/tool-policy.test.ts +++ b/packages/core/src/policy/tool-policy.test.ts @@ -203,6 +203,89 @@ describe("ToolPolicy", () => { expect(decision.mode).toBe("deny"); }); + it("denies encoded destructive shell commands", () => { + const policy = new ToolPolicy({ + mode: "confirm", + nonInteractiveApproval: "deny", + }); + const spec = makeToolSpec({ name: "bash", risk: "execute" }); + const inspection: ToolCallInspection = { + command: "bash -c 'cm0gLXJmIC8= | base64 -d | sh'", + }; + + const decision = policy.evaluate("bash", "{}", spec, inspection); + expect(decision.mode).toBe("deny"); + expect(decision.reason).toMatch(/dangerous command/i); + }); + + it("allows benign encoded text", () => { + const policy = new ToolPolicy({ + mode: "confirm", + nonInteractiveApproval: "deny", + }); + const spec = makeToolSpec({ name: "bash", risk: "execute" }); + const inspection: ToolCallInspection = { command: "echo SGVsbG8=" }; + + const decision = policy.evaluate("bash", "{}", spec, inspection); + expect(decision.mode).toBe("confirm"); + }); + + it("denies destructive rm paths beyond filesystem root", () => { + const policy = new ToolPolicy({ + mode: "auto", + nonInteractiveApproval: "allow", + }); + const spec = makeToolSpec({ name: "bash", risk: "execute" }); + const inspection: ToolCallInspection = { command: "rm -rf /tmp/test" }; + + const decision = policy.evaluate("bash", "{}", spec, inspection); + expect(decision.mode).toBe("deny"); + expect(decision.reason).toMatch(/dangerous command/i); + }); + + it("denies destructive find delete variants", () => { + const policy = new ToolPolicy({ + mode: "auto", + nonInteractiveApproval: "allow", + }); + const spec = makeToolSpec({ name: "bash", risk: "execute" }); + const inspection: ToolCallInspection = { + command: "find / -mindepth 1 -delete", + }; + + const decision = policy.evaluate("bash", "{}", spec, inspection); + expect(decision.mode).toBe("deny"); + expect(decision.reason).toMatch(/dangerous command/i); + }); + + it("denies destructive workspace wipe variants", () => { + const policy = new ToolPolicy({ + mode: "auto", + nonInteractiveApproval: "allow", + }); + const spec = makeToolSpec({ name: "bash", risk: "execute" }); + const inspection: ToolCallInspection = { + command: "find . -mindepth 1 -delete", + }; + + const decision = policy.evaluate("bash", "{}", spec, inspection); + expect(decision.mode).toBe("deny"); + expect(decision.reason).toMatch(/dangerous command/i); + }); + + it("denies git clean forced delete variants", () => { + const policy = new ToolPolicy({ + mode: "auto", + nonInteractiveApproval: "allow", + }); + const spec = makeToolSpec({ name: "bash", risk: "execute" }); + const inspection: ToolCallInspection = { command: "git clean -fdx" }; + + const decision = policy.evaluate("bash", "{}", spec, inspection); + expect(decision.mode).toBe("deny"); + expect(decision.reason).toMatch(/dangerous command/i); + }); + // -- Per-tool override precedence -- it("per-tool override takes precedence over mode-based decision", () => { diff --git a/packages/core/src/policy/tool-policy.ts b/packages/core/src/policy/tool-policy.ts index 1254b366..17836c14 100644 --- a/packages/core/src/policy/tool-policy.ts +++ b/packages/core/src/policy/tool-policy.ts @@ -18,7 +18,9 @@ export interface ToolPolicyConfig { } const DANGEROUS_COMMAND_PATTERNS: RegExp[] = [ - /\brm\s+-rf\s+\//i, + /\brm\s+-(?:[^\s-]*r[^\s-]*f|[^\s-]*f[^\s-]*r)\s+(?:--\s+)?(?:\/(?:\S*)?|~(?:\/\S*)?|\$HOME(?:\/\S*)?|\.\.?(?:\/\S*)?)(?:\s|$)/i, + /\bfind\s+(?:\/(?:\S*)?|~(?:\/\S*)?|\$HOME(?:\/\S*)?|\.\.?(?:\/\S*)?)(?:\s|$)[\s\S]*\s-delete(?:\s|$)/i, + /\bgit\s+clean\s+-[^\s]*f[^\s]*d/i, /\bshutdown\b/i, /\breboot\b/i, /\bmkfs\b/i, @@ -176,7 +178,21 @@ export class ToolPolicy implements ToolPermissionPolicy { function isDangerousCommand(command: string): boolean { const normalized = command.trim(); - return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized)); + if (DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized))) { + return true; + } + + return extractBase64Candidates(normalized).some((candidate) => { + try { + const decoded = Buffer.from(candidate, "base64").toString("utf8").trim(); + return ( + shouldInspectDecodedCommand(decoded) && + DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(decoded)) + ); + } catch { + return false; + } + }); } function shorten(text: string, maxChars: number): string { @@ -185,3 +201,22 @@ function shorten(text: string, maxChars: number): string { } return `${text.slice(0, Math.max(0, maxChars - 3))}...`; } + +function extractBase64Candidates(command: string): string[] { + return command.match(/\b[A-Za-z0-9+/]{8,256}={0,2}\b/g) ?? []; +} + +function shouldInspectDecodedCommand(decoded: string): boolean { + if (decoded.length < 4) { + return false; + } + + if (!/^[\t\n\r\x20-\x7e]+$/.test(decoded)) { + return false; + } + + return ( + /[|;&`$()<>]/.test(decoded) || + DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(decoded)) + ); +} diff --git a/packages/core/src/tools/security.test.ts b/packages/core/src/tools/security.test.ts index cd3b6c04..4a78493e 100644 --- a/packages/core/src/tools/security.test.ts +++ b/packages/core/src/tools/security.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - getToolSecurityIssue, - validateToolSecurity, -} from "./security.js"; +import { getToolSecurityIssue, validateToolSecurity } from "./security.js"; describe("security", () => { // -- getToolSecurityIssue ---------------------------------------------- diff --git a/packages/utils/src/clarification.test.ts b/packages/utils/src/clarification.test.ts index 72009f1d..7350c0c9 100644 --- a/packages/utils/src/clarification.test.ts +++ b/packages/utils/src/clarification.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import type { UserClarificationRequest, UserClarificationOption } from "@step-cli/protocol"; +import type { + UserClarificationRequest, + UserClarificationOption, +} from "@step-cli/protocol"; import { parseClarificationAnswer, formatClarificationOption, diff --git a/packages/utils/src/terminal-text.test.ts b/packages/utils/src/terminal-text.test.ts index e2040072..0bef9dbe 100644 Binary files a/packages/utils/src/terminal-text.test.ts and b/packages/utils/src/terminal-text.test.ts differ