@zhijiewang/openharness 2.8.0 → 2.9.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 +11 -0
  16. package/dist/harness/hooks.d.ts +14 -0
  17. package/dist/harness/hooks.js +47 -4
  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 +6 -6
  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
@@ -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)
@@ -9,10 +9,29 @@ export type BashRisk = {
9
9
  level: "safe" | "moderate" | "dangerous";
10
10
  reasons: string[];
11
11
  };
12
+ /**
13
+ * Strip leading process-wrapper tokens from a command string. Returns the
14
+ * underlying command (tokens + original separator). When the command is
15
+ * `timeout 30 npm test`, returns `npm test`. When no wrapper is present,
16
+ * returns the input unchanged. Conservative: only strips wrappers with at
17
+ * least one remaining token after them.
18
+ */
19
+ export declare function stripProcessWrappers(cmd: string): string;
20
+ /**
21
+ * Return true iff every sub-command in the pipeline/chain is a read-only
22
+ * operation. Any side-effecting sub-command disqualifies the whole command.
23
+ * Respects quotes and command substitution via the existing splitCommands/tokenize.
24
+ */
25
+ export declare function isReadOnlyBashCommand(command: string): boolean;
12
26
  /**
13
27
  * Analyze a bash command string for safety risks.
14
28
  * Does lightweight structural parsing — splits on pipes, semicolons,
15
29
  * and && / || operators to analyze each sub-command.
16
30
  */
17
31
  export declare function analyzeBashCommand(command: string): BashRisk;
32
+ /**
33
+ * Split a command string into sub-commands on |, ;, &&, ||
34
+ * Respects quoted strings and command substitutions.
35
+ */
36
+ export declare function splitCommands(cmd: string): string[];
18
37
  //# sourceMappingURL=bash-safety.d.ts.map
@@ -39,6 +39,184 @@ const INSTALL_COMMANDS = new Set([
39
39
  ]);
40
40
  // Commands that send data externally
41
41
  const NETWORK_EXFIL = new Set(["curl", "wget", "nc", "ncat", "socat", "ssh", "scp", "rsync"]);
42
+ /**
43
+ * Process-wrapper commands that don't change what runs, just how it runs.
44
+ * These are stripped off the front of a sub-command before permission matching
45
+ * so `timeout 10 rm file` matches the same rule as `rm file`. Mirrors Claude
46
+ * Code's wrapper-stripping for robust permission enforcement.
47
+ */
48
+ const PROCESS_WRAPPERS = new Set(["timeout", "time", "nice", "nohup", "stdbuf", "ionice", "unbuffer", "env"]);
49
+ /**
50
+ * Strip leading process-wrapper tokens from a command string. Returns the
51
+ * underlying command (tokens + original separator). When the command is
52
+ * `timeout 30 npm test`, returns `npm test`. When no wrapper is present,
53
+ * returns the input unchanged. Conservative: only strips wrappers with at
54
+ * least one remaining token after them.
55
+ */
56
+ export function stripProcessWrappers(cmd) {
57
+ const trimmed = cmd.trim();
58
+ let tokens = tokenize(trimmed);
59
+ while (tokens.length >= 2 && PROCESS_WRAPPERS.has(tokens[0])) {
60
+ const first = tokens[0];
61
+ let skip = 1;
62
+ // `timeout 30s`, `nice -n 10`, `stdbuf -oL` — skip option-like args
63
+ // belonging to the wrapper itself. Numeric or `-flag value` shapes are swallowed.
64
+ while (skip < tokens.length - 1) {
65
+ const t = tokens[skip];
66
+ if (t.startsWith("-") || /^[0-9.]+[a-zA-Z]?$/.test(t)) {
67
+ skip++;
68
+ }
69
+ else {
70
+ break;
71
+ }
72
+ }
73
+ tokens = tokens.slice(skip);
74
+ // Safety: if stripping removes everything, fall back to original
75
+ if (tokens.length === 0)
76
+ return trimmed;
77
+ // Detect and continue stripping nested wrappers (e.g., `timeout 5 nice rm`)
78
+ if (!PROCESS_WRAPPERS.has(tokens[0]))
79
+ break;
80
+ // Avoid infinite loops on malformed input
81
+ if (tokens[0] === first)
82
+ break;
83
+ }
84
+ return tokens.join(" ");
85
+ }
86
+ /**
87
+ * Pure read-only commands. A bash invocation consisting only of these
88
+ * commands (optionally piped/chained with each other) is safe to auto-approve
89
+ * without a permission prompt. Mirrors Claude Code's read-only allowlist so
90
+ * common inspection flows (`ls`, `cat file | head`, `git status`) don't pester
91
+ * the user.
92
+ *
93
+ * Strict criteria: no side effects, no network, no filesystem writes.
94
+ */
95
+ const READ_ONLY_COMMANDS = new Set([
96
+ "ls",
97
+ "cat",
98
+ "head",
99
+ "tail",
100
+ "grep",
101
+ "egrep",
102
+ "fgrep",
103
+ "find",
104
+ "wc",
105
+ "diff",
106
+ "stat",
107
+ "du",
108
+ "df",
109
+ "pwd",
110
+ "echo",
111
+ "printf",
112
+ "whoami",
113
+ "which",
114
+ "type",
115
+ "file",
116
+ "basename",
117
+ "dirname",
118
+ "realpath",
119
+ "readlink",
120
+ "date",
121
+ "true",
122
+ "false",
123
+ "sort",
124
+ "uniq",
125
+ "cut",
126
+ "tr",
127
+ "sed", // sed without -i is read-only (checked below)
128
+ "awk",
129
+ "column",
130
+ "tee", // tee IS a write, handled below
131
+ "tree",
132
+ "jq",
133
+ "yq",
134
+ "xxd",
135
+ "od",
136
+ "md5sum",
137
+ "sha1sum",
138
+ "sha256sum",
139
+ "env", // reading env; `export` / `env X=Y cmd` is not here
140
+ ]);
141
+ // Git subcommands that don't mutate the repo or working tree.
142
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
143
+ "status",
144
+ "log",
145
+ "show",
146
+ "diff",
147
+ "blame",
148
+ "branch",
149
+ "tag",
150
+ "describe",
151
+ "rev-parse",
152
+ "rev-list",
153
+ "ls-files",
154
+ "ls-tree",
155
+ "cat-file",
156
+ "config",
157
+ "remote",
158
+ "reflog",
159
+ "stash",
160
+ "for-each-ref",
161
+ "shortlog",
162
+ "grep",
163
+ "bisect",
164
+ "worktree",
165
+ ]);
166
+ /**
167
+ * Return true iff every sub-command in the pipeline/chain is a read-only
168
+ * operation. Any side-effecting sub-command disqualifies the whole command.
169
+ * Respects quotes and command substitution via the existing splitCommands/tokenize.
170
+ */
171
+ export function isReadOnlyBashCommand(command) {
172
+ const trimmed = command.trim();
173
+ if (!trimmed)
174
+ return false;
175
+ // Refuse any redirection that creates/overwrites files ( > >> | tee -a ... ).
176
+ // Append is still a write. `2>&1` alone is fine, but `> file` is not.
177
+ if (/(?<![<&])>+\s*[^&]/.test(trimmed))
178
+ return false;
179
+ const subCommands = splitCommands(trimmed);
180
+ if (subCommands.length === 0)
181
+ return false;
182
+ for (const sub of subCommands) {
183
+ const tokens = tokenize(sub);
184
+ if (tokens.length === 0)
185
+ continue;
186
+ const cmd = tokens[0];
187
+ const args = tokens.slice(1);
188
+ // `git <subcmd>` with read-only subcommand
189
+ if (cmd === "git") {
190
+ const sub = args.find((a) => !a.startsWith("-"));
191
+ if (!sub || !READ_ONLY_GIT_SUBCOMMANDS.has(sub))
192
+ return false;
193
+ // `git stash push`, `git stash pop`, `git stash drop` are writes — refuse.
194
+ if (sub === "stash") {
195
+ const action = args[args.indexOf("stash") + 1];
196
+ if (action && ["push", "pop", "drop", "apply", "clear", "save"].includes(action))
197
+ return false;
198
+ }
199
+ // `git branch -d`, `git branch -D` delete branches — refuse.
200
+ if (sub === "branch" && args.some((a) => a === "-d" || a === "-D"))
201
+ return false;
202
+ // `git config --global foo=bar` writes config — only read forms are safe.
203
+ if (sub === "config" &&
204
+ args.some((a) => !a.startsWith("-") && args.indexOf(a) > args.indexOf("config") && a.includes("="))) {
205
+ return false;
206
+ }
207
+ continue;
208
+ }
209
+ // `sed -i` is in-place edit — writes.
210
+ if (cmd === "sed" && args.some((a) => a === "-i" || a.startsWith("-i")))
211
+ return false;
212
+ // `tee` without `-a` is a write; `tee -a` is also a write. Refuse always.
213
+ if (cmd === "tee")
214
+ return false;
215
+ if (!READ_ONLY_COMMANDS.has(cmd))
216
+ return false;
217
+ }
218
+ return true;
219
+ }
42
220
  /**
43
221
  * Analyze a bash command string for safety risks.
44
222
  * Does lightweight structural parsing — splits on pipes, semicolons,
@@ -149,7 +327,7 @@ export function analyzeBashCommand(command) {
149
327
  * Split a command string into sub-commands on |, ;, &&, ||
150
328
  * Respects quoted strings and command substitutions.
151
329
  */
152
- function splitCommands(cmd) {
330
+ export function splitCommands(cmd) {
153
331
  const parts = [];
154
332
  let current = "";
155
333
  let inSingle = false;
@@ -4,7 +4,11 @@
4
4
  */
5
5
  /**
6
6
  * Filter process.env to remove credential-containing variables.
7
- * Merges optional extra env vars on top.
7
+ *
8
+ * Precedence order (later wins):
9
+ * 1. process.env (filtered)
10
+ * 2. .oh/config.yaml `env:` block (Claude Code parity — inject API keys etc.)
11
+ * 3. `extra` argument (call-site overrides — e.g. per-MCP-server env)
8
12
  */
9
13
  export declare function safeEnv(extra?: Record<string, string>): Record<string, string>;
10
14
  //# sourceMappingURL=safe-env.d.ts.map
@@ -2,6 +2,7 @@
2
2
  * Safe environment variable filtering.
3
3
  * Blocks credential-containing vars from being passed to subprocesses.
4
4
  */
5
+ import { readOhConfig } from "../harness/config.js";
5
6
  /** Env var names that should never be passed to subprocesses */
6
7
  const BLOCKED_PATTERNS = [
7
8
  /^ANTHROPIC_API_KEY$/i,
@@ -21,7 +22,11 @@ const BLOCKED_PATTERNS = [
21
22
  ];
22
23
  /**
23
24
  * Filter process.env to remove credential-containing variables.
24
- * Merges optional extra env vars on top.
25
+ *
26
+ * Precedence order (later wins):
27
+ * 1. process.env (filtered)
28
+ * 2. .oh/config.yaml `env:` block (Claude Code parity — inject API keys etc.)
29
+ * 3. `extra` argument (call-site overrides — e.g. per-MCP-server env)
25
30
  */
26
31
  export function safeEnv(extra) {
27
32
  const env = {};
@@ -32,6 +37,19 @@ export function safeEnv(extra) {
32
37
  continue;
33
38
  env[key] = value;
34
39
  }
40
+ // Layer in config-declared env vars if available.
41
+ try {
42
+ const cfg = readOhConfig();
43
+ if (cfg?.env) {
44
+ for (const [k, v] of Object.entries(cfg.env)) {
45
+ if (typeof v === "string")
46
+ env[k] = v;
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ /* config unavailable — fall through */
52
+ }
35
53
  if (extra) {
36
54
  Object.assign(env, extra);
37
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,8 @@
17
17
  "dist/**/*.d.ts",
18
18
  "!dist/**/*.test.*",
19
19
  "!dist/**/test-helpers.*",
20
+ "data/skills/**/*.md",
21
+ "data/registry.json",
20
22
  "README.md",
21
23
  "LICENSE"
22
24
  ],