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
12 changes: 11 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ order they are loaded (later files win on conflicts):
Windows uses the same XDG logic for the global config, so the default is
`%USERPROFILE%\.config\phosphor\phosphor.json` unless `XDG_CONFIG_HOME` is set.

All files are **deep-merged per JSON key**, not replaced wholesale — a project
file that sets only `models.large` leaves everything else in your global file
intact. The files with the higher numbers above override the lower ones for
any key they define.

The **global data config** is where Phosphor persists things it learns at runtime
(recently used models, per-provider settings saved from the TUI). The **project
config** is safe to commit to a repository to share settings with your team.
If the TUI ever writes something to the global data config that you disagree
with (e.g. a `models.small` selection), edit or delete that key there — it
overrides the same key in your global config.

The workspace also gets a **data directory** (default `.phosphor/` at the project
root, configurable via `options.data_directory` or `--data-dir`). It holds the
Expand Down Expand Up @@ -256,7 +264,9 @@ The minimum viable cloud setup:
```

`large` is the primary coding/reasoning model; `small` handles title generation
and lightweight sub-tasks. Model entries also accept sampling overrides
and lightweight sub-tasks (summarization, `agentic_fetch`). If `small` is
omitted it falls back to `large` — Phosphor never auto-selects a small model
from another provider. Model entries also accept sampling overrides
(`temperature`, `top_p`, `top_k`, `max_tokens`, `seed`, `min_p`,
`repetition_penalty`, `stop`, …) plus `reasoning_effort` (OpenAI-style),
`think` (Anthropic), and `max_thinking_tokens`.
Expand Down
16 changes: 16 additions & 0 deletions docs/MODELS_AND_PROVIDERS_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,22 @@ The `models` section selects which model to use for different tasks. Phosphor us
- **`large`** — Primary model for coding, reasoning, and complex tasks
- **`small`** — Fallback model for title generation, quick responses, and sub-agents

If `small` is omitted, it falls back to the `large` model rather than
auto-selecting one from the provider catalog. Every consumer of the small
slot (title generation, summarization, `agentic_fetch`) uses the large model
until you pin `models.small` explicitly. A `models.small` whose provider or
model no longer resolves also falls back to `large`.

The list of providers and models offered in the model dialog is **not**
stored in your config: it comes from the [Catwalk](https://catwalk.charm.land)
catalog, fetched at runtime and cached alongside the global data config
(`%LOCALAPPDATA%\phosphor\providers.json` on Windows,
`~/.local/share/phosphor/providers.json` on Linux/macOS; override the source
with `CATWALK_URL`). A known provider appears in the dialog as soon as its
API key is available in your environment — that does **not** mean it is
selected. `phosphor.json` only pins or overrides choices; anything you don't
pin resolves against the catalog.

### Model Fields

| Field | Type | Required | Description |
Expand Down
59 changes: 39 additions & 20 deletions docs/security/NETWORK_EGRESS_HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Phosphor's web-fetch and web-search tools now operate under an **Adaptive Trust*

## Components

### `internal/agent/tools/web_fetch.go`
### `pkg/agent/tools/web_fetch.go`

- `securityTransport` — custom `http.RoundTripper` that intercepts each request
- `allowList` — in-memory session allow cache (cleared on session restart)
Expand All @@ -42,7 +42,7 @@ Phosphor's web-fetch and web-search tools now operate under an **Adaptive Trust*
- `FetchURLAndConvertWithTrust` — wraps `FetchURLAndConvert` with the trust check
- `NewWebFetchTool` — constructs tool with `allowFn` callback (TUI permission prompt)

### `internal/agent/tools/web_search.go`
### `pkg/agent/tools/web_search.go`

- `NewWebSearchTool` — same security contract as `web_fetch`; panics if `client` is nil
- `sanitizeSearchResults` — regex scrubber redacts:
Expand All @@ -51,7 +51,7 @@ Phosphor's web-fetch and web-search tools now operate under an **Adaptive Trust*
- `randomDelay` — adds 100–150ms random delay per round-trip to stay under DDG's request-budget threshold
- OTel span tagged with `attribute.Int("gen_ai.tool.web_search.results", len(results))`

### `internal/agent/coordinator.go`
### `pkg/agent/coordinator.go`

- `tuiAllowPrompt` field added to `coordinator` struct
- Wired in `NewCoordinator` via `c.permissions.Request` with synthetic tool-call ID (`tui:allow:<host>`)
Expand Down Expand Up @@ -101,8 +101,8 @@ Phosphor's `web_fetch` and `web_search` tools consult the user's `phosphor.json`

| Field | Type | Default | Description |
|---|---|---|---|
| `IPAllowList` | `[]string` | `[]` | CIDR or literal IPs allowed to be reached |
| `AllowRawIPs` | `bool` | `false` | Opt-in for raw IP access (default: FQDN required) |
| `IPAllowList` | `[]string` | `[]` | CIDR or literal IPs allowed to be reached (consulted only when `allow_raw_ips` is true) |
| `AllowRawIPs` | `bool` | `false` | Gate for raw IP access; IPs are allowed only if they also match `IPAllowList` (default: FQDN required) |

### Example `phosphor.json`

Expand All @@ -126,9 +126,8 @@ Phosphor's `web_fetch` and `web_search` tools consult the user's `phosphor.json`
When `securityTransport` intercepts a request to a raw IP:

1. **127.0.0.1 / ::1** (localhost) → always allowed, no config needed
2. **`IPAllowList`** → literal match or CIDR match via `matchCIDR` → allowed
3. **`AllowRawIPs = true`** → allowed (if no CIDR match)
4. **No match** → TUI prompt shows "WebFetch is requesting access to [host]. Allow this domain?"
2. **`AllowRawIPs = true` + `IPAllowList`** → literal match or CIDR match via `matchCIDR` → allowed
3. **Anything else** → the request fails closed with an error; raw IPs never reach the TUI prompt (prompts apply to hostnames only)

## Bash Network Egress Policy

Expand Down Expand Up @@ -172,30 +171,50 @@ guarantees.
## Security Properties

- **Fail-closed**: no network requests succeed until the agent confirms the domain is trusted
- **IP-block policy**: raw IP addresses (e.g., `10.x.x.x`, `192.168.x.x`, public IPs) are rejected — FQDNs required
- **IP-block policy**: raw IP addresses are rejected for both IPv4 and IPv6 literal forms (e.g., `10.x.x.x`, `192.168.x.x`, `[::1]`, `[fd00::…]`) — FQDNs required
- **Resolved-address validation**: the same IP policy is enforced at dial time on the *resolved* socket address, so an approved hostname whose DNS answer rebinds into a private, link-local, or cloud-metadata range is refused at the socket (DNS-rebinding defense). Loopback stays allowed; `IPAllowList`/`AllowRawIPs` exempt like raw IPs; when an environment proxy is configured the operator's proxy is the egress decision and the strict path remains the egress broker tier
- **Opt-in overrides**: `AllowRawIPs` + `IPAllowList` (CIDR) allow raw IPs for local/network development
- **Layered trust**: workspace + global allow lists + session allow list + user prompt
- **OTel observability**: every allow/deny/event is captured as an OTel span with structured attributes
- **Cacheable**: user approvals persist across requests (in-memory cache) but are session-local
- **Shared transport**: both `web_fetch` and `web_search` share the same `*http.Client` → same `securityTransport`

- **Fail-closed**: no network requests succeed until the agent confirms the domain is trusted
- **IP-block policy**: raw IP addresses (e.g., `10.x.x.x`, `192.168.x.x`, public IPs) are rejected — FQDNs required
- **Layered trust**: workspace + global allow lists + session allow list + user prompt
- **OTel observability**: every allow/deny/event is captured as an OTel span with structured attributes
- **Cacheable**: user approvals persist across requests (in-memory cache) but are session-local
- **Shared transport**: both `web_fetch` and `web_search` share the same `*http.Client` → same `securityTransport`
## TLS Trust and CA Compromise

The IP-block policy and trust hierarchy govern *where* the agent may connect;
certificate validation governs *whether the peer is who it claims to be*. The
two layers are independent, and an AI agent depends on the second more than
ordinary software does: the agent carries provider API keys in every request,
fetches attacker-chosen URLs by design, and can download and execute code from
hosts it has never seen. A compromised certificate authority — one whose
intermediate keys leak and can sign certs for *any* domain — silently defeats
every hostname-level defense at once (allow lists, IP blocks, FQDN
requirements).

Phosphor performs no custom certificate handling. TLS verification is delegated
to the runtime trust store:

- **Windows / macOS**: the platform certificate store, so OS security updates
and browser-grade revocation (CRLSets, OneCRL) cover CA compromise.
- **Linux**: Phosphor builds with `CGO_ENABLED=0`, so Go uses its compiled-in
copy of the Mozilla root program. Keeping the Go toolchain patched —
`go.mod` pins the minimum version — is therefore part of the security
posture, not just a build detail.

Concretely: when an intermediate CA in a trusted root program is compromised,
the remedy is a toolchain/OS update, and users should update promptly the same
way they would for a browser.

## Files Modified

- `internal/agent/tools/web_fetch.go` — added security transport, allow lists, TUI prompt wiring
- `internal/agent/tools/web_search.go` — added sanitization, OTel tagging, random delay, nil client panic
- `internal/agent/coordinator.go` — added `tuiAllowPrompt` field and wiring
- `internal/agent/agentic_fetch_tool.go` — updated call site to pass `c.tuiAllowPrompt`
- `pkg/agent/tools/web_fetch.go` — added security transport, allow lists, TUI prompt wiring
- `pkg/agent/tools/web_search.go` — added sanitization, OTel tagging, random delay, nil client panic
- `pkg/agent/coordinator.go` — added `tuiAllowPrompt` field and wiring
- `pkg/agent/agentic_fetch_tool.go` — updated call site to pass `c.tuiAllowPrompt`

## Notes

- `go.mod` requires Go 1.26.4+ (running 1.25.0 locally — LSP errors may appear for tooling checks)
- `go.mod` requires Go 1.27.3+ (patched trust store; see TLS Trust and CA Compromise above)
- `fmt`, `net`, `strings`, `html/template`, `encoding/json`, `time`, `embed` imports are all used in body
- `maybeDelaySearch` removed from `web_search.go` (already present in `search.go`)
- `randomDelay` added for DDG request-budget threshold
Expand Down
7 changes: 6 additions & 1 deletion docs/security/SECURITY_VALIDATION_TEST_SUITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Validates that IsInside and SmartJoin handle complex path resolutions robustly.
| `TestPathObfuscation_SymlinkTraversal` | Symlinks pointing outside workspace resolved by EvalSymlinks and blocked |
| `TestPathObfuscation_AbsolutePathVariants` | Absolute paths on all platforms blocked: Windows (`C:\Windows\...`), Linux (`/etc/passwd`), macOS (`/Users/...`) |

### `internal/agent/tools/web_fetch_test.go`
### `pkg/agent/tools/web_fetch_test.go`

Validates network egress hardening: the `securityTransport` interceptor, IP-block policy, CIDR allow list, localhost exceptions, and TUI permission prompt.

Expand All @@ -129,6 +129,11 @@ Validates network egress hardening: the `securityTransport` interceptor, IP-bloc
| `TestWebFetchHardeningIPBlock` | Raw IP addresses (e.g. `192.168.1.1`) rejected — FQDN required |
| `TestWebFetchHardeningCIDRAllow` | CIDR allow list (`192.168.1.0/24`) respected — IP falls in range allowed |
| `TestWebFetchHardeningLocalhostAlwaysAllowed` | `127.0.0.1` / `::1` always allowed — no config needed |
| `TestWebFetchHardeningIPv6LiteralBlocked` | Bracketed IPv6 literals (`[::1]:6379`, `[fd00::…]`) rejected as raw IPs — never reach the trust hierarchy |
| `TestWebFetchHardeningIPv6LocalhostAllowed` | `[::1]` treated as localhost — passes the gate without a prompt |
| `TestWebFetchDialGateResolvedAddress` | DNS-rebinding defense: hostnames resolving to private/link-local/metadata ranges refused at the socket; loopback and public addresses pass |
| `TestWebFetchDialGateHonorsConfigOverrides` | `allow_raw_ips` and `ip_allow_list` CIDRs exempt the dial gate, matching the raw-IP policy |
| `TestWebFetchDialGateLocalhostHostnameAllowed` | Local dev flows survive the default gate — `127.0.0.1` httptest server reachable through the guarded dialer |
| `TestWebFetchHardeningUserPromptVerified` | Unknown host triggers TUI prompt — user granted access |
| `TestWebFetchHardeningUserPromptDenyVerified` | Unknown host triggers TUI prompt — user denied access → request blocked |

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/hackafterdark/phosphor

go 1.27.0
go 1.27.3

require (
charm.land/bubbles/v2 v2.2.1
Expand Down
12 changes: 11 additions & 1 deletion pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -2196,6 +2196,14 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
if systemPromptPrefix != "" {
prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
}
// Route the summarizer request through the same wire defenses as
// the main agent loop: the full history (including raw stored
// reasoning) rides this call, so it must meet the defang and the
// last-resort secret/PII mask too.
prepared.Messages = defangOutgoingMessages(prepared.Messages)
if a.outgoingRedactionEnabled() {
prepared.Messages = a.redactOutgoingMessages(prepared.Messages)
}
return callContext, prepared, nil
},
OnReasoningDelta: func(id string, text string) error {
Expand Down Expand Up @@ -2914,7 +2922,9 @@ func (a *sessionAgent) GenerateTitle(ctx context.Context, sessionID string, user
}

streamCall := fantasy.AgentStreamCall{
Prompt: userPrompt,
// The raw user prompt rides this call verbatim; give it the same
// defang and last-resort mask the main loop applies to history.
Prompt: a.redactOutgoingText(tools.DefangSpecialTokens(userPrompt)),
}

var resp *fantasy.AgentResult
Expand Down
2 changes: 1 addition & 1 deletion pkg/agent/agentic_fetch_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
return fantasy.ToolResponse{}, errors.New("small model provider not configured")
}

webFetchTool := tools.NewWebFetchTool(tmpDir, client, c.tuiAllowPrompt)
webFetchTool := tools.NewWebFetchTool(tmpDir, client, c.cfg.Config(), c.tuiAllowPrompt)
webSearchTool := tools.NewWebSearchTool(client)
fetchTools := []fantasy.AgentTool{
webFetchTool,
Expand Down
63 changes: 54 additions & 9 deletions pkg/agent/outgoing_redact.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agent

import (
"errors"
"log/slog"
"regexp"
"strings"
Expand Down Expand Up @@ -149,17 +150,61 @@ func (a *sessionAgent) redactOutgoingMessages(msgs []fantasy.Message) []fantasy.
parts := make([]fantasy.MessagePart, len(msg.Content))
changed := false
for j, part := range msg.Content {
tp, ok := part.(fantasy.TextPart)
if !ok {
switch p := part.(type) {
case fantasy.TextPart:
next := a.redactOutgoingText(p.Text)
if next != p.Text {
p.Text = next
changed = true
}
parts[j] = p
case fantasy.ToolCallPart:
// Tool-call inputs (bash command lines, MCP arguments, edit
// bodies) are replayed verbatim on every subsequent request;
// a credential typed into an argument would otherwise ride
// the wire in plaintext forever.
next := a.redactOutgoingText(p.Input)
if next != p.Input {
p.Input = next
changed = true
}
parts[j] = p
case fantasy.ToolResultPart:
// Text results are normally masked per-tool, but anything
// that reaches a result outside the redacted surfaces must
// still meet the last-resort mask at the wire. Error text
// and media-attached text ride the wire verbatim too.
switch out := p.Output.(type) {
case fantasy.ToolResultOutputContentText:
next := a.redactOutgoingText(out.Text)
if next != out.Text {
p.Output = fantasy.ToolResultOutputContentText{Text: next}
changed = true
}
case fantasy.ToolResultOutputContentError:
if out.Error == nil {
break
}
next := a.redactOutgoingText(out.Error.Error())
if next != out.Error.Error() {
p.Output = fantasy.ToolResultOutputContentError{Error: errors.New(next)}
changed = true
}
case fantasy.ToolResultOutputContentMedia:
if out.Text == "" {
break
}
next := a.redactOutgoingText(out.Text)
if next != out.Text {
out.Text = next
p.Output = out
changed = true
}
}
parts[j] = p
default:
parts[j] = part
continue
}
next := a.redactOutgoingText(tp.Text)
if next != tp.Text {
tp.Text = next
changed = true
}
parts[j] = tp
}
if changed {
out[i].Content = parts
Expand Down
86 changes: 86 additions & 0 deletions pkg/agent/outgoing_redact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,89 @@ func TestSessionAgent_OutgoingRedaction_ForceOnlyMasksThroughCallSite(t *testing
require.NotContains(t, textOf(out[0]), secret)
require.Contains(t, textOf(out[0]), "<redacted:gitleaks:")
}
func TestSessionAgent_RedactOutgoing_ToolCallInputMasked(t *testing.T) {
t.Parallel()

// A credential typed into a tool-call argument (bash command line, MCP
// input, edit body) is replayed verbatim on every subsequent request; the
// wire mask must catch it even though it is not a TextPart.
const secret = "AKIAIOSVPK253PIP5TGP"
msg := fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{fantasy.ToolCallPart{
ToolCallID: "call-1",
ToolName: "bash",
Input: `{"command":"curl -H \"Authorization: Bearer ` + secret + `\" https://api.example.com"}`,
}},
}

a := &sessionAgent{redactOutgoingSecrets: true}
out := a.redactOutgoingMessages([]fantasy.Message{msg})

got := out[0].Content[0].(fantasy.ToolCallPart).Input
require.NotContains(t, got, secret, "tool-call input secret must be masked on the wire")
require.Contains(t, got, "<redacted:gitleaks:")

// The input messages must not have been mutated (fantasy owns them).
orig := msg.Content[0].(fantasy.ToolCallPart).Input
require.Contains(t, orig, secret, "input mutated")
}

func TestSessionAgent_RedactOutgoing_ToolResultTextMasked(t *testing.T) {
t.Parallel()

// Text that reaches a tool result outside the per-tool redacted surfaces
// must still meet the last-resort mask at the wire.
const secret = "AKIAIOSVPK253PIP5TGP"
msg := fantasy.Message{
Role: fantasy.MessageRoleTool,
Content: []fantasy.MessagePart{fantasy.ToolResultPart{
ToolCallID: "call-1",
Output: fantasy.ToolResultOutputContentText{Text: "AWS_ACCESS_KEY_ID := \"" + secret + "\""},
}},
}

a := &sessionAgent{redactOutgoingSecrets: true}
out := a.redactOutgoingMessages([]fantasy.Message{msg})

txt, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](
out[0].Content[0].(fantasy.ToolResultPart).Output,
)
require.True(t, ok)
require.NotContains(t, txt.Text, secret, "tool-result text secret must be masked on the wire")
require.Contains(t, txt.Text, "<redacted:gitleaks:")

// The input messages must not have been mutated (fantasy owns them).
orig, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](
msg.Content[0].(fantasy.ToolResultPart).Output,
)
require.True(t, ok)
require.Contains(t, orig.Text, secret, "input mutated")
}

func TestSessionAgent_RedactOutgoing_ToolPartsDisabledIsPassthrough(t *testing.T) {
t.Parallel()

// With both passes off, tool-call inputs and tool results are returned
// verbatim (identity, not even copied).
const secret = "AKIAIOSVPK253PIP5TGP"
msg := fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{
fantasy.ToolCallPart{
ToolCallID: "call-1",
ToolName: "bash",
Input: `{"command":"echo AWS_ACCESS_KEY_ID=` + secret + `"}`,
},
fantasy.ToolResultPart{
ToolCallID: "call-1",
Output: fantasy.ToolResultOutputContentText{Text: secret},
},
},
}

a := &sessionAgent{}
in := []fantasy.Message{msg}
out := a.redactOutgoingMessages(in)
require.Equal(t, in, out)
}
Loading
Loading