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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions docs/ADRs/0024-harness-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment thread
ggallen marked this conversation as resolved.

### Example: triage harness (with container image)
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<per-run token> (if active)│ │
│ │ ├── FULLSEND_FETCH_URL=... (if allow_runtime_fetch)│
│ │ ├── FULLSEND_FETCH_TOKEN=<per-run token> (if above)│ │
│ │ └── sources .env.d/*.env files │ │
│ └──────────┬───────────────────────────────┘ │
│ ▼ │
Expand Down
8 changes: 7 additions & 1 deletion docs/guides/user/building-custom-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
5 changes: 5 additions & 0 deletions docs/guides/user/customizing-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/universal-harness-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 24 additions & 7 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,20 +565,20 @@ 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{
Comment thread
ggallen marked this conversation as resolved.
Harness: h,
FetchPolicy: fetch.DefaultPolicy,
WorkspaceRoot: absFullsendDir,
AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"),
TraceID: traceID,
SandboxName: sandboxName,
MaxFetches: fetchsvc.DefaultMaxFetches,
MaxFetches: h.EffectiveMaxRuntimeFetches(),
Uploader: &fetchsvc.SandboxUploader{},
Comment thread
ggallen marked this conversation as resolved.
SkillDestDir: sandbox.SandboxClaudeConfig + "/skills",
}, printer.StepWarn)
Expand Down Expand Up @@ -1099,13 +1099,30 @@ type fetchServiceEnv struct {
token string // bearer token
Comment thread
ggallen marked this conversation as resolved.
}

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
Comment thread
ggallen marked this conversation as resolved.
// should be started, and returns a deprecation warning if the harness relies
Comment thread
ggallen marked this conversation as resolved.
// on the legacy implicit opt-in via allowed_remote_resources.
func shouldStartFetchService(h *harness.Harness) (start bool, deprecationWarning string) {
Comment thread
ggallen marked this conversation as resolved.
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.
Comment thread
ggallen marked this conversation as resolved.
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 {
Expand Down
79 changes: 79 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,44 @@ func TestBootstrapEnv_SkipsFetchVarsWhenEmpty(t *testing.T) {
assert.Contains(t, err.Error(), "copying .env file to sandbox")
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
}

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"}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
8 changes: 4 additions & 4 deletions internal/harness/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
23 changes: 23 additions & 0 deletions internal/harness/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
24 changes: 24 additions & 0 deletions internal/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -352,6 +354,17 @@ func (h *Harness) Validate() error {
if err := h.ValidateResourceTypes(); err != nil {
Comment thread
ggallen marked this conversation as resolved.
return err
Comment thread
ggallen marked this conversation as resolved.
}
if h.AllowRuntimeFetch && len(h.AllowedRemoteResources) == 0 {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
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")
Comment thread
ggallen marked this conversation as resolved.
}
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
}
Expand Down Expand Up @@ -700,6 +713,17 @@ func (h *Harness) ValidateResourceTypes() error {
return nil
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
}

Comment thread
ggallen marked this conversation as resolved.
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 {
Expand Down
Loading
Loading