@zhijiewang/openharness 2.8.0 → 2.10.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.
Files changed (38) hide show
  1. package/data/registry.json +262 -0
  2. package/data/skills/code-review.md +19 -0
  3. package/data/skills/commit.md +17 -0
  4. package/data/skills/debug.md +24 -0
  5. package/data/skills/diagnose.md +24 -0
  6. package/data/skills/plan.md +25 -0
  7. package/data/skills/simplify.md +24 -0
  8. package/data/skills/tdd.md +22 -0
  9. package/dist/agents/roles.d.ts +12 -2
  10. package/dist/agents/roles.js +65 -6
  11. package/dist/commands/ai.js +27 -7
  12. package/dist/commands/skills.d.ts +1 -1
  13. package/dist/commands/skills.js +51 -6
  14. package/dist/components/App.js +7 -1
  15. package/dist/harness/config.d.ts +24 -0
  16. package/dist/harness/hooks.d.ts +14 -0
  17. package/dist/harness/hooks.js +205 -11
  18. package/dist/harness/marketplace.d.ts +77 -2
  19. package/dist/harness/marketplace.js +260 -38
  20. package/dist/harness/memory.d.ts +34 -0
  21. package/dist/harness/memory.js +96 -0
  22. package/dist/harness/plugins.d.ts +13 -3
  23. package/dist/harness/plugins.js +98 -17
  24. package/dist/harness/session-db.d.ts +8 -1
  25. package/dist/harness/session-db.js +24 -3
  26. package/dist/harness/skill-registry.d.ts +26 -2
  27. package/dist/harness/skill-registry.js +42 -4
  28. package/dist/tools/AgentTool/index.d.ts +2 -2
  29. package/dist/tools/DiagnosticsTool/index.d.ts +1 -1
  30. package/dist/tools/GrepTool/index.d.ts +6 -6
  31. package/dist/tools/MemoryTool/index.d.ts +4 -4
  32. package/dist/tools/MonitorTool/index.js +5 -1
  33. package/dist/types/permissions.js +104 -42
  34. package/dist/utils/bash-safety.d.ts +19 -0
  35. package/dist/utils/bash-safety.js +179 -1
  36. package/dist/utils/safe-env.d.ts +5 -1
  37. package/dist/utils/safe-env.js +19 -1
  38. package/package.json +3 -1
@@ -12,10 +12,31 @@
12
12
  */
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
- import { join, relative } from "node:path";
15
+ import { dirname, join, relative } from "node:path";
16
+ import { fileURLToPath } from "node:url";
16
17
  const PROJECT_SKILLS_DIR = join(".oh", "skills");
17
18
  const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
18
- /** Parse YAML frontmatter from a skill markdown file */
19
+ // Claude Code ecosystem mirror paths (Anthropic convention)
20
+ const CC_PROJECT_SKILLS_DIR = join(".claude", "skills");
21
+ const CC_GLOBAL_SKILLS_DIR = join(homedir(), ".claude", "skills");
22
+ // Bundled skills shipped with the openharness package itself.
23
+ // At runtime this resolves to <package-root>/data/skills/ both in dev (src/) and prod (dist/).
24
+ const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "data", "skills");
25
+ /** Parse a frontmatter list value. Accepts `[a, b]` (YAML inline) or `a b c` (space-separated, Anthropic spec). */
26
+ function parseListValue(raw) {
27
+ const trimmed = raw.trim();
28
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
29
+ return trimmed
30
+ .slice(1, -1)
31
+ .split(",")
32
+ .map((t) => t.trim())
33
+ .filter(Boolean);
34
+ }
35
+ // Strip surrounding quotes if present
36
+ const unquoted = trimmed.replace(/^["']|["']$/g, "");
37
+ return unquoted.split(/\s+/).filter(Boolean);
38
+ }
39
+ /** Parse YAML frontmatter from a skill markdown file. Accepts both OH camelCase and Anthropic kebab-case. */
19
40
  function parseSkillFrontmatter(content) {
20
41
  const match = content.match(/^---\n([\s\S]*?)\n---/);
21
42
  if (!match)
@@ -28,33 +49,74 @@ function parseSkillFrontmatter(content) {
28
49
  const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
29
50
  if (descMatch)
30
51
  result.description = descMatch[1].trim();
52
+ // trigger: OH-native field; when-to-use / whenToUse: Anthropic-style hint (also used as trigger fallback)
31
53
  const triggerMatch = frontmatter.match(/^trigger:\s*(.+)$/m);
32
54
  if (triggerMatch)
33
55
  result.trigger = triggerMatch[1].trim();
34
- const toolsMatch = frontmatter.match(/^tools:\s*\[(.+)\]$/m);
35
- if (toolsMatch)
36
- result.tools = toolsMatch[1].split(",").map((t) => t.trim());
37
- // Also parse allowedTools (used by built-in skills) and merge with tools
38
- const allowedToolsMatch = frontmatter.match(/^allowedTools:\s*\[(.+)\]$/m);
39
- if (allowedToolsMatch) {
40
- const allowed = allowedToolsMatch[1].split(",").map((t) => t.trim());
41
- result.tools = result.tools ? [...new Set([...result.tools, ...allowed])] : allowed;
56
+ const whenToUseMatch = frontmatter.match(/^(?:when-to-use|whenToUse):\s*(.+)$/m);
57
+ if (whenToUseMatch)
58
+ result.whenToUse = whenToUseMatch[1].trim();
59
+ // tools / allowedTools / allowed-tools — array OR space-separated. Merge all forms found.
60
+ const toolsCollected = new Set();
61
+ for (const re of [/^tools:\s*(.+)$/m, /^allowedTools:\s*(.+)$/m, /^allowed-tools:\s*(.+)$/m]) {
62
+ const m = frontmatter.match(re);
63
+ if (m)
64
+ for (const t of parseListValue(m[1]))
65
+ toolsCollected.add(t);
42
66
  }
43
- const argsMatch = frontmatter.match(/^args:\s*\[(.+)\]$/m);
67
+ if (toolsCollected.size > 0)
68
+ result.tools = [...toolsCollected];
69
+ // args / argument-hint
70
+ const argsMatch = frontmatter.match(/^(?:args|argument-hint):\s*(.+)$/m);
44
71
  if (argsMatch)
45
- result.args = argsMatch[1].split(",").map((a) => a.trim());
72
+ result.args = parseListValue(argsMatch[1]);
46
73
  // invokeModel: false OR disable-model-invocation: true → hidden from system prompt
47
74
  if (frontmatter.match(/^invokeModel:\s*false$/m) || frontmatter.match(/^disable-model-invocation:\s*true$/m)) {
48
75
  result.invokeModel = false;
49
76
  }
77
+ // license: SPDX identifier (e.g. MIT, Apache-2.0)
78
+ const licenseMatch = frontmatter.match(/^license:\s*(.+)$/m);
79
+ if (licenseMatch)
80
+ result.license = licenseMatch[1].trim().replace(/^["']|["']$/g, "");
81
+ // paths: glob list — scopes auto-surfacing to matching files
82
+ const pathsMatch = frontmatter.match(/^paths:\s*(.+)$/m);
83
+ if (pathsMatch)
84
+ result.paths = parseListValue(pathsMatch[1]);
85
+ // context: "default" | "fork" — when "fork", skill runs in a new sub-agent context
86
+ const contextMatch = frontmatter.match(/^context:\s*(.+)$/m);
87
+ if (contextMatch) {
88
+ const v = contextMatch[1].trim().replace(/^["']|["']$/g, "");
89
+ if (v === "fork" || v === "default")
90
+ result.context = v;
91
+ }
92
+ // agent: sub-agent type name (only meaningful when context: fork)
93
+ const agentMatch = frontmatter.match(/^agent:\s*(.+)$/m);
94
+ if (agentMatch)
95
+ result.agent = agentMatch[1].trim().replace(/^["']|["']$/g, "");
50
96
  return result;
51
97
  }
52
- /** Recursively collect all .md files from a directory tree */
98
+ /** Recursively collect skill .md files from a directory tree.
99
+ * Anthropic / Claude Code convention: a directory containing `SKILL.md` is a single
100
+ * directory-packaged skill — only the SKILL.md surfaces; sibling .md files are
101
+ * companion documentation (referenced via Read at runtime). Directories without
102
+ * SKILL.md fall through to the legacy flat-file behavior (every .md is a skill).
103
+ */
53
104
  function walkMdFiles(dir) {
54
105
  if (!existsSync(dir))
55
106
  return [];
107
+ let entries;
108
+ try {
109
+ entries = readdirSync(dir);
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ // Directory-packaged skill: only SKILL.md counts; siblings are companions.
115
+ if (entries.includes("SKILL.md")) {
116
+ return [join(dir, "SKILL.md")];
117
+ }
56
118
  const results = [];
57
- for (const entry of readdirSync(dir)) {
119
+ for (const entry of entries) {
58
120
  const full = join(dir, entry);
59
121
  try {
60
122
  if (statSync(full).isDirectory()) {
@@ -86,6 +148,11 @@ function loadSkillsFromDir(dir, source) {
86
148
  trigger: meta.trigger,
87
149
  tools: meta.tools,
88
150
  args: meta.args,
151
+ whenToUse: meta.whenToUse,
152
+ license: meta.license,
153
+ paths: meta.paths,
154
+ context: meta.context,
155
+ agent: meta.agent,
89
156
  content,
90
157
  filePath,
91
158
  source,
@@ -98,11 +165,17 @@ function loadSkillsFromDir(dir, source) {
98
165
  })
99
166
  .filter((s) => s !== null);
100
167
  }
101
- /** Discover all available skills from project + global dirs + installed plugins */
168
+ /** Discover all available skills from bundled + project + global dirs + installed plugins */
102
169
  export function discoverSkills() {
103
170
  const skills = [];
171
+ // Bundled (shipped with the openharness package)
172
+ skills.push(...loadSkillsFromDir(BUNDLED_SKILLS_DIR, "bundled"));
173
+ // OH-native paths
104
174
  skills.push(...loadSkillsFromDir(PROJECT_SKILLS_DIR, "project"));
105
175
  skills.push(...loadSkillsFromDir(GLOBAL_SKILLS_DIR, "global"));
176
+ // Claude Code ecosystem mirror paths — same source labels (project/global)
177
+ skills.push(...loadSkillsFromDir(CC_PROJECT_SKILLS_DIR, "project"));
178
+ skills.push(...loadSkillsFromDir(CC_GLOBAL_SKILLS_DIR, "global"));
106
179
  // Load skills from installed marketplace plugins (namespaced as plugin-name:skill-name)
107
180
  try {
108
181
  const { getInstalledPlugins } = require("./marketplace.js");
@@ -119,14 +192,22 @@ export function discoverSkills() {
119
192
  catch {
120
193
  /* marketplace module may not be loaded yet */
121
194
  }
122
- return skills;
195
+ // De-duplicate by name+filePath: if same skill appears in multiple paths (e.g. CC mirror), keep first.
196
+ const seen = new Set();
197
+ return skills.filter((s) => {
198
+ const key = `${s.name}::${s.filePath}`;
199
+ if (seen.has(key))
200
+ return false;
201
+ seen.add(key);
202
+ return true;
203
+ });
123
204
  }
124
205
  /** Find a skill by name (case-insensitive) */
125
206
  export function findSkill(name) {
126
207
  const skills = discoverSkills();
127
208
  return skills.find((s) => s.name.toLowerCase() === name.toLowerCase()) ?? null;
128
209
  }
129
- /** Find skills that match a trigger condition */
210
+ /** Find skills that match a trigger condition (substring match against `trigger` field). */
130
211
  export function findTriggeredSkills(userMessage) {
131
212
  const skills = discoverSkills();
132
213
  return skills.filter((s) => {
@@ -48,7 +48,14 @@ export declare function sessionToIndexEntry(session: Session): SessionIndexEntry
48
48
  * Rebuilds the FTS5 index from session JSON files on disk.
49
49
  */
50
50
  export declare function rebuildIndex(db: Database.Database, sessionsDir?: string): number;
51
- /** Get a shared DB connection (opens once, reuses thereafter) */
51
+ /**
52
+ * Get a shared DB connection (opens once, reuses thereafter).
53
+ *
54
+ * Honors the `OH_SESSION_DB_PATH` environment variable for test isolation.
55
+ * When the env var changes between calls, the old singleton is closed and a
56
+ * new one is opened at the new path — this lets tests point at a tmp dir
57
+ * without leaking into the user's real `~/.oh/sessions.db`.
58
+ */
52
59
  export declare function getSessionDb(): Database.Database;
53
60
  /** Close the singleton connection (call on process exit) */
54
61
  export declare function closeGlobalSessionDb(): void;
@@ -145,11 +145,32 @@ export function rebuildIndex(db, sessionsDir) {
145
145
  }
146
146
  // ── Singleton Connection ──
147
147
  let _singletonDb = null;
148
- /** Get a shared DB connection (opens once, reuses thereafter) */
148
+ let _singletonDbPath = null;
149
+ /**
150
+ * Get a shared DB connection (opens once, reuses thereafter).
151
+ *
152
+ * Honors the `OH_SESSION_DB_PATH` environment variable for test isolation.
153
+ * When the env var changes between calls, the old singleton is closed and a
154
+ * new one is opened at the new path — this lets tests point at a tmp dir
155
+ * without leaking into the user's real `~/.oh/sessions.db`.
156
+ */
149
157
  export function getSessionDb() {
150
- if (!_singletonDb) {
151
- _singletonDb = openSessionDb();
158
+ const envPath = process.env.OH_SESSION_DB_PATH;
159
+ const targetPath = envPath || DEFAULT_DB_PATH;
160
+ if (_singletonDb && _singletonDbPath === targetPath) {
161
+ return _singletonDb;
162
+ }
163
+ if (_singletonDb) {
164
+ try {
165
+ _singletonDb.close();
166
+ }
167
+ catch {
168
+ /* ignore */
169
+ }
170
+ _singletonDb = null;
152
171
  }
172
+ _singletonDb = openSessionDb(targetPath);
173
+ _singletonDbPath = targetPath;
153
174
  return _singletonDb;
154
175
  }
155
176
  /** Close the singleton connection (call on process exit) */
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Skills Registry — search and install community skills from a remote registry.
3
3
  */
4
+ /** SPDX identifiers for licenses we install without explicit user acceptance. */
5
+ export declare const PERMISSIVE_LICENSES: Set<string>;
4
6
  export type RegistrySkill = {
5
7
  name: string;
6
8
  description: string;
@@ -8,6 +10,14 @@ export type RegistrySkill = {
8
10
  version: string;
9
11
  source: string;
10
12
  tags: string[];
13
+ /** SPDX license identifier (e.g. "MIT"). Required for new entries; absent for legacy. */
14
+ license?: string;
15
+ /** Attribution string to preserve when installing (e.g. "© 2025 Jesse Vincent"). */
16
+ attribution?: string;
17
+ /** Upstream homepage / repo URL. */
18
+ upstream?: string;
19
+ /** When false, skill cannot be installed via this command — only linked to upstream. Used for viral licenses (CC-BY-SA, GPL). */
20
+ installable?: boolean;
11
21
  };
12
22
  export type Registry = {
13
23
  skills: RegistrySkill[];
@@ -16,6 +26,20 @@ export type Registry = {
16
26
  export declare function fetchRegistry(url?: string): Promise<Registry>;
17
27
  /** Search registry by query (matches name, description, tags) */
18
28
  export declare function searchRegistry(registry: Registry, query: string): RegistrySkill[];
19
- /** Install a skill from the registry to ~/.oh/skills/ */
20
- export declare function installSkill(skill: RegistrySkill): Promise<string>;
29
+ /** Result returned by installSkill either success with path, or refusal with reason. */
30
+ export type InstallResult = {
31
+ ok: true;
32
+ filePath: string;
33
+ } | {
34
+ ok: false;
35
+ reason: "not-installable" | "license-not-accepted";
36
+ message: string;
37
+ };
38
+ /** Install a skill from the registry to ~/.oh/skills/.
39
+ * Refuses non-permissive licenses unless `acceptLicense` matches the entry's license.
40
+ * Refuses entries with `installable: false` (e.g. viral-license skills that must be installed upstream).
41
+ */
42
+ export declare function installSkill(skill: RegistrySkill, opts?: {
43
+ acceptLicense?: string;
44
+ }): Promise<InstallResult>;
21
45
  //# sourceMappingURL=skill-registry.d.ts.map
@@ -6,6 +6,16 @@ import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/zhijiewong/openharness/main/data/registry.json";
8
8
  const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
9
+ /** SPDX identifiers for licenses we install without explicit user acceptance. */
10
+ export const PERMISSIVE_LICENSES = new Set([
11
+ "MIT",
12
+ "Apache-2.0",
13
+ "BSD-2-Clause",
14
+ "BSD-3-Clause",
15
+ "ISC",
16
+ "CC0-1.0",
17
+ "Unlicense",
18
+ ]);
9
19
  /** Fetch the registry from remote URL */
10
20
  export async function fetchRegistry(url = DEFAULT_REGISTRY_URL) {
11
21
  const response = await fetch(url);
@@ -20,16 +30,44 @@ export function searchRegistry(registry, query) {
20
30
  s.description.toLowerCase().includes(q) ||
21
31
  s.tags.some((t) => t.toLowerCase().includes(q)));
22
32
  }
23
- /** Install a skill from the registry to ~/.oh/skills/ */
24
- export async function installSkill(skill) {
33
+ /** Install a skill from the registry to ~/.oh/skills/.
34
+ * Refuses non-permissive licenses unless `acceptLicense` matches the entry's license.
35
+ * Refuses entries with `installable: false` (e.g. viral-license skills that must be installed upstream).
36
+ */
37
+ export async function installSkill(skill, opts = {}) {
38
+ // Gate 1: link-only entries
39
+ if (skill.installable === false) {
40
+ const upstream = skill.upstream ? ` Visit ${skill.upstream} to install under its license terms.` : "";
41
+ return {
42
+ ok: false,
43
+ reason: "not-installable",
44
+ message: `Skill "${skill.name}" is link-only (license: ${skill.license ?? "unknown"}).${upstream}`,
45
+ };
46
+ }
47
+ // Gate 2: license check
48
+ if (skill.license && !PERMISSIVE_LICENSES.has(skill.license)) {
49
+ if (opts.acceptLicense !== skill.license) {
50
+ return {
51
+ ok: false,
52
+ reason: "license-not-accepted",
53
+ message: `Skill "${skill.name}" is licensed under ${skill.license}, which is not in the auto-install allowlist.\n` +
54
+ `To install, re-run with --accept-license=${skill.license} to acknowledge its terms.`,
55
+ };
56
+ }
57
+ }
25
58
  const response = await fetch(skill.source);
26
59
  if (!response.ok)
27
60
  throw new Error(`Failed to download skill: ${response.status}`);
28
- const content = await response.text();
61
+ let content = await response.text();
62
+ // Preserve attribution by prepending an HTML comment if present and not already in the file
63
+ if (skill.attribution && !content.includes(skill.attribution)) {
64
+ const header = `<!-- Source: ${skill.upstream ?? skill.source}\n License: ${skill.license ?? "unknown"}\n ${skill.attribution} -->\n`;
65
+ content = header + content;
66
+ }
29
67
  mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
30
68
  const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
31
69
  const filePath = join(GLOBAL_SKILLS_DIR, `${slug}.md`);
32
70
  writeFileSync(filePath, content);
33
- return filePath;
71
+ return { ok: true, filePath };
34
72
  }
35
73
  //# sourceMappingURL=skill-registry.js.map
@@ -13,8 +13,8 @@ declare const inputSchema: z.ZodObject<{
13
13
  prompt: string;
14
14
  model?: string | undefined;
15
15
  description?: string | undefined;
16
- isolated?: boolean | undefined;
17
16
  isolation?: "worktree" | undefined;
17
+ isolated?: boolean | undefined;
18
18
  run_in_background?: boolean | undefined;
19
19
  subagent_type?: string | undefined;
20
20
  allowed_tools?: string[] | undefined;
@@ -22,8 +22,8 @@ declare const inputSchema: z.ZodObject<{
22
22
  prompt: string;
23
23
  model?: string | undefined;
24
24
  description?: string | undefined;
25
- isolated?: boolean | undefined;
26
25
  isolation?: "worktree" | undefined;
26
+ isolated?: boolean | undefined;
27
27
  run_in_background?: boolean | undefined;
28
28
  subagent_type?: string | undefined;
29
29
  allowed_tools?: string[] | undefined;
@@ -6,8 +6,8 @@ declare const inputSchema: z.ZodObject<{
6
6
  line: z.ZodOptional<z.ZodNumber>;
7
7
  character: z.ZodOptional<z.ZodNumber>;
8
8
  }, "strip", z.ZodTypeAny, {
9
- file_path: string;
10
9
  action: "diagnostics" | "definition" | "references" | "hover";
10
+ file_path: string;
11
11
  line?: number | undefined;
12
12
  character?: number | undefined;
13
13
  }, {
@@ -17,30 +17,30 @@ declare const inputSchema: z.ZodObject<{
17
17
  "-n": z.ZodOptional<z.ZodBoolean>;
18
18
  }, "strip", z.ZodTypeAny, {
19
19
  pattern: string;
20
- path?: string | undefined;
21
20
  type?: string | undefined;
21
+ "-i"?: boolean | undefined;
22
+ path?: string | undefined;
23
+ context?: number | undefined;
22
24
  glob?: string | undefined;
23
25
  offset?: number | undefined;
24
- context?: number | undefined;
25
26
  output_mode?: "content" | "files_with_matches" | "count" | undefined;
26
27
  head_limit?: number | undefined;
27
28
  multiline?: boolean | undefined;
28
- "-i"?: boolean | undefined;
29
29
  "-A"?: number | undefined;
30
30
  "-B"?: number | undefined;
31
31
  "-C"?: number | undefined;
32
32
  "-n"?: boolean | undefined;
33
33
  }, {
34
34
  pattern: string;
35
- path?: string | undefined;
36
35
  type?: string | undefined;
36
+ "-i"?: boolean | undefined;
37
+ path?: string | undefined;
38
+ context?: number | undefined;
37
39
  glob?: string | undefined;
38
40
  offset?: number | undefined;
39
- context?: number | undefined;
40
41
  output_mode?: "content" | "files_with_matches" | "count" | undefined;
41
42
  head_limit?: number | undefined;
42
43
  multiline?: boolean | undefined;
43
- "-i"?: boolean | undefined;
44
44
  "-A"?: number | undefined;
45
45
  "-B"?: number | undefined;
46
46
  "-C"?: number | undefined;
@@ -9,20 +9,20 @@ declare const inputSchema: z.ZodObject<{
9
9
  query: z.ZodOptional<z.ZodString>;
10
10
  global: z.ZodOptional<z.ZodBoolean>;
11
11
  }, "strip", z.ZodTypeAny, {
12
- action: "search" | "list" | "save";
12
+ action: "search" | "save" | "list";
13
13
  content?: string | undefined;
14
14
  type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
15
- global?: boolean | undefined;
16
15
  name?: string | undefined;
17
16
  description?: string | undefined;
17
+ global?: boolean | undefined;
18
18
  query?: string | undefined;
19
19
  }, {
20
- action: "search" | "list" | "save";
20
+ action: "search" | "save" | "list";
21
21
  content?: string | undefined;
22
22
  type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
23
- global?: boolean | undefined;
24
23
  name?: string | undefined;
25
24
  description?: string | undefined;
25
+ global?: boolean | undefined;
26
26
  query?: string | undefined;
27
27
  }>;
28
28
  export declare const MemoryTool: Tool<typeof inputSchema>;
@@ -78,7 +78,11 @@ export const MonitorTool = {
78
78
  for (const line of parts)
79
79
  handleLine(line);
80
80
  });
81
- proc.on("exit", (code) => {
81
+ // Use "close" rather than "exit": "exit" can fire before stdout data
82
+ // has been fully drained on fast-exiting commands, causing CI flakiness
83
+ // where output appears empty. "close" is guaranteed to fire after all
84
+ // stdio streams have closed.
85
+ proc.on("close", (code) => {
82
86
  if (!settled) {
83
87
  settled = true;
84
88
  clearTimeout(timer);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Permission types — tool permission context and risk-based gating.
3
3
  */
4
- import { analyzeBashCommand } from "../utils/bash-safety.js";
4
+ import { analyzeBashCommand, isReadOnlyBashCommand, splitCommands, stripProcessWrappers, } from "../utils/bash-safety.js";
5
5
  /** Tools auto-approved in acceptEdits mode */
6
6
  const EDIT_SAFE_TOOLS = new Set([
7
7
  "FileRead",
@@ -46,42 +46,94 @@ function matchArgGlob(pattern, value) {
46
46
  }
47
47
  }
48
48
  /** Find the first matching tool permission rule */
49
- function findToolRule(rules, toolName, toolInput) {
50
- if (!rules || rules.length === 0)
51
- return undefined;
52
- return rules.find((r) => {
53
- const { toolName: specToolName, argPattern } = parseToolSpecifier(r.tool);
54
- // Check tool name match (with prefix * support)
55
- if (!matchToolPattern(specToolName, toolName))
56
- return false;
57
- // If rule has an inline argument pattern (e.g., "Bash(npm run *)")
58
- if (argPattern && toolInput) {
59
- const input = toolInput;
60
- // For Bash: match against command string
61
- if (toolName === "Bash" && typeof input.command === "string") {
62
- return matchArgGlob(argPattern, input.command);
49
+ /**
50
+ * Priority of a rule action for "most restrictive wins" tie-breaking across
51
+ * subcommand matches: deny > ask > allow. Rules that don't apply return -1.
52
+ */
53
+ function actionPriority(action) {
54
+ return action === "deny" ? 2 : action === "ask" ? 1 : 0;
55
+ }
56
+ function matchesSingleRule(r, toolName, toolInput) {
57
+ const { toolName: specToolName, argPattern } = parseToolSpecifier(r.tool);
58
+ if (!matchToolPattern(specToolName, toolName))
59
+ return false;
60
+ if (argPattern && toolInput) {
61
+ const input = toolInput;
62
+ if (toolName === "Bash" && typeof input.command === "string") {
63
+ return matchArgGlob(argPattern, input.command);
64
+ }
65
+ if (["Edit", "Write", "Read"].includes(toolName) && typeof input.file_path === "string") {
66
+ return matchArgGlob(argPattern, input.file_path);
67
+ }
68
+ return false;
69
+ }
70
+ if (r.pattern && toolInput && toolName === "Bash") {
71
+ const command = toolInput?.command;
72
+ if (typeof command === "string") {
73
+ try {
74
+ return new RegExp(r.pattern).test(command);
63
75
  }
64
- // For file tools: match against file_path
65
- if (["Edit", "Write", "Read"].includes(toolName) && typeof input.file_path === "string") {
66
- return matchArgGlob(argPattern, input.file_path);
76
+ catch {
77
+ return false;
67
78
  }
68
- return false; // Has pattern but no matching field
69
79
  }
70
- // Legacy: separate pattern field (regex) for Bash commands
71
- if (r.pattern && toolInput && toolName === "Bash") {
72
- const command = toolInput?.command;
73
- if (typeof command === "string") {
74
- try {
75
- return new RegExp(r.pattern).test(command);
76
- }
77
- catch {
78
- return false;
79
- }
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+ /**
85
+ * Match permission rules against a Bash command.
86
+ *
87
+ * For compound commands (`cmd1 && cmd2`, `a | b`, `x; y`), evaluate rules
88
+ * against each sub-command independently with "most restrictive wins"
89
+ * semantics: any sub-command matching a `deny` rule returns deny; else any
90
+ * `ask` match returns ask; else any `allow` match returns allow; else no
91
+ * match. Process wrappers (`timeout 10 cmd`, `nice -n 5 cmd`) are stripped
92
+ * before matching so the real underlying command is what gets checked.
93
+ *
94
+ * Security note: this closes a common class of bypasses like
95
+ * `git log && rm -rf /` where a naive full-line match would hit the `git log`
96
+ * allow rule and skip the deny on `rm`.
97
+ */
98
+ function findBashRule(rules, command) {
99
+ const subs = splitCommands(command).map(stripProcessWrappers).filter(Boolean);
100
+ if (subs.length === 0)
101
+ return undefined;
102
+ let best;
103
+ let bestPriority = -1;
104
+ for (const sub of subs) {
105
+ const subInput = { command: sub };
106
+ for (const r of rules) {
107
+ if (!matchesSingleRule(r, "Bash", subInput))
108
+ continue;
109
+ const p = actionPriority(r.action);
110
+ if (p > bestPriority) {
111
+ best = r;
112
+ bestPriority = p;
113
+ if (p === 2)
114
+ return best; // deny short-circuits
80
115
  }
81
- return false;
82
116
  }
83
- return true;
84
- });
117
+ }
118
+ return best;
119
+ }
120
+ function findToolRule(rules, toolName, toolInput) {
121
+ if (!rules || rules.length === 0)
122
+ return undefined;
123
+ // Bash: always route through the compound-aware matcher. For single commands
124
+ // this just strips wrappers and does a normal rule search; for compound
125
+ // commands it evaluates each sub-command with most-restrictive-wins.
126
+ if (toolName === "Bash" && toolInput) {
127
+ const command = toolInput?.command;
128
+ if (typeof command === "string") {
129
+ const compoundMatch = findBashRule(rules, command);
130
+ if (compoundMatch)
131
+ return compoundMatch;
132
+ // Compound matcher returned nothing (single command or no match).
133
+ // Fall through to the default single-rule find below.
134
+ }
135
+ }
136
+ return rules.find((r) => matchesSingleRule(r, toolName, toolInput));
85
137
  }
86
138
  /** Cached tool permission rules — set by the REPL at startup */
87
139
  let toolPermissionRules;
@@ -103,23 +155,33 @@ export function checkPermission(mode, riskLevel, isReadOnly, toolName, toolInput
103
155
  }
104
156
  // Bash command safety analysis — detect destructive patterns
105
157
  let effectiveRisk = riskLevel;
158
+ let bashReadOnly = false;
106
159
  if (toolName === "Bash" && toolInput) {
107
160
  const command = toolInput?.command;
108
161
  if (typeof command === "string") {
109
- const analysis = analyzeBashCommand(command);
110
- if (analysis.level === "dangerous") {
111
- effectiveRisk = "high";
162
+ // Read-only allowlist short-circuit (Claude Code parity). A pure
163
+ // inspection pipeline like `ls | head` or `git status` should not
164
+ // prompt the user in any permission mode that gates read-only work.
165
+ if (isReadOnlyBashCommand(command)) {
166
+ bashReadOnly = true;
167
+ effectiveRisk = "low";
112
168
  }
113
- else if (analysis.level === "moderate" && effectiveRisk !== "high") {
114
- effectiveRisk = "medium";
115
- }
116
- else if (analysis.level === "safe") {
117
- effectiveRisk = "medium"; // bash is never fully "low" risk
169
+ else {
170
+ const analysis = analyzeBashCommand(command);
171
+ if (analysis.level === "dangerous") {
172
+ effectiveRisk = "high";
173
+ }
174
+ else if (analysis.level === "moderate" && effectiveRisk !== "high") {
175
+ effectiveRisk = "medium";
176
+ }
177
+ else if (analysis.level === "safe") {
178
+ effectiveRisk = "medium"; // bash is never fully "low" risk
179
+ }
118
180
  }
119
181
  }
120
182
  }
121
- // Always allow low-risk read-only
122
- if (effectiveRisk === "low" && isReadOnly) {
183
+ // Always allow low-risk read-only (now includes Bash commands matching the allowlist)
184
+ if (effectiveRisk === "low" && (isReadOnly || bashReadOnly)) {
123
185
  return { allowed: true, reason: "auto-approved", riskLevel: effectiveRisk };
124
186
  }
125
187
  // bypassPermissions — approve everything unconditionally (CI/testing only)