@fusengine/harness 0.1.88 → 0.1.90

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.
@@ -1,7 +1,7 @@
1
1
  import { t as evaluate } from "../../evaluate-C1VSI5pb.mjs";
2
2
  import { f as countLines } from "../../home-state-oUGFB4ds.mjs";
3
3
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
4
- import { n as canonicalizeCodexShellTool, r as parseApplyPatch, t as isBypassPermissions } from "../../permission-mode-B0MUl7FT.mjs";
4
+ import { i as parseApplyPatch, n as canonicalizeMcpToolName, r as canonicalizeCodexShellTool, t as isBypassPermissions } from "../../permission-mode-B-SFyR0X.mjs";
5
5
  import { _ as commandToString, c as readClaudeInput, i as denyResponse, r as contextResponse, s as informResponse } from "../../claude-5bC8TDAz.mjs";
6
6
  //#region src/adapters/codex/index.ts
7
7
  /**
@@ -51,7 +51,7 @@ function applyPatchPrompt(command) {
51
51
  function resolvePrompt(input) {
52
52
  const i = input.tool_input;
53
53
  const r = evaluate({
54
- tool: canonicalizeCodexShellTool("codex", input.tool_name ?? "Write"),
54
+ tool: canonicalizeCodexShellTool("codex", canonicalizeMcpToolName("codex", input.tool_name ?? "Write")),
55
55
  filePath: i?.file_path,
56
56
  content: i?.content ?? i?.new_string,
57
57
  command: commandToString(i?.command),
package/dist/cli/bin.mjs CHANGED
@@ -3,9 +3,9 @@ import { o as resolveStdinMaxBytes } from "../limits-DG5pl-5s.mjs";
3
3
  import { c as resolveTtlSec, r as loadDotenv } from "../dotenv-BLBkBTww.mjs";
4
4
  import { t as detectHarness } from "../harness-BMuLJ9lm.mjs";
5
5
  import { t as claudeHome } from "../home-state-oUGFB4ds.mjs";
6
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DdWG0Meg.mjs";
6
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-CdIlUtjv.mjs";
7
7
  import { n as writeInitFile, t as initFor } from "../run-TAXNRSpD.mjs";
8
- import { $ as runningVersion, Q as runDoctor, Z as notify, et as versionBanner, i as respond, t as handleHook, zt as todayUtc } from "../handle-DHhukRdh.mjs";
8
+ import { $ as runningVersion, Q as runDoctor, Z as notify, et as versionBanner, i as respond, t as handleHook, zt as todayUtc } from "../handle-CBaOcFZq.mjs";
9
9
  import { delimiter, join } from "node:path";
10
10
  import { existsSync, mkdirSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
@@ -1,7 +1,12 @@
1
1
  //#region src/cli/run.d.ts
2
2
  /** Staged files (Added/Copied/Modified/Renamed). Uses `node:child_process` (Bun shell can hang on `git show`). */
3
3
  declare function stagedFiles(): string[];
4
- /** Read a file's staged (index) content — not the working-tree version. */
4
+ /**
5
+ * Read a file's staged (index) content — not the working-tree version.
6
+ * Uses `execFileSync` with an argv array (never a shell), so a staged
7
+ * filename containing shell metacharacters (backticks, `$(...)`, quotes)
8
+ * reaches `git show` as a single literal argument — see issue #87.
9
+ */
5
10
  declare function stagedContent(path: string): string;
6
11
  /**
7
12
  * Evaluate staged code files against the policy core; return violation blocks.
@@ -1,2 +1,2 @@
1
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DdWG0Meg.mjs";
1
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-CdIlUtjv.mjs";
2
2
  export { checkStaged, stagedContent, stagedFiles };
@@ -14,7 +14,7 @@ import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as se
14
14
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
15
15
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
16
16
  import { C as apexAuthorizationGate, S as trivialCount, _ as recordBrainstormRequired, a as journalLogPath, b as recordTarget, c as appendEvent, d as signTrack, f as verifyTrack, g as recordAgent, h as emptyTrack, i as withTrack, l as withTrackLockSync, m as agentsFresh, o as readTrackSync, p as writeLastNonce, r as trackJournalEnabled, s as diffTrackEvents, t as loadTrack, u as LOCK_FAILED, v as recordDoc, x as recordTrivialEdit, y as recordRefRead } from "./store-5-ZPKb0u.mjs";
17
- import { n as canonicalizeCodexShellTool, r as parseApplyPatch, t as isBypassPermissions } from "./permission-mode-B0MUl7FT.mjs";
17
+ import { i as parseApplyPatch, n as canonicalizeMcpToolName, r as canonicalizeCodexShellTool, t as isBypassPermissions } from "./permission-mode-B-SFyR0X.mjs";
18
18
  import { _ as commandToString, d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText$1, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-5bC8TDAz.mjs";
19
19
  import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-D6X7b8wF.mjs";
20
20
  import { r as toHermesResponse } from "./hermes-kP6NUQlG.mjs";
@@ -90,7 +90,7 @@ function normalizeEvent(id, payload) {
90
90
  }
91
91
  const event = str(payload.hook_event_name) ?? "";
92
92
  const input = payload.tool_input ?? payload;
93
- const tool = canonicalizeCodexShellTool(id, str(payload.tool_name) ?? "");
93
+ const tool = canonicalizeCodexShellTool(id, canonicalizeMcpToolName(id, str(payload.tool_name) ?? ""));
94
94
  const base = {
95
95
  phase: /post|after/i.test(event) ? "post" : "pre",
96
96
  tool,
@@ -5326,8 +5326,13 @@ const RETRY_DELAYS = [
5326
5326
  1e3,
5327
5327
  2e3
5328
5328
  ];
5329
- /** Extract the longest assistant synthesis + queried library ids from a transcript. */
5330
- async function extractSynthesis(path) {
5329
+ /**
5330
+ * Extract the longest assistant synthesis + queried library ids from a transcript.
5331
+ * @param id - Harness adapter id, used to canonicalize `block.name` (Codex-mangled
5332
+ * underscore tool names) before the {@link TOOL_PATTERN} match — see
5333
+ * {@link cacheDocFromTranscript} for why this is a defensive, unconfirmed-case cover.
5334
+ */
5335
+ async function extractSynthesis(path, id) {
5331
5336
  const lines = readText$1(path).split("\n").filter(Boolean);
5332
5337
  const libraries = [];
5333
5338
  let synthesis = "";
@@ -5337,7 +5342,7 @@ async function extractSynthesis(path) {
5337
5342
  const contents = entry?.message?.content;
5338
5343
  if (!Array.isArray(contents)) continue;
5339
5344
  for (const block of contents) {
5340
- if (block.type === "tool_use" && TOOL_PATTERN.test(block.name ?? "")) {
5345
+ if (block.type === "tool_use" && TOOL_PATTERN.test(canonicalizeMcpToolName(id, block.name ?? ""))) {
5341
5346
  const lib = block.input?.libraryId ?? block.input?.query ?? "";
5342
5347
  if (lib && !libraries.includes(lib)) libraries.push(lib);
5343
5348
  }
@@ -5354,17 +5359,28 @@ async function extractSynthesis(path) {
5354
5359
  * @param transcript - Path to the agent JSONL transcript.
5355
5360
  * @param cwd - Fallback project root.
5356
5361
  * @param home - Home dir (defaults to `~`).
5357
- */
5358
- async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
5362
+ * @param id - Harness adapter id (defaults to "claude-code"). On `"codex"`,
5363
+ * every `tool_use.name` read from the transcript is canonicalized before the
5364
+ * {@link TOOL_PATTERN} match, defensively covering the SAME Codex-mangled
5365
+ * underscore form as `doc-cache-gate.ts`'s `docCacheGate`. UNLIKE that gate,
5366
+ * this is NOT a confirmed-reproduced bug: the proof that Codex emits the
5367
+ * underscore form (`sanitize_responses_api_tool_name()`, openai/codex#14605,
5368
+ * `SCENARIO_DASH_TOOL` in #18385) is about the live PreToolUse HOOK payload,
5369
+ * never observed here against transcript `tool_use.name` content. The
5370
+ * canonicalization is a no-op when the transcript already carries the dash
5371
+ * form (idempotence proven in `test/mcp-tool-name.test.ts`, test 2), so this
5372
+ * costs nothing if the hypothesis turns out false.
5373
+ */
5374
+ async function cacheDocFromTranscript(transcript, cwd, home = homedir(), id = "claude-code") {
5359
5375
  if (!transcript || !pathExists(transcript)) return;
5360
5376
  const projPath = projectRootFromPaths(await transcriptFilePaths(transcript)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
5361
5377
  const cacheDir = cacheDirFor("doc", projPath, home);
5362
5378
  const docsDir = join(cacheDir, "docs");
5363
- let result = await extractSynthesis(transcript);
5379
+ let result = await extractSynthesis(transcript, id);
5364
5380
  for (const delay of RETRY_DELAYS) {
5365
5381
  if (result.text.length >= MIN_TEXT_SIZE && result.libraries.length > 0) break;
5366
5382
  await sleep(delay);
5367
- result = await extractSynthesis(transcript);
5383
+ result = await extractSynthesis(transcript, id);
5368
5384
  }
5369
5385
  const { text, libraries } = result;
5370
5386
  if (text.length < MIN_TEXT_SIZE || libraries.length === 0) return;
@@ -5762,10 +5778,14 @@ function libraryOf(payload) {
5762
5778
  * @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
5763
5779
  * @param now - Clock (defaults to `Date.now()`).
5764
5780
  * @param home - Home dir (defaults to `~`).
5781
+ * @param id - Harness adapter id (defaults to "claude-code"); on `"codex"`,
5782
+ * `payload.tool_name` is canonicalized before the {@link GATED_TOOLS} match
5783
+ * so the Codex-mangled underscore form (`mcp__context7__query_docs`) gates
5784
+ * exactly like the dash form does on every other harness.
5765
5785
  * @returns A native deny response, or `null` to fall through to the live call.
5766
5786
  */
5767
- async function docCacheGate(payload, cwd, now = Date.now(), home = homedir()) {
5768
- const tool = String(payload.tool_name ?? "");
5787
+ async function docCacheGate(payload, cwd, now = Date.now(), home = homedir(), id = "claude-code") {
5788
+ const tool = canonicalizeMcpToolName(id, String(payload.tool_name ?? ""));
5769
5789
  if (!GATED_TOOLS.test(tool)) return null;
5770
5790
  const library = libraryOf(payload);
5771
5791
  if (!library) return null;
@@ -5981,11 +6001,11 @@ async function onSubagentStart(payload, cwd, now, home, id) {
5981
6001
  const agent = agentTypeOf(payload);
5982
6002
  return combineContext(await injectApexSubagentContext(cwd, home, id), await injectLessonsCache(cwd, home, now), await typeSpecificCache(agent, cwd, now, home));
5983
6003
  }
5984
- /** SubagentStop routing: transcript-driven cache writers, then the universal SOLID check. */
5985
- async function onSubagentStop(payload, cwd, home) {
6004
+ /** SubagentStop routing: transcript-driven cache writers, then the universal SOLID check. `id` selects the harness target (defaults to "claude-code"), threaded into `cacheDocFromTranscript`. */
6005
+ async function onSubagentStop(payload, cwd, home, id) {
5986
6006
  const agent = agentTypeOf(payload);
5987
6007
  const transcript = transcriptOf(payload);
5988
- if (agent.includes("research-expert")) await cacheDocFromTranscript(transcript, cwd, home);
6008
+ if (agent.includes("research-expert")) await cacheDocFromTranscript(transcript, cwd, home, id);
5989
6009
  if (agent.includes("sniper")) {
5990
6010
  await cacheSniperLessons(transcript, cwd, home);
5991
6011
  await cacheTestResults(transcript, cwd, home);
@@ -5999,12 +6019,12 @@ async function onSubagentStop(payload, cwd, home) {
5999
6019
  */
6000
6020
  async function dispatchAipilot(event, payload, cwd, now, home = homedir(), id = "claude-code") {
6001
6021
  if (event === "SubagentStart") return onSubagentStart(payload, cwd, now, home, id);
6002
- if (event === "SubagentStop") return onSubagentStop(payload, cwd, home);
6022
+ if (event === "SubagentStop") return onSubagentStop(payload, cwd, home, id);
6003
6023
  if (event === "SessionEnd" || event === "Stop") {
6004
6024
  await cacheAnalyticsSave(home, now);
6005
6025
  return "";
6006
6026
  }
6007
- if (event === "PreToolUse") return docCacheGate(payload, cwd, now, home);
6027
+ if (event === "PreToolUse") return docCacheGate(payload, cwd, now, home, id);
6008
6028
  return null;
6009
6029
  }
6010
6030
  /** PostToolUse (Write/Edit SOLID check, else TaskCreate/TaskUpdate sync) for the ai-pilot scope; `id` selects the harness target (defaults to "claude-code"). */
@@ -0,0 +1,226 @@
1
+ //#region src/adapters/codex/apply-patch.ts
2
+ const BEGIN = "*** Begin Patch";
3
+ const END = "*** End Patch";
4
+ const ADD = "*** Add File: ";
5
+ const DEL = "*** Delete File: ";
6
+ const UPD = "*** Update File: ";
7
+ const MOVE = "*** Move to: ";
8
+ const EOF = "*** End of File";
9
+ const ENV = "*** Environment ID: ";
10
+ /**
11
+ * Parse a Codex freeform patch into its per-file changes. Lenient on whitespace
12
+ * around structural markers (as Codex's own parser is); returns `[]` when no
13
+ * recognizable hunk is present (malformed input fails open, not closed).
14
+ * @param text - Raw patch body from `tool_input.command`.
15
+ * @returns One {@link PatchedFile} per Add/Update/Delete hunk, in order.
16
+ */
17
+ function parseApplyPatch(text) {
18
+ const files = [];
19
+ let cur = null;
20
+ let buf = [];
21
+ const flush = () => {
22
+ if (cur) {
23
+ cur.content = buf.join("\n");
24
+ files.push(cur);
25
+ }
26
+ cur = null;
27
+ buf = [];
28
+ };
29
+ for (const line of text.split("\n")) {
30
+ const marker = line.trimStart();
31
+ if (marker === BEGIN || marker.startsWith(ENV)) continue;
32
+ if (marker === END) {
33
+ flush();
34
+ continue;
35
+ }
36
+ if (marker.startsWith(ADD)) {
37
+ flush();
38
+ cur = {
39
+ path: marker.slice(14).trim(),
40
+ content: "",
41
+ op: "add"
42
+ };
43
+ continue;
44
+ }
45
+ if (marker.startsWith(DEL)) {
46
+ flush();
47
+ files.push({
48
+ path: marker.slice(17).trim(),
49
+ content: "",
50
+ op: "delete"
51
+ });
52
+ continue;
53
+ }
54
+ if (marker.startsWith(UPD)) {
55
+ flush();
56
+ cur = {
57
+ path: marker.slice(17).trim(),
58
+ content: "",
59
+ op: "update"
60
+ };
61
+ continue;
62
+ }
63
+ if (marker.startsWith(MOVE)) {
64
+ if (cur) cur.path = marker.slice(13).trim();
65
+ continue;
66
+ }
67
+ if (marker === EOF || !cur) continue;
68
+ if (cur.op === "add") {
69
+ if (line.startsWith("+")) buf.push(line.slice(1));
70
+ continue;
71
+ }
72
+ if (line.startsWith("@@")) continue;
73
+ if (line.startsWith("+")) buf.push(line.slice(1));
74
+ else if (line.startsWith("-")) continue;
75
+ else buf.push(line.startsWith(" ") ? line.slice(1) : line);
76
+ }
77
+ flush();
78
+ return files;
79
+ }
80
+ //#endregion
81
+ //#region src/runtime/codex-shell-tool.ts
82
+ /**
83
+ * @module codex-shell-tool
84
+ * Canonicalizes a Codex-only shell `tool_name` alias to `"Bash"`, so every
85
+ * existing `tool === "Bash"` consumer (activity credit, explore-tools
86
+ * classification, the protected-path guard, the design corpus read gate, …)
87
+ * treats a Code Mode exec dispatch exactly like a native Bash call — no
88
+ * per-consumer change needed.
89
+ *
90
+ * A Code Mode-wrapped call (`functions.exec` -> `tools.exec_command(...)`)
91
+ * surfaces via the generic `CoreToolRuntime` hook default (openai/codex#23757,
92
+ * commit 5c20513), which keeps the raw function-tool name `"exec_command"`
93
+ * verbatim — unlike a DIRECT `exec_command` dispatch, which
94
+ * `ExecCommandHandler`'s own `pre_tool_use_payload` override re-labels
95
+ * `"Bash"` (`tool_name: HookToolName::bash()`). This is the exact call chain
96
+ * a real Codex CLI 0.146.0 session was observed emitting for a `sed -n` skill
97
+ * read. Only ONE alias is confirmed here — `ToolName::plain("exec_command")`
98
+ * in `core/src/tools/handlers/unified_exec/exec_command.rs`; `shell`,
99
+ * `unified_exec`, `local_shell`, and `shell_command` were audited but not
100
+ * evidenced as a distinct hook `tool_name` value, so they are deliberately
101
+ * left out rather than guessed.
102
+ * @packageDocumentation
103
+ */
104
+ /** Codex `tool_name` values that are a shell execution but not literally `"Bash"`. */
105
+ const CODEX_SHELL_TOOL_ALIASES = /* @__PURE__ */ new Set(["exec_command"]);
106
+ /**
107
+ * Canonicalize a Codex-only shell tool alias to `"Bash"`. Scoped to
108
+ * `id === "codex"` — every other harness id, and every non-aliased tool
109
+ * name, passes through unchanged (see `test/codex-shell-read-credit.test.ts`
110
+ * for the non-regression proof).
111
+ * @param id - Harness adapter id (e.g. "codex", "claude-code", "kimi").
112
+ * @param tool - Raw `tool_name` from the hook payload.
113
+ */
114
+ function canonicalizeCodexShellTool(id, tool) {
115
+ return id === "codex" && CODEX_SHELL_TOOL_ALIASES.has(tool) ? "Bash" : tool;
116
+ }
117
+ //#endregion
118
+ //#region src/runtime/mcp-tool-name.ts
119
+ /**
120
+ * @module mcp-tool-name
121
+ * Canonicalizes a Codex-only MCP `tool_name` alias back to its original
122
+ * hyphenated form, so every existing dash-keyed consumer (`SHOT_TOOLS`,
123
+ * `RESEARCH_TOOLS`/`classifyExplore`, `docSourceOf`, the design-pipeline
124
+ * `NAV`/`SCROLL`/`GEMINI` constants, …) recognizes a Codex MCP call exactly
125
+ * like the same call on Claude/Kimi/Cursor/Cline/Gemini — no per-consumer
126
+ * change needed.
127
+ *
128
+ * Codex CLI globally rewrites every `-` to `_` across the FULL qualified MCP
129
+ * tool identifier before it ever reaches a hook, in `sanitize_responses_api_tool_name()`
130
+ * (`codex-rs/core/src/mcp_connection_manager.rs`, introduced by
131
+ * openai/codex#14605 — "Normalize MCP tool names to code-mode safe form",
132
+ * code-mode forbids `-` in identifiers). openai/codex#18385's own
133
+ * `SCENARIO_DASH_TOOL` test proves the REWRITTEN form is what reaches hooks:
134
+ * the `echo-tool` tool of server `rmcp` is delivered to hooks as
135
+ * `mcp__rmcp__echo_tool`. Confirmed independently in THIS repo's terrain:
136
+ * `~/.codex/config.toml` declares `[mcp_servers.fuse-browser]` (dash) while
137
+ * Codex session logs and this harness's own dual-marketplace hook payloads
138
+ * (`~/.codex/plugins/.../hooks.json` matchers) observe `mcp__fuse_browser__*`
139
+ * (underscore) — Claude never emits that form (0 occurrences across its
140
+ * transcripts vs 137x the dash form).
141
+ *
142
+ * The rewrite is deliberately NOT undone with a generic `_` -> `-` regex:
143
+ * openai/codex#15832 and #15565 document it as NON-INVERSIBLE — a server
144
+ * literally named `autok_local` collides with a dash-named `autok-local`
145
+ * once both pass through the same rewrite, and OpenAI's own fix was a closed
146
+ * canonical table, never a general regex. The SAME ambiguity exists on our
147
+ * side: a generic reversal would corrupt tool segments that legitimately
148
+ * contain an underscore already (`browser_screenshot`, `node_repl`,
149
+ * `build_sim`, …). So this module only reverses the FOUR entries proven
150
+ * above to matter to this repo's gates — every other segment passes through
151
+ * untouched.
152
+ * @packageDocumentation
153
+ */
154
+ /**
155
+ * Codex-mangled MCP server segment -> its real (dash) server name. Closed set
156
+ * — see module doc. `Object.create(null)` (never a `{}` literal): `serverKey`
157
+ * is an attacker-influenced string (an MCP server can register itself as
158
+ * `"__proto__"`/`"constructor"`/`"toString"`), and a `{}`-literal lookup by
159
+ * such a key returns the INHERITED `Object.prototype` member instead of
160
+ * `undefined` — an object/function, so `?? serverKey` never falls back and
161
+ * the result silently corrupts to `"mcp__[object Object]__…"` (proven in
162
+ * `test/mcp-tool-name.test.ts`, prototype-pollution-read case). A null-
163
+ * prototype object has no inherited members, so every lookup outside the two
164
+ * literal keys below is a clean miss.
165
+ */
166
+ const SERVER_ALIASES = Object.assign(Object.create(null), {
167
+ fuse_browser: "fuse-browser",
168
+ gemini_design: "gemini-design"
169
+ });
170
+ /**
171
+ * Codex-mangled MCP tool segment -> its real (dash) tool name. Both entries
172
+ * are `context7`-exclusive tool names — applied ONLY when the canonical
173
+ * server is `"context7"` (see the explicit guard in
174
+ * {@link canonicalizeMcpToolName}), never globally. A same-named tool on any
175
+ * other server (e.g. a hypothetical `exa__query_docs`) passes through
176
+ * untouched: openai/codex#15832 documents exactly this class of collision as
177
+ * the reason the rewrite must stay a closed, scoped table, never a blanket
178
+ * one. Closed set, same null-prototype rationale as {@link SERVER_ALIASES}.
179
+ */
180
+ const TOOL_ALIASES = Object.assign(Object.create(null), {
181
+ query_docs: "query-docs",
182
+ resolve_library_id: "resolve-library-id"
183
+ });
184
+ /**
185
+ * Canonicalize a Codex-only MCP tool-name alias to its dash form. Scoped to
186
+ * `id === "codex"` — every other harness id passes through unchanged. The
187
+ * server segment is rewritten via the closed {@link SERVER_ALIASES} table;
188
+ * the tool segment is rewritten via {@link TOOL_ALIASES} ONLY when the
189
+ * (already-resolved) canonical server is `"context7"` — every other server's
190
+ * tool segment is left untouched even if it happens to share a name with a
191
+ * context7 tool. Every other `mcp__<server>__<tool>` segment (or non-MCP
192
+ * tool name) is returned byte-identical (see `test/mcp-tool-name.test.ts`
193
+ * for the non-regression proof across ids, trap tool names, and the
194
+ * server-scoping cases).
195
+ * @param id - Harness adapter id (e.g. "codex", "claude-code", "kimi").
196
+ * @param tool - Raw `tool_name` from the hook payload.
197
+ */
198
+ function canonicalizeMcpToolName(id, tool) {
199
+ if (id !== "codex") return tool;
200
+ const parts = tool.split("__");
201
+ if (parts.length < 3 || parts[0] !== "mcp") return tool;
202
+ const serverKey = parts[1] ?? "";
203
+ const toolKey = parts.slice(2).join("__");
204
+ const server = SERVER_ALIASES[serverKey] ?? serverKey;
205
+ return `mcp__${server}__${server === "context7" ? TOOL_ALIASES[toolKey] ?? toolKey : toolKey}`;
206
+ }
207
+ //#endregion
208
+ //#region src/adapters/codex/permission-mode.ts
209
+ /**
210
+ * Codex maps `AskForApproval::Never` -> `permission_mode: "bypassPermissions"`
211
+ * in its hook payload (codex-rs/core/src/hook_runtime.rs::hook_permission_mode)
212
+ * — the only value that isolates `never`; the other three approval policies
213
+ * (`untrusted`, `on-failure`, `on-request`) all collapse to `"default"`.
214
+ * Verified at openai/codex@342e4d4b, hook_runtime.rs run_pre_tool_use_hooks +
215
+ * pre_tool_use.rs PreToolUseRequest. Undocumented implementation detail, not
216
+ * a public contract — see the mapping-regression test that pins this string.
217
+ * @param permissionMode - The payload's `permission_mode` field, if present.
218
+ * @returns True only on an EXACT match — fail-closed. A near-miss
219
+ * (`"BypassPermissions"`, `"bypassPermissions "`, or a substring match) must
220
+ * never accidentally grant the neverApproval exemption.
221
+ */
222
+ function isBypassPermissions(permissionMode) {
223
+ return permissionMode === "bypassPermissions";
224
+ }
225
+ //#endregion
226
+ export { parseApplyPatch as i, canonicalizeMcpToolName as n, canonicalizeCodexShellTool as r, isBypassPermissions as t };
@@ -1,15 +1,25 @@
1
1
  import { t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
2
2
  import { t as evaluate } from "./evaluate-C1VSI5pb.mjs";
3
3
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
4
- import { execSync } from "node:child_process";
4
+ import { execFileSync } from "node:child_process";
5
5
  //#region src/cli/run.ts
6
6
  /** Staged files (Added/Copied/Modified/Renamed). Uses `node:child_process` (Bun shell can hang on `git show`). */
7
7
  function stagedFiles() {
8
- return execSync("git diff --cached --name-only --diff-filter=ACMR", { encoding: "utf8" }).trim().split("\n").filter(Boolean);
8
+ return execFileSync("git", [
9
+ "diff",
10
+ "--cached",
11
+ "--name-only",
12
+ "--diff-filter=ACMR"
13
+ ], { encoding: "utf8" }).trim().split("\n").filter(Boolean);
9
14
  }
10
- /** Read a file's staged (index) content — not the working-tree version. */
15
+ /**
16
+ * Read a file's staged (index) content — not the working-tree version.
17
+ * Uses `execFileSync` with an argv array (never a shell), so a staged
18
+ * filename containing shell metacharacters (backticks, `$(...)`, quotes)
19
+ * reaches `git show` as a single literal argument — see issue #87.
20
+ */
11
21
  function stagedContent(path) {
12
- return execSync(`git show ":${path}"`, { encoding: "utf8" });
22
+ return execFileSync("git", ["show", `:${path}`], { encoding: "utf8" });
13
23
  }
14
24
  /**
15
25
  * Evaluate staged code files against the policy core; return violation blocks.
@@ -1,6 +1,6 @@
1
1
  import { r as projectLayout } from "../layout-KWoE_Mqn.mjs";
2
2
  import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-oUGFB4ds.mjs";
3
- import { A as trackMcpResearch, At as defaultStateDir, B as generateProjectMap, C as seoPostToolUse, Ct as trimLogFile, D as securityAdvisoryForPatch, Dt as claudeMdKey, E as securityAdvisory, Et as projectContext, F as dispatchAipilot, Ft as loadSecurityState, G as countFiles, H as writeTree, I as dispatchLessons, It as saveSecurityState, J as lessonsArchiveFileFor, K as getFileDesc, L as cartoSessionStart, Lt as securityStateDir, M as trackEnrichment, Mt as trackFile, N as dispatchLifecycle, Nt as normalizeEvent, O as postTrackingSideEffects, Ot as promptSubmitContext, P as aipilotPostToolUse, Pt as isoUtc, R as generateEcosystemMap, Rt as securityStatePath, S as postEditContext, St as removeOldFiles, T as dispatchMemory, Tt as gitContext, U as loadEnriched, V as isProject, W as mergeLines, X as lessonsStateFileFor, Y as lessonsFileFor, _ as preCommitGate, _t as readRules, a as recordActivity, at as saveApexState, b as extractSymbols, bt as pruneEmptyDirs, c as MCP_TTL_MS, ct as trackAgentMemory, d as isMcpTool, dt as validateSolidGate, f as queryOf, ft as checkFileSize, g as gate, gt as injectRules, h as TRIVIAL_BUDGET, ht as solidDetectStart, i as respond, it as cleanupSession, j as trackSkillRead, jt as projectHash, k as trackWatchResearch, kt as taskContext, l as WEBFETCH_TTL_MS, lt as subagentCacheContext, m as REQUIRED_AGENTS, mt as detectSolidProfile, n as activityFor, nt as trackSessionChanges, o as mcpPostStore, ot as logToolFailure, p as DEFAULT_WINDOW_MS, pt as countLoc, q as listChildren, r as handlePre, rt as validateRulesLoaded, s as mcpPreIntercept, st as validateTeammateOutput, t as handleHook, tt as postEditTypescript, u as cacheQueryOf, ut as validateTailwind, v as detectDuplication, vt as runSessionStartCleanups, w as seoPostToolUseResponse, wt as devContext, x as lifecycleStdout, xt as purgeTtlTree, y as dryGate, yt as sessionStartCore, z as writePluginMap, zt as todayUtc } from "../handle-DHhukRdh.mjs";
3
+ import { A as trackMcpResearch, At as defaultStateDir, B as generateProjectMap, C as seoPostToolUse, Ct as trimLogFile, D as securityAdvisoryForPatch, Dt as claudeMdKey, E as securityAdvisory, Et as projectContext, F as dispatchAipilot, Ft as loadSecurityState, G as countFiles, H as writeTree, I as dispatchLessons, It as saveSecurityState, J as lessonsArchiveFileFor, K as getFileDesc, L as cartoSessionStart, Lt as securityStateDir, M as trackEnrichment, Mt as trackFile, N as dispatchLifecycle, Nt as normalizeEvent, O as postTrackingSideEffects, Ot as promptSubmitContext, P as aipilotPostToolUse, Pt as isoUtc, R as generateEcosystemMap, Rt as securityStatePath, S as postEditContext, St as removeOldFiles, T as dispatchMemory, Tt as gitContext, U as loadEnriched, V as isProject, W as mergeLines, X as lessonsStateFileFor, Y as lessonsFileFor, _ as preCommitGate, _t as readRules, a as recordActivity, at as saveApexState, b as extractSymbols, bt as pruneEmptyDirs, c as MCP_TTL_MS, ct as trackAgentMemory, d as isMcpTool, dt as validateSolidGate, f as queryOf, ft as checkFileSize, g as gate, gt as injectRules, h as TRIVIAL_BUDGET, ht as solidDetectStart, i as respond, it as cleanupSession, j as trackSkillRead, jt as projectHash, k as trackWatchResearch, kt as taskContext, l as WEBFETCH_TTL_MS, lt as subagentCacheContext, m as REQUIRED_AGENTS, mt as detectSolidProfile, n as activityFor, nt as trackSessionChanges, o as mcpPostStore, ot as logToolFailure, p as DEFAULT_WINDOW_MS, pt as countLoc, q as listChildren, r as handlePre, rt as validateRulesLoaded, s as mcpPreIntercept, st as validateTeammateOutput, t as handleHook, tt as postEditTypescript, u as cacheQueryOf, ut as validateTailwind, v as detectDuplication, vt as runSessionStartCleanups, w as seoPostToolUseResponse, wt as devContext, x as lifecycleStdout, xt as purgeTtlTree, y as dryGate, yt as sessionStartCore, z as writePluginMap, zt as todayUtc } from "../handle-CBaOcFZq.mjs";
4
4
  //#region src/runtime/storage.ts
5
5
  /**
6
6
  * The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -1,136 +0,0 @@
1
- //#region src/adapters/codex/apply-patch.ts
2
- const BEGIN = "*** Begin Patch";
3
- const END = "*** End Patch";
4
- const ADD = "*** Add File: ";
5
- const DEL = "*** Delete File: ";
6
- const UPD = "*** Update File: ";
7
- const MOVE = "*** Move to: ";
8
- const EOF = "*** End of File";
9
- const ENV = "*** Environment ID: ";
10
- /**
11
- * Parse a Codex freeform patch into its per-file changes. Lenient on whitespace
12
- * around structural markers (as Codex's own parser is); returns `[]` when no
13
- * recognizable hunk is present (malformed input fails open, not closed).
14
- * @param text - Raw patch body from `tool_input.command`.
15
- * @returns One {@link PatchedFile} per Add/Update/Delete hunk, in order.
16
- */
17
- function parseApplyPatch(text) {
18
- const files = [];
19
- let cur = null;
20
- let buf = [];
21
- const flush = () => {
22
- if (cur) {
23
- cur.content = buf.join("\n");
24
- files.push(cur);
25
- }
26
- cur = null;
27
- buf = [];
28
- };
29
- for (const line of text.split("\n")) {
30
- const marker = line.trimStart();
31
- if (marker === BEGIN || marker.startsWith(ENV)) continue;
32
- if (marker === END) {
33
- flush();
34
- continue;
35
- }
36
- if (marker.startsWith(ADD)) {
37
- flush();
38
- cur = {
39
- path: marker.slice(14).trim(),
40
- content: "",
41
- op: "add"
42
- };
43
- continue;
44
- }
45
- if (marker.startsWith(DEL)) {
46
- flush();
47
- files.push({
48
- path: marker.slice(17).trim(),
49
- content: "",
50
- op: "delete"
51
- });
52
- continue;
53
- }
54
- if (marker.startsWith(UPD)) {
55
- flush();
56
- cur = {
57
- path: marker.slice(17).trim(),
58
- content: "",
59
- op: "update"
60
- };
61
- continue;
62
- }
63
- if (marker.startsWith(MOVE)) {
64
- if (cur) cur.path = marker.slice(13).trim();
65
- continue;
66
- }
67
- if (marker === EOF || !cur) continue;
68
- if (cur.op === "add") {
69
- if (line.startsWith("+")) buf.push(line.slice(1));
70
- continue;
71
- }
72
- if (line.startsWith("@@")) continue;
73
- if (line.startsWith("+")) buf.push(line.slice(1));
74
- else if (line.startsWith("-")) continue;
75
- else buf.push(line.startsWith(" ") ? line.slice(1) : line);
76
- }
77
- flush();
78
- return files;
79
- }
80
- //#endregion
81
- //#region src/runtime/codex-shell-tool.ts
82
- /**
83
- * @module codex-shell-tool
84
- * Canonicalizes a Codex-only shell `tool_name` alias to `"Bash"`, so every
85
- * existing `tool === "Bash"` consumer (activity credit, explore-tools
86
- * classification, the protected-path guard, the design corpus read gate, …)
87
- * treats a Code Mode exec dispatch exactly like a native Bash call — no
88
- * per-consumer change needed.
89
- *
90
- * A Code Mode-wrapped call (`functions.exec` -> `tools.exec_command(...)`)
91
- * surfaces via the generic `CoreToolRuntime` hook default (openai/codex#23757,
92
- * commit 5c20513), which keeps the raw function-tool name `"exec_command"`
93
- * verbatim — unlike a DIRECT `exec_command` dispatch, which
94
- * `ExecCommandHandler`'s own `pre_tool_use_payload` override re-labels
95
- * `"Bash"` (`tool_name: HookToolName::bash()`). This is the exact call chain
96
- * a real Codex CLI 0.146.0 session was observed emitting for a `sed -n` skill
97
- * read. Only ONE alias is confirmed here — `ToolName::plain("exec_command")`
98
- * in `core/src/tools/handlers/unified_exec/exec_command.rs`; `shell`,
99
- * `unified_exec`, `local_shell`, and `shell_command` were audited but not
100
- * evidenced as a distinct hook `tool_name` value, so they are deliberately
101
- * left out rather than guessed.
102
- * @packageDocumentation
103
- */
104
- /** Codex `tool_name` values that are a shell execution but not literally `"Bash"`. */
105
- const CODEX_SHELL_TOOL_ALIASES = /* @__PURE__ */ new Set(["exec_command"]);
106
- /**
107
- * Canonicalize a Codex-only shell tool alias to `"Bash"`. Scoped to
108
- * `id === "codex"` — every other harness id, and every non-aliased tool
109
- * name, passes through unchanged (see `test/codex-shell-read-credit.test.ts`
110
- * for the non-regression proof).
111
- * @param id - Harness adapter id (e.g. "codex", "claude-code", "kimi").
112
- * @param tool - Raw `tool_name` from the hook payload.
113
- */
114
- function canonicalizeCodexShellTool(id, tool) {
115
- return id === "codex" && CODEX_SHELL_TOOL_ALIASES.has(tool) ? "Bash" : tool;
116
- }
117
- //#endregion
118
- //#region src/adapters/codex/permission-mode.ts
119
- /**
120
- * Codex maps `AskForApproval::Never` -> `permission_mode: "bypassPermissions"`
121
- * in its hook payload (codex-rs/core/src/hook_runtime.rs::hook_permission_mode)
122
- * — the only value that isolates `never`; the other three approval policies
123
- * (`untrusted`, `on-failure`, `on-request`) all collapse to `"default"`.
124
- * Verified at openai/codex@342e4d4b, hook_runtime.rs run_pre_tool_use_hooks +
125
- * pre_tool_use.rs PreToolUseRequest. Undocumented implementation detail, not
126
- * a public contract — see the mapping-regression test that pins this string.
127
- * @param permissionMode - The payload's `permission_mode` field, if present.
128
- * @returns True only on an EXACT match — fail-closed. A near-miss
129
- * (`"BypassPermissions"`, `"bypassPermissions "`, or a substring match) must
130
- * never accidentally grant the neverApproval exemption.
131
- */
132
- function isBypassPermissions(permissionMode) {
133
- return permissionMode === "bypassPermissions";
134
- }
135
- //#endregion
136
- export { canonicalizeCodexShellTool as n, parseApplyPatch as r, isBypassPermissions as t };