Skip to content

Commit b12baf2

Browse files
phase3devclaude
andauthored
Harden launchers/patchers from external audit + add CI (#4)
* Harden launchers and patchers (Codex audit fixes) Fixes from an external audit of the workaround launchers and patchers: - Windows wrappers no longer shell out to claude.cmd/.bat. They resolve the npm shim to node + cli.js and spawn with shell:false, failing closed if it cannot be resolved, so prompt/arg metacharacters (& | < > ^ % ") can no longer be interpreted locally. - Thinking launchers (bash + win.js) handle --flag=value forms and validate CC_THINKING_DISPLAY (summarized|omitted). - Context-icon patchers skip ambiguous multi-match bundles instead of replacing globally, matching fix-context-icon.py. - proxy.js strips hop-by-hop headers on both request and response paths and validates CC_THINKING_DISPLAY. - fix-context-icon.py writes via a same-directory temp + os.replace, preserving owner/group/mode, instead of truncate-in-place. - patch-extension.sh drops the Bash 4 mapfile for a portable read loop. - test-thinking-display.sh uses mktemp + trap and optional timeout. - Add tests/test_regressions.py (10 tests) covering the above, including the context-icon single-match happy path; ignore Python bytecode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add CI workflow and note untested cross-owner path - .github/workflows/ci.yml runs bash -n, ShellCheck (severity=warning), node --check, py_compile, and the regression suite on push and PRs. ShellCheck gates at warning severity: the only findings are info-level SC2015 (A && B || C) notes where the C branch is the intended best-effort behavior. - Note in the #5 patcher test that the root-patches-user-owned-file chown path is not exercised by the single-user test and is verified by inspection only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent edb50a9 commit b12baf2

13 files changed

Lines changed: 994 additions & 91 deletions

‎.github/workflows/ci.yml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
checks:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- name: Set up Python
15+
uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.x"
18+
19+
- name: Set up Node
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: "20"
23+
24+
- name: Bash syntax check (bash -n)
25+
run: bash -n claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh
26+
27+
- name: ShellCheck
28+
# ubuntu-latest ships shellcheck. Gate at warning severity: the only
29+
# findings are info-level SC2015 (A && B || C) notes in the launchers,
30+
# where the C branch firing is the intended best-effort behavior.
31+
run: shellcheck --severity=warning claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh
32+
33+
- name: Node syntax check (node --check)
34+
run: |
35+
for f in claude-think.win.js claude-context.win.js claudemax.win.js proxy.js; do
36+
node --check "$f"
37+
done
38+
39+
- name: Python compile
40+
run: python3 -m py_compile fix-context-icon.py tests/test_regressions.py
41+
42+
- name: Regression tests
43+
run: python3 -m unittest discover -s tests -v

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ node_modules/
88
# Python bytecode (e.g. from running fix-context-icon.py)
99
__pycache__/
1010
*.pyc
11+
*.py[cod]
1112

1213
# Editor / OS noise
1314
*.bak.*

‎claude-context‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,11 @@ fi
115115
CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}"
116116

117117
_cc_patch_index_js() {
118-
local f="$1" tmp tmp2
118+
local f="$1" tmp tmp2 old_count
119119
[ -f "$f" ] && [ -w "$f" ] || return 0
120120
if grep -q '>=101)return null}' "$f" 2>/dev/null; then return 0; fi # already patched
121-
if ! grep -q '>=50)return null}' "$f" 2>/dev/null; then return 0; fi # gate absent (version changed)
121+
old_count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')"
122+
if [ "$old_count" != "1" ]; then return 0; fi # absent or ambiguous (version changed)
122123
if [ ! -e "$f.bak-context-icon" ]; then cp -p "$f" "$f.bak-context-icon" 2>/dev/null || true; fi
123124
tmp="${f}.ccpatch.$$"
124125
tmp2="${f}.ccpatch2.$$"
@@ -127,7 +128,7 @@ _cc_patch_index_js() {
127128
# second temp, copy that back over the metadata-preserving temp, then atomically
128129
# replace the original. A failed/partial step leaves the original untouched.
129130
cp -p "$f" "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 0; }
130-
if sed 's/>=50)return null}/>=101)return null}/g' "$f" > "$tmp2" 2>/dev/null \
131+
if sed 's/>=50)return null}/>=101)return null}/' "$f" > "$tmp2" 2>/dev/null \
131132
&& [ -s "$tmp2" ] \
132133
&& grep -q '>=101)return null}' "$tmp2" 2>/dev/null; then
133134
cat "$tmp2" > "$tmp" && mv -f "$tmp" "$f" || true

‎claude-context.win.js‎

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,89 @@ function findClaude() {
8282
return null;
8383
}
8484

85+
function findExecutableOnPath(name) {
86+
const lookup = process.platform === "win32" ? "where" : "which";
87+
try {
88+
const out = execFileSync(lookup, [name], {
89+
encoding: "utf8",
90+
stdio: ["ignore", "pipe", "ignore"],
91+
});
92+
const hit = out
93+
.split(/\r?\n/)
94+
.map((s) => s.trim())
95+
.find((s) => s && fs.existsSync(s));
96+
if (hit) return hit;
97+
} catch (_) {
98+
/* not on PATH */
99+
}
100+
return null;
101+
}
102+
103+
function expandShimPath(raw, shimDir) {
104+
let s = raw.trim().replace(/^["']|["']$/g, "");
105+
s = s.replace(/%~?dp0%?/gi, shimDir + path.sep);
106+
s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m);
107+
return path.isAbsolute(s) ? s : path.resolve(shimDir, s);
108+
}
109+
110+
function resolveShimEntrypoint(shim) {
111+
const shimDir = path.dirname(path.resolve(shim));
112+
const candidates = [
113+
path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"),
114+
path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"),
115+
path.resolve(
116+
shimDir,
117+
"..",
118+
"node_modules",
119+
"@anthropic-ai",
120+
"claude-code",
121+
"cli.js"
122+
),
123+
];
124+
for (const c of candidates) {
125+
if (fs.existsSync(c)) return c;
126+
}
127+
try {
128+
const data = fs.readFileSync(shim, "utf8");
129+
const matches = data.matchAll(
130+
/(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi
131+
);
132+
for (const m of matches) {
133+
const hit = expandShimPath(m[1] || m[2] || m[3], shimDir);
134+
if (fs.existsSync(hit)) return hit;
135+
}
136+
} catch (_) {
137+
/* unreadable shim */
138+
}
139+
return null;
140+
}
141+
142+
function resolveNodeForShim(shim) {
143+
const shimDir = path.dirname(path.resolve(shim));
144+
const candidates = [
145+
process.env.CC_NODE_BIN,
146+
path.join(shimDir, "node.exe"),
147+
path.join(shimDir, "node"),
148+
process.pkg ? null : process.execPath,
149+
findExecutableOnPath("node"),
150+
].filter(Boolean);
151+
for (const c of candidates) {
152+
if (fs.existsSync(c)) return c;
153+
}
154+
return null;
155+
}
156+
157+
function resolveClaudeInvocation(command, args) {
158+
if (!/\.(cmd|bat)$/i.test(command)) return { command, args };
159+
const cli = resolveShimEntrypoint(command);
160+
const node = resolveNodeForShim(command);
161+
if (cli && node) return { command: node, args: [cli, ...args] };
162+
console.error(
163+
"claude-context: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe"
164+
);
165+
return null;
166+
}
167+
85168
// Process-wrapper convention: the official VS Code extension invokes the wrapper
86169
// as <wrapper> <REAL_CLAUDE...> <args...>, passing the real CLI ahead of the
87170
// args. <REAL_CLAUDE...> is either a single native-binary path (".../claude.exe")
@@ -151,7 +234,8 @@ function ccPatchIndexJs(file) {
151234
return; // not readable
152235
}
153236
if (data.indexOf(ICON_NEW) !== -1) return; // already patched
154-
if (data.indexOf(ICON_OLD) === -1) return; // gate absent (version changed)
237+
const oldMatches = data.split(ICON_OLD).length - 1;
238+
if (oldMatches !== 1) return; // gate absent or ambiguous (version changed)
155239
const bak = file + ".bak-context-icon";
156240
if (!fs.existsSync(bak)) {
157241
try {
@@ -160,7 +244,7 @@ function ccPatchIndexJs(file) {
160244
/* best-effort backup */
161245
}
162246
}
163-
const patched = data.split(ICON_OLD).join(ICON_NEW);
247+
const patched = data.replace(ICON_OLD, ICON_NEW);
164248
if (patched.indexOf(ICON_NEW) === -1) return; // sanity: substitution took
165249
const tmp = file + ".ccpatch." + process.pid;
166250
try {
@@ -238,11 +322,11 @@ restoreContextIcon(wrapperBin);
238322

239323
// This variant injects nothing into the args - it only patches the webview, then
240324
// forwards every argument through to the real claude unchanged.
241-
// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly.
242-
const useShell = /\.(cmd|bat)$/i.test(claude);
243-
const res = spawnSync(claude, argv, {
325+
const invocation = resolveClaudeInvocation(claude, argv);
326+
if (!invocation) process.exit(1);
327+
const res = spawnSync(invocation.command, invocation.args, {
244328
stdio: "inherit",
245329
env: process.env,
246-
shell: useShell,
330+
shell: false,
247331
});
248332
process.exit(res.status == null ? 1 : res.status);

‎claude-think‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ fi
9090

9191
# Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries.
9292
DISPLAY_VALUE="${CC_THINKING_DISPLAY:-summarized}"
93+
case "$DISPLAY_VALUE" in
94+
summarized|omitted) ;;
95+
*)
96+
echo "claude-think: invalid CC_THINKING_DISPLAY=$DISPLAY_VALUE; using summarized" >&2
97+
DISPLAY_VALUE="summarized"
98+
;;
99+
esac
93100

94101
# --- Optional customizations ------------------------------------------------
95102
#
@@ -129,9 +136,21 @@ prev=""
129136

130137
for a in "$@"; do
131138
case "$a" in
132-
--thinking-display)
139+
--thinking-display|--thinking-display=*)
133140
have_display=true
134141
;;
142+
--thinking=adaptive|--thinking=enabled)
143+
thinking_adaptive=true
144+
;;
145+
--thinking=disabled)
146+
thinking_disabled=true
147+
;;
148+
--max-thinking-tokens=*)
149+
v="${a#*=}"
150+
if [ -n "$v" ] && [ "$v" != "0" ]; then
151+
max_thinking_on=true
152+
fi
153+
;;
135154
-p|--print)
136155
print_mode=true
137156
;;

‎claude-think.win.js‎

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,97 @@ function findClaude() {
7575
return null;
7676
}
7777

78+
function findExecutableOnPath(name) {
79+
const lookup = process.platform === "win32" ? "where" : "which";
80+
try {
81+
const out = execFileSync(lookup, [name], {
82+
encoding: "utf8",
83+
stdio: ["ignore", "pipe", "ignore"],
84+
});
85+
const hit = out
86+
.split(/\r?\n/)
87+
.map((s) => s.trim())
88+
.find((s) => s && fs.existsSync(s));
89+
if (hit) return hit;
90+
} catch (_) {
91+
/* not on PATH */
92+
}
93+
return null;
94+
}
95+
96+
function expandShimPath(raw, shimDir) {
97+
let s = raw.trim().replace(/^["']|["']$/g, "");
98+
s = s.replace(/%~?dp0%?/gi, shimDir + path.sep);
99+
s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m);
100+
return path.isAbsolute(s) ? s : path.resolve(shimDir, s);
101+
}
102+
103+
function resolveShimEntrypoint(shim) {
104+
const shimDir = path.dirname(path.resolve(shim));
105+
const candidates = [
106+
path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"),
107+
path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"),
108+
path.resolve(
109+
shimDir,
110+
"..",
111+
"node_modules",
112+
"@anthropic-ai",
113+
"claude-code",
114+
"cli.js"
115+
),
116+
];
117+
for (const c of candidates) {
118+
if (fs.existsSync(c)) return c;
119+
}
120+
try {
121+
const data = fs.readFileSync(shim, "utf8");
122+
const matches = data.matchAll(
123+
/(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi
124+
);
125+
for (const m of matches) {
126+
const hit = expandShimPath(m[1] || m[2] || m[3], shimDir);
127+
if (fs.existsSync(hit)) return hit;
128+
}
129+
} catch (_) {
130+
/* unreadable shim */
131+
}
132+
return null;
133+
}
134+
135+
function resolveNodeForShim(shim) {
136+
const shimDir = path.dirname(path.resolve(shim));
137+
const candidates = [
138+
process.env.CC_NODE_BIN,
139+
path.join(shimDir, "node.exe"),
140+
path.join(shimDir, "node"),
141+
process.pkg ? null : process.execPath,
142+
findExecutableOnPath("node"),
143+
].filter(Boolean);
144+
for (const c of candidates) {
145+
if (fs.existsSync(c)) return c;
146+
}
147+
return null;
148+
}
149+
150+
function resolveClaudeInvocation(command, args) {
151+
if (!/\.(cmd|bat)$/i.test(command)) return { command, args };
152+
const cli = resolveShimEntrypoint(command);
153+
const node = resolveNodeForShim(command);
154+
if (cli && node) return { command: node, args: [cli, ...args] };
155+
console.error(
156+
"claude-think: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe"
157+
);
158+
return null;
159+
}
160+
161+
function normalizeDisplayValue(value) {
162+
if (value === "summarized" || value === "omitted") return value;
163+
console.error(
164+
`claude-think: invalid CC_THINKING_DISPLAY=${value}; using summarized`
165+
);
166+
return "summarized";
167+
}
168+
78169
// Process-wrapper convention: the official VS Code extension invokes the wrapper
79170
// as <wrapper> <REAL_CLAUDE...> <args...>, passing the real CLI ahead of the
80171
// args. <REAL_CLAUDE...> is either a single native-binary path (".../claude.exe")
@@ -119,7 +210,9 @@ if (!claude) {
119210

120211
// --- Behavior --------------------------------------------------------------
121212
// Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries.
122-
const displayValue = process.env.CC_THINKING_DISPLAY || "summarized";
213+
const displayValue = normalizeDisplayValue(
214+
process.env.CC_THINKING_DISPLAY || "summarized"
215+
);
123216

124217
// --- Optional customizations -----------------------------------------------
125218
//
@@ -150,8 +243,18 @@ let haveDisplay = false,
150243
maxThinkingOn = false;
151244
for (let i = 0; i < argv.length; i++) {
152245
const a = argv[i];
153-
if (a === "--thinking-display") haveDisplay = true;
246+
if (a === "--thinking-display" || a.startsWith("--thinking-display=")) {
247+
haveDisplay = true;
248+
}
154249
if (a === "-p" || a === "--print") printMode = true;
250+
if (a === "--thinking=adaptive" || a === "--thinking=enabled") {
251+
thinkingAdaptive = true;
252+
}
253+
if (a === "--thinking=disabled") thinkingDisabled = true;
254+
if (a.startsWith("--max-thinking-tokens=")) {
255+
const v = a.slice("--max-thinking-tokens=".length);
256+
if (v && v !== "0") maxThinkingOn = true;
257+
}
155258
if (a === "--max-thinking-tokens") {
156259
const v = argv[i + 1];
157260
if (v && v !== "0") maxThinkingOn = true;
@@ -171,11 +274,11 @@ if (
171274
args.push("--thinking-display", displayValue);
172275
}
173276

174-
// .cmd/.bat (npm install) need a shell; .exe (native install) is exec'd directly.
175-
const useShell = /\.(cmd|bat)$/i.test(claude);
176-
const res = spawnSync(claude, args, {
277+
const invocation = resolveClaudeInvocation(claude, args);
278+
if (!invocation) process.exit(1);
279+
const res = spawnSync(invocation.command, invocation.args, {
177280
stdio: "inherit",
178281
env: process.env,
179-
shell: useShell,
282+
shell: false,
180283
});
181284
process.exit(res.status == null ? 1 : res.status);

0 commit comments

Comments
 (0)