From f449b94be8a1260fdeb87388a1a465795753d26d Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Mon, 15 Jun 2026 16:44:18 -0400 Subject: [PATCH] feat(harness): add allow_runtime_fetch and max_runtime_fetches fields (ADR-0038 Phase 4 PR 3) Signed-off-by: Claude Signed-off-by: Greg Allen --- README.md | 2 + docs/ADRs/0024-harness-definitions.md | 16 +++ docs/guides/dev/cli-internals.md | 4 +- docs/guides/user/building-custom-agents.md | 8 +- docs/guides/user/customizing-agents.md | 5 + docs/plans/universal-harness-access.md | 2 +- internal/cli/run.go | 31 ++++-- internal/cli/run_test.go | 79 +++++++++++++ internal/harness/compose.go | 8 +- internal/harness/compose_test.go | 23 ++++ internal/harness/harness.go | 24 ++++ internal/harness/harness_test.go | 122 +++++++++++++++++++++ 12 files changed, 309 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6454946b7c..45b56b1ffe 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ This is not a product spec. It's an evolving exploration of a hard problem space - [Universal Harness Access](docs/plans/universal-harness-access.md) — Making harnesses and agents universally accessible via URLs and paths, enabling community sharing and composability - [Universal Harness Access — Phase 1 Implementation](docs/plans/universal-harness-access-phase1.md) — Phased PR breakdown for ADR-0038 Phase 1 (MVP) - [Universal Harness Access — Phase 2 Implementation](docs/plans/universal-harness-access-phase2.md) — Phased PR breakdown for ADR-0038 Phase 2 (transitive dependency resolution) + - [Universal Harness Access — Phase 3 Implementation](docs/plans/universal-harness-access-phase3.md) — Phased PR breakdown for ADR-0038 Phase 3 (lock files and integrity verification) + - [Universal Harness Access — Phase 4 Implementation](docs/plans/universal-harness-access-phase4.md) — Phased PR breakdown for ADR-0038 Phase 4 (runtime dependency loading) - [Agent Execution Environment](docs/plans/agent-execution-environment.md) — Sandbox and runtime environment for agent execution - [Vertex AI Inference Provisioning](docs/plans/vertex-inference-provisioning.md) — Provisioning and configuration for Vertex AI inference endpoints - [ADR-0045 Forge-Portable Harness Schema — Phase 1](docs/plans/adr-0045-forge-portable-harness-phase1.md) — Implementation plan for ADR-0045 forge-portable harness schema (Phase 1) diff --git a/docs/ADRs/0024-harness-definitions.md b/docs/ADRs/0024-harness-definitions.md index b11ba54bcf..bf9f13425a 100644 --- a/docs/ADRs/0024-harness-definitions.md +++ b/docs/ADRs/0024-harness-definitions.md @@ -424,6 +424,22 @@ security: enabled: true ssrf_pretool: true secret_redact_posttool: true + +# Remote resource access (ADR-0038). URL-prefix allowlist for skills, agents, +# and policies fetched from HTTPS endpoints with SHA256 integrity verification. +allowed_remote_resources: + - https://example.com/skills/ + - https://example.com/policies/ + +# Opt-in to runtime skill fetching. When true, the runner starts a fetch +# service that agents can call mid-run via `fullsend fetch-skill`. Requires +# at least one entry in allowed_remote_resources. Default: false. +allow_runtime_fetch: true + +# Maximum number of runtime fetch requests per agent run. Requires +# allow_runtime_fetch to be true. When omitted, uses the default (10). +# Must be between 1 and 1000. +max_runtime_fetches: 10 ``` ### Example: triage harness (with container image) diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 80dcb82bbd..df610731ed 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -320,8 +320,8 @@ Vendoring commit messages use title + body (upload and stale delete). `admin ana │ │ ├── PATH=/sandbox/workspace/bin:$PATH │ │ │ │ ├── CLAUDE_CONFIG_DIR=/sandbox/claude-config│ │ │ │ ├── FULLSEND_OUTPUT_DIR=... │ │ -│ │ ├── FULLSEND_FETCH_URL=http://host:port/fetch (if active)│ │ -│ │ ├── FULLSEND_FETCH_TOKEN= (if active)│ │ +│ │ ├── FULLSEND_FETCH_URL=... (if allow_runtime_fetch)│ │ +│ │ ├── FULLSEND_FETCH_TOKEN= (if above)│ │ │ │ └── sources .env.d/*.env files │ │ │ └──────────┬───────────────────────────────┘ │ │ ▼ │ diff --git a/docs/guides/user/building-custom-agents.md b/docs/guides/user/building-custom-agents.md index 50c5fe409d..e078237b75 100644 --- a/docs/guides/user/building-custom-agents.md +++ b/docs/guides/user/building-custom-agents.md @@ -150,9 +150,15 @@ runner_env: FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/customized/schemas/my-agent-result.schema.json timeout_minutes: 20 + +# Optional: enable runtime skill fetching (ADR-0038 Phase 4) +# allowed_remote_resources: +# - https://github.com/org/skills/ +# allow_runtime_fetch: true +# max_runtime_fetches: 10 ``` -See [Customizing agents — Harness YAML Structure](customizing-agents.md#harness-yaml-structure) for the full field reference (including optional `security`, `providers`, and `plugins` blocks). +See [Customizing agents — Harness YAML Structure](customizing-agents.md#harness-yaml-structure) for the full field reference (including optional `security`, `providers`, `plugins`, and runtime fetch blocks). The key pattern to understand is how data flows into the sandbox through `host_files`: diff --git a/docs/guides/user/customizing-agents.md b/docs/guides/user/customizing-agents.md index ac2b03bb5f..6a7c811937 100644 --- a/docs/guides/user/customizing-agents.md +++ b/docs/guides/user/customizing-agents.md @@ -55,6 +55,11 @@ providers: # Inference providers (loaded from providers/ d validation_loop: feedback_mode: stderr # "stderr", "stdout", or "exit_code" (optional) +allowed_remote_resources: # URL prefixes allowed for remote skills/agents/policies + - https://github.com/org/ # Resources must match a prefix to be fetched +allow_runtime_fetch: true # Opt-in to runtime skill fetching (default: false) +max_runtime_fetches: 10 # Max runtime fetch requests per run (1–1000, default: 10) + security: # Security is enabled by default with fail_mode: closed enabled: true # All scanners enabled by default fail_mode: closed # "closed" (reject on failure) or "open" (warn only) diff --git a/docs/plans/universal-harness-access.md b/docs/plans/universal-harness-access.md index 2b9637f086..d8ce9847b0 100644 --- a/docs/plans/universal-harness-access.md +++ b/docs/plans/universal-harness-access.md @@ -340,7 +340,7 @@ This requires: - Fetch requests are rate-limited (max 10 per agent run) - Anomalous fetch patterns trigger alerts -**Status:** Not implemented in initial design. Tracked in a future issue. +**Status:** Implemented in Phase 4. Harness schema fields (`allow_runtime_fetch`, `max_runtime_fetches`) and CLI wiring added. See `docs/plans/universal-harness-access-phase4.md`. ### Access Policy Model diff --git a/internal/cli/run.go b/internal/cli/run.go index cecb1a0e91..0102dddde5 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -565,12 +565,12 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep traceID := security.GenerateTraceID() // 6. Start runtime fetch service (Phase 4, ADR-0038). - // Only started when the harness declares remote resources — without - // them there is nothing to fetch, and skipping avoids exposing the - // service to prompt-injected agents. PR 3 will add a dedicated - // allow_runtime_fetch harness field for finer-grained control. var fetchEnvVal fetchServiceEnv - if h.HasURLSkills() || len(h.AllowedRemoteResources) > 0 { + startFetch, deprecationWarning := shouldStartFetchService(h) + if deprecationWarning != "" { + printer.StepWarn(deprecationWarning) + } + if startFetch { env, fetchShutdown, fetchErr := setupFetchService(ctx, rFlags.forgeClient, h, resolveToken, fetchsvc.ServiceConfig{ Harness: h, FetchPolicy: fetch.DefaultPolicy, @@ -578,7 +578,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"), TraceID: traceID, SandboxName: sandboxName, - MaxFetches: fetchsvc.DefaultMaxFetches, + MaxFetches: h.EffectiveMaxRuntimeFetches(), Uploader: &fetchsvc.SandboxUploader{}, SkillDestDir: sandbox.SandboxClaudeConfig + "/skills", }, printer.StepWarn) @@ -1099,13 +1099,30 @@ type fetchServiceEnv struct { token string // bearer token } +const deprecatedImplicitFetchWarning = "Harness declares allowed_remote_resources without allow_runtime_fetch: true; " + + "the runtime fetch service will start for backward compatibility, but this behavior is " + + "deprecated — add allow_runtime_fetch: true to the harness to silence this warning" + +// shouldStartFetchService decides whether the runtime fetch HTTP service +// should be started, and returns a deprecation warning if the harness relies +// on the legacy implicit opt-in via allowed_remote_resources. +func shouldStartFetchService(h *harness.Harness) (start bool, deprecationWarning string) { + if h.HasURLSkills() || h.AllowRuntimeFetch { + return true, "" + } + if len(h.AllowedRemoteResources) > 0 { + return true, deprecatedImplicitFetchWarning + } + return false, "" +} + // setupFetchService resolves a forge client for runtime fetching and starts // the HTTP fetch service. It returns the service address/token as a // fetchServiceEnv, a shutdown function, and any error. func setupFetchService(ctx context.Context, forgeClient forge.Client, h *harness.Harness, resolveToken func() (string, error), cfg fetchsvc.ServiceConfig, warn func(string)) (fetchServiceEnv, func(), error) { if forgeClient != nil { cfg.ForgeClient = forgeClient - } else if h.HasURLSkills() || len(h.AllowedRemoteResources) > 0 { + } else if h.HasURLSkills() || h.AllowRuntimeFetch || len(h.AllowedRemoteResources) > 0 { if token, err := resolveToken(); err == nil { cfg.ForgeClient = gh.New(token) } else { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 8818e3738a..d22da1dad2 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -1209,6 +1209,44 @@ func TestBootstrapEnv_SkipsFetchVarsWhenEmpty(t *testing.T) { assert.Contains(t, err.Error(), "copying .env file to sandbox") } +func TestShouldStartFetchService_AllowRuntimeFetch(t *testing.T) { + h := &harness.Harness{ + Agent: "agents/test.md", + AllowRuntimeFetch: true, + AllowedRemoteResources: []string{"https://github.com/org/"}, + } + start, warning := shouldStartFetchService(h) + assert.True(t, start) + assert.Empty(t, warning) +} + +func TestShouldStartFetchService_URLSkills(t *testing.T) { + h := &harness.Harness{ + Agent: "agents/test.md", + Skills: []string{"https://github.com/org/skills/tree/abc/rust#sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + } + start, warning := shouldStartFetchService(h) + assert.True(t, start) + assert.Empty(t, warning) +} + +func TestShouldStartFetchService_AllowedRemoteResourcesOnly(t *testing.T) { + h := &harness.Harness{ + Agent: "agents/test.md", + AllowedRemoteResources: []string{"https://github.com/org/"}, + } + start, warning := shouldStartFetchService(h) + assert.True(t, start) + assert.Contains(t, warning, "deprecated") +} + +func TestShouldStartFetchService_NoRemoteResources(t *testing.T) { + h := &harness.Harness{Agent: "agents/test.md"} + start, warning := shouldStartFetchService(h) + assert.False(t, start) + assert.Empty(t, warning) +} + func TestSetupFetchService_WithForgeClient(t *testing.T) { tmpDir := t.TempDir() h := &harness.Harness{Agent: "agents/test.md"} @@ -1239,6 +1277,7 @@ func TestSetupFetchService_ResolvesTokenWhenNoForgeClient(t *testing.T) { h := &harness.Harness{ Agent: "agents/test.md", AllowedRemoteResources: []string{"https://github.com/org/"}, + AllowRuntimeFetch: true, } tokenResolved := false @@ -1283,11 +1322,43 @@ func TestSetupFetchService_NoForgeClientNoRemoteResources(t *testing.T) { assert.NotEmpty(t, env.addr) } +func TestSetupFetchService_CustomMaxFetches(t *testing.T) { + tmpDir := t.TempDir() + maxFetches := 50 + h := &harness.Harness{ + Agent: "agents/test.md", + AllowRuntimeFetch: true, + AllowedRemoteResources: []string{"https://github.com/org/"}, + MaxRuntimeFetches: &maxFetches, + } + + cfg := fetchsvc.ServiceConfig{ + Harness: h, + WorkspaceRoot: tmpDir, + MaxFetches: h.EffectiveMaxRuntimeFetches(), + } + assert.Equal(t, 50, cfg.MaxFetches) + + env, shutdown, err := setupFetchService( + context.Background(), + nil, + h, + func() (string, error) { return "ghp_test", nil }, + cfg, + func(string) {}, + ) + require.NoError(t, err) + defer shutdown() + + assert.NotEmpty(t, env.addr) +} + func TestSetupFetchService_TokenResolutionFails(t *testing.T) { tmpDir := t.TempDir() h := &harness.Harness{ Agent: "agents/test.md", AllowedRemoteResources: []string{"https://github.com/org/"}, + AllowRuntimeFetch: true, } var warned string @@ -1310,6 +1381,14 @@ func TestSetupFetchService_TokenResolutionFails(t *testing.T) { assert.Contains(t, warned, "no token available") } +func TestEffectiveMaxRuntimeFetches_MatchesFetchsvcDefault(t *testing.T) { + h := &harness.Harness{} + if h.EffectiveMaxRuntimeFetches() != fetchsvc.DefaultMaxFetches { + t.Fatalf("harness default %d != fetchsvc.DefaultMaxFetches %d — update defaultMaxRuntimeFetches in harness.go", + h.EffectiveMaxRuntimeFetches(), fetchsvc.DefaultMaxFetches) + } +} + type mockForgeClient struct { forge.Client } diff --git a/internal/harness/compose.go b/internal/harness/compose.go index 5668b7b158..a8441e2db1 100644 --- a/internal/harness/compose.go +++ b/internal/harness/compose.go @@ -414,10 +414,10 @@ func mergeBaseIntoChild(base, child *Harness) { merged = append(merged, child.Providers...) child.Providers = merged } - // AllowedRemoteResources is NOT merged from base harnesses to prevent - // privilege escalation: a base cannot inject arbitrary URL prefixes - // into the child's allowlist. The child must declare its own allowlist - // which is validated against the org-level allowlist. + // AllowedRemoteResources, AllowRuntimeFetch, and MaxRuntimeFetches are + // NOT merged from base harnesses to prevent privilege escalation: a base + // cannot inject arbitrary URL prefixes or enable runtime fetching in the + // child. The child must declare its own allowlist and fetch settings. if base.APIServers != nil { merged := make([]APIServer, 0, len(base.APIServers)+len(child.APIServers)) merged = append(merged, base.APIServers...) diff --git a/internal/harness/compose_test.go b/internal/harness/compose_test.go index a1fa85b35e..fff4e871bb 100644 --- a/internal/harness/compose_test.go +++ b/internal/harness/compose_test.go @@ -1097,3 +1097,26 @@ runner_env: assert.Equal(t, map[string]string{"KEY1": "value1"}, h.RunnerEnv) } + +func TestLoadWithBase_RuntimeFetchFieldsNotInherited(t *testing.T) { + dir := t.TempDir() + + writeTestHarness(t, dir, "base.yaml", ` +agent: agents/test.md +allowed_remote_resources: + - https://example.com/ +allow_runtime_fetch: true +max_runtime_fetches: 50 +`) + + path := writeTestHarness(t, dir, "child.yaml", ` +base: base.yaml +`) + + h, _, err := LoadWithBase(context.Background(), path, ComposeOpts{}) + require.NoError(t, err) + + assert.False(t, h.AllowRuntimeFetch) + assert.Nil(t, h.MaxRuntimeFetches) + assert.Empty(t, h.AllowedRemoteResources) +} diff --git a/internal/harness/harness.go b/internal/harness/harness.go index ce6d90b209..b4002e02d5 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -218,6 +218,8 @@ type Harness struct { SandboxTimeoutSeconds int `yaml:"sandbox_timeout_seconds,omitempty"` Security *SecurityConfig `yaml:"security,omitempty"` AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"` + AllowRuntimeFetch bool `yaml:"allow_runtime_fetch,omitempty"` // opt-in to runtime skill fetching (default: false) + MaxRuntimeFetches *int `yaml:"max_runtime_fetches,omitempty"` // per-run fetch cap; nil = default (10), valid range 1-1000 Forge map[string]*ForgeConfig `yaml:"forge,omitempty"` } @@ -352,6 +354,17 @@ func (h *Harness) Validate() error { if err := h.ValidateResourceTypes(); err != nil { return err } + if h.AllowRuntimeFetch && len(h.AllowedRemoteResources) == 0 { + return fmt.Errorf("allow_runtime_fetch requires at least one entry in allowed_remote_resources") + } + if h.MaxRuntimeFetches != nil { + if !h.AllowRuntimeFetch { + return fmt.Errorf("max_runtime_fetches requires allow_runtime_fetch to be true") + } + if *h.MaxRuntimeFetches <= 0 || *h.MaxRuntimeFetches > 1000 { + return fmt.Errorf("max_runtime_fetches must be between 1 and 1000, got %d", *h.MaxRuntimeFetches) + } + } if err := h.validateForge(); err != nil { return err } @@ -700,6 +713,17 @@ func (h *Harness) ValidateResourceTypes() error { return nil } +const defaultMaxRuntimeFetches = 10 // must match fetchsvc.DefaultMaxFetches + +// EffectiveMaxRuntimeFetches returns the configured max runtime fetches, +// or defaultMaxRuntimeFetches (10) when the field is omitted. +func (h *Harness) EffectiveMaxRuntimeFetches() int { + if h.MaxRuntimeFetches == nil { + return defaultMaxRuntimeFetches + } + return *h.MaxRuntimeFetches +} + // HasURLSkills reports whether any skill field contains a URL. Used to determine // whether a forge client is needed for resolution. func (h *Harness) HasURLSkills() bool { diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go index d65ea21372..76b862dfb5 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -1296,6 +1296,128 @@ forge: assert.Contains(t, err.Error(), "not configured") } +// --- Runtime fetch field tests --- + +func TestValidate_AllowRuntimeFetchWithoutAllowedResources(t *testing.T) { + h := &Harness{ + Agent: "agents/code.md", + AllowRuntimeFetch: true, + } + err := h.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "allow_runtime_fetch requires at least one entry in allowed_remote_resources") +} + +func TestValidate_MaxRuntimeFetchesWithoutAllowRuntimeFetch(t *testing.T) { + v := 5 + h := &Harness{ + Agent: "agents/code.md", + MaxRuntimeFetches: &v, + } + err := h.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "max_runtime_fetches requires allow_runtime_fetch to be true") +} + +func TestValidate_MaxRuntimeFetchesNegative(t *testing.T) { + v := -1 + h := &Harness{ + Agent: "agents/code.md", + AllowRuntimeFetch: true, + AllowedRemoteResources: []string{"https://github.com/fullsend-ai/library/"}, + MaxRuntimeFetches: &v, + } + err := h.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "max_runtime_fetches must be between 1 and 1000") +} + +func TestValidate_MaxRuntimeFetchesExceedsUpperBound(t *testing.T) { + v := 1001 + h := &Harness{ + Agent: "agents/code.md", + AllowRuntimeFetch: true, + AllowedRemoteResources: []string{"https://github.com/fullsend-ai/library/"}, + MaxRuntimeFetches: &v, + } + err := h.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "max_runtime_fetches must be between 1 and 1000") +} + +func TestValidate_AllowRuntimeFetchValid(t *testing.T) { + v := 5 + h := &Harness{ + Agent: "agents/code.md", + AllowRuntimeFetch: true, + MaxRuntimeFetches: &v, + AllowedRemoteResources: []string{"https://github.com/fullsend-ai/library/"}, + } + err := h.Validate() + require.NoError(t, err) +} + +func TestValidate_AllowRuntimeFetchDefaultMaxFetches(t *testing.T) { + h := &Harness{ + Agent: "agents/code.md", + AllowRuntimeFetch: true, + AllowedRemoteResources: []string{"https://github.com/fullsend-ai/library/"}, + } + err := h.Validate() + require.NoError(t, err) + assert.Nil(t, h.MaxRuntimeFetches) + assert.Equal(t, 10, h.EffectiveMaxRuntimeFetches()) +} + +func TestValidate_MaxRuntimeFetchesExplicitZero(t *testing.T) { + v := 0 + h := &Harness{ + Agent: "agents/code.md", + AllowRuntimeFetch: true, + AllowedRemoteResources: []string{"https://github.com/fullsend-ai/library/"}, + MaxRuntimeFetches: &v, + } + err := h.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "max_runtime_fetches must be between 1 and 1000") +} + +func TestLoad_RuntimeFetchFields(t *testing.T) { + content := ` +agent: agents/code.md +allowed_remote_resources: + - https://github.com/fullsend-ai/library/ +allow_runtime_fetch: true +max_runtime_fetches: 15 +` + dir := t.TempDir() + path := filepath.Join(dir, "fetch.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := Load(path) + require.NoError(t, err) + + assert.True(t, h.AllowRuntimeFetch) + require.NotNil(t, h.MaxRuntimeFetches) + assert.Equal(t, 15, *h.MaxRuntimeFetches) + assert.Equal(t, 15, h.EffectiveMaxRuntimeFetches()) +} + +func TestLoad_RuntimeFetchFieldsOmitted(t *testing.T) { + content := ` +agent: agents/code.md +` + dir := t.TempDir() + path := filepath.Join(dir, "minimal.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := Load(path) + require.NoError(t, err) + + assert.False(t, h.AllowRuntimeFetch) + assert.Nil(t, h.MaxRuntimeFetches) +} + // --- ValidForgePlatform tests --- func TestValidForgePlatform(t *testing.T) {