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
25 changes: 23 additions & 2 deletions pkg/workflow/enclaves.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@ const (
enclaveMCPConnectTimeout = 120
enclaveMCPReadinessTimeoutMS = 120000
maxEnclaveTimingBucketSeconds = 4800
enclaveMCPTransportAllowance = 60
// maxDynamicEnclaveTimeoutSeconds bounds enclaves[].timeout for dynamic
// enclaves. It matches gh-aw-firewall's MAX_ENCLAVE_TIMEOUT_SECONDS
// preflight limit and the awf-config schema, so gh-aw and AWF cannot
// disagree about what compiles. It also keeps
// time.Duration(enclave.Timeout) * time.Second well within int64 for the
// mcpg delegation envelope's max_identity_ttl field.
maxDynamicEnclaveTimeoutSeconds = 4740
enclaveMCPTransportAllowance = 60
// enclaveDelegationControlPortOffset is added to the job's MCP gateway data-plane
// port to derive the private, host-only listener port for mcpg's
// github-repository-delegation-v1 control plane. Deriving it from the (per-job
Expand Down Expand Up @@ -466,6 +473,9 @@ func validateDynamicEnclaveBounds(index int, enclave *EnclaveConfig, policy *Dyn
if enclave.Timeout <= 0 || enclave.MemoryLimit == "" || enclave.PIDsLimit <= 0 || enclave.TmpfsLimit == "" || enclave.MaxOutputBytes <= 0 || enclave.MaxInvocations <= 0 {
return fmt.Errorf("enclaves[%d] dynamic agent entries must declare finite timeout, memory-limit, cpu-limit, pids-limit, tmpfs-limit, max-output-bytes, and max-invocations", index)
}
if enclave.Timeout > maxDynamicEnclaveTimeoutSeconds {
return fmt.Errorf("enclaves[%d].timeout must be at most %d seconds for dynamic enclaves, got %d", index, maxDynamicEnclaveTimeoutSeconds, enclave.Timeout)
}
if enclave.Agent.MaxTaskBytes <= 0 || enclave.Agent.MaxModelRequests <= 0 || enclave.Agent.MaxModelTokens <= 0 {
return fmt.Errorf("enclaves[%d].agent dynamic entries must declare finite max-task-bytes, max-model-requests, and max-model-tokens", index)
}
Expand Down Expand Up @@ -745,6 +755,17 @@ func buildAWFDynamicEnclavePolicy(enclave *EnclaveConfig) map[string]any {
// snake_case set (run_id, enclave_backend, allowed_owners, allowed_repositories,
// tool_policy, allowed_schema_hashes, max_dynamic_schema_hashes, max_identity_ttl,
// expires_at) causes the gateway to reject the envelope at startup.
//
// max_identity_ttl uses Go time.Duration units. mcpg decodes the field into a
// time.Duration, whose JSON representation is an integer number of nanoseconds,
// so a configured timeout of 120 seconds must be emitted as 120000000000. The
// runtime envelope expiry clamp (expires_at / MCP_GATEWAY_DELEGATION_EXPIRES_AT
// / buildDynamicEnclaveExpiryScript) is a separate contract and continues to
// use seconds / RFC3339.
//
// validateDynamicEnclaveBounds guarantees 0 < enclave.Timeout <=
// maxDynamicEnclaveTimeoutSeconds (4740), so the int64 multiplication below
// cannot overflow.
func buildMCPGatewayDelegationEnvelope(enclave *EnclaveConfig) map[string]any {
policy := enclave.Dynamic
return map[string]any{
Expand All @@ -762,7 +783,7 @@ func buildMCPGatewayDelegationEnvelope(enclave *EnclaveConfig) map[string]any {
// enclaves[].dynamic.max-repositories limit (DynamicEnclavePolicy.MaxRepositories),
// since each admitted repository corresponds to exactly one schema hash.
"max_dynamic_schema_hashes": policy.MaxRepositories,
"max_identity_ttl": enclave.Timeout,
"max_identity_ttl": time.Duration(enclave.Timeout) * time.Second,
"expires_at": "${" + enclaveDelegationExpiresAtEnv + "}",
}
}
Expand Down
73 changes: 73 additions & 0 deletions pkg/workflow/enclaves_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,14 @@ func TestDynamicEnclaveGatewayContract(t *testing.T) {
assert.Contains(t, generated, `\"enclave_backend\":\"github\"`)
assert.Contains(t, generated, `\"tool_policy\":\"github-repository-read-v1\"`)
assert.Contains(t, generated, `\"max_dynamic_schema_hashes\":4`)
// max_identity_ttl uses Go time.Duration units (nanoseconds). A 120-second
// enclave timeout must serialize as 120000000000, not 120, so that mcpg's
// Store.validateAgainstEnvelope does not reject every realistic AWF
// create-or-confirm request as exceeding a 120-nanosecond identity ceiling.
// The runtime envelope expiry clamp (expires_at) is a separate contract
// and remains in seconds/RFC3339.
assert.Contains(t, generated, `\"max_identity_ttl\":120000000000`)
assert.NotContains(t, generated, `\"max_identity_ttl\":120,`)
assert.Contains(t, generated, `\"expires_at\":\"${MCP_GATEWAY_DELEGATION_EXPIRES_AT}\"`)
assert.NotContains(t, generated, `\"version\":\"github-repository-read-v1\"`)
assert.NotContains(t, generated, `\"tools\":[`)
Expand Down Expand Up @@ -779,3 +787,68 @@ Use the enclave script executor.
assert.NotContains(t, lock, "Start Enclave MCP")
assert.NotContains(t, lock, "start_enclave")
}

// TestBuildMCPGatewayDelegationEnvelopeMaxIdentityTTLNanoseconds pins the
// units contract between gh-aw and mcpg v0.4.17's delegation.Envelope. mcpg
// decodes max_identity_ttl into a Go time.Duration, whose JSON representation
// is an integer number of nanoseconds, so a 120-second configured timeout must
// serialize as 120000000000 and round-trip to exactly 120 * time.Second. The
// AWF client sends its requested TTL in the same units
// (see gh-aw-firewall src/enclave/delegation-control-client.ts's
// secondsToGoDurationNanos), so a 120-second requested TTL must also compare
// as <= the envelope's MaxIdentityTTL. This is what mcpg's
// Store.validateAgainstEnvelope enforces at runtime.
func TestBuildMCPGatewayDelegationEnvelopeMaxIdentityTTLNanoseconds(t *testing.T) {
enclave := &EnclaveConfig{
Timeout: 120,
Dynamic: &DynamicEnclavePolicy{MaxRepositories: 4},
}
envelope := buildMCPGatewayDelegationEnvelope(enclave)

// The map value carries time.Duration units so encoding/json emits the
// integer nanosecond count that mcpg's decoder expects.
assert.Equal(t, 120*time.Second, envelope["max_identity_ttl"],
"max_identity_ttl must carry Go time.Duration units so JSON serialization emits nanoseconds")

raw, err := json.Marshal(envelope)
require.NoError(t, err)
assert.Contains(t, string(raw), `"max_identity_ttl":120000000000`,
"120-second enclave timeout must serialize to its exact nanosecond ceiling; a units regression would emit \"max_identity_ttl\":120 and cause mcpg to reject every realistic AWF request")

// Mirror mcpg v0.4.17's delegation.Envelope decode: MaxIdentityTTL is a
// time.Duration, which decodes from a JSON integer of nanoseconds.
var decoded struct {
MaxIdentityTTL time.Duration `json:"max_identity_ttl"`
}
require.NoError(t, json.Unmarshal(raw, &decoded))
assert.Equal(t, 120*time.Second, decoded.MaxIdentityTTL)

// AWF's delegation-control-client sends requested_ttl in the same units
// (nanoseconds). A 120-second request must not exceed a 120-second envelope
// ceiling; a request one second over must exceed it. This is exactly the
// comparison mcpg's Store.validateAgainstEnvelope performs.
requestedTTL := 120 * time.Second
assert.LessOrEqual(t, requestedTTL, decoded.MaxIdentityTTL,
"an AWF-requested 120s TTL must be accepted by a 120s envelope ceiling; if this fails, gh-aw and mcpg disagree about units")
assert.Greater(t, 121*time.Second, decoded.MaxIdentityTTL,
"an AWF-requested 121s TTL must exceed a 120s envelope ceiling; if this fails, the ceiling collapsed to zero or wrapped")
}

// TestValidateDynamicEnclaveBoundsRejectsOversizedTimeout preserves fail-closed
// validation for enclave timeouts that would otherwise be forwarded to mcpg.
// gh-aw's compile-time bound matches gh-aw-firewall's
// MAX_ENCLAVE_TIMEOUT_SECONDS preflight limit so the two sides cannot disagree
// about what compiles, and it keeps
// time.Duration(enclave.Timeout) * time.Second safely inside int64.
func TestValidateDynamicEnclaveBoundsRejectsOversizedTimeout(t *testing.T) {
data := dynamicEnclaveWorkflowData()
data.Enclaves[0].Timeout = maxDynamicEnclaveTimeoutSeconds + 1
err := validateEnclavesConfig(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "timeout must be at most")

data = dynamicEnclaveWorkflowData()
data.Enclaves[0].Timeout = maxDynamicEnclaveTimeoutSeconds
require.NoError(t, validateEnclavesConfig(data),
"the AWF-compatible maximum timeout must still validate cleanly")
}
Loading