Skip to content

Commit 881d41d

Browse files
authored
fix(setup): handle read-only .claude directory in sandboxed environments (#702)
## Summary Fixes #699 - Add `accessSync(W_OK)` pre-check in `installAgentSkills()` before attempting file creation — prevents sandbox process termination when `~/.claude` is read-only - Report setup step failures to Sentry via `captureException` in `bestEffort()` and the agent-skills catch block for visibility into errors that were previously silently swallowed - Add test for sandbox scenario (read-only `.claude` directory with `0o555` permissions)
1 parent 74898e8 commit 881d41d

File tree

3 files changed

+37
-2
lines changed

3 files changed

+37
-2
lines changed

src/commands/cli/setup.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { existsSync, unlinkSync } from "node:fs";
1010
import { dirname, join } from "node:path";
11+
import { captureException } from "@sentry/node-core/light";
1112
import type { SentryContext } from "../../context.js";
1213
import { installAgentSkills } from "../../lib/agent-skills.js";
1314
import {
@@ -331,6 +332,10 @@ async function bestEffort(
331332
await fn();
332333
} catch (error) {
333334
warn(stepName, error);
335+
captureException(error, {
336+
level: "warning",
337+
tags: { "setup.step": stepName },
338+
});
334339
}
335340
}
336341

src/lib/agent-skills.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
* produced by script/generate-skill.ts), so no network fetch is needed.
99
*/
1010

11-
import { existsSync, mkdirSync } from "node:fs";
11+
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
1212
import { dirname, join } from "node:path";
13+
import { captureException } from "@sentry/node-core/light";
1314
import { SKILL_FILES } from "../generated/skill-content.js";
1415

1516
/** Where skills are installed */
@@ -62,6 +63,16 @@ export async function installAgentSkills(
6263
return null;
6364
}
6465

66+
// Verify .claude is writable before attempting file creation.
67+
// In sandboxed environments (e.g., Claude Code sandbox), .claude may exist
68+
// but be read-only. Some sandboxes terminate the process on write attempts,
69+
// bypassing JavaScript error handling — so we must check before writing.
70+
try {
71+
accessSync(join(homeDir, ".claude"), constants.W_OK);
72+
} catch {
73+
return null;
74+
}
75+
6576
try {
6677
const skillPath = getSkillInstallPath(homeDir);
6778
const skillDir = dirname(skillPath);
@@ -86,7 +97,11 @@ export async function installAgentSkills(
8697
created: !alreadyExists,
8798
referenceCount,
8899
};
89-
} catch {
100+
} catch (error) {
101+
captureException(error, {
102+
level: "warning",
103+
tags: { "setup.step": "agent-skills" },
104+
});
90105
return null;
91106
}
92107
}

test/lib/agent-skills.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,20 @@ describe("agent-skills", () => {
129129
// Restore write permission so afterEach cleanup can remove it
130130
chmodSync(join(testDir, ".claude"), 0o755);
131131
});
132+
133+
test("returns null when .claude exists but is not writable (sandbox)", async () => {
134+
// Simulate a sandboxed .claude directory: readable + executable, not writable.
135+
// The accessSync(W_OK) pre-check should catch this before any write attempt.
136+
mkdirSync(join(testDir, ".claude"), { recursive: true, mode: 0o555 });
137+
138+
const result = await installAgentSkills(testDir);
139+
expect(result).toBeNull();
140+
141+
// Verify no write was attempted — skills subdir should not exist
142+
expect(existsSync(join(testDir, ".claude", "skills"))).toBe(false);
143+
144+
// Restore write permission so afterEach cleanup can remove it
145+
chmodSync(join(testDir, ".claude"), 0o755);
146+
});
132147
});
133148
});

0 commit comments

Comments
 (0)