diff --git a/cmd/run.go b/cmd/run.go index 21cd556..eec4c71 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -6,11 +6,13 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "net/url" "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" @@ -146,7 +148,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { return err } env = newEnv - fmt.Fprintf(os.Stderr, "%s routing HTTP/HTTPS through MITM proxy (127.0.0.1:%d)\n", successText("agent-vault:"), mitmPort) + fmt.Fprintf(os.Stderr, "%s routing HTTP/HTTPS through MITM proxy (%s)\n", successText("agent-vault:"), net.JoinHostPort(resolveMITMHost(addr), strconv.Itoa(mitmPort))) // 7. If the target command is a supported agent, offer to install the // Agent Vault skill (only when not already present). @@ -369,6 +371,18 @@ func stripEnvKeys(env []string, keys map[string]struct{}) []string { return out } +// resolveMITMHost extracts the host the child process should dial for +// the MITM proxy from the configured server address. Falls back to +// loopback when addr is unparseable or has no host. +func resolveMITMHost(addr string) string { + if u, err := url.Parse(addr); err == nil { + if h := u.Hostname(); h != "" { + return h + } + } + return "127.0.0.1" +} + // requireMITMEnv calls augmentEnvWithMITM and converts both transport // failures and a server-side --mitm-port 0 into actionable errors. // MITM is the only ingress, so neither case is recoverable for vault run. @@ -424,16 +438,9 @@ func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]stri return env, 0, false, fmt.Errorf("write CA: %w", err) } - mitmHost := "127.0.0.1" - if u, err := url.Parse(addr); err == nil { - if h := u.Hostname(); h != "" { - mitmHost = h - } - } - env = stripEnvKeys(env, mitmInjectedKeys) env = append(env, isolation.BuildProxyEnv(isolation.ProxyEnvParams{ - Host: mitmHost, + Host: resolveMITMHost(addr), Port: port, Token: token, Vault: vault, diff --git a/internal/isolation/env.go b/internal/isolation/env.go index daf19dc..23d957b 100644 --- a/internal/isolation/env.go +++ b/internal/isolation/env.go @@ -4,7 +4,9 @@ package isolation import ( "fmt" + "net" "net/url" + "strconv" ) const ( @@ -46,7 +48,7 @@ func BuildProxyEnv(p ProxyEnvParams) []string { proxyURL := (&url.URL{ Scheme: scheme, User: url.UserPassword(p.Token, p.Vault), - Host: fmt.Sprintf("%s:%d", p.Host, p.Port), + Host: net.JoinHostPort(p.Host, strconv.Itoa(p.Port)), }).String() return []string{ "HTTPS_PROXY=" + proxyURL, diff --git a/internal/isolation/env_test.go b/internal/isolation/env_test.go index f2aa692..781b4f2 100644 --- a/internal/isolation/env_test.go +++ b/internal/isolation/env_test.go @@ -95,6 +95,34 @@ func TestBuildContainerEnv_FirewallPortsEmitted(t *testing.T) { } } +// IPv6 literals must be bracketed in the proxy URL authority so +// net.SplitHostPort and downstream HTTP clients accept them. The bare +// "::1:port" form is rejected as "too many colons". +func TestBuildProxyEnv_IPv6HostIsBracketed(t *testing.T) { + env := BuildProxyEnv(ProxyEnvParams{ + Host: "::1", + Port: 14322, + Token: "tok", + Vault: "v", + CAPath: "/tmp/ca.pem", + MITMTLS: true, + }) + vars := envMap(env) + u, err := url.Parse(vars["HTTPS_PROXY"]) + if err != nil { + t.Fatalf("parse HTTPS_PROXY %q: %v", vars["HTTPS_PROXY"], err) + } + if u.Hostname() != "::1" { + t.Errorf("hostname = %q, want ::1", u.Hostname()) + } + if u.Port() != "14322" { + t.Errorf("port = %q, want 14322", u.Port()) + } + if !strings.Contains(vars["HTTPS_PROXY"], "[::1]:14322") { + t.Errorf("HTTPS_PROXY = %q, want bracketed [::1]:14322 authority", vars["HTTPS_PROXY"]) + } +} + // HTTP_PROXY mirrors HTTPS_PROXY: both point at the same TLS-wrapped // MITM ingress so plain http:// upstreams route through the broker via // absolute-form forward-proxy requests. diff --git a/internal/mitm/forward_test.go b/internal/mitm/forward_test.go index 46b7337..97bc385 100644 --- a/internal/mitm/forward_test.go +++ b/internal/mitm/forward_test.go @@ -591,17 +591,19 @@ func TestMITMForwardKeepalivePersistsAcrossRequests(t *testing.T) { } } -// TestMITMForwardIPv6LiteralCanonicalises guards against the IPv6 -// double-bracket regression: net.SplitHostPort/JoinHostPort on the -// bracketed-no-port form ("[::1]") produces "[[::1]]:80". Going -// through r.URL.Hostname() / r.URL.Port() instead yields the -// canonical "[::1]:80" target. Sending raw via dialProxyTLS because -// Go's http.Client routinely rewrites URLs through ProxyURL in ways +// TestMITMForwardIPv6PreservesHostHeader locks in the Host-header +// port-preservation fix on the IPv6 forward-proxy path: outReq.Host +// must equal target ("[::1]:port"), not the port-stripped form ("::1") +// the old code emitted. The request line carries an explicit port so +// the no-port canonicalisation branch (URL.Hostname/Port instead of +// net.SplitHostPort) is not driven here — exercising that end-to-end +// would require binding port 80 on ::1. Sending raw via dialProxyTLS +// because Go's http.Client rewrites URLs through ProxyURL in ways // that would obscure what we want to assert. -func TestMITMForwardIPv6LiteralCanonicalises(t *testing.T) { - // Bind an upstream on ::1 so the URL we send is exactly the - // IPv6-literal-no-port form. SkipNow if the host has no IPv6 - // loopback (CI sometimes). +func TestMITMForwardIPv6PreservesHostHeader(t *testing.T) { + // Bind an upstream on an ephemeral ::1 port so we can send an + // IPv6-literal-with-port URL through the forward proxy. SkipNow if + // the host has no IPv6 loopback (CI sometimes). l, err := net.Listen("tcp", "[::1]:0") if err != nil { t.Skipf("no IPv6 loopback available: %v", err) diff --git a/internal/server/instructions.txt b/internal/server/instructions.txt deleted file mode 100644 index 9466fff..0000000 --- a/internal/server/instructions.txt +++ /dev/null @@ -1,64 +0,0 @@ -You have access to Agent Vault, a transparent HTTP/HTTPS proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -## Quick Start - -1. Store env vars from the redemption response: AGENT_VAULT_ADDR (server URL), AGENT_VAULT_SESSION_TOKEN (bearer token), AGENT_VAULT_VAULT (vault name). -2. Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (the response carries the MITM port in the X-MITM-Port header, default 14322) and set HTTPS_PROXY=https://{AGENT_VAULT_SESSION_TOKEN}:{AGENT_VAULT_VAULT}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. -3. Call GET {AGENT_VAULT_ADDR}/discover with Authorization: Bearer {AGENT_VAULT_SESSION_TOKEN} to learn which hosts have credentials configured. -4. For any host listed in /discover, just call the real upstream URL (over https:// or http://) — Agent Vault intercepts the request, injects credentials, and forwards. For all others, call directly (the proxy passes them through unmodified). - -Example: GET https://api.stripe.com/v1/charges?limit=10 - -## Proposals — Requesting Access to New Services - -When you need a credential or proxy access to a host not in /discover, raise a proposal. A 403 response includes a proposal_hint with the denied host. - -IMPORTANT: Before raising a proposal, you MUST look up how the target service authenticates API requests. Check the service's API docs to determine the correct auth type. Do not guess — wrong auth wastes the operator's time and will fail at the proxy. - -### Auth Types - - bearer — Authorization: Bearer {"auth": {"type": "bearer", "token": "SECRET_KEY"}} - basic — HTTP Basic (user, optional password) {"auth": {"type": "basic", "username": "API_KEY"}} - api-key — key in a named header, optional prefix {"auth": {"type": "api-key", "key": "SECRET", "header": "x-api-key"}} - custom — freeform header templates {"auth": {"type": "custom", "headers": {"X-Key": "{{ SECRET }}"}}} - -Common services: Stripe (bearer), GitHub (bearer), OpenAI (bearer), Ashby (basic — API key as username), Jira (basic — email + token), Anthropic (api-key, header: x-api-key). If unlisted, check the API docs. - -### Creating a Proposal - - POST {AGENT_VAULT_ADDR}/v1/proposals - Authorization: Bearer {AGENT_VAULT_SESSION_TOKEN} - Content-Type: application/json - - { - "services": [{"action": "set", "host": "api.stripe.com", "description": "Stripe API", "auth": {"type": "bearer", "token": "STRIPE_KEY"}}], - "credentials": [{"action": "set", "key": "STRIPE_KEY", "description": "Stripe credential key", "obtain": "https://dashboard.stripe.com/apikeys", "obtain_instructions": "Developers → API Keys → Reveal test key"}], - "message": "Need Stripe API key for billing feature", - "user_message": "I need access to your Stripe account to build the checkout page." - } - -Key fields: -- services[].action: "set" (upsert, needs host + auth) or "delete" (needs host only) -- credentials[].action: "set" (omit value for human to supply; include value to store back) or "delete" -- credentials: only declare credentials not already in available_credentials from /discover -- message: developer-facing explanation; user_message: shown on approval page -- credentials[].obtain: URL where the human can get the credential; obtain_instructions: steps to find it - -Response includes approval_url. After creating: -1. Present approval_url to the user conversationally -2. Immediately start polling GET {AGENT_VAULT_ADDR}/v1/proposals/{id} — do NOT wait for the user to say "go on" or confirm. Poll every 3s for the first 30s, then every 10s. Stop after 10 minutes (proposal may have expired). -3. Once status is "applied", automatically retry your original request and continue your task - -## Error Handling - -- 401: Invalid/expired token — check AGENT_VAULT_SESSION_TOKEN -- 403: Host not allowed — raise a proposal -- 429: Too many pending proposals — wait for review -- 502: Missing credential or upstream unreachable — tell user - -## Rules - -- Never attempt to extract, log, or display credentials -- Never hardcode tokens — always use AGENT_VAULT_SESSION_TOKEN -- Only proxy hosts returned by /discover — if not listed, raise a proposal -- If you receive a credential_not_found error, inform the user which credential is missing diff --git a/internal/server/persistent_agent_instructions.txt b/internal/server/persistent_agent_instructions.txt deleted file mode 100644 index a362664..0000000 --- a/internal/server/persistent_agent_instructions.txt +++ /dev/null @@ -1,66 +0,0 @@ -You are a persistent agent registered with Agent Vault, an HTTP proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -## Token - -You received an agent token (av_agent_token). Use it for all proxy/discover/proposal calls. If your token has no expiry, it works indefinitely. If it does expire, contact your operator for a new token. - -## Discovery & Proxy - - GET {AGENT_VAULT_ADDR}/discover — list brokerable services + available_credentials - -Authorization: Bearer {av_agent_token}, plus X-Vault: {vault_name} on every vault-scoped request. - -Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (the response's X-MITM-Port header gives you the proxy port, default 14322) and set HTTPS_PROXY=https://{av_agent_token}:{vault_name}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. Then call the real upstream URL (over https:// or http://) — Agent Vault attaches real credentials and forwards. Hosts not in /discover pass through unmodified. - -## Proposals — Requesting Access to New Services - -When you need a credential or proxy access to a host not in /discover, raise a proposal. A 403 response includes a proposal_hint with the denied host. - -IMPORTANT: Before raising a proposal, you MUST look up how the target service authenticates API requests. Check the service's API docs to determine the correct auth type. Do not guess — wrong auth wastes the operator's time and will fail at the proxy. - -### Auth Types - - bearer — Authorization: Bearer {"auth": {"type": "bearer", "token": "SECRET_KEY"}} - basic — HTTP Basic (user, optional password) {"auth": {"type": "basic", "username": "API_KEY"}} - api-key — key in a named header, optional prefix {"auth": {"type": "api-key", "key": "SECRET", "header": "x-api-key"}} - custom — freeform header templates {"auth": {"type": "custom", "headers": {"X-Key": "{{ SECRET }}"}}} - -Common services: Stripe (bearer), GitHub (bearer), OpenAI (bearer), Ashby (basic — API key as username), Jira (basic — email + token), Anthropic (api-key, header: x-api-key). If unlisted, check the API docs. - -### Creating a Proposal - - POST {AGENT_VAULT_ADDR}/v1/proposals - Authorization: Bearer {av_agent_token} - Content-Type: application/json - - { - "services": [{"action": "set", "host": "api.stripe.com", "description": "Stripe API", "auth": {"type": "bearer", "token": "STRIPE_KEY"}}], - "credentials": [{"action": "set", "key": "STRIPE_KEY", "description": "Stripe API key", "obtain": "https://dashboard.stripe.com/apikeys", "obtain_instructions": "Developers → API Keys → Reveal test key"}], - "message": "Need Stripe API key for billing feature", - "user_message": "I need access to your Stripe account to build the checkout page." - } - -Key fields: -- services[].action: "set" (upsert, needs host + auth) or "delete" (needs host only) -- credentials[].action: "set" (omit value for human to supply; include value to store back) or "delete" -- credentials: only declare credentials not already in available_credentials from /discover -- message: developer-facing explanation; user_message: shown on approval page -- credentials[].obtain: URL where the human can get the credential; obtain_instructions: steps to find it - -Response includes approval_url. After creating: -1. Present approval_url to the user conversationally -2. Immediately start polling GET {AGENT_VAULT_ADDR}/v1/proposals/{id} — do NOT wait for the user to say "go on" or confirm. Poll every 3s for the first 30s, then every 10s. Stop after 10 minutes (proposal may have expired). -3. Once status is "applied", automatically retry your original request and continue your task - -## Error Handling - -- 401: Invalid/expired token — contact your operator for a new token -- 403: Host not allowed — raise a proposal -- 429: Too many pending proposals — wait for review -- 502: Missing credential or upstream unreachable — tell user - -## Rules - -- Never attempt to extract, log, or expose credentials -- Only proxy hosts returned by /discover — if not listed, raise a proposal -- If you receive a credential_not_found error, inform the user which credential is missing diff --git a/internal/server/persistent_instructions_member.txt b/internal/server/persistent_instructions_member.txt deleted file mode 100644 index 295e0c8..0000000 --- a/internal/server/persistent_instructions_member.txt +++ /dev/null @@ -1,36 +0,0 @@ -You are a persistent agent registered with Agent Vault, an HTTP proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -IMPORTANT: Whenever your task involves ANY external service or API (Linear, GitHub, Stripe, Jira, Slack, Asana, GitLab, or any other authenticated service), you MUST check Agent Vault BEFORE telling the user you lack access. Call /discover to see what's available, then route requests through the proxy. If the service isn't listed, raise a proposal — never just say "I can't access that." - -Your vault role is **member**. You can proxy requests, discover services, raise proposals, set/delete credentials directly, approve/reject proposals, manage vault services, and invite proxy-level agents. - -## Token - -You received an agent token (av_agent_token). Use it for all proxy/discover/proposal calls. If your token has no expiry, it works indefinitely. If it does expire, contact your operator for a new token. - -## Quick Start - -1. Call GET {AGENT_VAULT_ADDR}/discover with Authorization: Bearer {av_agent_token} — do this FIRST. -2. Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (X-MITM-Port response header gives the port, default 14322) and set HTTPS_PROXY=https://{av_agent_token}:{vault_name}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. -3. For hosts in the discover response, just call the real upstream URL (over https:// or http://) — Agent Vault injects credentials. For others, requests pass through unmodified. -4. If a needed service isn't listed, raise a proposal. - -## Member Capabilities - -As a member, you can also: -- Set credentials: POST {AGENT_VAULT_ADDR}/v1/credentials with {"vault": "...", "credentials": {"KEY": "value"}} -- Delete credentials: DELETE {AGENT_VAULT_ADDR}/v1/credentials with {"vault": "...", "keys": ["KEY"]} -- Approve proposals: POST {AGENT_VAULT_ADDR}/v1/admin/proposals/{id}/approve with {"vault": "...", "credentials": {"KEY": "value"}} -- Reject proposals: POST {AGENT_VAULT_ADDR}/v1/admin/proposals/{id}/reject with {"vault": "...", "reason": "..."} -- Invite proxy agents: POST {AGENT_VAULT_ADDR}/v1/invites with {"vault": "...", "vault_role": "proxy"} - -## Full Reference - -For the complete Agent Vault reference including auth types, proposal format, error handling, and behavioral rules, fetch: - - GET {AGENT_VAULT_ADDR}/v1/skills/http - -## Rules - -- Never attempt to extract, log, or expose credentials -- Only proxy hosts returned by /discover — if not listed, raise a proposal diff --git a/internal/server/persistent_instructions_proxy.txt b/internal/server/persistent_instructions_proxy.txt deleted file mode 100644 index 2f8c2f8..0000000 --- a/internal/server/persistent_instructions_proxy.txt +++ /dev/null @@ -1,27 +0,0 @@ -You are a persistent agent registered with Agent Vault, an HTTP proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -IMPORTANT: Whenever your task involves ANY external service or API (Linear, GitHub, Stripe, Jira, Slack, Asana, GitLab, or any other authenticated service), you MUST check Agent Vault BEFORE telling the user you lack access. Call /discover to see what's available, then route requests through the proxy. If the service isn't listed, raise a proposal — never just say "I can't access that." - -Your vault role is **proxy**. You can proxy requests, discover services, and raise proposals. You cannot set/delete credentials directly or approve/reject proposals — a member or admin must do that. - -## Token - -You received an agent token (av_agent_token). Use it for all proxy/discover/proposal calls. If your token has no expiry, it works indefinitely. If it does expire, contact your operator for a new token. - -## Quick Start - -1. Call GET {AGENT_VAULT_ADDR}/discover with Authorization: Bearer {av_agent_token} — do this FIRST. -2. Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (X-MITM-Port response header gives the port, default 14322) and set HTTPS_PROXY=https://{av_agent_token}:{vault_name}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. -3. For hosts in the discover response, just call the real upstream URL (over https:// or http://) — Agent Vault injects credentials. For others, requests pass through unmodified. -4. If a needed service isn't listed, raise a proposal. - -## Full Reference - -For the complete Agent Vault reference including auth types, proposal format, error handling, and behavioral rules, fetch: - - GET {AGENT_VAULT_ADDR}/v1/skills/http - -## Rules - -- Never attempt to extract, log, or expose credentials -- Only proxy hosts returned by /discover — if not listed, raise a proposal