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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ Multi-module Go workspace with three modules:
- `forge-cli/` — CLI commands, TUI wizard, runtime
- `forge-plugins/` — Channel plugins (telegram, slack), markdown converter

## Platform-integration contracts (used by initializ AIP)

- **Tenancy headers**: every forge→platform HTTP callout (admission, remote session store, MCP platform token resolver) MUST send `Org-Id` + `Workspace-Id` from `FORGE_ORG_ID`/`FORGE_WORKSPACE_ID` env alongside `Authorization: Bearer ${FORGE_PLATFORM_TOKEN}` — the platform verifies a PER-ORG HS256 token and needs Org-Id to pick the signing secret BEFORE it can validate the bearer (omitting it → 401 "missing org-id header"). Pattern lives in `admission_loader.go` / `remote_session_store.go` / `mcp/platform_token.go`.
- **MCP auth types** (`auth.type`): `oauth`/`bearer`/`static` + managed `platform` (agent-principal token from `ForgeConfig.Platform.token_endpoint`) and `user` (delegated, lazy — cannot be `required:true`). The platform owns the op→identity split; forge only reads per-server config.
- **DEFER approval is FULLY BUILT & enforced** (`forge-core/security/deferpolicy/`, Slack Block-Kit delivery in `forge-plugins/channels/slack/approvals.go`): `security.defer.tools.<toolName>{to: channel:slack:#x, approvers, timeout}` — keyed by the RUNTIME tool name, which for MCP is **`<server>__<op>`**. Guardrails `approvalGates` are parsed but NOT enforced — use `security.defer.*`.
- **MCP OAuth discovery/DCR** (`forge-core/mcp/oauth_discovery.go`, #316/#320): RFC 9728→8414→7591. `wellKnown()` must INSERT the well-known segment for path-qualified issuers (Atlassian `auth.atlassian.com/<id>`) — replacing the path resolves the wrong AS.

## Pre-Commit Requirements

**Always run before committing:**
Expand Down
13 changes: 13 additions & 0 deletions forge-core/mcp/platform_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ func (p *platformTokenSource) fetch(ctx context.Context) (string, time.Duration,
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+identity)
// Tenancy headers, exactly as the admission + remote-session-store
// clients send them: the platform verifies a PER-ORG HS256 token, so it
// needs Org-Id to select the signing secret BEFORE it can validate the
// bearer (without this the endpoint 401s "missing org-id header"), and
// Workspace-Id to authorize the request against the entry (entitlement).
// Read from the standard FORGE_ORG_ID / FORGE_WORKSPACE_ID env the
// platform always injects; omitted when empty (standalone/dev).
if org := os.Getenv("FORGE_ORG_ID"); org != "" {
req.Header.Set("Org-Id", org)
}
if ws := os.Getenv("FORGE_WORKSPACE_ID"); ws != "" {
req.Header.Set("Workspace-Id", ws)
}

client := p.client
if client == nil {
Expand Down
10 changes: 10 additions & 0 deletions forge-core/mcp/platform_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@ import (
// server ref, and can emit a (forbidden) refresh_token.
func newFakeResolver(t *testing.T, expiresIn int, includeRefresh bool) (*httptest.Server, *int) {
t.Helper()
t.Setenv("FORGE_ORG_ID", "org-42")
t.Setenv("FORGE_WORKSPACE_ID", "ws-7")
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
// Tenancy headers must ride along so the platform can select the
// per-org HS256 secret + authorize the workspace (the "missing
// org-id header" 401 fix).
if r.Header.Get("Org-Id") != "org-42" || r.Header.Get("Workspace-Id") != "ws-7" {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "missing org-id header"})
return
}
if r.Header.Get("Authorization") != "Bearer agent-cred-1" {
w.WriteHeader(http.StatusUnauthorized)
return
Expand Down
Loading