Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Skills define what Warden analyzes and when.
| `ignorePaths` | Files to exclude (glob patterns) |
| `failOn` | Minimum severity to fail: `critical`, `high`, `medium`, `low`, `info`, `off` |
| `reportOn` | Minimum severity to report |
| `remote` | GitHub repository for remote skills: `owner/repo` or `owner/repo@sha` |
| `remote` | GitHub repository for remote skills: `owner/repo` or `owner/repo@sha`. Hosted private remotes use `github-token` auth on `github.com`. |
| `model` | Model override |
| `maxTurns` | Max agentic turns per hunk |

Expand Down Expand Up @@ -473,7 +473,7 @@ jobs:

| Input | Default | Description |
|-------|---------|-------------|
| `github-token` | `GITHUB_TOKEN` | GitHub token for posting comments |
| `github-token` | `GITHUB_TOKEN` | GitHub token for posting comments and private remote-skill fetch auth in hosted runs |
| `anthropic-api-key` | - | Anthropic API key (falls back to `WARDEN_ANTHROPIC_API_KEY`) |
| `config-path` | `warden.toml` | Path to config file |
| `fail-on` | - | Minimum severity to fail the check |
Expand Down
15 changes: 13 additions & 2 deletions packages/docs/src/pages/config.astro
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ actions = ["opened", "synchronize"]`}
<dt>reportOn</dt>
<dd>Minimum severity to report</dd>
<dt>remote</dt>
<dd>GitHub repository for remote skills: <code>owner/repo</code> or <code>owner/repo@sha</code></dd>
<dd>
GitHub repository for remote skills: <code>owner/repo</code> or <code>owner/repo@sha</code>.
In hosted GitHub Actions runs, private remotes use <code>github-token</code> auth on <code>github.com</code>.
</dd>
<dt>model</dt>
<dd>Model override (optional)</dd>
<dt>maxTurns</dt>
Expand Down Expand Up @@ -448,7 +451,7 @@ jobs:

<dl>
<dt>github-token</dt>
<dd>GitHub token for posting comments. Default: <code>GITHUB_TOKEN</code></dd>
<dd>GitHub token for posting comments and private remote-skill fetch auth in hosted runs. Default: <code>GITHUB_TOKEN</code></dd>
<dt>anthropic-api-key</dt>
<dd>Anthropic API key (falls back to <code>WARDEN_ANTHROPIC_API_KEY</code>)</dd>
<dt>config-path</dt>
Expand All @@ -466,5 +469,13 @@ jobs:
<dt>parallel</dt>
<dd>Maximum concurrent trigger executions. Default: <code>5</code></dd>
</dl>
<h3>Private Remote Troubleshooting</h3>

<ul>
<li>Use a token with repository read access (for example <code>contents: read</code> equivalent).</li>
<li>For GitHub App tokens, ensure the app is installed on the remote skill repository.</li>
<li>Hosted auth support in this version is scoped to <code>github.com</code> remotes.</li>
<li>SSH URLs are no longer required in hosted checks when token access is configured.</li>
</ul>
</DocsPageShell>
</Base>
165 changes: 165 additions & 0 deletions specs/private-remote-git-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Private Remote Git Auth

## Problem

Remote skills can be sourced from GitHub repositories via `skills[].remote`.

That works for public repositories and for private repositories when the runtime can use SSH credentials. It does not work reliably in hosted GitHub Actions runs, where:

- the checkout token is available as `GITHUB_TOKEN`
- SSH keys are usually not configured
- remote skill repositories may be private

The result is that private remote skills are difficult to use in hosted runs even when GitHub already issued a token with the right repository access.

## Goals

- Allow hosted GitHub Action runs to fetch private remote skills from `github.com` using the existing `github-token` input.
- Preserve current behavior for public remotes and for non-GitHub remotes.
- Avoid embedding credentials in clone URLs, persisted cache state, or user-visible error messages.
- Keep remote resolution behavior consistent across PR and schedule workflows.

## Non-Goals

- Add a general-purpose credential system for arbitrary git hosts.
- Persist credentials in git config, state files, or cache directories.
- Change CLI auth flows beyond using the options already threaded into remote resolution.
- Add token discovery from new environment variables as part of this change.

## User-Facing Behavior

### Configuration

No config format changes are required.

Remote skills continue to use:

- `owner/repo`
- `owner/repo@sha`
- `https://github.com/owner/repo.git`
- `git@github.com:owner/repo.git`

### Hosted GitHub Actions

When Warden resolves a remote skill or agent in GitHub Actions:

- the action passes `github-token` into remote resolution
- if the remote resolves to `github.com` and the token is non-empty, fetches use one-shot git auth env injection
- authenticated runtime transport uses `https://github.com/owner/repo.git`
- configured remote syntax is still preserved in cache metadata for future non-authenticated refreshes

### Non-GitHub Remotes

If a cached or configured remote is not a `github.com` remote:

- no GitHub auth env is injected
- existing clone/fetch behavior is preserved
- failures from that host should surface as the underlying git failure, not as GitHub-token guidance

## Auth Model

### Token Source

This change relies on the existing action input:

- `github-token`, which already defaults to `GITHUB_TOKEN`

Whitespace-only values are treated as unset.

### Git Transport

For authenticated `github.com` fetches:

- Warden does not rewrite the configured remote reference
- Warden builds a per-command git environment using `http.https://github.com/.extraheader`
- the Authorization header is sent only for that git subprocess
- the token is never placed in the clone URL

### Persistence Rules

Warden may persist the original remote URL form in cache state for refresh behavior.

Warden must not persist:

- the token
- an authenticated URL
- an auth header

## Resolution Rules

### Remote Detection

GitHub auth is used only when both conditions hold:

1. A non-empty `githubToken` is present.
2. The effective remote is a `github.com` remote.

The effective remote is determined from:

- the explicit remote URL from the current ref, if present
- otherwise the stored `cloneUrl` in cache state, if present
- otherwise shorthand `owner/repo`, which is treated as GitHub

### Refresh Semantics

For unpinned remotes:

- cached explicit remote URLs remain authoritative for refresh behavior
- cached SSH remotes continue to refresh via SSH unless GitHub auth is actively being used for that fetch
- authenticated GitHub fetches reset to `FETCH_HEAD`

For pinned remotes:

- the cached SHA remains authoritative
- the repository is only fetched when not cached or when force-refresh behavior requires it

## Error Semantics

### Authenticated GitHub Fetches

When the authenticated GitHub path is in use and git returns an auth-shaped failure, Warden should raise a high-signal loader error:

- `Failed to authenticate when cloning owner/repo. Ensure the provided GitHub token has read access ...`

The original error must be preserved as `cause`.

### Unauthenticated Shorthand HTTPS Fetches

When a shorthand GitHub remote falls back to HTTPS without a token and git cannot prompt for credentials, Warden should raise guidance that points to:

- providing a GitHub token
- or using the SSH remote form

### Non-GitHub Failures

If GitHub auth was not used, Warden should not rewrite failures as GitHub token problems merely because a token happened to be present.

## Integration Points

The token must be threaded through:

- action input parsing
- PR workflow trigger execution
- schedule workflow skill execution
- loader remote resolution for both skills and agents

This change does not require schema changes in `warden.toml`.

## Tests

Required coverage:

- token is threaded from action workflows into remote resolution
- whitespace-only tokens are ignored
- GitHub SSH remotes use runtime HTTPS plus auth env when a token is present
- non-GitHub remotes do not receive GitHub auth env
- non-GitHub failures are not rewritten into GitHub-specific auth errors
- auth-related errors preserve the original cause
- tokens do not appear in thrown messages
- concurrent fetches keep auth env isolated per command

## Operational Notes

- GitHub token must have repository read access to the remote skill repository.
- For GitHub App tokens, the app must be installed on both the code repository and the remote skill repository.
- This spec intentionally limits authenticated remote support to `github.com`.
17 changes: 16 additions & 1 deletion src/action/triggers/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('../../output/renderer.js', () => ({
import { runSkillTask } from '../../cli/output/tasks.js';
import { createSkillCheck, updateSkillCheck, failSkillCheck } from '../../output/github-checks.js';
import { renderSkillReport } from '../../output/renderer.js';
import { resolveSkillAsync } from '../../skills/loader.js';

describe('executeTrigger', () => {
// Suppress console output during tests
Expand Down Expand Up @@ -75,6 +76,7 @@ describe('executeTrigger', () => {
context: mockContext,
config: mockConfig,
anthropicApiKey: 'test-key',
githubToken: 'gh-token',
claudePath: '/test/claude',
globalMaxFindings: 10,
};
Expand Down Expand Up @@ -102,7 +104,12 @@ describe('executeTrigger', () => {
vi.mocked(updateSkillCheck).mockResolvedValue(undefined);
vi.mocked(renderSkillReport).mockReturnValue(mockRenderResult);

const result = await executeTrigger(mockTrigger, mockDeps);
const triggerWithRemote: ResolvedTrigger = {
...mockTrigger,
remote: 'owner/repo',
};

const result = await executeTrigger(triggerWithRemote, mockDeps);

expect(result.triggerName).toBe('test-trigger');
expect(result.report).toBe(mockReport);
Expand All @@ -122,6 +129,14 @@ describe('executeTrigger', () => {
minConfidence: 'medium',
failCheck: undefined,
});

const taskOptions = vi.mocked(runSkillTask).mock.calls[0]?.[0];
expect(taskOptions).toBeDefined();
await taskOptions?.resolveSkill();
expect(resolveSkillAsync).toHaveBeenCalledWith('test-skill', '/test/path', {
remote: 'owner/repo',
githubToken: 'gh-token',
});
});

it('executes a trigger successfully with no findings', async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/action/triggers/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TriggerExecutorDeps {
context: EventContext;
config: WardenConfig;
anthropicApiKey: string;
githubToken?: string;
claudePath: string;
/** Global fail-on from action inputs (trigger-specific takes precedence) */
globalFailOn?: SeverityThreshold;
Expand Down Expand Up @@ -131,6 +132,7 @@ export async function executeTrigger(
failOn,
resolveSkill: () => resolveSkillAsync(trigger.skill, context.repoPath, {
remote: trigger.remote,
githubToken: deps.githubToken,
}),
context: filterContextByPaths(context, trigger.filters),
runnerOptions: {
Expand Down
5 changes: 3 additions & 2 deletions src/action/workflow/pr-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ async function executeAllTriggers(
context,
config,
anthropicApiKey: inputs.anthropicApiKey,
githubToken: inputs.githubToken,
claudePath,
globalFailOn: inputs.failOn,
globalReportOn: inputs.reportOn,
Expand Down Expand Up @@ -413,7 +414,7 @@ async function evaluateFixesAndResolveStale(
logAction(`Resolved ${resolvedCount} comments via fix evaluation`);
}
// Track only actually resolved comments for allResolved check
resolvedIds.forEach((id) => commentsResolvedByFixEval.add(id));
for (const id of resolvedIds) commentsResolvedByFixEval.add(id);
}

// Post replies for failed fixes and track them so stale pass doesn't override
Expand Down Expand Up @@ -477,7 +478,7 @@ async function evaluateFixesAndResolveStale(
emitStaleResolutionMetric(count, skill);
}
}
resolvedIds.forEach((id) => commentsResolvedByStale.add(id));
for (const id of resolvedIds) commentsResolvedByStale.add(id);
}
} catch (error) {
Sentry.captureException(error, { tags: { operation: 'resolve_stale_comments' } });
Expand Down
4 changes: 4 additions & 0 deletions src/action/workflow/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ describe('runScheduleWorkflow', () => {

await runScheduleWorkflow(mockOctokit, createDefaultInputs(), SCHEDULE_FIXTURES);

expect(mockResolveSkillAsync).toHaveBeenCalledWith('test-skill', SCHEDULE_FIXTURES, {
remote: undefined,
githubToken: 'test-github-token',
});
expect(mockRunSkill).toHaveBeenCalledTimes(1);
expect(mockCreateOrUpdateIssue).toHaveBeenCalledWith(
mockOctokit,
Expand Down
1 change: 1 addition & 0 deletions src/action/workflow/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export async function runScheduleWorkflow(
// Run skill
const skill = await resolveSkillAsync(resolved.skill, repoPath, {
remote: resolved.remote,
githubToken: inputs.githubToken,
});
const claudePath = await findClaudeCodeExecutable();
const report = await runSkill(skill, context, {
Expand Down
15 changes: 15 additions & 0 deletions src/skills/auth-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Optional authentication options for remote skill/agent fetches.
*/
export interface RemoteAuthOptions {
/**
* GitHub token for authenticating private remote skill/agent fetches.
*
* Accepts: PAT (classic/fine-grained), GitHub App token, or GITHUB_TOKEN.
* Must have repository read access to the remote skill repo.
* For GitHub Apps, the app must be installed on the remote repo.
*
* Whitespace-only values are treated as unset (useful for CI env vars).
*/
githubToken?: string;
}
2 changes: 2 additions & 0 deletions src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type {
ResolveSkillOptions,
} from './loader.js';

export type { RemoteAuthOptions } from './auth-options.js';

export {
parseRemoteRef,
formatRemoteRef,
Expand Down
Loading
Loading