Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.7.1 - Unreleased

- 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.
- 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.

## 0.7.0 - 2026-06-15

Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Environment overrides:
- `CLAWPATCH_PROVIDER`
- `CLAWPATCH_MODEL`
- `CLAWPATCH_REASONING_EFFORT`
- `CLAWPATCH_CLAUDE_AUTH_CONTEXT` (`isolated` or `host`; default `isolated`)

`provider.codexConfig` passes primitive values to Codex as `-c key=value`.
Only config loaded by `--config` or `CLAWPATCH_CONFIG` may set non-empty
Expand Down
40 changes: 32 additions & 8 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,26 @@ The `claude` provider shells out to the local
[Claude Code CLI](https://code.claude.com/docs/en/cli-usage) in non-interactive
print mode.

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

```bash
export ANTHROPIC_API_KEY=sk-ant-...
claude --version
```

To use the configured Claude Code binary's `/login` or setup-token auth state,
opt into host auth context:

```bash
CLAWPATCH_CLAUDE_AUTH_CONTEXT=host clawpatch doctor --provider claude
CLAWPATCH_CLAUDE_AUTH_CONTEXT=host clawpatch review --provider claude
```

Host auth context requires Claude Code 2.1.169 or newer.
Set `CLAWPATCH_CLAUDE_BIN` to an executable path when Clawpatch should invoke a
specific Claude Code installation.

Provider selection:

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

How the Claude provider works:

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

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

## Grok

Expand Down
148 changes: 132 additions & 16 deletions src/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ const {
addCodexConfigArgs,
addCodexModelArgs,
addCodexSandboxArgs,
assertClaudeAuthContextSupported,
assertClaudeVersionAllowed,
buildAcpxJsonArgs,
claudeArgs,
claudeAuthContext,
claudeEffort,
claudeEnv,
claudeExitCode,
Expand Down Expand Up @@ -711,6 +713,7 @@ describe("Claude provider helpers", () => {
{ type: "object" },
{ model: null, reasoningEffort: null, skipGitRepoCheck: false },
true,
"isolated",
);

expect(args).toEqual([
Expand Down Expand Up @@ -738,6 +741,7 @@ describe("Claude provider helpers", () => {
{ type: "object" },
{ model: null, reasoningEffort: null, skipGitRepoCheck: false },
false,
"isolated",
);

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

it("uses safe mode instead of bare mode for host auth context", () => {
const args = claudeArgs(
{ type: "object" },
{ model: null, reasoningEffort: null, skipGitRepoCheck: false },
true,
"host",
);

expect(args).toContain("--safe-mode");
expect(args).not.toContain("--bare");
expect(args).toContain("--strict-mcp-config");
expect(args).toContain("--disable-slash-commands");
});

it("passes model and supported effort while ignoring skipGitRepoCheck", () => {
const args = ["-p"];

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

expect(claudeEnv(false, "/tmp/claude")).toEqual({
expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({
PATH: "/bin",
HOME: "/tmp/claude/home",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
Expand All @@ -787,7 +805,7 @@ describe("Claude provider helpers", () => {
TMP: "/tmp/claude",
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
});
expect(claudeEnv(true, "/tmp/claude")).toEqual({
expect(claudeEnv(true, "/tmp/claude", "isolated")).toEqual({
PATH: "/bin",
HOME: "/tmp/claude/home",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
Expand All @@ -801,6 +819,53 @@ describe("Claude provider helpers", () => {
});
});

it("exposes only Claude host auth locators in host auth context", () => {
process.env = {
PATH: "/bin",
HOME: "/host-home",
USERPROFILE: "C:\\Users\\operator",
CLAUDE_CONFIG_DIR: "/host-claude-config",
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
OPENAI_API_KEY: "must-not-leak",
DATABASE_URL: "must-not-leak",
};

expect(claudeEnv(true, "/tmp/claude", "host")).toEqual({
PATH: "/bin",
HOME: "/host-home",
USERPROFILE: "C:\\Users\\operator",
CLAUDE_CONFIG_DIR: "/host-claude-config",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
XDG_CACHE_HOME: "/tmp/claude/xdg-cache",
XDG_DATA_HOME: "/tmp/claude/xdg-data",
TMPDIR: "/tmp/claude",
TEMP: "/tmp/claude",
TMP: "/tmp/claude",
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
});
});

it("validates Claude auth context and safe-mode version", () => {
delete process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"];
expect(claudeAuthContext()).toBe("isolated");

process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"] = "host";
expect(claudeAuthContext()).toBe("host");
expect(() => assertClaudeAuthContextSupported("2.1.169 (Claude Code)", "host")).not.toThrow();
expect(() => assertClaudeAuthContextSupported("2.1.168 (Claude Code)", "host")).toThrow(
/2\.1\.169 or newer/u,
);
expect(() => assertClaudeAuthContextSupported("unknown", "host")).toThrow(
/2\.1\.169 or newer/u,
);

process.env["CLAWPATCH_CLAUDE_AUTH_CONTEXT"] = "everything";
expect(() => claudeAuthContext()).toThrow(/must be isolated or host/u);
});

it("passes Vertex AI auth env vars only when auth is included", () => {
process.env = {
PATH: "/bin",
Expand All @@ -819,7 +884,7 @@ describe("Claude provider helpers", () => {
OPENAI_API_KEY: "must-not-leak",
};

expect(claudeEnv(false, "/tmp/claude")).toEqual({
expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({
PATH: "/bin",
HOME: "/tmp/claude/home",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
Expand All @@ -830,7 +895,7 @@ describe("Claude provider helpers", () => {
TMP: "/tmp/claude",
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
});
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
CLAUDE_CODE_USE_VERTEX: "1",
ANTHROPIC_BASE_URL: "https://llm-gateway.example.com",
ANTHROPIC_AUTH_TOKEN: "gateway-token",
Expand All @@ -844,7 +909,7 @@ describe("Claude provider helpers", () => {
CLOUDSDK_CORE_PROJECT: "sdk-project",
CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
});
expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("OPENAI_API_KEY");
expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("OPENAI_API_KEY");
});

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

expect(claudeEnv(false, "/tmp/claude")).not.toHaveProperty("CLAUDE_CODE_USE_BEDROCK");
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
expect(claudeEnv(false, "/tmp/claude", "isolated")).not.toHaveProperty(
"CLAUDE_CODE_USE_BEDROCK",
);
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
CLAUDE_CODE_USE_BEDROCK: "1",
CLAUDE_CODE_SKIP_BEDROCK_AUTH: "1",
ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock-runtime.us-east-1.amazonaws.com",
Expand All @@ -884,7 +951,7 @@ describe("Claude provider helpers", () => {
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/clawpatch",
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/aws/web-identity-token",
});
expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("DATABASE_URL");
expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("DATABASE_URL");
});

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

expect(claudeEnv(true, "/tmp/claude")).not.toHaveProperty("AWS_PROFILE");
expect(claudeEnv(true, "/tmp/claude", "isolated")).not.toHaveProperty("AWS_PROFILE");

process.env["AWS_CONFIG_FILE"] = "/var/aws/config";
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
AWS_CONFIG_FILE: "/var/aws/config",
AWS_PROFILE: "clawpatch",
});

delete process.env["AWS_CONFIG_FILE"];
process.env["AWS_SHARED_CREDENTIALS_FILE"] = "/var/aws/credentials";
expect(claudeEnv(true, "/tmp/claude")).toMatchObject({
expect(claudeEnv(true, "/tmp/claude", "isolated")).toMatchObject({
AWS_SHARED_CREDENTIALS_FILE: "/var/aws/credentials",
AWS_PROFILE: "clawpatch",
});
Expand All @@ -917,11 +984,11 @@ describe("Claude provider helpers", () => {
ANTHROPIC_API_KEY: "secret",
};

expect(claudeEnv(true, "C:\\Temp\\claude")).toMatchObject({
expect(claudeEnv(true, "C:\\Temp\\claude", "isolated")).toMatchObject({
Path: "C:\\Tools",
ANTHROPIC_API_KEY: "secret",
});
expect(claudeEnv(true, "C:\\Temp\\claude")).not.toHaveProperty("PATH");
expect(claudeEnv(true, "C:\\Temp\\claude", "isolated")).not.toHaveProperty("PATH");
});

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

it("classifies string Claude error envelopes", () => {
try {
extractClaudeStructuredOutput(JSON.stringify({ error: "authentication_failed" }));
} catch (err) {
expect(err).toBeInstanceOf(ClawpatchError);
expect((err as ClawpatchError).message).toContain("authentication_failed");
expect((err as ClawpatchError).exitCode).toBe(4);
return;
}
throw new Error("expected Claude provider failure");
});

it("does not preview arbitrary or token-shaped string Claude errors", () => {
for (const secret of ["SOURCE CONTEXT SECRET", "sk-ant-secret-shaped-value"]) {
try {
extractClaudeStructuredOutput(JSON.stringify({ error: secret }));
} catch (err) {
expect(err).toBeInstanceOf(ClawpatchError);
expect((err as ClawpatchError).message).toBe("claude provider error: provider-error");
expect((err as ClawpatchError).message).not.toContain(secret);
continue;
}
throw new Error("expected Claude provider failure");
}
});

it("does not include stdout or prompt previews in Claude failure messages", () => {
const message = claudeFailureMessage("SOURCE_CONTEXT_SECRET", "SOURCE_CONTEXT_SECRET", 1);

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

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

expect(message).toBe("claude provider auth/config failed");
expect(message).toContain("claude provider auth/config failed");
expect(message).toContain("error=authentication_failed");
expect(message).not.toContain("SOURCE_CONTEXT_SECRET");
expect(claudeExitCode(stdout, "", 1)).toBe(4);
});
Expand All @@ -1034,14 +1128,36 @@ describe("Claude provider helpers", () => {
result: "SOURCE_CONTEXT_SECRET",
});

expect(claudeFailureMessage(auth, "", 1)).toBe("claude provider auth/config failed");
expect(claudeFailureMessage(auth, "", 1)).toContain("claude provider auth/config failed");
expect(claudeExitCode(auth, "", 1)).toBe(4);
expect(claudeFailureMessage(quota, "", 1)).toBe("claude provider quota/rate-limit failed");
expect(claudeFailureMessage(quota, "", 1)).toContain("claude provider quota/rate-limit failed");
expect(claudeExitCode(quota, "", 1)).toBe(5);
expect(claudeFailureMessage(auth, "", 1)).not.toContain("SOURCE_CONTEXT_SECRET");
expect(claudeFailureMessage(quota, "", 1)).not.toContain("SOURCE_CONTEXT_SECRET");
});

it("surfaces the reported OAuth failure shape without leaking result text", () => {
const stdout = [
JSON.stringify({ type: "assistant", error: "authentication_failed" }),
JSON.stringify({
type: "result",
subtype: "success",
is_error: true,
result: "Not logged in · Please run /login",
terminal_reason: "completed",
}),
].join("\n");

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

expect(message).toContain("claude provider auth/config failed");
expect(message).toContain("error=authentication_failed");
expect(message).toContain("is_error=true");
expect(message).toContain("reason=not-logged-in");
expect(message).not.toContain("Please run /login");
expect(claudeExitCode(stdout, "", 1)).toBe(4);
});

it("omits Claude error.message from stdout failure signals", () => {
const stdout = JSON.stringify({
type: "result",
Expand Down
Loading