Skip to content

Commit 29ceaf4

Browse files
authored
fix(model): resolve CLI exec paths for Windows .cmd shims (#250)
* fix(model): resolve CLI exec paths for Windows .cmd shims - Resolve codex/claude/cursor/copilot exec paths via shutil.which at config load so bare npm .cmd shims spawn on Windows (bare 'codex' -> codex.CMD; CreateProcess does not search PATHEXT, so bare names raise WinError 2). - os.symlink uses target_is_directory + copytree/copy2 fallback (OfficeQA on Windows). - Add tests for _resolve_cli_path. * fix(model): fail closed on symlink collisions; redact Copilot JSONL Address maintainer review on #250: - prepare_workspace symlink fallback fails closed: re-raise when the destination already exists (lexists), never copytree into an existing dst, and only fall back for the Windows symlink-privilege-not-held error. Prevents a colliding extra_files or duplicate link_dirs entry from mutating source data outside the work dir. - Sanitize Copilot stdout/stderr structurally (mapping-key aware) so JSON objects carrying a token/secret field are redacted even when the value is a quoted mapping (which the string-level redactor missed). - Add tests: symlink privilege fallback, existing/duplicate dst, non-privilege re-raise, and Copilot JSONL secret-field redaction. * fix(model): key-aware redaction of quoted JSON in copilot/cursor traces - Fold quoted-JSON whole-value capture into the shared _redact_cursor_error (run first) so a quoted value with spaces is scrubbed whole instead of truncating at the first space and leaking the remainder. One change covers the copilot fallback, the cursor stderr paths, and all string-leaf callers. - Regressions: prefixed/multiline quoted JSON, space-in-value, cursor parity. * fix(model): endswith-based secret-key detection in quoted-JSON redaction - The substring regex over-scrubbed diagnostics like token_count / token_budget / secret_version. Decide with the project's endswith-based rule (matching _is_secret_mapping_key) so only real credential keys are redacted, while a quoted value that contains spaces is still captured whole. - Regressions: token-count diagnostic retention + nested secret key. * fix(model): also treat bare "bearer" key as a credential - Add "bearer" to the exact secret-key set so a standalone "bearer": <value> JSON key is scrubbed (the common "Authorization": "Bearer ..." form was already covered via the authorization key). - Regression: bare bearer key redaction. * fix(model): unify copilot redaction key policy + unbounded embedded-JSON parsing - _redact_copilot_json now uses the same endswith-based _is_copilot_secret_key as the embedded-JSON fallback, so camelCase keys like githubToken are redacted. - Replace the single-level regex and line-by-line parsing with a bracket-matched, unbounded-nesting embedded-JSON scan, so deeply nested / pretty-printed JSON in non-JSON text is structurally redacted. - Add run-level regressions (success trace + error detail) for camelCase keys and deep-nested embedded JSON. --------- Co-authored-by: WODE25500 <WODE25500@users.noreply.github.com>
1 parent eb8c1e7 commit 29ceaf4

5 files changed

Lines changed: 513 additions & 14 deletions

File tree

‎skillopt/model/backend_config.py‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import json
55
import os
6+
import shutil
67
import warnings
78
from collections.abc import Mapping
89
from typing import Any
@@ -34,10 +35,23 @@ def _coerce_bool_setting(value: Any, *, name: str) -> bool:
3435
)
3536

3637

38+
def _resolve_cli_path(value: str) -> str:
39+
"""Resolve a CLI name/executable via PATH + PATHEXT.
40+
41+
On Windows these npm CLIs install as ``.cmd`` shims, and CreateProcess does
42+
not search PATHEXT for a bare name (so a bare ``codex`` spawn raises
43+
WinError 2). ``shutil.which`` finds the real executable; fall back to the
44+
given value so a configured path still passes through unchanged when it
45+
cannot be resolved (e.g. a name that is not on this PATH).
46+
"""
47+
resolved = shutil.which(value)
48+
return resolved or value
49+
50+
3751
OPTIMIZER_BACKEND = normalize_backend_name(os.environ.get("OPTIMIZER_BACKEND", "openai_chat"))
3852
TARGET_BACKEND = normalize_backend_name(os.environ.get("TARGET_BACKEND", "openai_chat"))
3953

40-
CODEX_EXEC_PATH = os.environ.get("CODEX_EXEC_PATH") or os.environ.get("CODEX_CLI_BIN") or os.environ.get("CODEX_PATH") or "codex"
54+
CODEX_EXEC_PATH = _resolve_cli_path(os.environ.get("CODEX_EXEC_PATH") or os.environ.get("CODEX_CLI_BIN") or os.environ.get("CODEX_PATH") or "codex")
4155
CODEX_EXEC_SANDBOX = os.environ.get("CODEX_EXEC_SANDBOX") or os.environ.get("CODEX_SANDBOX_MODE") or os.environ.get("CODEX_SANDBOX") or "workspace-write"
4256
CODEX_EXEC_PROFILE = os.environ.get("CODEX_EXEC_PROFILE", "")
4357
_CODEX_EXEC_FULL_AUTO_ENV = os.environ.get("CODEX_EXEC_FULL_AUTO")
@@ -49,13 +63,13 @@ def _coerce_bool_setting(value: Any, *, name: str) -> bool:
4963
CODEX_EXEC_NETWORK_ACCESS = _parse_bool(os.environ.get("CODEX_EXEC_NETWORK_ACCESS"), False)
5064
CODEX_EXEC_WEB_SEARCH = _parse_bool(os.environ.get("CODEX_EXEC_WEB_SEARCH"), False)
5165
CODEX_EXEC_APPROVAL_POLICY = os.environ.get("CODEX_EXEC_APPROVAL_POLICY", "never")
52-
CLAUDE_CODE_EXEC_PATH = os.environ.get("CLAUDE_CODE_EXEC_PATH", "claude")
66+
CLAUDE_CODE_EXEC_PATH = _resolve_cli_path(os.environ.get("CLAUDE_CODE_EXEC_PATH", "claude"))
5367
CLAUDE_CODE_EXEC_PROFILE = os.environ.get("CLAUDE_CODE_EXEC_PROFILE", "")
5468
CLAUDE_CODE_EXEC_USE_SDK = os.environ.get("CLAUDE_CODE_EXEC_USE_SDK", "auto")
5569
CLAUDE_CODE_EXEC_EFFORT = os.environ.get("CLAUDE_CODE_EXEC_EFFORT", "medium")
56-
CURSOR_EXEC_PATH = os.environ.get("CURSOR_EXEC_PATH", "cursor-agent")
70+
CURSOR_EXEC_PATH = _resolve_cli_path(os.environ.get("CURSOR_EXEC_PATH", "cursor-agent"))
5771
CURSOR_EXEC_SANDBOX = os.environ.get("CURSOR_EXEC_SANDBOX", "enabled")
58-
COPILOT_EXEC_PATH = os.environ.get("COPILOT_EXEC_PATH", "copilot")
72+
COPILOT_EXEC_PATH = _resolve_cli_path(os.environ.get("COPILOT_EXEC_PATH", "copilot"))
5973
COPILOT_EXEC_HOME = os.environ.get("COPILOT_EXEC_HOME", "")
6074
COPILOT_EXEC_ALLOW_ALL_TOOLS = (
6175
"1" if _parse_bool(os.environ.get("COPILOT_EXEC_ALLOW_ALL_TOOLS"), False) else "0"
@@ -222,7 +236,7 @@ def configure_codex_exec(
222236
else _coerce_bool_setting(web_search, name="codex_exec_web_search")
223237
)
224238
if path is not None:
225-
CODEX_EXEC_PATH = str(path).strip() or "codex"
239+
CODEX_EXEC_PATH = _resolve_cli_path(str(path).strip() or "codex")
226240
os.environ["CODEX_EXEC_PATH"] = CODEX_EXEC_PATH
227241
os.environ["CODEX_CLI_BIN"] = CODEX_EXEC_PATH
228242
if sandbox is not None:
@@ -361,7 +375,7 @@ def configure_claude_code_exec(
361375
) -> None:
362376
global CLAUDE_CODE_EXEC_PATH, CLAUDE_CODE_EXEC_PROFILE, CLAUDE_CODE_EXEC_USE_SDK, CLAUDE_CODE_EXEC_EFFORT, CLAUDE_CODE_EXEC_MAX_THINKING_TOKENS
363377
if path is not None:
364-
CLAUDE_CODE_EXEC_PATH = str(path).strip() or "claude"
378+
CLAUDE_CODE_EXEC_PATH = _resolve_cli_path(str(path).strip() or "claude")
365379
os.environ["CLAUDE_CODE_EXEC_PATH"] = CLAUDE_CODE_EXEC_PATH
366380
if profile is not None:
367381
CLAUDE_CODE_EXEC_PROFILE = str(profile).strip()
@@ -398,7 +412,7 @@ def configure_cursor_exec(
398412
) -> None:
399413
global CURSOR_EXEC_PATH, CURSOR_EXEC_SANDBOX
400414
if path is not None:
401-
CURSOR_EXEC_PATH = str(path).strip() or "cursor-agent"
415+
CURSOR_EXEC_PATH = _resolve_cli_path(str(path).strip() or "cursor-agent")
402416
os.environ["CURSOR_EXEC_PATH"] = CURSOR_EXEC_PATH
403417
if sandbox is not None:
404418
normalized_sandbox = str(sandbox).strip().lower() or "enabled"
@@ -433,7 +447,7 @@ def configure_copilot_exec(
433447
"""
434448
global COPILOT_EXEC_PATH, COPILOT_EXEC_HOME, COPILOT_EXEC_ALLOW_ALL_TOOLS
435449
if path is not None:
436-
COPILOT_EXEC_PATH = str(path).strip() or "copilot"
450+
COPILOT_EXEC_PATH = _resolve_cli_path(str(path).strip() or "copilot")
437451
os.environ["COPILOT_EXEC_PATH"] = COPILOT_EXEC_PATH
438452
if home is not None:
439453
COPILOT_EXEC_HOME = str(home).strip()
@@ -478,7 +492,7 @@ def configure_copilot_chat(
478492
global COPILOT_EXEC_PATH, COPILOT_EXEC_HOME
479493
global COPILOT_CHAT_OPTIMIZER_MODEL, COPILOT_CHAT_TARGET_MODEL, COPILOT_CHAT_TIMEOUT
480494
if path is not None:
481-
COPILOT_EXEC_PATH = str(path).strip() or "copilot"
495+
COPILOT_EXEC_PATH = _resolve_cli_path(str(path).strip() or "copilot")
482496
os.environ["COPILOT_EXEC_PATH"] = COPILOT_EXEC_PATH
483497
if home is not None:
484498
COPILOT_EXEC_HOME = str(home).strip()

‎skillopt/model/codex_harness.py‎

Lines changed: 184 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import asyncio
5+
import errno
56
import json
67
import os
78
import re
@@ -73,6 +74,25 @@ def render_skill_md(
7374
return "\n".join(chunks)
7475

7576

77+
def _is_symlink_privilege_error(exc: OSError) -> bool:
78+
"""Return True only for the Windows 'symlink privilege not held' case.
79+
80+
We must not mask a real collision/error by silently falling back to a copy;
81+
only the case where the OS refuses to create a symlink because the caller
82+
lacks SeCreateSymbolicLinkPrivilege (Windows Developer Mode / elevation)
83+
should fall back to a copy inside a private work dir.
84+
"""
85+
if getattr(exc, "winerror", None) in (1314,): # ERROR_PRIVILEGE_NOT_HELD
86+
return True
87+
if isinstance(exc, OSError):
88+
return exc.errno in {
89+
getattr(errno, "EPERM", -1),
90+
getattr(errno, "ENOTSUP", -1),
91+
getattr(errno, "EOPNOTSUPP", -1),
92+
}
93+
return False
94+
95+
7696
def prepare_workspace(
7797
*,
7898
work_dir: str,
@@ -120,7 +140,22 @@ def prepare_workspace(
120140
parent = os.path.dirname(dst)
121141
if parent:
122142
os.makedirs(parent, exist_ok=True)
123-
os.symlink(os.path.abspath(src), dst)
143+
src_abs = os.path.abspath(src)
144+
if os.path.lexists(dst):
145+
raise FileExistsError(
146+
f"link destination already exists: {dst} (from {src})"
147+
)
148+
try:
149+
os.symlink(src_abs, dst, target_is_directory=os.path.isdir(src_abs))
150+
except OSError as exc:
151+
# Fail closed: only fall back for the Windows symlink-privilege
152+
# case, and never merge into an existing destination.
153+
if not _is_symlink_privilege_error(exc):
154+
raise
155+
if os.path.isdir(src_abs):
156+
shutil.copytree(src_abs, dst)
157+
else:
158+
shutil.copy2(src_abs, dst)
124159

125160
attachment_lines: list[str] = []
126161
if images:
@@ -1479,8 +1514,115 @@ def run_codex_exec(
14791514
}
14801515

14811516

1517+
# ``"token": "..."`` / ``"accessToken": {...}`` quoted JSON pairs, matched in
1518+
# arbitrary (possibly non-JSON) text. Value may be a string, number, bool, or a
1519+
# nested object/array literal quoted as a unit — we redact the whole payload.
1520+
# Applied FIRST inside ``_redact_cursor_error``: the unquoted keyword regex below
1521+
# stops at the first whitespace, so ``"token": "a b c"`` would otherwise leak
1522+
# ``b c"``. Capturing the whole quoted payload up front fixes that, and since
1523+
# ``_redact_cursor_error`` is the single shared string redactor, one change
1524+
# covers the copilot fallback, the cursor stderr paths, and string leaves.
1525+
# ponytail: the object branch is single-level only; deep-nested values under a
1526+
# secret key in non-JSON text are not stripped (valid-JSON lines already go
1527+
# through the structural walker). Add an unbounded nest parser if that ever
1528+
# appears in real stderr.
1529+
_COPILOT_SECRET_KEY_SUFFIXES = (
1530+
"apikey",
1531+
"accesstoken",
1532+
"refreshtoken",
1533+
"token",
1534+
"password",
1535+
"passwd",
1536+
"clientsecret",
1537+
"secret",
1538+
"secretkey",
1539+
"secretaccesskey",
1540+
"sharedaccesskey",
1541+
"privatekey",
1542+
"accountkey",
1543+
"cookie",
1544+
"setcookie",
1545+
)
1546+
_COPILOT_SECRET_KEY_EXACT = {"pwd", "sig", "authorization", "bearer"}
1547+
1548+
def _is_copilot_secret_key(field: str) -> bool:
1549+
"""The single mapping-aware secret-key policy for the Copilot path.
1550+
1551+
Uses endswith on the compacted key (mirroring ``_is_secret_mapping_key``), so
1552+
``token`` / ``api_key`` / ``refreshToken`` / ``bearer`` / ``cookie`` are
1553+
redacted, but ``token_count`` / ``token_budget`` / ``secret_version``
1554+
diagnostics are preserved.
1555+
"""
1556+
compact = re.sub(r"[^a-z0-9]", "", (field or "").casefold())
1557+
return compact in _COPILOT_SECRET_KEY_EXACT or compact.endswith(_COPILOT_SECRET_KEY_SUFFIXES)
1558+
1559+
1560+
def _find_json_end(text: str, start: int) -> int | None:
1561+
"""Bracket-match a JSON object/array starting at ``start`` (unbounded nesting).
1562+
1563+
String-aware (handles quotes and escapes), so a ``{`` inside a string value
1564+
does not confuse the matching.
1565+
"""
1566+
open_ch = text[start]
1567+
close_ch = "}" if open_ch == "{" else "]"
1568+
depth = 0
1569+
in_str = False
1570+
escaped = False
1571+
for k in range(start, len(text)):
1572+
ch = text[k]
1573+
if in_str:
1574+
if escaped:
1575+
escaped = False
1576+
elif ch == "\\":
1577+
escaped = True
1578+
elif ch == '"':
1579+
in_str = False
1580+
else:
1581+
if ch == '"':
1582+
in_str = True
1583+
elif ch == open_ch:
1584+
depth += 1
1585+
elif ch == close_ch:
1586+
depth -= 1
1587+
if depth == 0:
1588+
return k
1589+
return None
1590+
1591+
1592+
def _redact_embedded_json(text: str) -> str:
1593+
"""Structurally redact JSON objects/arrays embedded in plain text.
1594+
1595+
Finds balanced JSON fragments (unbounded nesting, string-aware) and walks
1596+
each with the mapping-aware redactor, so deeply nested or pretty-printed
1597+
JSON embedded in a non-JSON line no longer leaks. Non-JSON text is preserved.
1598+
"""
1599+
out: list[str] = []
1600+
i = 0
1601+
n = len(text)
1602+
while i < n:
1603+
ch = text[i]
1604+
if ch in "{[":
1605+
end = _find_json_end(text, i)
1606+
if end is not None:
1607+
frag = text[i:end + 1]
1608+
try:
1609+
obj = json.loads(frag)
1610+
except (ValueError, TypeError):
1611+
out.append(ch)
1612+
i += 1
1613+
continue
1614+
out.append(json.dumps(_redact_copilot_json(obj), ensure_ascii=False))
1615+
i = end + 1
1616+
continue
1617+
out.append(ch)
1618+
i += 1
1619+
return "".join(out)
1620+
1621+
14821622
def _redact_cursor_error(value: str) -> str:
1483-
text = _CURSOR_SECRET_ASSIGNMENT.sub(r"\1\2[REDACTED]", value or "")
1623+
text = value or ""
1624+
text = _redact_embedded_json(text)
1625+
text = _CURSOR_SECRET_ASSIGNMENT.sub(r"\1\2[REDACTED]", text)
14841626
return _CURSOR_SECRET_TOKEN.sub("[REDACTED]", text)
14851627

14861628

@@ -1505,6 +1647,42 @@ def _sanitize_cursor_json(value: Any, *, field: str = "") -> Any:
15051647
return value
15061648

15071649

1650+
def _redact_copilot_json(value: Any, *, field: str = "") -> Any:
1651+
"""Mapping-key-aware redaction for Copilot JSONL.
1652+
1653+
Unlike the cursor trace sanitizer, this does NOT omit ``content``/``prompt``
1654+
(those are the CLI output we want to keep debuggable); it redacts by secret
1655+
field name and applies the string-level redactor to remaining string leaves.
1656+
Uses the SAME key policy as the embedded-JSON fallback so valid JSON and
1657+
non-JSON fragments agree on what a secret field is.
1658+
"""
1659+
if _is_copilot_secret_key(field):
1660+
return "[REDACTED]"
1661+
if isinstance(value, dict):
1662+
return {
1663+
str(key): _redact_copilot_json(item, field=str(key))
1664+
for key, item in value.items()
1665+
}
1666+
if isinstance(value, list):
1667+
return [_redact_copilot_json(item) for item in value]
1668+
if isinstance(value, str):
1669+
return _redact_cursor_error(value)
1670+
return value
1671+
1672+
1673+
def _redact_copilot_trace(raw: str | bytes) -> str:
1674+
"""Sanitize Copilot JSONL output (mapping-key aware, unbounded nesting).
1675+
1676+
The whole text is scanned for JSON objects/arrays (single-line, multiple
1677+
fragments, or pretty-printed / deeply nested) and each is walked with the
1678+
mapping-aware redactor; remaining non-JSON text gets string-level redaction
1679+
for ``key=value`` and token patterns. This replaces the old line-by-line
1680+
regex, which only handled single-level object values.
1681+
"""
1682+
text = _cursor_process_text(raw)
1683+
return _redact_cursor_error(text)
1684+
1685+
15081686
def _cursor_process_text(value: str | bytes) -> str:
15091687
if isinstance(value, bytes):
15101688
return value.decode("utf-8", errors="replace")
@@ -1771,14 +1949,15 @@ def run_copilot_exec(
17711949

17721950
stdout = proc.stdout or ""
17731951
stderr = proc.stderr or ""
1774-
safe_raw = stdout
1952+
safe_raw = _redact_copilot_trace(stdout)
17751953
if stderr:
1776-
safe_raw = f"{safe_raw}\n[stderr]\n{stderr}" if safe_raw else f"[stderr]\n{stderr}"
1954+
safe_stderr = _redact_copilot_trace(stderr)
1955+
safe_raw = f"{safe_raw}\n[stderr]\n{safe_stderr}" if safe_raw else f"[stderr]\n{safe_stderr}"
17771956
all_raw.append(f"===== COPILOT CLI ATTEMPT {attempt + 1} =====\n{safe_raw}")
17781957
combined = "\n\n".join(all_raw)
17791958

17801959
if proc.returncode != 0:
1781-
detail = (stderr or stdout).strip()[:4000]
1960+
detail = _redact_copilot_trace((stderr or stdout).strip())[:4000]
17821961
raise RuntimeError(
17831962
f"Copilot CLI failed with exit code {proc.returncode}: {detail}"
17841963
)

‎tests/test_cli_path_resolution.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Tests for CLI exec-path resolution (Windows bare-name .cmd shims)."""
2+
3+
from __future__ import annotations
4+
5+
from skillopt.model.backend_config import _resolve_cli_path
6+
7+
8+
def test_resolve_cli_path_uses_shutil_which(monkeypatch):
9+
"""A name found on PATH resolves to its real executable."""
10+
monkeypatch.setattr("shutil.which", lambda v: f"/resolved/{v}")
11+
assert _resolve_cli_path("codex") == "/resolved/codex"
12+
13+
14+
def test_resolve_cli_path_falls_back_to_original(monkeypatch):
15+
"""A name not on PATH (or a bare name on a host without it) passes through."""
16+
monkeypatch.setattr("shutil.which", lambda v: None)
17+
assert _resolve_cli_path("codex") == "codex"
18+
19+
20+
def test_resolve_cli_path_keeps_configured_absolute_path(monkeypatch):
21+
"""An absolute configured path that cannot be resolved is preserved."""
22+
monkeypatch.setattr("shutil.which", lambda v: None)
23+
assert _resolve_cli_path("/opt/bin/codex") == "/opt/bin/codex"
24+
25+
26+
def test_resolve_cli_path_not_called_with_empty(monkeypatch):
27+
"""Empty input is not handed to shutil.which in a way that corrupts."""
28+
monkeypatch.setattr("shutil.which", lambda v: None)
29+
assert _resolve_cli_path("") == ""

0 commit comments

Comments
 (0)