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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
__debug_bin*
.idea
.DS_Store

# OpenSpec slash-command tooling (regenerate with `openspec init`); not tracked
.opencode/
39 changes: 22 additions & 17 deletions CLAUDE.md → AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# CLAUDE.md — Alpaca
# AGENTS.md - Alpaca

This is the contributor and AI-agent guide for Alpaca. It applies to any coding assistant or harness, not a specific tool.

## Project Overview

Expand All @@ -7,7 +9,7 @@ Alpaca is a local HTTP proxy for command-line tools written in Go. It supports:
- Proxy Auto-Configuration (PAC) files
- NTLM authentication
- Basic HTTP authentication
- Kerberos/Negotiate authentication (macOS only)
- Kerberos/Negotiate authentication (macOS via GSS.framework, Windows via SSPI)
- System keyring integration (macOS, Windows, Linux/GNOME)
- Automatic network switching (bypasses unreachable proxies)

Expand Down Expand Up @@ -43,7 +45,10 @@ alpaca/
├── authenticator.go # NTLM authentication
├── basicauth.go # Basic HTTP proxy authentication
├── multiauth.go # authChain: picks authenticators for a 407 response
├── kerberos*.go # Kerberos/Negotiate auth (macOS-specific)
├── kerberos_common.go # Shared Negotiate authenticator (darwin || windows)
├── kerberos_darwin.go # macOS Kerberos backend (GSS.framework, cgo)
├── kerberos_windows.go # Windows Kerberos backend (SSPI)
├── kerberos.go # Stub for platforms without a Kerberos backend
├── credentials.go # Credential sourcing (terminal, env, keyring)
├── keyring*.go # System keyring integration per platform
├── pacfinder*.go # PAC URL discovery (platform-specific)
Expand All @@ -65,10 +70,10 @@ alpaca/

Requests flow through a middleware chain built in `main.go:createServer`:

1. **AddContextID** assigns a unique ID to each request via context
2. **ProxyFinder.WrapHandler** discovers upstream proxy via PAC evaluation
3. **ProxyHandler.WrapHandler** routes proxy requests (CONNECT or absolute-form URIs); non-proxy requests pass through to the mux
4. **RequestLogger** logs all requests and responses
1. **AddContextID** - assigns a unique ID to each request via context
2. **ProxyFinder.WrapHandler** - discovers upstream proxy via PAC evaluation
3. **ProxyHandler.WrapHandler** - routes proxy requests (CONNECT or absolute-form URIs); non-proxy requests pass through to the mux
4. **RequestLogger** - logs all requests and responses

### Authentication Chain

Expand All @@ -89,7 +94,7 @@ connection-lifecycle invariants:
Type 1 → Type 3 sequence within a single method.
- The header `Proxy-Authorization` is cleared between attempts.
- Any error returned by a method aborts the chain (this is the
abort-on-error invariant see test `TestRetryProxyRequest_AbortsChainOnError`).
abort-on-error invariant - see test `TestRetryProxyRequest_AbortsChainOnError`).

Negotiate availability is re-checked per-407 via `applicableTo()` rather
than at startup, so a Kerberos ticket that arrives after alpaca starts
Expand All @@ -107,10 +112,10 @@ applicability rules.

### Key Interfaces

- `proxyAuthenticator` (in `proxy.go`) implemented by `authenticator`
- `proxyAuthenticator` (in `proxy.go`) - implemented by `authenticator`
(NTLM), `basicAuthenticator`, and `negotiateAuthenticator`. Methods:
`do(req, rt) (resp, err)`, `scheme()`, `applicableTo(host)`.
- `*authChain` (in `multiauth.go`) picks the ordered list of
- `*authChain` (in `multiauth.go`) - picks the ordered list of
authenticators to try given the schemes the proxy advertised. NOT a
`proxyAuthenticator` itself.

Expand Down Expand Up @@ -158,7 +163,7 @@ Both are enforced in CI.

### Style

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I'm noticing is that Claude is adding a lot of documentation in both markdown files and in comments, and often even repeating comments in multiple places. This is creating a few problems:

  1. General noise - the actual code that we're interested in is buried in comments (if we want to preserve the original intent, that's already in the spec files, so these code-level comments are redundant). This is distracting to humans like me, but I also imagine it is going to eat into the context window for coding agents too.
  2. Churn - e.g. every PR has to update all the comments that talk about which platforms are supported, and this makes even the diffs noisy. I mentioned this elsewhere in this PR review (as well as the previous one) but I might not have caught everything.

So there are a few things we should instruct coding agents to do, with regards to comments:

  • Prioritise writing readable, self-documenting code with descriptive variable and function names over adding comments.
  • Avoid writing comments that simply explain what the code is doing. Prefer writing comments to explain why a non-obvious approach was taken, such as working around a known library bug or optimising a critical bottleneck.
  • Avoid repeating the same information in multiple comments.
  • README.md contains all user-facing documentation. If there is detailed documentation for specific features that only apply to a subset of users, it should link to a file in the doc/ subdirectory.
  • Documentation should be as close to the code as possible. This means that code-level (or source-file-level) comments are preferred over markdown docs, unless there is too much information for a comment. In that case, the comment should reference a file in the doc/ subdirectory.

If you've got any other prompts, let's discuss adding them too. Are you able to ask Claude to remove comments based on these guidelines and see if and how much this helps?

Also I'm not sure where this belongs, maybe in another section...

  • Logs and error messages need to have enough detail to troubleshoot what went wrong.
  • Error messages also need to include information on how users can fix the error.


- **100-character line limit** enforced in CI
- **100-character line limit** - enforced in CI
- **Formatting:** `goimports` (not just `gofmt`)
- **Linting:** `golangci-lint`
- Follow [Effective Go](https://go.dev/doc/effective_go) patterns
Expand All @@ -172,15 +177,15 @@ Both are enforced in CI.
### Testing

- Use **table-driven tests** where applicable
- Use `assert` and `require` from [testify](https://github.com/stretchr/testify) not bare `if` checks
- Use `assert` and `require` from [testify](https://github.com/stretchr/testify) - not bare `if` checks
- Use `httptest.NewServer()` / `httptest.NewTLSServer()` for integration tests
- Every major component should have test coverage

### Commits

- Write clear, descriptive commit messages in plain English
- **Do not** use Conventional Commits prefixes (no `feat:`, `fix:`, `chore:`, etc.)
- Keep commits small and atomic do not mix refactors with feature work
- Keep commits small and atomic - do not mix refactors with feature work

## CI/CD

Expand Down Expand Up @@ -215,7 +220,7 @@ Triggered on tags matching `v*`. Creates a GitHub release and uploads platform-s

Files with platform build tags:

- `*_darwin.go` macOS-specific (Keychain, Kerberos, PAC via SCDynamicStore)
- `*_unix.go` Unix/Linux-specific (PAC discovery)
- `*_windows.go` Windows-specific (PAC discovery, credential management)
- `*_other.go` Fallback stubs for unsupported platforms
- `*_darwin.go` - macOS-specific (Keychain, Kerberos, PAC via SCDynamicStore)
- `*_unix.go` - Unix/Linux-specific (PAC discovery)
- `*_windows.go` - Windows-specific (PAC discovery, credential management)
- `*_other.go` - Fallback stubs for unsupported platforms
67 changes: 41 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

Alpaca is a local HTTP proxy for command-line tools. It supports proxy
auto-configuration (PAC) files, NTLM authentication, HTTP Basic
authentication, and (on macOS) Kerberos/Negotiate (SPNEGO) authentication.
authentication, and Kerberos/Negotiate (SPNEGO) authentication on macOS (and,
experimentally, Windows).
![alt text](assets/alpaca-banner.png)

## Install using Homebrew
Expand Down Expand Up @@ -61,12 +62,15 @@ If the proxy server requires valid authentication credentials, you can provide t

- HTTP Basic authentication, if `BASIC_CREDENTIALS=login:password` is set in
the environment;
- Kerberos / Negotiate, **automatically on macOS** when a ticket from Apple SSO
/ Ticket Viewer / `kinit` is available — no flag required (pass
`--no-kerberos` to opt out). Tickets that arrive *after* alpaca starts are
picked up automatically: alpaca re-checks credential availability on every
407 response, so a user who launches alpaca before signing in to Apple SSO
does not need to restart it once the ticket lands;
- Kerberos / Negotiate, **automatically on macOS** (and, **experimentally**, on
**Windows**) when a ticket is available, with no flag required (pass
`--no-kerberos` to opt out). macOS reads the
system credential cache via `GSS.framework` (populated by Apple SSO, Ticket
Viewer, or `kinit`); Windows reads the logon session's credential via SSPI.
Tickets that arrive *after* alpaca starts are picked up automatically: alpaca
re-checks credential availability on every 407 response, so a user who
launches alpaca before signing in does not need to restart it once the ticket
lands;
- NTLM via the shell prompt, if `-d` is passed;
- NTLM via the shell environment, if `NTLM_CREDENTIALS` is set;
- the system keyring (macOS, Windows and Linux/GNOME supported), if none of
Expand Down Expand Up @@ -187,24 +191,35 @@ When auth misbehaves, the first thing to check is alpaca's own log:

### Platform support for Kerberos

Kerberos / Negotiate authentication in this build is **macOS only**. It uses
Apple's `GSS.framework` to consume the system Kerberos credential cache —
the same one populated by Apple SSO, Ticket Viewer, and `kinit` — so no
extra configuration is required when a ticket is already present.

Windows and Linux Kerberos handling is intentionally out of scope for this
change; on those platforms `newNegotiateAuthenticator` returns `nil` and
Negotiate is transparently absent from the auth chain. Adding support on
either platform is a follow-up:

- **Windows** has system-wide Kerberos via SSPI (`Negotiate` package) and
could be implemented either via cgo against `security.h` or in pure Go
via `github.com/alexbrainman/sspi`.
- **Linux** has no system-wide credential store but `github.com/jcmturner/gokrb5`
can read the per-user `krb5cc_$UID` cache produced by `kinit`.

Both are clean drop-in additions next to `kerberos_darwin.go`, sharing
the same `proxyAuthenticator` interface.
Kerberos / Negotiate authentication is available on **macOS and Windows**:

- **macOS** uses Apple's `GSS.framework` to consume the system Kerberos
credential cache (the same one populated by Apple SSO, Ticket Viewer, and
`kinit`), and requests the service principal in the GSS host-based form
`HTTP@proxyhost`.
- **Windows** *(experimental)* uses SSPI's `Negotiate` package (via
`github.com/alexbrainman/sspi`) to consume the logon session's Kerberos
credential, and requests the service principal in the SPN form
`HTTP/proxyhost` that Active Directory registers. No cgo is required on
Windows.

> **Windows Kerberos support is experimental.** It has been validated
> end-to-end against a reference environment (a Windows 11 24H2 client joined
> to a Samba Active Directory domain, authenticating through a
> Negotiate-advertising Squid), but has not yet seen broad testing against
> production Active Directory and a range of proxies. Please report any issues
> you hit.

Both backends share one `negotiateAuthenticator` (in `kerberos_common.go`):
they differ only in the two platform calls, checking credential presence and
generating the SPNEGO token. Negotiate spans both Kerberos and NTLM per
RFC 4559; alpaca requests a single initiator token and lets the proxy validate
it, with no mutual-authentication round trips.

**Linux** has no system-wide credential store, so Negotiate is transparently
absent there (`newNegotiateAuthenticator` returns `nil`). Adding it is a
follow-up: `github.com/jcmturner/gokrb5` can read the per-user `krb5cc_$UID`
cache produced by `kinit`, as a clean drop-in next to the existing backends.

### Shell Prompt

Expand Down Expand Up @@ -270,7 +285,7 @@ can set this manually using the `-C` flag.
| `-d` | (none) | Domain of the proxy account (for NTLM auth) |
| `-u` | current user | Username for proxy auth (NTLM) |
| `-H` | `false` | Print hashed NTLM credentials and exit |
| `-no-kerberos` | `false` | Disable Kerberos / Negotiate auto-detection (macOS only) |
| `-no-kerberos` | `false` | Disable Kerberos / Negotiate auto-detection (macOS and Windows) |
| `-enable-socks` | `false` | Allow SOCKS5 proxies from PAC files. SOCKS5 has its own auth model and bypasses alpaca's HTTP authentication chain (and therefore the proxy-auth allowlist). |
| `-q` | `false` | Quiet mode, suppress all log output. Also suppresses the proxy-auth-allowlist startup nudge. |
| `-version` | `false` | Print version and exit |
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/samuong/alpaca/v2
go 1.25.0

require (
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e
github.com/gobwas/glob v0.2.3
github.com/keybase/go-keychain v0.0.1
github.com/robertkrimen/otto v0.5.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down
7 changes: 4 additions & 3 deletions kerberos.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build !darwin
//go:build !darwin && !windows

package main

// newNegotiateAuthenticator is a stub for non-macOS platforms. Kerberos
// authentication via GSS.framework is only available on macOS.
// newNegotiateAuthenticator is a stub for platforms without a Kerberos
// backend. Kerberos/Negotiate is implemented on macOS (GSS.framework,
// kerberos_darwin.go) and Windows (SSPI, kerberos_windows.go).
Comment on lines +20 to +21

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove the second sentence of this comment, so that it doesn't churn every time we add a new platform?

func newNegotiateAuthenticator() proxyAuthenticator {
return nil
}
103 changes: 103 additions & 0 deletions kerberos_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2026 The Alpaca Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build darwin || windows

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the various kerberos files were a bit confusing to me at first glance, can we rename them to:

  1. kerberos.go -> kerberos_stub.go
  2. kerberos_common.go -> kerberos.go


package main

import (
"encoding/base64"
"fmt"
"log"
"net/http"
"net/url"
)

// negotiateAuthenticator implements proxyAuthenticator using SPNEGO
// (Kerberos/Negotiate). The platform backends supply the two functions
// it depends on: checkKerberosTicket and generateSPNEGOToken. macOS supplies
// them via GSS.framework (kerberos_darwin.go), Windows via SSPI
// (kerberos_windows.go).
//
// It does NOT enforce a host allowlist itself; that's the picker's job
// (see *authChain.allowedHost), which applies uniformly to Basic, NTLM,
// and Negotiate. The only per-method applicability check Negotiate
// enforces is "do we currently have a Kerberos ticket?", re-checked on
// every 407 so a ticket that arrives mid-session is honoured
// automatically without an alpaca restart.
type negotiateAuthenticator struct {
// hasTicket is the ticket-availability check used by applicableTo
// at picker time. Defaults to checkKerberosTicket; tests inject
// their own to avoid depending on the developer's real Kerberos
// state.
hasTicket func() bool
}

func (n *negotiateAuthenticator) scheme() string { return "Negotiate" }

// applicableTo enforces two policies at picker time:
//
// 1. The proxy host must be non-empty (we cannot generate an SPN
// without it).
// 2. A usable credential must currently be available, as reported by the
// platform's checkKerberosTicket. We re-check on every 407 because the
// credential may have expired or been revoked since alpaca started; if
// it has, returning false here causes the picker to omit Negotiate and
// fall through to NTLM / Basic instead of failing the chain.
//
// Host policy (the ALPACA_PROXY_AUTH_ALLOWLIST gate) is enforced at the
// picker level in *authChain.pick, uniformly across Basic, NTLM, and
// Negotiate, so this method intentionally doesn't repeat that check.
//
// Returning false is silent fall-through; the chain proceeds to the
// next configured authenticator.
func (n *negotiateAuthenticator) applicableTo(proxyHost string) bool {
if proxyHost == "" {
return false
}
check := n.hasTicket
if check == nil {
check = checkKerberosTicket
}
if !check() {
log.Printf("Kerberos ticket no longer valid; skipping Negotiate for %s",
proxyHost)
return false
}
return true
}

// do performs Negotiate/SPNEGO proxy authentication. It generates a SPNEGO
// token for the upstream proxy and sends the request with a
// Proxy-Authorization: Negotiate header.
func (n *negotiateAuthenticator) do(req *http.Request, rt http.RoundTripper) (*http.Response, error) {
// Get the proxy host from the request context.
proxyHost := ""
if value := req.Context().Value(contextKeyProxy); value != nil {
proxy := value.(*url.URL)
proxyHost = proxy.Hostname()
}
if proxyHost == "" {
return nil, fmt.Errorf("cannot determine proxy host for Negotiate auth")
}

token, err := generateSPNEGOToken(proxyHost)
if err != nil {
log.Printf("Error generating SPNEGO token for %s: %v", proxyHost, err)
return nil, err
}

req.Header.Set("Proxy-Authorization", "Negotiate "+base64.StdEncoding.EncodeToString(token))
return rt.RoundTrip(req)
}
Loading