@zhijiewang/openharness 2.22.1 → 2.24.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 +61 -1
- package/README.zh-CN.md +61 -1
- package/dist/commands/index.d.ts +7 -0
- package/dist/commands/index.js +19 -11
- package/dist/commands/session.js +5 -2
- package/dist/commands/settings.d.ts +1 -1
- package/dist/commands/settings.js +47 -6
- package/dist/harness/approvals.d.ts +45 -0
- package/dist/harness/approvals.js +100 -0
- package/dist/harness/config.d.ts +34 -0
- package/dist/harness/config.js +24 -0
- package/dist/harness/hooks.js +25 -1
- package/dist/harness/status-line-script.d.ts +52 -0
- package/dist/harness/status-line-script.js +88 -0
- package/dist/harness/trust.d.ts +42 -0
- package/dist/harness/trust.js +99 -0
- package/dist/query/tools.js +36 -0
- package/dist/renderer/cells.d.ts +6 -0
- package/dist/renderer/cells.js +48 -2
- package/dist/renderer/differ.js +18 -1
- package/dist/renderer/index.d.ts +11 -2
- package/dist/renderer/index.js +37 -4
- package/dist/renderer/input.js +7 -0
- package/dist/renderer/layout-sections.js +27 -5
- package/dist/renderer/layout.d.ts +8 -0
- package/dist/renderer/layout.js +5 -1
- package/dist/renderer/markdown.js +4 -1
- package/dist/repl.js +115 -11
- package/dist/tools/ExaSearchTool/index.d.ts +101 -0
- package/dist/tools/ExaSearchTool/index.js +165 -0
- package/dist/tools.js +2 -0
- package/dist/utils/fuzzy.d.ts +39 -0
- package/dist/utils/fuzzy.js +70 -0
- package/package.json +1 -1
package/dist/harness/hooks.js
CHANGED
|
@@ -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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-envelope status line script runner (audit U-B1).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's `statusLine` config. The user configures a shell
|
|
5
|
+
* command in `.oh/config.yaml`:
|
|
6
|
+
*
|
|
7
|
+
* statusLine:
|
|
8
|
+
* command: "~/scripts/oh-status.sh"
|
|
9
|
+
* refreshMs: 2000
|
|
10
|
+
*
|
|
11
|
+
* On each REPL refresh, OH:
|
|
12
|
+
* 1. Builds a JSON envelope of session state (model, tokens, cost, etc.)
|
|
13
|
+
* 2. If the cache window hasn't expired AND the envelope hasn't changed,
|
|
14
|
+
* returns the cached stdout — no spawn cost on every keypress.
|
|
15
|
+
* 3. Otherwise spawns the command through the shell, pipes the envelope
|
|
16
|
+
* on stdin, captures stdout (timeout: 2s), trims to the first line.
|
|
17
|
+
*
|
|
18
|
+
* Caller is responsible for the workspace-trust gate (`isTrusted(cwd)`);
|
|
19
|
+
* this module just runs the command. Failures (non-zero exit, timeout,
|
|
20
|
+
* spawn error) return null so the caller can fall back to template /
|
|
21
|
+
* default rendering.
|
|
22
|
+
*
|
|
23
|
+
* Synchronous spawn used so the renderer doesn't need an async path —
|
|
24
|
+
* keeps the keypress loop hot. Trade-off: a slow script blocks the render
|
|
25
|
+
* up to `timeoutMs`; the cache makes this rare.
|
|
26
|
+
*/
|
|
27
|
+
export interface StatusLineEnvelope {
|
|
28
|
+
model: string;
|
|
29
|
+
tokens: {
|
|
30
|
+
input: number;
|
|
31
|
+
output: number;
|
|
32
|
+
};
|
|
33
|
+
cost: number;
|
|
34
|
+
contextPercent: number;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
gitBranch?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface StatusLineConfig {
|
|
40
|
+
command: string;
|
|
41
|
+
refreshMs?: number;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run the status line script with the given envelope. Returns the trimmed
|
|
46
|
+
* first line of stdout, or null on failure / empty output. Caches results
|
|
47
|
+
* for `refreshMs`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runStatusLineScript(env: StatusLineEnvelope, cfg: StatusLineConfig): string | null;
|
|
50
|
+
/** @internal Test-only reset. */
|
|
51
|
+
export declare function _resetStatusLineCacheForTest(): void;
|
|
52
|
+
//# sourceMappingURL=status-line-script.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-envelope status line script runner (audit U-B1).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's `statusLine` config. The user configures a shell
|
|
5
|
+
* command in `.oh/config.yaml`:
|
|
6
|
+
*
|
|
7
|
+
* statusLine:
|
|
8
|
+
* command: "~/scripts/oh-status.sh"
|
|
9
|
+
* refreshMs: 2000
|
|
10
|
+
*
|
|
11
|
+
* On each REPL refresh, OH:
|
|
12
|
+
* 1. Builds a JSON envelope of session state (model, tokens, cost, etc.)
|
|
13
|
+
* 2. If the cache window hasn't expired AND the envelope hasn't changed,
|
|
14
|
+
* returns the cached stdout — no spawn cost on every keypress.
|
|
15
|
+
* 3. Otherwise spawns the command through the shell, pipes the envelope
|
|
16
|
+
* on stdin, captures stdout (timeout: 2s), trims to the first line.
|
|
17
|
+
*
|
|
18
|
+
* Caller is responsible for the workspace-trust gate (`isTrusted(cwd)`);
|
|
19
|
+
* this module just runs the command. Failures (non-zero exit, timeout,
|
|
20
|
+
* spawn error) return null so the caller can fall back to template /
|
|
21
|
+
* default rendering.
|
|
22
|
+
*
|
|
23
|
+
* Synchronous spawn used so the renderer doesn't need an async path —
|
|
24
|
+
* keeps the keypress loop hot. Trade-off: a slow script blocks the render
|
|
25
|
+
* up to `timeoutMs`; the cache makes this rare.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
let cache = null;
|
|
29
|
+
const DEFAULT_REFRESH_MS = 1000;
|
|
30
|
+
const MIN_REFRESH_MS = 100;
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 2000;
|
|
32
|
+
/**
|
|
33
|
+
* Stable cache key from the envelope. Excludes timestamps / sessionId-y
|
|
34
|
+
* things that don't actually change the script's output (the script reads
|
|
35
|
+
* the envelope it gets — if the input is the same, the output should be
|
|
36
|
+
* too).
|
|
37
|
+
*/
|
|
38
|
+
function envelopeKey(env) {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
model: env.model,
|
|
41
|
+
tokens: env.tokens,
|
|
42
|
+
cost: env.cost,
|
|
43
|
+
contextPercent: env.contextPercent,
|
|
44
|
+
cwd: env.cwd,
|
|
45
|
+
gitBranch: env.gitBranch,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run the status line script with the given envelope. Returns the trimmed
|
|
50
|
+
* first line of stdout, or null on failure / empty output. Caches results
|
|
51
|
+
* for `refreshMs`.
|
|
52
|
+
*/
|
|
53
|
+
export function runStatusLineScript(env, cfg) {
|
|
54
|
+
const refresh = Math.max(MIN_REFRESH_MS, cfg.refreshMs ?? DEFAULT_REFRESH_MS);
|
|
55
|
+
const timeout = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
56
|
+
const key = envelopeKey(env);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (cache && cache.envelopeKey === key && now - cache.timestamp < refresh) {
|
|
59
|
+
return cache.output;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = spawnSync(cfg.command, {
|
|
63
|
+
shell: true,
|
|
64
|
+
timeout,
|
|
65
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
66
|
+
input: JSON.stringify(env),
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
});
|
|
69
|
+
if (result.error || result.status !== 0) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const out = (result.stdout ?? "").toString().trim();
|
|
73
|
+
if (!out)
|
|
74
|
+
return null;
|
|
75
|
+
// Truncate to first line — multi-line output would corrupt the status row.
|
|
76
|
+
const firstLine = out.split(/\r?\n/)[0];
|
|
77
|
+
cache = { envelopeKey: key, output: firstLine, timestamp: now };
|
|
78
|
+
return firstLine;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** @internal Test-only reset. */
|
|
85
|
+
export function _resetStatusLineCacheForTest() {
|
|
86
|
+
cache = null;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=status-line-script.js.map
|
|
@@ -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
|
package/dist/query/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool execution — permission checking, batching, output capping.
|
|
3
3
|
*/
|
|
4
|
+
import { previewArgs, recordApproval } from "../harness/approvals.js";
|
|
4
5
|
import { createCheckpoint, getAffectedFiles } from "../harness/checkpoints.js";
|
|
5
6
|
import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
|
|
6
7
|
import { findToolByName } from "../Tool.js";
|
|
@@ -91,6 +92,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
91
92
|
permissionMode,
|
|
92
93
|
permissionAction: "ask",
|
|
93
94
|
});
|
|
95
|
+
const argsPreview = previewArgs(JSON.stringify(toolCall.arguments));
|
|
94
96
|
const denyAndEmit = (source, reason, output) => {
|
|
95
97
|
emitHook("permissionDenied", {
|
|
96
98
|
toolName: tool.name,
|
|
@@ -99,10 +101,31 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
99
101
|
denySource: source,
|
|
100
102
|
denyReason: reason,
|
|
101
103
|
});
|
|
104
|
+
// Audit U-B5: persist denial to ~/.oh/approvals.log so /permissions log
|
|
105
|
+
// can replay the session's approval history. The cast is safe — the
|
|
106
|
+
// four call sites below pass exact string literals matching ApprovalSource.
|
|
107
|
+
recordApproval({
|
|
108
|
+
tool: tool.name,
|
|
109
|
+
decision: "deny",
|
|
110
|
+
source: source,
|
|
111
|
+
argsPreview,
|
|
112
|
+
reason,
|
|
113
|
+
cwd: process.cwd(),
|
|
114
|
+
});
|
|
102
115
|
return { output, isError: true };
|
|
103
116
|
};
|
|
117
|
+
const recordAllow = (source) => {
|
|
118
|
+
recordApproval({
|
|
119
|
+
tool: tool.name,
|
|
120
|
+
decision: "allow",
|
|
121
|
+
source,
|
|
122
|
+
argsPreview,
|
|
123
|
+
cwd: process.cwd(),
|
|
124
|
+
});
|
|
125
|
+
};
|
|
104
126
|
if (hookOutcome.permissionDecision === "allow") {
|
|
105
127
|
// Hook granted permission — proceed to execution.
|
|
128
|
+
recordAllow("hook");
|
|
106
129
|
}
|
|
107
130
|
else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
|
|
108
131
|
const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
|
|
@@ -118,6 +141,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
118
141
|
const promptDecision = await callPermissionPromptTool(permissionPromptTool, tools, context, tool.name, parsed.data);
|
|
119
142
|
if (promptDecision.behavior === "allow") {
|
|
120
143
|
// Permission tool granted — proceed.
|
|
144
|
+
recordAllow("permission-prompt-tool");
|
|
121
145
|
}
|
|
122
146
|
else if (promptDecision.behavior === "deny") {
|
|
123
147
|
return denyAndEmit("permission-prompt-tool", promptDecision.message ?? "denied", `Permission denied by ${permissionPromptTool}${promptDecision.message ? `: ${promptDecision.message}` : ""}`);
|
|
@@ -131,6 +155,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
131
155
|
if (!allowed) {
|
|
132
156
|
return denyAndEmit("user", "user declined", "Permission denied by user.");
|
|
133
157
|
}
|
|
158
|
+
recordAllow("user");
|
|
134
159
|
}
|
|
135
160
|
else {
|
|
136
161
|
return denyAndEmit("headless", "permission-prompt-tool unavailable and no interactive prompt", `Permission denied: ${permissionPromptTool} did not produce a usable decision and no interactive prompt is available.`);
|
|
@@ -144,6 +169,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
144
169
|
if (!allowed) {
|
|
145
170
|
return denyAndEmit("user", "user declined", "Permission denied by user.");
|
|
146
171
|
}
|
|
172
|
+
recordAllow("user");
|
|
147
173
|
}
|
|
148
174
|
else {
|
|
149
175
|
// Headless mode with no hook decision and no interactive prompt:
|
|
@@ -161,6 +187,16 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
161
187
|
denySource: "policy",
|
|
162
188
|
denyReason: perm.reason,
|
|
163
189
|
});
|
|
190
|
+
// Audit U-B5: a `tool-rule-deny` reason came from an explicit
|
|
191
|
+
// `toolPermissions` rule the user wrote; everything else is policy.
|
|
192
|
+
recordApproval({
|
|
193
|
+
tool: tool.name,
|
|
194
|
+
decision: "deny",
|
|
195
|
+
source: perm.reason === "tool-rule-deny" ? "rule" : "policy",
|
|
196
|
+
argsPreview: previewArgs(JSON.stringify(toolCall.arguments)),
|
|
197
|
+
reason: perm.reason,
|
|
198
|
+
cwd: process.cwd(),
|
|
199
|
+
});
|
|
164
200
|
return { output: `Permission denied: ${perm.reason}`, isError: true };
|
|
165
201
|
}
|
|
166
202
|
}
|
package/dist/renderer/cells.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type Style = {
|
|
|
7
7
|
bold: boolean;
|
|
8
8
|
dim: boolean;
|
|
9
9
|
underline: boolean;
|
|
10
|
+
hyperlink?: string | null;
|
|
10
11
|
};
|
|
11
12
|
export type Cell = {
|
|
12
13
|
char: string;
|
|
@@ -32,6 +33,11 @@ export declare class CellGrid {
|
|
|
32
33
|
* Returns the number of rows consumed.
|
|
33
34
|
*/
|
|
34
35
|
writeWrapped(row: number, col: number, text: string, style: Style, wrapWidth: number, maxRow?: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* Write text on a single row (no wrapping), tagging http(s):// and file:// runs
|
|
38
|
+
* with the OSC 8 hyperlink attribute (cyan + underline). Stops at maxCol or grid width.
|
|
39
|
+
*/
|
|
40
|
+
writeTextWithLinks(row: number, col: number, text: string, baseStyle: Style, maxCol?: number): void;
|
|
35
41
|
clone(): CellGrid;
|
|
36
42
|
}
|
|
37
43
|
//# sourceMappingURL=cells.d.ts.map
|
package/dist/renderer/cells.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cell grid — 2D array of styled characters for terminal rendering.
|
|
3
3
|
*/
|
|
4
|
-
export const EMPTY_STYLE = { fg: null, bg: null, bold: false, dim: false, underline: false };
|
|
4
|
+
export const EMPTY_STYLE = { fg: null, bg: null, bold: false, dim: false, underline: false, hyperlink: null };
|
|
5
5
|
export const EMPTY_CELL = { char: " ", style: { ...EMPTY_STYLE } };
|
|
6
6
|
export function cellsEqual(a, b) {
|
|
7
7
|
return (a.char === b.char &&
|
|
@@ -9,7 +9,8 @@ export function cellsEqual(a, b) {
|
|
|
9
9
|
a.style.bg === b.style.bg &&
|
|
10
10
|
a.style.bold === b.style.bold &&
|
|
11
11
|
a.style.dim === b.style.dim &&
|
|
12
|
-
a.style.underline === b.style.underline
|
|
12
|
+
a.style.underline === b.style.underline &&
|
|
13
|
+
(a.style.hyperlink ?? null) === (b.style.hyperlink ?? null));
|
|
13
14
|
}
|
|
14
15
|
export class CellGrid {
|
|
15
16
|
width;
|
|
@@ -37,6 +38,7 @@ export class CellGrid {
|
|
|
37
38
|
cell.style.bold = false;
|
|
38
39
|
cell.style.dim = false;
|
|
39
40
|
cell.style.underline = false;
|
|
41
|
+
cell.style.hyperlink = null;
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
}
|
|
@@ -51,6 +53,7 @@ export class CellGrid {
|
|
|
51
53
|
cell.style.bold = s.bold;
|
|
52
54
|
cell.style.dim = s.dim;
|
|
53
55
|
cell.style.underline = s.underline;
|
|
56
|
+
cell.style.hyperlink = s.hyperlink ?? null;
|
|
54
57
|
}
|
|
55
58
|
/**
|
|
56
59
|
* Write a string into the grid at (row, col). Handles \n for line breaks.
|
|
@@ -122,6 +125,49 @@ export class CellGrid {
|
|
|
122
125
|
}
|
|
123
126
|
return r - row;
|
|
124
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Write text on a single row (no wrapping), tagging http(s):// and file:// runs
|
|
130
|
+
* with the OSC 8 hyperlink attribute (cyan + underline). Stops at maxCol or grid width.
|
|
131
|
+
*/
|
|
132
|
+
writeTextWithLinks(row, col, text, baseStyle, maxCol) {
|
|
133
|
+
const limit = Math.min(maxCol ?? this.width, this.width);
|
|
134
|
+
if (row < 0 || row >= this.height)
|
|
135
|
+
return;
|
|
136
|
+
const linkRegex = /(https?:\/\/[^\s)\]'"<>]+|file:\/\/[^\s)\]'"<>]+)/g;
|
|
137
|
+
let cursor = 0;
|
|
138
|
+
let c = col;
|
|
139
|
+
while (true) {
|
|
140
|
+
const m = linkRegex.exec(text);
|
|
141
|
+
if (m === null)
|
|
142
|
+
break;
|
|
143
|
+
const before = text.slice(cursor, m.index);
|
|
144
|
+
for (let i = 0; i < before.length && c < limit; i++) {
|
|
145
|
+
this.setCell(row, c, before[i], baseStyle);
|
|
146
|
+
c++;
|
|
147
|
+
}
|
|
148
|
+
if (c >= limit)
|
|
149
|
+
return;
|
|
150
|
+
// Strip trailing punctuation that's almost never part of the URL.
|
|
151
|
+
let url = m[0];
|
|
152
|
+
while (url.length > 0 && /[.,;:!?]/.test(url[url.length - 1])) {
|
|
153
|
+
url = url.slice(0, -1);
|
|
154
|
+
}
|
|
155
|
+
if (url.length === 0) {
|
|
156
|
+
cursor = m.index + m[0].length;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const linkStyle = { ...baseStyle, fg: "cyan", underline: true, hyperlink: url };
|
|
160
|
+
for (let i = 0; i < url.length && c < limit; i++) {
|
|
161
|
+
this.setCell(row, c, url[i], linkStyle);
|
|
162
|
+
c++;
|
|
163
|
+
}
|
|
164
|
+
cursor = m.index + url.length;
|
|
165
|
+
}
|
|
166
|
+
for (let i = cursor; i < text.length && c < limit; i++) {
|
|
167
|
+
this.setCell(row, c, text[i], baseStyle);
|
|
168
|
+
c++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
125
171
|
clone() {
|
|
126
172
|
const g = new CellGrid(this.width, this.height);
|
|
127
173
|
for (let r = 0; r < this.height; r++) {
|
package/dist/renderer/differ.js
CHANGED
|
@@ -18,6 +18,11 @@ export function styleToSGR(style) {
|
|
|
18
18
|
codes.push(BG_CODES[style.bg]);
|
|
19
19
|
return `\x1b[${codes.join(";")}m`;
|
|
20
20
|
}
|
|
21
|
+
/** OSC 8 hyperlink open/close sequences (ST = ESC \). */
|
|
22
|
+
function osc8Open(url) {
|
|
23
|
+
return `\x1b]8;;${url}\x1b\\`;
|
|
24
|
+
}
|
|
25
|
+
const OSC8_CLOSE = "\x1b]8;;\x1b\\";
|
|
21
26
|
/**
|
|
22
27
|
* Compare two grids and return the ANSI string that transforms prev into next.
|
|
23
28
|
* Only emits escape sequences for changed cells.
|
|
@@ -25,6 +30,7 @@ export function styleToSGR(style) {
|
|
|
25
30
|
export function diff(prev, next, rowOffset = 0) {
|
|
26
31
|
const parts = [];
|
|
27
32
|
let lastStyle = null;
|
|
33
|
+
let lastHyperlink = null;
|
|
28
34
|
let expectedRow = -1;
|
|
29
35
|
let expectedCol = -1;
|
|
30
36
|
for (let r = 0; r < next.height; r++) {
|
|
@@ -43,13 +49,24 @@ export function diff(prev, next, rowOffset = 0) {
|
|
|
43
49
|
parts.push(sgr);
|
|
44
50
|
lastStyle = sgr;
|
|
45
51
|
}
|
|
52
|
+
// Apply OSC 8 hyperlink transitions independently of SGR
|
|
53
|
+
const nextHyperlink = nextCell.style.hyperlink ?? null;
|
|
54
|
+
if (nextHyperlink !== lastHyperlink) {
|
|
55
|
+
if (lastHyperlink !== null)
|
|
56
|
+
parts.push(OSC8_CLOSE);
|
|
57
|
+
if (nextHyperlink !== null)
|
|
58
|
+
parts.push(osc8Open(nextHyperlink));
|
|
59
|
+
lastHyperlink = nextHyperlink;
|
|
60
|
+
}
|
|
46
61
|
parts.push(nextCell.char);
|
|
47
62
|
expectedRow = r;
|
|
48
63
|
expectedCol = c + 1;
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
|
-
//
|
|
66
|
+
// Close any open hyperlink and reset style at end
|
|
52
67
|
if (parts.length > 0) {
|
|
68
|
+
if (lastHyperlink !== null)
|
|
69
|
+
parts.push(OSC8_CLOSE);
|
|
53
70
|
parts.push("\x1b[0m");
|
|
54
71
|
}
|
|
55
72
|
return parts.join("");
|
package/dist/renderer/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|