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
10 changes: 7 additions & 3 deletions .claude/skills/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,9 @@ The agent loop calls tools the LLM asks for. The registry merges:
`mcp.servers[]` block. Names are namespaced `<server>__<tool>` (e.g.
`linear__create_issue`). Phase 1 ships HTTP transport only; stdio is
rejected at validate time with a roadmap pointer. OAuth 2.1 PKCE
supported via `forge mcp login`.
supported via `forge mcp login`; `client_id`/`authorize_url`/`token_url`
are optional — discovered from the server url via RFC 9728/8414 with
RFC 7591 dynamic client registration when omitted (#316).

`cli_execute` ships 13 security layers — shell denylist, binary
allowlist, `LookPath` resolution at startup, argument validation
Expand Down Expand Up @@ -821,9 +823,9 @@ enterprise raw-capture path.
`docs/security/audit-logging.md` § Trace cross-link,
`docs/security/egress-control.md` § OTel collector auto-extension.

### 12.11 Governance framework R1–R9 (#216 umbrella)
### 12.11 Governance framework R1–R10 (#216 umbrella)

Six MUST + three SHOULD requirements from an agent-runtime governance framework. Complete on `main` after #245 / #246 / #247 / #248 land (R4c is the last piece).
Six MUST + three SHOULD requirements from an agent-runtime governance framework, complete on `main` after #245 / #246 / #247 / #248 land (R4c is the last piece). **R10 (delegated identity) is a proposed fourth SHOULD** — not yet implemented; see #317 / #318.

| # | Requirement | Type | Where it lives | Related PR |
|---|---|:-:|---|:-:|
Expand All @@ -838,6 +840,7 @@ Six MUST + three SHOULD requirements from an agent-runtime governance framework.
| R7 | Semantic distance | SHOULD | `forge-core/security/intent/drift.go`; `security.intent_drift` on top of R3. Rolling-window mean-below-threshold + monotone-decrease trip conditions. State-transition dedup (one `entered`, one `recovered` — no per-call flood). | #246 |
| R8 | OpenTelemetry export | SHOULD | `observability.tracing` in yaml. Real tracer provider; OTLP HTTP/gRPC export. Audit events carry `trace_id` + `span_id` closing the loop between the SIEM channel and the APM channel. Baseline (#108). | — |
| R9 | Least-privilege credentials | SHOULD | `forge-core/credentials/`; top-level `credentials:` in yaml. Providers: `static`, `sts_assume_role`. Fresh credentials per tool call; injected into subprocess env (`cli_execute`) or outbound headers (`http_request`). `credential_issued` / `credential_revoked` audit events. **No credential material in audit payloads.** | #236 |
| R10 | Delegated identity / on-behalf-of authorization | SHOULD (proposed) | Downstream tool calls (esp. remote MCP) execute under the **requesting user's** delegated identity, not a shared service grant — per-user, per-session, on-behalf-of. Where R9 scopes *what a tool may do*, R10 scopes *whose authority it acts under*. One `BearerToken(server, subject, session)` seam, **resolver behind it** (`design-tool-registry.md` §18): the **Forge-local resolver** (interactive OAuth, ephemeral per session; standalone-capable — #317) and the **managed broker resolver** (vaulted 3LO → ID-JAG; holds the IdP trust relationship — initializ, `initializ/aip/mcp-delegated-identity-broker.md`; #318 = the thin `id_jag` `method:` sliver in the Forge repo). Forge never learns which resolver answered. Delegation follows authorization (never mint speculatively); token injected at egress, never through the agent; writes still route through DEFER regardless of token validity (§18.5). Planned audit events: `mcp_auth_requested` / `mcp_auth_completed` / `mcp_auth_denied`. | #317 / #318 |

**Config off by default.** Every governance block ships disabled — an absent block leaves the hook unregistered and the wire shape unchanged from a pre-governance Forge. Rollout: turn each on independently, warn-only first (`hard_threshold: -1`), gather the score distribution against your embedder + workload, then set `hard_threshold` a bit **below** the observed floor of your normal traffic (typically ~0.2–0.3; the default is `0.3`). `hard_threshold` is "score below → deny", so a high value like `0.85` would deny almost all legitimate calls — aligned actions cluster well above it (0.6–0.9 on OpenAI `text-embedding-3-small`).

Expand All @@ -850,6 +853,7 @@ Six MUST + three SHOULD requirements from an agent-runtime governance framework.
- `docs/security/audit-signing.md` (R6)
- `docs/security/audit-tamper-evidence.md` (R5)
- `docs/security/least-privilege-credentials.md` (R9)
- R10 (delegated identity) — proposed; authority `design-tool-registry.md` §18; #317 (Forge-local interactive resolver) / #318 (`id_jag` sliver) / `initializ/aip/mcp-delegated-identity-broker.md` (broker resolvers).
- `docs/security/policy-decisions.md` (five-decision enum reference)

---
Expand Down
105 changes: 95 additions & 10 deletions docs/mcp/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ mcp:
url: https://mcp.linear.app/sse # required for transport: http
auth: # optional
type: oauth # oauth | bearer | static
client_id: my-client-id # required if type=oauth
client_id: my-client-id # optional for oauth (see Discovery)
scopes: [read, write] # optional
authorize_url: https://... # required if type=oauth
token_url: https://... # required if type=oauth
authorize_url: https://... # optional for oauth (discovered if omitted)
token_url: https://... # optional for oauth (discovered if omitted)
token_env: NAME_OF_ENV_VAR # required if type=bearer|static
tools: # default-deny — at least one of:
allow: [create_issue, list_issues] # explicit names or ["*"]
Expand Down Expand Up @@ -62,15 +62,75 @@ supported.
Optional. Omit for unauthenticated servers (e.g., trusted in-cluster
MCPs).

| `auth.type` | Required fields | When to use |
|-------------|------------------------------------------|-------------|
| `oauth` | `client_id`, `authorize_url`, `token_url` | Hosted MCPs (Linear, Notion, GitHub hosted) |
| `bearer` | `token_env` | In-cluster sidecars; CI machine-to-machine |
| `static` | `token_env` | Same as bearer; named for clarity |
| `auth.type` | Required fields | When to use |
|-------------|-----------------|-------------|
| `oauth` | *(none required — see Discovery)* | Hosted MCPs (Linear, Notion, GitHub hosted) |
| `bearer` | `token_env` | In-cluster sidecars; CI machine-to-machine |
| `static` | `token_env` | Same as bearer; named for clarity |

`token_env` is the name of an environment variable; the variable's
value is read at runtime — never stored in `forge.yaml`.

#### OAuth discovery & dynamic client registration (#316)

For `type: oauth`, `client_id` / `authorize_url` / `token_url` are all
**optional**. When omitted, Forge discovers them from the server `url`
at `forge mcp login` time, using the MCP Authorization spec:

1. **RFC 9728** — protected-resource metadata (from the server's `401`
`WWW-Authenticate` header, or `{origin}/.well-known/oauth-protected-resource`)
to find the authorization server.
2. **RFC 8414** — authorization-server metadata
(`/.well-known/oauth-authorization-server`, with the OpenID
`openid-configuration` variant as a fallback) to discover the
`authorize` / `token` / `registration` endpoints.
3. **RFC 7591** — dynamic client registration mints a `client_id` at
first login; it is persisted (encrypted, alongside the token) and
**reused** on refresh — never re-minted per run.

So a fully zero-config OAuth server is just:

```yaml
- name: linear
transport: http
url: https://mcp.linear.app/mcp
auth:
type: oauth
scopes: [read, write] # optional; discovery uses scopes_supported when omitted
tools:
allow: [create_issue, list_issues]
```

**Precedence & rules:**

- **Discovery is the standalone default; explicit config always wins.**
Setting `client_id`/`authorize_url`/`token_url` is both the *static
override* (for servers that don't advertise metadata or don't support
DCR) **and the platform-materialized path** — the normal case when a
control plane materializes these fields from a registry entry. Explicit
config is not just the exception; under managed/admission-time
provisioning it is the common case.
- `authorize_url` and `token_url` must be set **together** (or both
omitted); a partial pair is a validation error.
- **Fail-closed:** if a server advertises no metadata / no
`registration_endpoint` and no `client_id` is configured, login fails
with a clear message — supply the fields explicitly, or use a
discovery-capable server.
- **Egress:** the discovered authorization-server host isn't in
`forge.yaml` to pre-seed the allowlist, so it is learned from the
login-time registration record and merged into the egress allowlist at
runtime automatically. (Discovery itself runs at laptop-time
`forge mcp login`, off the egress-enforced path.)
- **Recovery (revoked/expired client):** a dynamically-registered client
is minted once and never re-minted. If the authorization server revokes
it (or `client_secret_expires_at` passes), run `forge mcp logout <name>`
— that clears both the token and the stored registration — then
`forge mcp login <name>` again to re-discover and re-register.
- **Confidential clients are not supported.** Forge registers a public
(PKCE) client and sends no `client_secret`. If a server insists on
issuing a confidential client, login fails closed — configure
`client_id`/`authorize_url`/`token_url` explicitly for that server.

### `mcp.servers[].tools`

**Default-deny.** Validation rejects entries where both `allow` and
Expand Down Expand Up @@ -98,14 +158,39 @@ Per-RPC timeout. Default 60s. Minimum 1s.

## Worked examples

### Vendor-hosted MCP with OAuth
### Vendor-hosted MCP with OAuth (discovery — preferred)

Point at the server's Streamable HTTP endpoint (`/mcp`, not the legacy
`/sse`) and let discovery resolve everything:

```yaml
mcp:
servers:
- name: linear
transport: http
url: https://mcp.linear.app/mcp
auth:
type: oauth
scopes: [read, write] # client_id + endpoints discovered (#316)
tools:
allow: [create_issue, list_issues]
required: true
```

Then `forge mcp login linear` once — Forge discovers the endpoints and
registers a client automatically.

### Vendor-hosted MCP with OAuth (explicit override)

For a server that doesn't advertise metadata or doesn't support dynamic
client registration, pin the fields (this overrides discovery):

```yaml
mcp:
servers:
- name: linear
transport: http
url: https://mcp.linear.app/sse
url: https://mcp.linear.app/mcp
auth:
type: oauth
client_id: ${LINEAR_OAUTH_CLIENT_ID}
Expand Down
1 change: 1 addition & 0 deletions forge-cli/cmd/mcp_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func mcpLoginRun(cmd *cobra.Command, args []string) error {
fmt.Printf("opening browser to authorize Forge against %s...\n", name)
fmt.Println("(if a browser does not open, look for the URL on stdout below)")
if err := flow.Login(ctx, name, mcp.OAuthServerConfig{
ServerURL: spec.URL, // enables RFC 9728/8414/7591 discovery when endpoints are omitted (#316)
ClientID: spec.Auth.ClientID,
Scopes: spec.Auth.Scopes,
AuthorizeURL: spec.Auth.AuthorizeURL,
Expand Down
10 changes: 10 additions & 0 deletions forge-cli/cmd/mcp_test_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"time"

"github.com/initializ/forge/forge-core/llm/oauth"
"github.com/initializ/forge/forge-core/mcp"
"github.com/spf13/cobra"
)
Expand All @@ -32,6 +33,15 @@ func mcpTestRun(cmd *cobra.Command, args []string) error {
return err
}

// Apply the token-store-path override (forge.yaml > env) so `mcp test`
// reads OAuth tokens from the same location `mcp login` wrote them —
// mirroring mcp_login.go / mcp_logout.go. Without this, `test` always
// looked in the default ~/.forge/credentials and reported "no stored
// token" for any server logged in under MCP_TOKEN_STORE_PATH.
if path := loginTokenStorePath(cfg); path != "" {
oauth.SetCredentialsDir(path)
}

ctx, cancel := context.WithTimeout(context.Background(), 2*timeout+5*time.Second)
defer cancel()

Expand Down
5 changes: 5 additions & 0 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,11 @@ func (r *Runner) Run(ctx context.Context) error {
// Same for MCP servers — without this, every HTTPS MCP call would
// be silently blocked. Mirror the AuthDomains pattern.
egressDomains = append(egressDomains, security.MCPDomains(r.cfg.Config.MCP)...)
// #316: with OAuth discovery the authorization-server host is not in
// forge.yaml to pre-seed the allowlist — it is learned at login time
// and persisted in the registration record. mcpRegisteredOAuthHosts
// applies the store-path override and reads those hosts back.
egressDomains = append(egressDomains, mcpRegisteredOAuthHosts(r.cfg.Config.MCP)...)
// Phase 6 (#107 / #108) — same for the OTel collector. Without
// this, dev runs with `observability.tracing.enabled: true` and
// `egress.mode: allowlist` would silently drop spans on shutdown.
Expand Down
24 changes: 24 additions & 0 deletions forge-cli/runtime/runner_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,32 @@ import (
"github.com/initializ/forge/forge-core/llm/oauth"
"github.com/initializ/forge/forge-core/mcp"
coreruntime "github.com/initializ/forge/forge-core/runtime"
"github.com/initializ/forge/forge-core/types"
)

// oauthServerNames returns the names of the oauth-typed MCP servers.
func oauthServerNames(cfg types.MCPConfig) []string {
var names []string
for _, s := range cfg.Servers {
if s.Auth != nil && s.Auth.Type == "oauth" {
names = append(names, s.Name)
}
}
return names
}

// mcpRegisteredOAuthHosts applies the MCP token-store override, then
// returns the authorize/token/registration hosts persisted at login
// time by OAuth discovery (#316) — merged into the egress allowlist so
// a discovered authorization-server host (absent from forge.yaml) is
// reachable at runtime.
func mcpRegisteredOAuthHosts(cfg types.MCPConfig) []string {
if sp := mcpTokenStorePath(cfg.TokenStorePath); sp != "" {
oauth.SetCredentialsDir(sp)
}
return mcp.RegisteredOAuthHosts(oauthServerNames(cfg))
}

// mcpTokenStorePath returns the effective OAuth credentials
// directory for MCP. Precedence (highest first):
//
Expand Down
105 changes: 105 additions & 0 deletions forge-core/llm/oauth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,111 @@ func MigrateToEncrypted(provider string) error {
return nil
}

// --- generic JSON records (beyond Token) ---
//
// SaveRecord / LoadRecord / DeleteRecord persist an arbitrary
// JSON-serializable value under a caller-supplied key, using the same
// encrypted-preferred, plaintext-fallback strategy as the token
// helpers. Added for the MCP OAuth discovery/registration record
// (#316): the minted client_id + discovered endpoints must survive
// across a refresh and a pod restart, exactly like the token itself.

// SaveRecord persists v (marshaled to JSON) under key.
func SaveRecord(key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshaling record: %w", err)
}
if ep := encryptedProvider(); ep != nil {
if err := ep.Set(key, string(data)); err != nil {
return fmt.Errorf("saving encrypted record: %w", err)
}
_ = removeRecordFile(key) // migration clean-up
return nil
}
return saveRecordPlaintext(key, data)
}

// LoadRecord loads a record saved by SaveRecord into v. Returns
// found=false (nil error) when no record exists for key.
func LoadRecord(key string, v any) (found bool, err error) {
if ep := encryptedProvider(); ep != nil {
val, gErr := ep.Get(key)
if gErr == nil {
if jErr := json.Unmarshal([]byte(val), v); jErr != nil {
return false, fmt.Errorf("parsing encrypted record: %w", jErr)
}
return true, nil
}
if !secrets.IsNotFound(gErr) {
return false, fmt.Errorf("reading encrypted record: %w", gErr)
}
// Not found in encrypted store — fall through to plaintext.
}
return loadRecordPlaintext(key, v)
}

// DeleteRecord removes a record from both stores. Idempotent.
func DeleteRecord(key string) error {
if ep := encryptedProvider(); ep != nil {
if err := ep.Delete(key); err != nil && !secrets.IsNotFound(err) {
return fmt.Errorf("deleting encrypted record: %w", err)
}
}
return removeRecordFile(key)
}

func recordPath(key string) (string, error) {
dir, err := DefaultCredentialsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, key+".json"), nil
}

func saveRecordPlaintext(key string, data []byte) error {
dir, err := DefaultCredentialsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating credentials directory: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, key+".json"), data, 0o600); err != nil {
return fmt.Errorf("writing record: %w", err)
}
return nil
}

func loadRecordPlaintext(key string, v any) (bool, error) {
path, err := recordPath(key)
if err != nil {
return false, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("reading record: %w", err)
}
if err := json.Unmarshal(data, v); err != nil {
return false, fmt.Errorf("parsing record: %w", err)
}
return true, nil
}

func removeRecordFile(key string) error {
path, err := recordPath(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing record: %w", err)
}
return nil
}

// --- plaintext helpers ---

func savePlaintext(provider string, token *Token) error {
Expand Down
Loading
Loading