@zhijiewang/openharness 2.22.1 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,8 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
42
42
  - [Cybergotchi](#cybergotchi)
43
43
  - [MCP Servers](#mcp-servers)
44
44
  - [Providers](#providers)
45
+ - [Auth](#auth)
46
+ - [Update](#update)
45
47
  - [FAQ](#faq)
46
48
  - [Install](#install)
47
49
  - [Development](#development)
@@ -216,6 +218,7 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
216
218
  | `/clear` | Clear conversation history |
217
219
  | `/compact` | Compress conversation to free context |
218
220
  | `/export` | Export conversation to markdown |
221
+ | `/copy [n]` | Copy the Nth-last assistant response to the system clipboard |
219
222
  | `/history [n]` | List recent sessions; `/history search <term>` to search |
220
223
  | `/browse` | Interactive session browser with preview |
221
224
  | `/resume <id>` | Resume a saved session |
@@ -249,12 +252,16 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
249
252
  | `/theme dark\|light` | Switch theme (saved to config) |
250
253
  | `/vim` | Toggle Vim mode |
251
254
  | `/companion off\|on` | Toggle companion visibility |
255
+ | `/keys` | Show keyboard shortcuts |
256
+ | `/keybindings` | Open `~/.oh/keybindings.json` in `$EDITOR` (creates a starter file if missing) |
252
257
 
253
258
  **AI:**
254
259
  | Command | Description |
255
260
  |---------|-------------|
256
261
  | `/plan <task>` | Enter plan mode |
257
262
  | `/review` | Review recent code changes |
263
+ | `/summarize` | Summarize the current conversation |
264
+ | `/recap` | One-sentence recap of the session (lighter than `/summarize`) |
258
265
 
259
266
  **Pet:**
260
267
  | Command | Description |
@@ -299,7 +306,7 @@ hooks:
299
306
  command: "scripts/cleanup.sh"
300
307
  ```
301
308
 
302
- **Event types** (23 total):
309
+ **Event types** (27 total — matches Claude Code's stable surface):
303
310
 
304
311
  | Event | When it fires | Can block? |
305
312
  |-------|---------------|------------|
@@ -325,8 +332,14 @@ hooks:
325
332
  | `notification` | A notification is dispatched | — |
326
333
  | `taskCreated` | `TaskCreate` persists a new task | — |
327
334
  | `taskCompleted` | `TaskUpdate` transitions a task to `completed` | — |
335
+ | `worktreeCreate` | `EnterWorktreeTool` creates an isolated git worktree | — |
336
+ | `worktreeRemove` | `ExitWorktreeTool` removes a git worktree | — |
337
+ | `elicitation` | An MCP server requests user input via `elicitation/create` | yes — `decision: allow\|deny` |
338
+ | `elicitationResult` | After the elicitation response has been decided (audit trail) | — |
328
339
  | `instructionsLoaded` | `loadRulesAsPrompt` rebuilt the system prompt with rules in scope | — |
329
340
 
341
+ Set `disableAllHooks: true` in `.oh/config.yaml` to globally disable hook execution while keeping definitions on disk for auditability.
342
+
330
343
  Live introspection: run `/hooks` in-session to see exactly which hooks are loaded, grouped by event.
331
344
 
332
345
  **Environment variables** available to hook scripts:
@@ -557,6 +570,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
557
570
  oh session --model gpt-4o --max-budget-usd 5
558
571
  ```
559
572
 
573
+ ### CLI flags for CI / SDK use
574
+
575
+ | Flag | Effect |
576
+ |------|--------|
577
+ | `--bare` | Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline. Faster startup on repos with many CLAUDE.md / RULES.md files. |
578
+ | `--debug [categories]` | Enable categorized debug logs. `--debug` alone enables all; `--debug mcp,hooks` filters. Falls back to `OH_DEBUG` env var. |
579
+ | `--debug-file <path>` | Append debug lines to a file instead of stderr. Falls back to `OH_DEBUG_FILE`. |
580
+ | `--mcp-config <path>` | Load MCP servers from an external JSON file (in addition to `.oh/config.yaml`). |
581
+ | `--strict-mcp-config` | With `--mcp-config`, ignore `.oh/config.yaml` MCP servers entirely. |
582
+ | `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | File-path variants of `--system-prompt` / `--append-system-prompt`. |
583
+ | `--no-session-persistence` | Skip writing the session record to `~/.oh/sessions/` for ephemeral CI runs. |
584
+ | `--fallback-model <model>` | Fallback used when the primary fails with a retriable error. REPLACES `.oh/config.yaml` `fallbackProviders` for this run. |
585
+ | `--permission-prompt-tool <mcp_tool>` | Delegate tool-permission decisions to a configured MCP tool (e.g. `mcp__myperm__check`). |
586
+ | `--init` / `--init-only` | Run the interactive setup wizard before / instead of the command. |
587
+
588
+ All flags work on both `oh run` and `oh session`. See `oh run --help` and `oh session --help` for the full surface.
589
+
560
590
  ### Structured output with `--json-schema`
561
591
 
562
592
  Constrain the model's output to a JSON Schema. Useful for CI scripts that
@@ -650,6 +680,35 @@ oh --model llamacpp/my-model
650
680
  oh models # list available models
651
681
  ```
652
682
 
683
+ ## Auth
684
+
685
+ Provider-agnostic credential management. Local LLMs (Ollama / llama.cpp / LM Studio) need no auth — configure them via `oh init`.
686
+
687
+ ```bash
688
+ oh auth login [provider] [--key <value>] # store API key for a provider
689
+ oh auth logout [provider] # clear stored API key
690
+ oh auth status # show stored providers + env-var overrides
691
+ ```
692
+
693
+ `[provider]` defaults to your configured default. `--key` supplies the value inline; otherwise OH prompts (TTY) or reads from stdin (piped).
694
+
695
+ ### Script-based key resolution (`apiKeyHelper`)
696
+
697
+ Avoid storing keys in plaintext / the encrypted store by plugging in a helper script (1Password, `pass`, vault, cloud secret manager). The configured command runs at credential-fetch time with `OH_PROVIDER` set, and its trimmed stdout becomes the key.
698
+
699
+ ```yaml
700
+ # .oh/config.yaml
701
+ apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
702
+ ```
703
+
704
+ Resolution priority: env var → encrypted store → `apiKeyHelper` → legacy plaintext config.
705
+
706
+ ## Update
707
+
708
+ ```bash
709
+ oh update # detects how OH was installed (npm-global / npx / local clone) and prints the right upgrade command
710
+ ```
711
+
653
712
  ## Configuration Hierarchy
654
713
 
655
714
  Config is loaded in layers (later overrides earlier):
package/README.zh-CN.md CHANGED
@@ -42,6 +42,8 @@
42
42
  - [电子宠物 Cybergotchi](#电子宠物-cybergotchi)
43
43
  - [MCP 服务器](#mcp-服务器)
44
44
  - [模型提供商](#模型提供商)
45
+ - [鉴权(Auth)](#鉴权auth)
46
+ - [自动更新(Update)](#自动更新update)
45
47
  - [常见问题](#常见问题)
46
48
  - [安装](#安装)
47
49
  - [开发](#开发)
@@ -216,6 +218,7 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
216
218
  | `/clear` | 清空对话历史 |
217
219
  | `/compact` | 压缩对话以释放上下文 |
218
220
  | `/export` | 将对话导出为 markdown |
221
+ | `/copy [n]` | 复制倒数第 N 条助手回复到系统剪贴板 |
219
222
  | `/history [n]` | 列出最近的会话;`/history search <term>` 搜索 |
220
223
  | `/browse` | 带预览的交互式会话浏览器 |
221
224
  | `/resume <id>` | 恢复已保存的会话 |
@@ -249,12 +252,16 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
249
252
  | `/theme dark\|light` | 切换主题(自动保存到配置) |
250
253
  | `/vim` | 切换 Vim 模式 |
251
254
  | `/companion off\|on` | 切换电子宠物可见性 |
255
+ | `/keys` | 显示键盘快捷键 |
256
+ | `/keybindings` | 在 `$EDITOR` 中打开 `~/.oh/keybindings.json`(首次运行会创建初始文件) |
252
257
 
253
258
  **AI:**
254
259
  | 命令 | 描述 |
255
260
  |---------|-------------|
256
261
  | `/plan <task>` | 进入规划模式 |
257
262
  | `/review` | 审查最近的代码变更 |
263
+ | `/summarize` | 总结当前对话 |
264
+ | `/recap` | 一句话回顾本次会话(比 `/summarize` 更轻量) |
258
265
 
259
266
  **宠物:**
260
267
  | 命令 | 描述 |
@@ -299,7 +306,7 @@ hooks:
299
306
  command: "scripts/cleanup.sh"
300
307
  ```
301
308
 
302
- **事件类型**(共 23 个):
309
+ **事件类型**(共 27 个 —— 与 Claude Code 稳定版一致):
303
310
 
304
311
  | 事件 | 触发时机 | 是否可阻止 |
305
312
  |-------|---------------|------------|
@@ -325,8 +332,14 @@ hooks:
325
332
  | `notification` | 通知被派发 | — |
326
333
  | `taskCreated` | `TaskCreate` 持久化新任务后 | — |
327
334
  | `taskCompleted` | `TaskUpdate` 将任务状态切换为 `completed` 时 | — |
335
+ | `worktreeCreate` | `EnterWorktreeTool` 创建隔离的 git worktree 时 | — |
336
+ | `worktreeRemove` | `ExitWorktreeTool` 移除 git worktree 时 | — |
337
+ | `elicitation` | MCP 服务器通过 `elicitation/create` 请求用户输入 | 是 —— `decision: allow\|deny` |
338
+ | `elicitationResult` | elicitation 响应决定之后(用于审计追踪) | — |
328
339
  | `instructionsLoaded` | `loadRulesAsPrompt` 重新构建系统提示并加载规则之后 | — |
329
340
 
341
+ 在 `.oh/config.yaml` 中设置 `disableAllHooks: true` 可全局禁用钩子执行,同时保留磁盘上的定义以便审计。
342
+
330
343
  实时查看:在会话中运行 `/hooks` 可以按事件分组查看当前已加载的钩子。
331
344
 
332
345
  **环境变量**(钩子脚本可用):
@@ -557,6 +570,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
557
570
  oh session --model gpt-4o --max-budget-usd 5
558
571
  ```
559
572
 
573
+ ### CI / SDK 常用 CLI 标志
574
+
575
+ | 标志 | 作用 |
576
+ |------|------|
577
+ | `--bare` | 跳过启动时的可选工作(项目检测、插件、记忆、技能、MCP)。系统提示仅保留工具使用基线,对包含大量 CLAUDE.md / RULES.md 的仓库启动更快。 |
578
+ | `--debug [类别]` | 启用分类调试日志。`--debug` 启用全部;`--debug mcp,hooks` 仅启用指定类别。也读取 `OH_DEBUG` 环境变量。 |
579
+ | `--debug-file <path>` | 把调试日志追加到文件而非 stderr。也读取 `OH_DEBUG_FILE`。 |
580
+ | `--mcp-config <path>` | 从外部 JSON 文件加载 MCP 服务器(叠加在 `.oh/config.yaml` 之上)。 |
581
+ | `--strict-mcp-config` | 配合 `--mcp-config`,完全忽略 `.oh/config.yaml` 中的 MCP 服务器。 |
582
+ | `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | `--system-prompt` / `--append-system-prompt` 的文件路径变体。 |
583
+ | `--no-session-persistence` | 跳过会话写入 `~/.oh/sessions/`,适合一次性 CI 运行。 |
584
+ | `--fallback-model <model>` | 主模型遇到可重试错误时使用的回退模型。本次运行内会替代 `.oh/config.yaml` 的 `fallbackProviders`。 |
585
+ | `--permission-prompt-tool <mcp_tool>` | 把工具授权决策委托给指定的 MCP 工具(例如 `mcp__myperm__check`)。 |
586
+ | `--init` / `--init-only` | 在执行命令前 / 替代执行命令运行交互式安装向导。 |
587
+
588
+ 所有标志在 `oh run` 与 `oh session` 上都可用。完整列表见 `oh run --help` 与 `oh session --help`。
589
+
560
590
  ### 使用 `--json-schema` 约束结构化输出
561
591
 
562
592
  按 JSON Schema 约束模型输出。适用于需要以编程方式解析模型输出、避免正则启发式的 CI 脚本:
@@ -649,6 +679,35 @@ oh --model llamacpp/my-model
649
679
  oh models # 列出可用模型
650
680
  ```
651
681
 
682
+ ## 鉴权(Auth)
683
+
684
+ 提供商无关的凭据管理。本地 LLM(Ollama / llama.cpp / LM Studio)无需鉴权 —— 通过 `oh init` 配置即可。
685
+
686
+ ```bash
687
+ oh auth login [provider] [--key <value>] # 存储某个提供商的 API key
688
+ oh auth logout [provider] # 清除已存储的 API key
689
+ oh auth status # 显示已存储的提供商及环境变量覆盖情况
690
+ ```
691
+
692
+ `[provider]` 默认使用配置好的默认提供商。`--key` 可直接传入;否则 OH 会在 TTY 下交互询问,在管道输入下读到 EOF。
693
+
694
+ ### 脚本化 key 解析(`apiKeyHelper`)
695
+
696
+ 通过插入辅助脚本(1Password、`pass`、vault、云端密钥管理器等)避免把 key 写入纯文本或加密存储。配置好的命令在取 key 时执行,环境变量带 `OH_PROVIDER`,去掉首尾空白的 stdout 即为 key。
697
+
698
+ ```yaml
699
+ # .oh/config.yaml
700
+ apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
701
+ ```
702
+
703
+ 解析优先级:环境变量 → 加密存储 → `apiKeyHelper` → 旧版纯文本配置。
704
+
705
+ ## 自动更新(Update)
706
+
707
+ ```bash
708
+ oh update # 检测安装方式(npm 全局 / npx / 本地克隆),打印对应的升级命令
709
+ ```
710
+
652
711
  ## 配置层级
653
712
 
654
713
  配置按层加载(后者覆盖前者):
@@ -14,6 +14,12 @@
14
14
  */
15
15
  export type { CommandContext, CommandHandler, CommandResult } from "./types.js";
16
16
  import type { CommandContext, CommandResult } from "./types.js";
17
+ /**
18
+ * Slash command categories shown in the autocomplete picker (audit U-A3).
19
+ * Names are user-facing — keep them short. Order here is the grouping
20
+ * order in the picker: most-frequently-used first.
21
+ */
22
+ export type CommandCategory = "Session" | "Git" | "Info" | "Settings" | "AI" | "Skills" | "MCP" | "Other";
17
23
  /**
18
24
  * Check if input is a slash command. If so, execute it.
19
25
  */
@@ -25,6 +31,7 @@ export declare function getCommandNames(): string[];
25
31
  export declare function getCommandEntries(): Array<{
26
32
  name: string;
27
33
  description: string;
34
+ category: CommandCategory;
28
35
  }>;
29
36
  /**
30
37
  * Register MCP-server prompts as `/server:prompt` slash commands. Called from
@@ -18,18 +18,25 @@ import { registerInfoCommands } from "./info.js";
18
18
  import { registerSessionCommands } from "./session.js";
19
19
  import { registerSettingsCommands } from "./settings.js";
20
20
  import { registerSkillCommands } from "./skills.js";
21
- // ── Command Registry ──
22
21
  const commands = new Map();
23
- function register(name, description, handler) {
24
- commands.set(name, { description, handler });
22
+ /**
23
+ * Adapter: each `registerXxx(register)` call is wrapped with a category-bound
24
+ * register so per-domain command files don't need a 4th argument. Adding a
25
+ * new category is a one-line change here, not a sweep across the command
26
+ * files.
27
+ */
28
+ function registerFor(category) {
29
+ return (name, description, handler) => {
30
+ commands.set(name, { description, handler, category });
31
+ };
25
32
  }
26
- // Register all command groups
27
- registerSessionCommands(register);
28
- registerGitCommands(register);
29
- registerInfoCommands(register, () => commands);
30
- registerSettingsCommands(register);
31
- registerAICommands(register);
32
- registerSkillCommands(register);
33
+ // Register all command groups — category derived from the source file split.
34
+ registerSessionCommands(registerFor("Session"));
35
+ registerGitCommands(registerFor("Git"));
36
+ registerInfoCommands(registerFor("Info"), () => commands);
37
+ registerSettingsCommands(registerFor("Settings"));
38
+ registerAICommands(registerFor("AI"));
39
+ registerSkillCommands(registerFor("Skills"));
33
40
  // ── Command Parser ──
34
41
  /**
35
42
  * Check if input is a slash command. If so, execute it.
@@ -65,7 +72,7 @@ export function getCommandNames() {
65
72
  return [...commands.keys()];
66
73
  }
67
74
  export function getCommandEntries() {
68
- return [...commands.entries()].map(([name, { description }]) => ({ name, description }));
75
+ return [...commands.entries()].map(([name, { description, category }]) => ({ name, description, category }));
69
76
  }
70
77
  let mcpPromptKeys = [];
71
78
  export function registerMcpPromptCommands(prompts) {
@@ -79,6 +86,7 @@ export function registerMcpPromptCommands(prompts) {
79
86
  const usageBits = [...required.map((n) => `${n}=<value>`), ...optional.map((n) => `[${n}=<value>]`)].join(" ");
80
87
  commands.set(key, {
81
88
  description: handle.description,
89
+ category: "MCP",
82
90
  handler: async (args) => {
83
91
  const parsed = parseMcpPromptArgs(args);
84
92
  const missing = required.filter((n) => !(n in parsed));
@@ -181,10 +181,13 @@ export function registerSessionCommands(register) {
181
181
  register("browse", "Open interactive session browser", () => {
182
182
  return { output: "__OPEN_SESSION_BROWSER__", handled: true };
183
183
  });
184
- register("resume", "Resume a saved session by ID", (args) => {
184
+ register("resume", "Resume a saved session opens a picker if no ID given", (args) => {
185
185
  const id = args.trim();
186
+ // Audit U-A6: with no id, open the interactive session browser instead
187
+ // of erroring with a usage hint. Mirrors Claude Code's `/resume`
188
+ // picker. The browser already supports Enter-to-resume.
186
189
  if (!id)
187
- return { output: "Usage: /resume <session-id>", handled: true };
190
+ return { output: "__OPEN_SESSION_BROWSER__", handled: true };
188
191
  const sessionDir = join(homedir(), ".oh", "sessions");
189
192
  try {
190
193
  loadSession(id, sessionDir);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
2
+ * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools, /trust
3
3
  */
4
4
  import type { CommandHandler } from "./types.js";
5
5
  export declare function registerSettingsCommands(register: (name: string, description: string, handler: CommandHandler) => void): void;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
2
+ * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools, /trust
3
3
  */
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -7,6 +7,7 @@ import { homedir, platform } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
8
  import { readOhConfig } from "../harness/config.js";
9
9
  import { loadKeybindings } from "../harness/keybindings.js";
10
+ import { isTrusted, listTrusted, trust } from "../harness/trust.js";
10
11
  const KEYBINDINGS_TEMPLATE = `[
11
12
  { "key": "ctrl+d", "action": "/diff" },
12
13
  { "key": "ctrl+l", "action": "/clear" },
@@ -174,6 +175,25 @@ export function registerSettingsCommands(register) {
174
175
  register("vim", "Toggle Vim mode", () => {
175
176
  return { output: "__TOGGLE_VIM__", handled: true };
176
177
  });
178
+ register("trust", "Trust this workspace for shell hooks / status-line scripts (or list / add a path)", (args) => {
179
+ const arg = args.trim();
180
+ if (arg === "list") {
181
+ const trusted = listTrusted();
182
+ if (trusted.length === 0) {
183
+ return { output: "No trusted workspaces yet.\nRun `/trust` to add the current directory.", handled: true };
184
+ }
185
+ return { output: `Trusted workspaces:\n${trusted.map((d) => ` ${d}`).join("\n")}`, handled: true };
186
+ }
187
+ const target = arg || process.cwd();
188
+ if (isTrusted(target)) {
189
+ return { output: `Already trusted: ${target}`, handled: true };
190
+ }
191
+ trust(target);
192
+ return {
193
+ output: `Trusted: ${target}\nShell hooks and status-line scripts will now execute in this directory.`,
194
+ handled: true,
195
+ };
196
+ });
177
197
  register("login", "Set API key for current provider", (args, ctx) => {
178
198
  const key = args.trim();
179
199
  if (!key) {
@@ -223,5 +223,18 @@ export declare function readOhConfig(root?: string, sources?: readonly SettingSo
223
223
  * Unknown names are silently dropped.
224
224
  */
225
225
  export declare function parseSettingSources(raw: string | undefined): SettingSource[] | undefined;
226
+ /**
227
+ * Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
228
+ * keypress in the permission prompt — the user has just approved a tool
229
+ * call and wants future calls to that tool to skip the prompt.
230
+ *
231
+ * No-ops when no project config exists (user is running on auto-detected
232
+ * settings; we don't auto-create `.oh/config.yaml` just to add a rule).
233
+ * De-dupes against an exact-tool rule with no pattern.
234
+ *
235
+ * Returns `true` if a rule was written, `false` if already present or
236
+ * skipped because no config exists.
237
+ */
238
+ export declare function appendToolPermission(toolName: string, action?: "allow" | "deny", root?: string): boolean;
226
239
  export declare function writeOhConfig(cfg: OhConfig, root?: string): void;
227
240
  //# sourceMappingURL=config.d.ts.map
@@ -101,6 +101,30 @@ export function parseSettingSources(raw) {
101
101
  .filter((s) => valid.has(s));
102
102
  return out.length > 0 ? out : undefined;
103
103
  }
104
+ /**
105
+ * Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
106
+ * keypress in the permission prompt — the user has just approved a tool
107
+ * call and wants future calls to that tool to skip the prompt.
108
+ *
109
+ * No-ops when no project config exists (user is running on auto-detected
110
+ * settings; we don't auto-create `.oh/config.yaml` just to add a rule).
111
+ * De-dupes against an exact-tool rule with no pattern.
112
+ *
113
+ * Returns `true` if a rule was written, `false` if already present or
114
+ * skipped because no config exists.
115
+ */
116
+ export function appendToolPermission(toolName, action = "allow", root) {
117
+ const cfg = readOhConfig(root);
118
+ if (!cfg)
119
+ return false;
120
+ const existing = cfg.toolPermissions ?? [];
121
+ if (existing.some((r) => r.tool === toolName && !r.pattern && r.action === action)) {
122
+ return false;
123
+ }
124
+ cfg.toolPermissions = [...existing, { tool: toolName, action }];
125
+ writeOhConfig(cfg, root);
126
+ return true;
127
+ }
104
128
  export function writeOhConfig(cfg, root) {
105
129
  invalidateConfigCache();
106
130
  // Emit configChange hook (lazy import to avoid circular dependency)
@@ -12,6 +12,7 @@
12
12
  import { spawn, spawnSync } from "node:child_process";
13
13
  import { debug } from "../utils/debug.js";
14
14
  import { readOhConfig } from "./config.js";
15
+ import { isTrusted, trustSystemActive } from "./trust.js";
15
16
  let cachedHooks;
16
17
  export function getHooks() {
17
18
  if (cachedHooks !== undefined)
@@ -403,6 +404,22 @@ async function runPromptHook(promptText, ctx, timeoutMs = 10_000) {
403
404
  /** Execute a single hook definition. Returns true if allowed. */
404
405
  async function executeHookDef(def, event, ctx) {
405
406
  const timeout = def.timeout ?? 10_000;
407
+ // Workspace-trust gate (audit U-A4). Shell-executing hook types
408
+ // (`command`, `http`) require the cwd to be on the trust list — a fresh
409
+ // clone of a hostile repo can't auto-execute on first launch. Allowed by
410
+ // default for `prompt` hooks (LLM-only, no shell).
411
+ //
412
+ // Soft rollout: the gate is only enforced once the user has interacted
413
+ // with the trust system (i.e., `~/.oh/trusted-dirs.json` exists). Until
414
+ // then, existing behavior is preserved. The first session in a hooked
415
+ // workspace fires a startup prompt that creates the file — from that
416
+ // point on every other dir requires explicit trust.
417
+ if ((def.command || def.http) && trustSystemActive() && !isTrusted(process.cwd())) {
418
+ // Allow as if the hook didn't exist. The REPL surfaces a one-time
419
+ // prompt at session start when hooks are configured but the dir is
420
+ // untrusted; the user can also grant trust via `/trust`.
421
+ return true;
422
+ }
406
423
  if (def.command) {
407
424
  const env = buildEnv(event, ctx);
408
425
  // JSON-mode (Claude Code convention): send `{event, ...ctx}` on stdin,
@@ -439,10 +456,17 @@ export function emitHook(event, ctx = {}) {
439
456
  debug("hooks", "fire", { event, count: defs.length, tool: ctx.toolName });
440
457
  const env = buildEnv(event, ctx);
441
458
  if (event === "preToolUse") {
442
- // preToolUse command hooks must be synchronous — they gate tool execution
459
+ // preToolUse command hooks must be synchronous — they gate tool execution.
460
+ // Workspace-trust gate (audit U-A4): once the trust system is active
461
+ // (file exists), shell-executing hooks in untrusted dirs act as absent.
462
+ // Soft rollout: when no trust file exists at all, treat as legacy mode
463
+ // and run all hooks normally.
464
+ const enforceTrust = trustSystemActive() && !isTrusted(process.cwd());
443
465
  for (const def of defs) {
444
466
  if (!matchesHook(def, ctx))
445
467
  continue;
468
+ if ((def.command || def.http) && enforceTrust)
469
+ continue;
446
470
  if (def.command) {
447
471
  const input = def.jsonIO ? JSON.stringify({ event, ...ctx }) : undefined;
448
472
  const result = spawnSync(def.command, {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Workspace-trust store (audit U-A4).
3
+ *
4
+ * OH lets users configure shell hooks (`.oh/config.yaml` `hooks:`) and
5
+ * arbitrary status-line scripts (Tier U-B1) that auto-execute as part of
6
+ * the session loop. That's a footgun for fresh-cloned projects — a hostile
7
+ * `.oh/config.yaml` could run shell on first launch.
8
+ *
9
+ * This module gates user-defined-shell execution on a one-time
10
+ * "trust this directory" prompt, persisted in `~/.oh/trusted-dirs.json`.
11
+ * The first time a hook or status-line script tries to run in an untrusted
12
+ * directory, the REPL pops a question; trusted dirs skip the prompt forever.
13
+ *
14
+ * Mirrors Claude Code's workspace-trust model. Per the prior audit's
15
+ * already-built check, OH had zero `trustedDirectories` matches anywhere —
16
+ * this is genuinely new.
17
+ */
18
+ /** Check whether `dir` is trusted. Pure read — never prompts. */
19
+ export declare function isTrusted(dir: string): boolean;
20
+ /**
21
+ * Whether the user has ever interacted with the trust system. Used by the
22
+ * hook gate as a soft-rollout switch: before the file exists, we treat all
23
+ * dirs as trusted (legacy behavior — existing users not affected). Once
24
+ * the user grants trust to even one workspace, the gate switches on for
25
+ * every other dir. Mirrors the design pattern of "explicit opt-in once,
26
+ * enforce always after."
27
+ *
28
+ * Bypasses the in-memory cache so it picks up writes from a parallel
29
+ * process (e.g., `oh trust` run from another shell while a session is up).
30
+ */
31
+ export declare function trustSystemActive(): boolean;
32
+ /**
33
+ * Mark `dir` as trusted. Idempotent — a second call is a no-op. Persists
34
+ * immediately so a process crash before the next prompt doesn't lose the
35
+ * grant.
36
+ */
37
+ export declare function trust(dir: string): void;
38
+ /** List currently-trusted dirs. For diagnostics / `oh status`. */
39
+ export declare function listTrusted(): readonly string[];
40
+ /** @internal Test-only reset. */
41
+ export declare function _resetTrustForTest(): void;
42
+ //# sourceMappingURL=trust.d.ts.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Workspace-trust store (audit U-A4).
3
+ *
4
+ * OH lets users configure shell hooks (`.oh/config.yaml` `hooks:`) and
5
+ * arbitrary status-line scripts (Tier U-B1) that auto-execute as part of
6
+ * the session loop. That's a footgun for fresh-cloned projects — a hostile
7
+ * `.oh/config.yaml` could run shell on first launch.
8
+ *
9
+ * This module gates user-defined-shell execution on a one-time
10
+ * "trust this directory" prompt, persisted in `~/.oh/trusted-dirs.json`.
11
+ * The first time a hook or status-line script tries to run in an untrusted
12
+ * directory, the REPL pops a question; trusted dirs skip the prompt forever.
13
+ *
14
+ * Mirrors Claude Code's workspace-trust model. Per the prior audit's
15
+ * already-built check, OH had zero `trustedDirectories` matches anywhere —
16
+ * this is genuinely new.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+ const TRUST_FILE = join(homedir(), ".oh", "trusted-dirs.json");
22
+ let cached;
23
+ function loadStore() {
24
+ if (cached)
25
+ return cached;
26
+ if (!existsSync(TRUST_FILE)) {
27
+ cached = { trusted: [] };
28
+ return cached;
29
+ }
30
+ try {
31
+ const raw = readFileSync(TRUST_FILE, "utf8");
32
+ const parsed = JSON.parse(raw);
33
+ if (Array.isArray(parsed.trusted)) {
34
+ cached = { trusted: parsed.trusted.filter((p) => typeof p === "string") };
35
+ return cached;
36
+ }
37
+ }
38
+ catch {
39
+ /* malformed file — treat as empty so the user can re-grant */
40
+ }
41
+ cached = { trusted: [] };
42
+ return cached;
43
+ }
44
+ function saveStore(store) {
45
+ cached = store;
46
+ mkdirSync(dirname(TRUST_FILE), { recursive: true });
47
+ writeFileSync(TRUST_FILE, JSON.stringify({ trusted: store.trusted }, null, 2));
48
+ }
49
+ /**
50
+ * Normalize a directory for comparison. Resolves to an absolute path and
51
+ * lowercases on Windows (which is case-insensitive for paths). Other
52
+ * platforms keep case so distinct dirs that differ only in case are treated
53
+ * as distinct.
54
+ */
55
+ function normalize(dir) {
56
+ const abs = resolve(dir);
57
+ return process.platform === "win32" ? abs.toLowerCase() : abs;
58
+ }
59
+ /** Check whether `dir` is trusted. Pure read — never prompts. */
60
+ export function isTrusted(dir) {
61
+ const store = loadStore();
62
+ const target = normalize(dir);
63
+ return store.trusted.some((t) => normalize(t) === target);
64
+ }
65
+ /**
66
+ * Whether the user has ever interacted with the trust system. Used by the
67
+ * hook gate as a soft-rollout switch: before the file exists, we treat all
68
+ * dirs as trusted (legacy behavior — existing users not affected). Once
69
+ * the user grants trust to even one workspace, the gate switches on for
70
+ * every other dir. Mirrors the design pattern of "explicit opt-in once,
71
+ * enforce always after."
72
+ *
73
+ * Bypasses the in-memory cache so it picks up writes from a parallel
74
+ * process (e.g., `oh trust` run from another shell while a session is up).
75
+ */
76
+ export function trustSystemActive() {
77
+ return existsSync(TRUST_FILE);
78
+ }
79
+ /**
80
+ * Mark `dir` as trusted. Idempotent — a second call is a no-op. Persists
81
+ * immediately so a process crash before the next prompt doesn't lose the
82
+ * grant.
83
+ */
84
+ export function trust(dir) {
85
+ const store = loadStore();
86
+ const target = normalize(dir);
87
+ if (store.trusted.some((t) => normalize(t) === target))
88
+ return;
89
+ saveStore({ trusted: [...store.trusted, dir] });
90
+ }
91
+ /** List currently-trusted dirs. For diagnostics / `oh status`. */
92
+ export function listTrusted() {
93
+ return loadStore().trusted;
94
+ }
95
+ /** @internal Test-only reset. */
96
+ export function _resetTrustForTest() {
97
+ cached = undefined;
98
+ }
99
+ //# sourceMappingURL=trust.js.map
@@ -45,7 +45,7 @@ export declare class TerminalRenderer {
45
45
  setCompanion(lines: string[] | null, color: string): void;
46
46
  setStatusHints(text: string): void;
47
47
  setBannerLines(lines: string[]): void;
48
- setAutocomplete(suggestions: string[], index: number, descriptions?: string[]): void;
48
+ setAutocomplete(suggestions: string[], index: number, descriptions?: string[], categories?: string[]): void;
49
49
  setStatusLine(text: string): void;
50
50
  setContextWarning(warning: {
51
51
  text: string;
@@ -82,7 +82,16 @@ export declare class TerminalRenderer {
82
82
  clearLiveArea(): void;
83
83
  onKeypress(handler: (key: KeyEvent) => void): void;
84
84
  onAnimation(handler: (frame: number) => void): void;
85
- /** Handle permission prompt keys (Y/N/D). Returns true if key was consumed. */
85
+ /**
86
+ * Handle permission prompt keys (Y/N/A/D).
87
+ * - Y / N: approve or deny this single call.
88
+ * - A: approve AND persist a `toolPermissions: { tool, action: "allow" }`
89
+ * rule to `.oh/config.yaml` so future calls to this tool skip the prompt
90
+ * entirely (audit U-A2). Mirrors Claude Code's "yes, don't ask again".
91
+ * - D: toggle inline diff (when available).
92
+ *
93
+ * Returns true if key was consumed.
94
+ */
86
95
  private handlePermissionKey;
87
96
  /** Handle question prompt text input. Returns true if key was consumed. */
88
97
  private handleQuestionKey;
@@ -62,6 +62,7 @@ export class TerminalRenderer {
62
62
  questionPrompt: null,
63
63
  autocomplete: [],
64
64
  autocompleteDescriptions: [],
65
+ autocompleteCategories: [],
65
66
  autocompleteIndex: -1,
66
67
  manualScroll: 0,
67
68
  codeBlocksExpanded: false,
@@ -195,9 +196,10 @@ export class TerminalRenderer {
195
196
  this.state.bannerLines = lines;
196
197
  this.scheduleRender();
197
198
  }
198
- setAutocomplete(suggestions, index, descriptions) {
199
+ setAutocomplete(suggestions, index, descriptions, categories) {
199
200
  this.state.autocomplete = suggestions;
200
201
  this.state.autocompleteDescriptions = descriptions ?? [];
202
+ this.state.autocompleteCategories = categories ?? [];
201
203
  this.state.autocompleteIndex = index;
202
204
  this.scheduleRender();
203
205
  }
@@ -366,20 +368,44 @@ export class TerminalRenderer {
366
368
  this.animationCallback = handler;
367
369
  }
368
370
  // ── Input routing ──
369
- /** Handle permission prompt keys (Y/N/D). Returns true if key was consumed. */
371
+ /**
372
+ * Handle permission prompt keys (Y/N/A/D).
373
+ * - Y / N: approve or deny this single call.
374
+ * - A: approve AND persist a `toolPermissions: { tool, action: "allow" }`
375
+ * rule to `.oh/config.yaml` so future calls to this tool skip the prompt
376
+ * entirely (audit U-A2). Mirrors Claude Code's "yes, don't ask again".
377
+ * - D: toggle inline diff (when available).
378
+ *
379
+ * Returns true if key was consumed.
380
+ */
370
381
  handlePermissionKey(key) {
371
382
  if (!this.permissionResolve)
372
383
  return false;
373
384
  const k = key.char.toLowerCase();
374
- if (k === "y" || k === "n") {
385
+ if (k === "y" || k === "n" || k === "a") {
375
386
  const resolve = this.permissionResolve;
387
+ const toolName = this.state.permissionBox?.toolName;
376
388
  this.permissionResolve = null;
377
389
  this.permissionPrompt = null;
378
390
  this.state.permissionBox = null;
379
391
  this.state.permissionDiffVisible = false;
380
392
  this.state.permissionDiffInfo = null;
393
+ // Persist before resolving — any error in the write should not block
394
+ // the resolution. The persist call itself is no-op when no .oh/config.yaml
395
+ // exists (we don't auto-create on first interaction).
396
+ if (k === "a" && toolName) {
397
+ try {
398
+ // Lazy import to avoid pulling config into the renderer bundle
399
+ // for callers that don't trip the permission path.
400
+ const { appendToolPermission } = require("../harness/config.js");
401
+ appendToolPermission(toolName);
402
+ }
403
+ catch {
404
+ /* persistence failure must not block the agent */
405
+ }
406
+ }
381
407
  this.scheduleRender();
382
- resolve(k === "y");
408
+ resolve(k === "y" || k === "a");
383
409
  }
384
410
  else if (k === "d" && this.state.permissionDiffInfo) {
385
411
  this.state.permissionDiffVisible = !this.state.permissionDiffVisible;
@@ -626,6 +652,8 @@ export class TerminalRenderer {
626
652
  if (this.state.statusLine)
627
653
  rows += 1;
628
654
  rows += this.state.autocomplete.length;
655
+ // Audit U-A3: one row per distinct category header.
656
+ rows += new Set((this.state.autocompleteCategories ?? []).filter((c) => c && c.length > 0)).size;
629
657
  if (this.state.permissionBox) {
630
658
  rows += 3;
631
659
  if (this.state.permissionDiffVisible && this.state.permissionDiffInfo)
@@ -84,6 +84,13 @@ export function parseKey(data, offset) {
84
84
  return { event: key("", "pageup", seq.slice(0, 4)), consumed: 4 };
85
85
  if (seq.startsWith("\x1b[6~"))
86
86
  return { event: key("", "pagedown", seq.slice(0, 4)), consumed: 4 };
87
+ // Shift+Tab (xterm "backtab"): ESC [ Z. Used as a quick-toggle for
88
+ // permission mode (mirrors Claude Code's Shift+Tab cycler).
89
+ if (seq.startsWith("\x1b[Z"))
90
+ return {
91
+ event: { char: "", name: "tab", ctrl: false, meta: false, shift: true, sequence: seq.slice(0, 3) },
92
+ consumed: 3,
93
+ };
87
94
  // Shift+Arrow: ESC [ 1 ; 2 A/B/C/D
88
95
  if (seq.startsWith("\x1b[1;2A"))
89
96
  return {
@@ -280,6 +280,13 @@ export function renderPermissionBoxSection(state, grid, nextRow, h, opts) {
280
280
  kc += 1;
281
281
  grid.writeText(nextRow, kc, "o", S_DIM);
282
282
  kc += 1;
283
+ grid.writeText(nextRow, kc, " ", S_DIM);
284
+ kc += 2;
285
+ // Audit U-A2: "always allow this tool" — persists toolPermissions rule.
286
+ grid.writeText(nextRow, kc, "A", S_KEY_GREEN);
287
+ kc += 1;
288
+ grid.writeText(nextRow, kc, "lways", S_DIM);
289
+ kc += 5;
283
290
  if (state.permissionDiffInfo) {
284
291
  grid.writeText(nextRow, kc, " ", S_DIM);
285
292
  kc += 2;
@@ -300,10 +307,12 @@ export function renderPermissionBoxSection(state, grid, nextRow, h, opts) {
300
307
  grid.writeText(nextRow, 1, "Y", S_KEY_GREEN);
301
308
  grid.writeText(nextRow, 2, "es ", S_DIM);
302
309
  grid.writeText(nextRow, 6, "N", S_KEY_RED);
303
- grid.writeText(nextRow, 7, "o", S_DIM);
310
+ grid.writeText(nextRow, 7, "o ", S_DIM);
311
+ grid.writeText(nextRow, 10, "A", S_KEY_GREEN);
312
+ grid.writeText(nextRow, 11, "lways", S_DIM);
304
313
  if (state.permissionDiffInfo) {
305
- grid.writeText(nextRow, 10, "D", S_KEY_CYAN);
306
- grid.writeText(nextRow, 11, "iff", S_DIM);
314
+ grid.writeText(nextRow, 18, "D", S_KEY_CYAN);
315
+ grid.writeText(nextRow, 19, "iff", S_DIM);
307
316
  }
308
317
  nextRow++;
309
318
  if (state.permissionDiffVisible && state.permissionDiffInfo && nextRow + 3 < h) {
@@ -375,7 +384,20 @@ export function renderAutocompleteSection(state, grid, nextRow, limit, promptWid
375
384
  if (state.autocomplete.length === 0)
376
385
  return nextRow;
377
386
  const w = grid.width;
387
+ let lastCategory = "";
378
388
  for (let ai = 0; ai < state.autocomplete.length; ai++) {
389
+ if (nextRow >= limit)
390
+ break;
391
+ // Category header — draw whenever the category changes between entries.
392
+ // First-entry header is drawn when the category is non-empty (audit U-A3).
393
+ const cat = state.autocompleteCategories?.[ai] ?? "";
394
+ if (cat && cat !== lastCategory) {
395
+ if (nextRow >= limit)
396
+ break;
397
+ grid.writeText(nextRow, promptWidth, `── ${cat} ──`, S_DIM);
398
+ nextRow++;
399
+ lastCategory = cat;
400
+ }
379
401
  if (nextRow >= limit)
380
402
  break;
381
403
  const cmd = state.autocomplete[ai];
@@ -57,6 +57,14 @@ export type LayoutState = {
57
57
  } | null;
58
58
  autocomplete: string[];
59
59
  autocompleteDescriptions: string[];
60
+ /**
61
+ * Optional category label per autocomplete entry (audit U-A3). When two
62
+ * adjacent entries differ in category, the renderer draws a header line
63
+ * before the second. Empty / missing category strings render flat (the
64
+ * pre-A3 behavior). Optional so older test fixtures + non-REPL callers
65
+ * don't need to thread an empty array.
66
+ */
67
+ autocompleteCategories?: string[];
60
68
  autocompleteIndex: number;
61
69
  manualScroll: number;
62
70
  codeBlocksExpanded: boolean;
@@ -28,7 +28,11 @@ export function rasterize(state, grid) {
28
28
  const questionHeight = state.questionPrompt ? 4 + (state.questionPrompt.options?.length ?? 0) : 0;
29
29
  const statusLineHeight = state.statusLine ? 1 : 0;
30
30
  const contextWarningHeight = state.contextWarning ? 1 : 0;
31
- const autocompleteHeight = state.autocomplete.length;
31
+ // Autocomplete height — each entry is one row, plus one extra row per
32
+ // distinct category (audit U-A3 header lines). Distinct-category count
33
+ // is bounded by entry count so this stays cheap.
34
+ const distinctCategories = new Set((state.autocompleteCategories ?? []).filter((c) => c && c.length > 0));
35
+ const autocompleteHeight = state.autocomplete.length + distinctCategories.size;
32
36
  const inputLineCount = Math.min(5, (state.inputText.match(/\n/g)?.length ?? 0) + 1);
33
37
  const rawFooterHeight = Math.max(2 + inputLineCount + statusLineHeight + autocompleteHeight, companionHeight + 1) +
34
38
  permissionHeight +
package/dist/repl.js CHANGED
@@ -105,6 +105,9 @@ export async function startREPL(config) {
105
105
  let fastMode = s().fastMode;
106
106
  let acSuggestions = s().acSuggestions;
107
107
  let acDescriptions = s().acDescriptions;
108
+ // Audit U-A3: parallel category array for the picker. Local-only — no
109
+ // need to round-trip through `store` since no other consumer reads it.
110
+ let acCategories = [];
108
111
  let acIndex = s().acIndex;
109
112
  let acTokenStart = s().acTokenStart;
110
113
  let acIsPath = s().acIsPath;
@@ -128,13 +131,16 @@ export async function startREPL(config) {
128
131
  function updateAutocomplete() {
129
132
  acIsPath = false;
130
133
  if (inputText.startsWith("/") && inputText.length > 1 && !inputText.includes(" ")) {
131
- // Slash command autocomplete
134
+ // Slash command autocomplete — entries arrive in registration order
135
+ // (Session → Git → Info → Settings → AI → Skills → MCP), so categories
136
+ // are naturally contiguous after a startsWith filter (audit U-A3).
132
137
  const prefix = inputText.slice(1).toLowerCase();
133
138
  const entries = getCommandEntries()
134
139
  .filter((e) => e.name.startsWith(prefix))
135
- .slice(0, 5);
140
+ .slice(0, 8);
136
141
  acSuggestions = entries.map((e) => e.name);
137
142
  acDescriptions = entries.map((e) => e.description);
143
+ acCategories = entries.map((e) => e.category);
138
144
  acTokenStart = 0;
139
145
  acIndex = -1;
140
146
  }
@@ -176,26 +182,30 @@ export async function startREPL(config) {
176
182
  return "";
177
183
  }
178
184
  });
185
+ acCategories = [];
179
186
  acIsPath = acSuggestions.length > 0;
180
187
  }
181
188
  catch {
182
189
  acSuggestions = [];
183
190
  acDescriptions = [];
191
+ acCategories = [];
184
192
  }
185
193
  acIndex = -1;
186
194
  }
187
195
  else {
188
196
  acSuggestions = [];
189
197
  acDescriptions = [];
198
+ acCategories = [];
190
199
  acIndex = -1;
191
200
  }
192
201
  }
193
202
  else {
194
203
  acSuggestions = [];
195
204
  acDescriptions = [];
205
+ acCategories = [];
196
206
  acIndex = -1;
197
207
  }
198
- renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions);
208
+ renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions, acCategories);
199
209
  }
200
210
  // Companion
201
211
  let companionVisible = true;
@@ -516,6 +526,14 @@ export async function startREPL(config) {
516
526
  }
517
527
  if (key.name === "pageup" || key.name === "pagedown" || key.name === "mouse")
518
528
  return;
529
+ // Shift+Tab: cycle permission mode (audit U-A1). Mirrors Claude Code's
530
+ // quick-toggle. Cycles ask → acceptEdits → plan → trust → ask. The
531
+ // session-level mode is mutated on `config` so all downstream callers
532
+ // (`query()`, `cronExecutor`, status line) read the new value.
533
+ if (key.name === "tab" && key.shift) {
534
+ cyclePermissionMode();
535
+ return;
536
+ }
519
537
  // Tab: autocomplete slash commands or file paths, or cycle tool call expansion
520
538
  if (key.name === "tab" && !loading) {
521
539
  if (acSuggestions.length > 0) {
@@ -533,7 +551,7 @@ export async function startREPL(config) {
533
551
  }
534
552
  renderer.setInputText(inputText);
535
553
  renderer.setInputCursor(inputCursor);
536
- renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions);
554
+ renderer.setAutocomplete(acSuggestions, acIndex, acDescriptions, acCategories);
537
555
  return;
538
556
  }
539
557
  renderer.cycleToolCallExpansion();
@@ -621,6 +639,26 @@ export async function startREPL(config) {
621
639
  acIsPath,
622
640
  });
623
641
  });
642
+ /**
643
+ * Cycle the session permission mode (audit U-A1, Shift+Tab). The cycle
644
+ * intentionally covers the four interactive modes a user is likely to
645
+ * toggle between — `ask`, `acceptEdits`, `plan`, `trust`. The other modes
646
+ * (`deny`, `auto`, `bypassPermissions`) stay reachable via `/permissions
647
+ * <mode>` but aren't on the quick-cycle path because they're either
648
+ * destructive (`bypassPermissions`) or seldom-used.
649
+ *
650
+ * Mutates `config.permissionMode` directly so every existing read site
651
+ * (the `query()` call sites, `cronExecutor`, status hints) sees the new
652
+ * value without extra plumbing.
653
+ */
654
+ function cyclePermissionMode() {
655
+ const cycle = ["ask", "acceptEdits", "plan", "trust"];
656
+ const idx = cycle.indexOf(config.permissionMode);
657
+ const next = cycle[(idx === -1 ? 0 : idx + 1) % cycle.length];
658
+ config.permissionMode = next;
659
+ messages.push(createInfoMessage(`Permission mode → ${next}`));
660
+ syncRenderer();
661
+ }
624
662
  function navigateHistory(dir) {
625
663
  if (dir < 0 && historyIndex < inputHistory.length - 1) {
626
664
  historyIndex++;
@@ -1063,5 +1101,39 @@ export async function startREPL(config) {
1063
1101
  renderer.start();
1064
1102
  // Banner is already printed to stdout by main.tsx (visible in terminal scrollback)
1065
1103
  syncRenderer();
1104
+ // Workspace-trust prompt (audit U-A4). Fires once per session when:
1105
+ // - the cwd isn't already on the trust list, AND
1106
+ // - `.oh/config.yaml` defines at least one shell-executing hook
1107
+ // (command/http) — `prompt` hooks don't trip the gate.
1108
+ // Untrusted cwd silently skips command/http hooks via the gate in
1109
+ // `harness/hooks.ts`. The prompt is non-blocking: we fire-and-forget
1110
+ // the askQuestion so the REPL stays responsive while the question is
1111
+ // displayed.
1112
+ void (async () => {
1113
+ try {
1114
+ const { isTrusted, trust } = await import("./harness/trust.js");
1115
+ if (isTrusted(process.cwd()))
1116
+ return;
1117
+ const cfgWithHooks = readOhConfig();
1118
+ const hooks = cfgWithHooks?.hooks;
1119
+ if (!hooks)
1120
+ return;
1121
+ const hasShellHook = Object.values(hooks).some((defs) => Array.isArray(defs) && defs.some((d) => d.command || d.http));
1122
+ if (!hasShellHook)
1123
+ return;
1124
+ const answer = await renderer.askQuestion(`Trust this workspace? Shell hooks are configured in ${process.cwd()}. (yes/no)`);
1125
+ if (answer.toLowerCase().startsWith("y")) {
1126
+ trust(process.cwd());
1127
+ messages.push(createInfoMessage(`Trusted ${process.cwd()} — shell hooks will now execute.`));
1128
+ }
1129
+ else {
1130
+ messages.push(createInfoMessage(`Workspace not trusted — shell hooks are silently skipped. Run /trust to grant.`));
1131
+ }
1132
+ syncRenderer();
1133
+ }
1134
+ catch {
1135
+ /* trust prompt is best-effort; never block the REPL */
1136
+ }
1137
+ })();
1066
1138
  }
1067
1139
  //# sourceMappingURL=repl.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.22.1",
3
+ "version": "2.23.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {