Skip to content

Commit 87f0350

Browse files
authored
feat(claude): add bounded host auth context (#151)
* feat(claude): add bounded host auth context * fix(claude): allowlist public string errors
1 parent 1ffaf19 commit 87f0350

5 files changed

Lines changed: 281 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.7.1 - Unreleased
44

55
- Fixed revalidation prompts to compact historical and feature metadata and hard-cap metadata lists even when configured file limits are high, preventing provider input overflows, thanks @pai-scaffolde.
6+
- Added an opt-in Claude host auth context that preserves the default-deny environment, uses Claude Code safe mode, validates auth through doctor, and reports redacted OAuth failure signals, thanks @grantjayy.
67

78
## 0.7.0 - 2026-06-15
89

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Environment overrides:
7272
- `CLAWPATCH_PROVIDER`
7373
- `CLAWPATCH_MODEL`
7474
- `CLAWPATCH_REASONING_EFFORT`
75+
- `CLAWPATCH_CLAUDE_AUTH_CONTEXT` (`isolated` or `host`; default `isolated`)
7576

7677
`provider.codexConfig` passes primitive values to Codex as `-c key=value`.
7778
Only config loaded by `--config` or `CLAWPATCH_CONFIG` may set non-empty

docs/providers.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,26 @@ The `claude` provider shells out to the local
144144
[Claude Code CLI](https://code.claude.com/docs/en/cli-usage) in non-interactive
145145
print mode.
146146

147-
Install Claude Code and authenticate with an Anthropic API key:
147+
Install Claude Code. The default isolated mode accepts an Anthropic API key or
148+
the supported cloud-provider auth variables:
148149

149150
```bash
150151
export ANTHROPIC_API_KEY=sk-ant-...
151152
claude --version
152153
```
153154

155+
To use the configured Claude Code binary's `/login` or setup-token auth state,
156+
opt into host auth context:
157+
158+
```bash
159+
CLAWPATCH_CLAUDE_AUTH_CONTEXT=host clawpatch doctor --provider claude
160+
CLAWPATCH_CLAUDE_AUTH_CONTEXT=host clawpatch review --provider claude
161+
```
162+
163+
Host auth context requires Claude Code 2.1.169 or newer.
164+
Set `CLAWPATCH_CLAUDE_BIN` to an executable path when Clawpatch should invoke a
165+
specific Claude Code installation.
166+
154167
Provider selection:
155168

156169
```bash
@@ -168,10 +181,11 @@ clawpatch review --provider claude --model claude-haiku-4-5-20251001 --limit 1
168181

169182
How the Claude provider works:
170183

171-
- Doctor: `clawpatch doctor --provider claude` only checks that the Claude Code
184+
- Doctor: `clawpatch doctor --provider claude` checks that the Claude Code
172185
binary is available, reads `claude --version`, and blocks known vulnerable
173-
versions. It does not validate auth or make a network call; auth failures are
174-
reported on the first provider-backed command.
186+
versions. In host auth context it also runs one minimal structured provider
187+
query through the same auth/runtime boundary; this makes a network request
188+
and may consume provider credits.
175189
- Auth/isolation: provider runs use `--bare` with a default-deny environment.
176190
Clawpatch forwards only minimal execution variables plus explicit Anthropic
177191
API key, Vertex AI, Google ADC, cloud-gateway, and Bedrock/AWS auth variables.
@@ -181,6 +195,14 @@ How the Claude provider works:
181195
Claude tool subprocess env scrubbing is enabled. AWS profile names are
182196
forwarded only when `AWS_CONFIG_FILE` or `AWS_SHARED_CREDENTIALS_FILE` points
183197
at explicit profile files.
198+
- Host auth context: `CLAWPATCH_CLAUDE_AUTH_CONTEXT=host` replaces `--bare`
199+
with Claude Code `--safe-mode`, exposes only the host `HOME`/`USERPROFILE`
200+
and optional `CLAUDE_CONFIG_DIR` auth locators, and permits
201+
`CLAUDE_CODE_OAUTH_TOKEN`. It does not inherit the whole host environment;
202+
the auth allowlist, temporary XDG/cache/data directories, subprocess env
203+
scrubbing, tool limits, empty strict MCP config, disabled slash commands,
204+
and disabled browser integration remain in force. `--safe-mode` disables
205+
user and repository customizations while retaining normal authentication.
184206
- Structured output: provider runs use `--output-format json --json-schema`
185207
and parse the returned `structured_output` field.
186208
- Read-only operations (map, review, revalidate): use
@@ -199,10 +221,12 @@ How the Claude provider works:
199221
- Timeout: 180 seconds by default, override with `CLAWPATCH_CLAUDE_TIMEOUT_MS`
200222
or `CLAWPATCH_PROVIDER_TIMEOUT_MS`.
201223

202-
Permission caveat: Claude tool restrictions are enforced by Claude Code. For
203-
write operations during `fix`, Claude may edit the current worktree. For
204-
untrusted code, run `clawpatch fix --provider claude` inside an isolated
205-
checkout.
224+
Permission caveat: Claude tool restrictions are enforced by Claude Code, and
225+
safe mode is configuration isolation rather than an OS sandbox. Host auth
226+
context makes the host auth locator visible to the Claude process; use it only
227+
for trusted repositories. For write operations during `fix`, Claude may edit
228+
the current worktree. For untrusted code, keep the default isolated auth
229+
context and run `clawpatch fix --provider claude` inside an isolated checkout.
206230

207231
## Grok
208232

src/provider.test.ts

Lines changed: 132 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ const {
1414
addCodexConfigArgs,
1515
addCodexModelArgs,
1616
addCodexSandboxArgs,
17+
assertClaudeAuthContextSupported,
1718
assertClaudeVersionAllowed,
1819
buildAcpxJsonArgs,
1920
claudeArgs,
21+
claudeAuthContext,
2022
claudeEffort,
2123
claudeEnv,
2224
claudeExitCode,
@@ -711,6 +713,7 @@ describe("Claude provider helpers", () => {
711713
{ type: "object" },
712714
{ model: null, reasoningEffort: null, skipGitRepoCheck: false },
713715
true,
716+
"isolated",
714717
);
715718

716719
expect(args).toEqual([
@@ -738,6 +741,7 @@ describe("Claude provider helpers", () => {
738741
{ type: "object" },
739742
{ model: null, reasoningEffort: null, skipGitRepoCheck: false },
740743
false,
744+
"isolated",
741745
);
742746

743747
expect(args).toContain("default");
@@ -746,6 +750,20 @@ describe("Claude provider helpers", () => {
746750
expect(args).not.toContain("dontAsk");
747751
});
748752

753+
it("uses safe mode instead of bare mode for host auth context", () => {
754+
const args = claudeArgs(
755+
{ type: "object" },
756+
{ model: null, reasoningEffort: null, skipGitRepoCheck: false },
757+
true,
758+
"host",
759+
);
760+
761+
expect(args).toContain("--safe-mode");
762+
expect(args).not.toContain("--bare");
763+
expect(args).toContain("--strict-mcp-config");
764+
expect(args).toContain("--disable-slash-commands");
765+
});
766+
749767
it("passes model and supported effort while ignoring skipGitRepoCheck", () => {
750768
const args = ["-p"];
751769

@@ -776,7 +794,7 @@ describe("Claude provider helpers", () => {
776794
CLAUDE_CODE_OAUTH_TOKEN: "must-not-leak",
777795
};
778796

779-
expect(claudeEnv(false, "/tmp/claude")).toEqual({
797+
expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({
780798
PATH: "/bin",
781799
HOME: "/tmp/claude/home",
782800
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
@@ -787,7 +805,7 @@ describe("Claude provider helpers", () => {
787805
TMP: "/tmp/claude",
788806
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
789807
});
790-
expect(claudeEnv(true, "/tmp/claude")).toEqual({
808+
expect(claudeEnv(true, "/tmp/claude", "isolated")).toEqual({
791809
PATH: "/bin",
792810
HOME: "/tmp/claude/home",
793811
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
@@ -801,6 +819,53 @@ describe("Claude provider helpers", () => {
801819
});
802820
});
803821

822+
it("exposes only Claude host auth locators in host auth context", () => {
823+
process.env = {
824+
PATH: "/bin",
825+
HOME: "/host-home",
826+
USERPROFILE: "C:\\Users\\operator",
827+
CLAUDE_CONFIG_DIR: "/host-claude-config",
828+
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
829+
ANTHROPIC_API_KEY: "api-key",
830+
OPENAI_API_KEY: "must-not-leak",
831+
DATABASE_URL: "must-not-leak",
832+
};
833+
834+
expect(claudeEnv(true, "/tmp/claude", "host")).toEqual({
835+
PATH: "/bin",
836+
HOME: "/host-home",
837+
USERPROFILE: "C:\\Users\\operator",
838+
CLAUDE_CONFIG_DIR: "/host-claude-config",
839+
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
840+
XDG_CACHE_HOME: "/tmp/claude/xdg-cache",
841+
XDG_DATA_HOME: "/tmp/claude/xdg-data",
842+
TMPDIR: "/tmp/claude",
843+
TEMP: "/tmp/claude",
844+
TMP: "/tmp/claude",
845+
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
846+
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
847+
ANTHROPIC_API_KEY: "api-key",
848+
});
849+
});
850+
851+
it("validates Claude auth context and safe-mode version", () => {
852+
delete process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"];
853+
expect(claudeAuthContext()).toBe("isolated");
854+
855+
process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"] = "host";
856+
expect(claudeAuthContext()).toBe("host");
857+
expect(() => assertClaudeAuthContextSupported("2.1.169 (Claude Code)", "host")).not.toThrow();
858+
expect(() => assertClaudeAuthContextSupported("2.1.168 (Claude Code)", "host")).toThrow(
859+
/2\.1\.169 or newer/u,
860+
);
861+
expect(() => assertClaudeAuthContextSupported("unknown", "host")).toThrow(
862+
/2\.1\.169 or newer/u,
863+
);
864+
865+
process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"] = "everything";
866+
expect(() => claudeAuthContext()).toThrow(/must be isolated or host/u);
867+
});
868+
804869
it("passes Vertex AI auth env vars only when auth is included", () => {
805870
process.env = {
806871
PATH: "/bin",
@@ -819,7 +884,7 @@ describe("Claude provider helpers", () => {
819884
OPENAI_API_KEY: "must-not-leak",
820885
};
821886

822-
expect(claudeEnv(false, "/tmp/claude")).toEqual({
887+
expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({
823888
PATH: "/bin",
824889
HOME: "/tmp/claude/home",
825890
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
@@ -830,7 +895,7 @@ describe("Claude provider helpers", () => {
830895
TMP: "/tmp/claude",
831896
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
832897
});
833-
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
898+
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
834899
CLAUDE_CODE_USE_VERTEX: "1",
835900
ANTHROPIC_BASE_URL: "https://llm-gateway.example.com",
836901
ANTHROPIC_AUTH_TOKEN: "gateway-token",
@@ -844,7 +909,7 @@ describe("Claude provider helpers", () => {
844909
CLOUDSDK_CORE_PROJECT: "sdk-project",
845910
CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
846911
});
847-
expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("OPENAI_API_KEY");
912+
expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("OPENAI_API_KEY");
848913
});
849914

850915
it("passes Bedrock auth env vars only when auth is included", () => {
@@ -867,8 +932,10 @@ describe("Claude provider helpers", () => {
867932
DATABASE_URL: "must-not-leak",
868933
};
869934

870-
expect(claudeEnv(false, "/tmp/claude")).not.toHaveProperty("CLAUDE_CODE_USE_BEDROCK");
871-
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
935+
expect(claudeEnv(false, "/tmp/claude", "isolated")).not.toHaveProperty(
936+
"CLAUDE_CODE_USE_BEDROCK",
937+
);
938+
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
872939
CLAUDE_CODE_USE_BEDROCK: "1",
873940
CLAUDE_CODE_SKIP_BEDROCK_AUTH: "1",
874941
ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock-runtime.us-east-1.amazonaws.com",
@@ -884,7 +951,7 @@ describe("Claude provider helpers", () => {
884951
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/clawpatch",
885952
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/aws/web-identity-token",
886953
});
887-
expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("DATABASE_URL");
954+
expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("DATABASE_URL");
888955
});
889956

890957
it("passes AWS_PROFILE only with explicit AWS config or credentials file paths", () => {
@@ -895,17 +962,17 @@ describe("Claude provider helpers", () => {
895962
AWS_PROFILE: "clawpatch",
896963
};
897964

898-
expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("AWS_PROFILE");
965+
expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("AWS_PROFILE");
899966

900967
process.env["AWS_CONFIG_FILE"] = "/var/aws/config";
901-
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
968+
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
902969
AWS_CONFIG_FILE: "/var/aws/config",
903970
AWS_PROFILE: "clawpatch",
904971
});
905972

906973
delete process.env["AWS_CONFIG_FILE"];
907974
process.env["AWS_SHARED_CREDENTIALS_FILE"] = "/var/aws/credentials";
908-
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
975+
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
909976
AWS_SHARED_CREDENTIALS_FILE: "/var/aws/credentials",
910977
AWS_PROFILE: "clawpatch",
911978
});
@@ -917,11 +984,11 @@ describe("Claude provider helpers", () => {
917984
ANTHROPIC_API_KEY: "secret",
918985
};
919986

920-
expect(claudeEnv(true, "C:\\Temp\\claude")).toMatchObject({
987+
expect(claudeEnv(true, "C:\\Temp\\claude", "isolated")).toMatchObject({
921988
Path: "C:\\Tools",
922989
ANTHROPIC_API_KEY: "secret",
923990
});
924-
expect(claudeEnv(true, "C:\\Temp\\claude")).not.toHaveProperty("PATH");
991+
expect(claudeEnv(true, "C:\\Temp\\claude", "isolated")).not.toHaveProperty("PATH");
925992
});
926993

927994
it("extracts structured_output from Claude JSON envelopes", () => {
@@ -985,6 +1052,32 @@ describe("Claude provider helpers", () => {
9851052
throw new Error("expected Claude provider failure");
9861053
});
9871054

1055+
it("classifies string Claude error envelopes", () => {
1056+
try {
1057+
extractClaudeStructuredOutput(JSON.stringify({ error: "authentication_failed" }));
1058+
} catch (err) {
1059+
expect(err).toBeInstanceOf(ClawpatchError);
1060+
expect((err as ClawpatchError).message).toContain("authentication_failed");
1061+
expect((err as ClawpatchError).exitCode).toBe(4);
1062+
return;
1063+
}
1064+
throw new Error("expected Claude provider failure");
1065+
});
1066+
1067+
it("does not preview arbitrary or token-shaped string Claude errors", () => {
1068+
for (const secret of ["SOURCE CONTEXT SECRET", "sk-ant-secret-shaped-value"]) {
1069+
try {
1070+
extractClaudeStructuredOutput(JSON.stringify({ error: secret }));
1071+
} catch (err) {
1072+
expect(err).toBeInstanceOf(ClawpatchError);
1073+
expect((err as ClawpatchError).message).toBe("claude provider error: provider-error");
1074+
expect((err as ClawpatchError).message).not.toContain(secret);
1075+
continue;
1076+
}
1077+
throw new Error("expected Claude provider failure");
1078+
}
1079+
});
1080+
9881081
it("does not include stdout or prompt previews in Claude failure messages", () => {
9891082
const message = claudeFailureMessage("SOURCE_CONTEXT_SECRET", "SOURCE_CONTEXT_SECRET", 1);
9901083

@@ -1013,7 +1106,8 @@ describe("Claude provider helpers", () => {
10131106

10141107
const message = claudeFailureMessage(stdout, "", 1);
10151108

1016-
expect(message).toBe("claude provider auth/config failed");
1109+
expect(message).toContain("claude provider auth/config failed");
1110+
expect(message).toContain("error=authentication_failed");
10171111
expect(message).not.toContain("SOURCE_CONTEXT_SECRET");
10181112
expect(claudeExitCode(stdout, "", 1)).toBe(4);
10191113
});
@@ -1034,14 +1128,36 @@ describe("Claude provider helpers", () => {
10341128
result: "SOURCE_CONTEXT_SECRET",
10351129
});
10361130

1037-
expect(claudeFailureMessage(auth, "", 1)).toBe("claude provider auth/config failed");
1131+
expect(claudeFailureMessage(auth, "", 1)).toContain("claude provider auth/config failed");
10381132
expect(claudeExitCode(auth, "", 1)).toBe(4);
1039-
expect(claudeFailureMessage(quota, "", 1)).toBe("claude provider quota/rate-limit failed");
1133+
expect(claudeFailureMessage(quota, "", 1)).toContain("claude provider quota/rate-limit failed");
10401134
expect(claudeExitCode(quota, "", 1)).toBe(5);
10411135
expect(claudeFailureMessage(auth, "", 1)).not.toContain("SOURCE_CONTEXT_SECRET");
10421136
expect(claudeFailureMessage(quota, "", 1)).not.toContain("SOURCE_CONTEXT_SECRET");
10431137
});
10441138

1139+
it("surfaces the reported OAuth failure shape without leaking result text", () => {
1140+
const stdout = [
1141+
JSON.stringify({ type: "assistant", error: "authentication_failed" }),
1142+
JSON.stringify({
1143+
type: "result",
1144+
subtype: "success",
1145+
is_error: true,
1146+
result: "Not logged in · Please run /login",
1147+
terminal_reason: "completed",
1148+
}),
1149+
].join("\n");
1150+
1151+
const message = claudeFailureMessage(stdout, "", 1);
1152+
1153+
expect(message).toContain("claude provider auth/config failed");
1154+
expect(message).toContain("error=authentication_failed");
1155+
expect(message).toContain("is_error=true");
1156+
expect(message).toContain("reason=not-logged-in");
1157+
expect(message).not.toContain("Please run /login");
1158+
expect(claudeExitCode(stdout, "", 1)).toBe(4);
1159+
});
1160+
10451161
it("omits Claude error.message from stdout failure signals", () => {
10461162
const stdout = JSON.stringify({
10471163
type: "result",

0 commit comments

Comments
 (0)