diff --git a/docs/ADRs/0038-universal-harness-access.md b/docs/ADRs/0038-universal-harness-access.md index e85aef7931..681b081cf4 100644 --- a/docs/ADRs/0038-universal-harness-access.md +++ b/docs/ADRs/0038-universal-harness-access.md @@ -132,7 +132,8 @@ All resources remain local paths. Sharing requires manual copy-paste. **Hybrid approach: Option A for declarative resources combined with Option C's restriction on executable resources:** - Support URLs, absolute paths, and relative paths uniformly for **declarative** harness resources (agents, skills, policies, schemas) -- **Executable resources (scripts, binaries) must be local files** (Option C restriction) to preserve auditability and prevent direct code execution from untrusted sources +- **Executable resources (scripts, binaries) must be local files** (Option C restriction) to preserve auditability and prevent direct code execution from untrusted sources. Standalone URL references in script fields (`pre_script: https://...`) are rejected at validation time +- **Exception: `base:` composition (ADR-0045).** When a harness inherits from a URL-referenced base via the `base:` field, scripts declared in the base harness are fetched from the same source as the base itself. The trust model is transitive: the base harness content is SHA256-pinned, and scripts referenced within that pinned content are fetched from the same origin. Script integrity depends on the base URL pointing to an immutable ref (e.g., a commit SHA in the URL path, not a branch name). When the base URL uses a mutable ref such as `main`, scripts could change between fetches even though the base harness hash is pinned — operators should ensure base URLs contain commit SHAs for production use. After fetching, scripts are cached content-addressed and their paths are rewritten to local cache paths before validation, preserving the invariant that all script fields are local paths at validation time - Fetch and cache remote resources content-addressed by SHA256 - Validate integrity, apply SSRF protection, and enforce per-resource policies (read-only vs executable) - Extend transitive closure to all referenced resources @@ -146,7 +147,7 @@ With the hybrid approach (URL support for declarative resources, local files for ### What changes -- **Harness schema:** Declarative resource path fields (`agent`, `policy`, `skills[]`) accept URLs. Executable resource fields (`pre_script`, `post_script`) and configuration files (`host_files[].src`) must be local paths (see "Security implications" section for rationale). +- **Harness schema:** Declarative resource path fields (`agent`, `policy`, `skills[]`) accept URLs. Executable resource fields (`pre_script`, `post_script`, `validation_loop.script`, `agent_input`) and configuration files (`host_files[].src`) must be local paths when set directly in a harness. However, when inherited from a URL-referenced `base:` harness (ADR-0045), these fields are resolved by fetching the scripts from the base's source URL, caching them locally, and rewriting the paths. See "Security implications" section for rationale. - **Skill resolution model:** Skills referenced via URL point to directories, not individual `SKILL.md` files. The resolver uses forge APIs (GitHub Contents API, GitLab equivalent) to list directory contents, fetch all files, and reconstruct the directory tree in the local cache. Skills from non-forge HTTPS URLs are rejected because HTTP has no standard directory listing mechanism. Agents and policies remain single-file resources and work with any HTTPS URL. - **Resolution logic:** The runner resolves URLs by fetching, caching (content-addressed), and validating before use. - **Transitive closure (Phase 2 feature):** URL-referenced resources can themselves reference other resources via URL, creating a dependency tree. Phase 1 implementation limits URL references to single-level only (harness can reference URL-based resources, but those resources cannot reference additional URLs). Phase 2 adds full transitive resolution with: @@ -176,7 +177,7 @@ With the hybrid approach (URL support for declarative resources, local files for - All skills (local or remote) pass through the same security scanners (unicode normalization, context injection detection, LLM Guard). - Remote skills are subject to more restrictive policies than local skills (e.g., cannot reference executable scripts). -5. **Executable code from URLs:** Pre/post scripts fetched from URLs run on the runner host with full privileges. **Mitigation:** Apply **Option C** restriction: scripts and binaries must be local files. Only declarative resources (agents, skills, policies, schemas) can be URLs. **Alternative (future):** URL-sourced scripts could run in a restricted sandbox with no access to secrets, no network, and no filesystem writes outside `/tmp`. This requires designing an in-sandbox pre/post command execution mechanism (something like `pre_commands`/`post_commands` that run inside the sandbox before/after the agent's main execution). Today, `pre_script` and `post_script` run outside the sandbox. Any relaxation of the "scripts must be local" restriction depends on this prerequisite capability being implemented first. +5. **Executable code from URLs:** Pre/post scripts fetched from URLs run on the runner host with full privileges. **Mitigation:** Apply **Option C** restriction: standalone URL references in script fields are rejected at validation time (`pre_script: https://...` is invalid). Only declarative resources (agents, skills, policies, schemas) accept standalone URL values. **Exception for `base:` composition:** When a harness inherits scripts from a URL-referenced base (ADR-0045), those scripts are fetched through the same integrity-verified pipeline as all other resources. The security argument: the base harness is SHA256-pinned, and scripts declared within that pinned content are part of the same trusted artifact. The scripts are fetched from the same domain/commit as the base, verified against the `allowed_remote_resources` allowlist, cached content-addressed, and their paths are rewritten to local cache paths. A URL-to-hash index enables offline mode for previously-fetched scripts. This provides the same auditability as local scripts (the content is deterministic and cached) while enabling fully standalone agent repositories. 6. **Runtime dependency discovery increases attack surface:** If agents can fetch resources at runtime based on dynamic input (e.g., "I need a Python linting skill for this repo"), an attacker can manipulate input to trigger fetch of a malicious resource. **Mitigations:** - Runtime resource loading is opt-in per harness (disabled by default). diff --git a/docs/ADRs/0045-forge-portable-harness-schema.md b/docs/ADRs/0045-forge-portable-harness-schema.md index 76efc274b1..204d871a6c 100644 --- a/docs/ADRs/0045-forge-portable-harness-schema.md +++ b/docs/ADRs/0045-forge-portable-harness-schema.md @@ -382,10 +382,14 @@ the org's `allowed_remote_resources` allowlist, fetched via the SSRF-hardened fetch layer, and cached in `.fullsend-cache/`. Relative paths in the merged result (e.g., `pre_script: scripts/pre.sh`) -resolve against the local `.fullsend/` directory, not the base's origin. -This works because scripts are always scaffolded locally (ADR 0038's -"no remote executables" rule) — `base` handles declarative config while -scripts stay local and customizable. +resolve against the local `.fullsend/` directory when the base is a +local file. When the base is a URL, script fields (`pre_script`, +`post_script`, `validation_loop.script`) declared in the base harness +are fetched from the base URL's directory, cached content-addressed, +and rewritten to local cache paths before validation (see ADR 0038's +`base:` composition exception). `agent_input` is excluded from URL-base +resolution because it is a directory, not a single file. Scripts in the +child harness always resolve against the local `.fullsend/` directory. #### Depth limit and circular detection diff --git a/internal/cli/lock.go b/internal/cli/lock.go index bdd850ac90..0c053577cc 100644 --- a/internal/cli/lock.go +++ b/internal/cli/lock.go @@ -636,6 +636,31 @@ func resolveFromLock(h *harness.Harness, entry *lock.HarnessLock, workspaceRoot // Base composition is already resolved by LoadWithBase before // resolveFromLock runs. This entry exists only for cache // verification. + case m.field == "pre_script": + h.PreScript = m.localPath + if err := os.Chmod(m.localPath, 0o755); err != nil { + return nil, fmt.Errorf("setting executable permission on cached pre_script: %w", err) + } + case m.field == "post_script": + h.PostScript = m.localPath + if err := os.Chmod(m.localPath, 0o755); err != nil { + return nil, fmt.Errorf("setting executable permission on cached post_script: %w", err) + } + case m.field == "validation_loop.script": + if h.ValidationLoop != nil { + h.ValidationLoop.Script = m.localPath + if err := os.Chmod(m.localPath, 0o755); err != nil { + return nil, fmt.Errorf("setting executable permission on cached validation_loop.script: %w", err) + } + } + case strings.HasPrefix(m.field, "forge.") && strings.HasSuffix(m.field, ".pre_script"): + // Forge scripts are resolved before forge promotion; the field + // name is informational — the actual path was already set during + // LoadWithBase. This entry exists for cache verification. + case strings.HasPrefix(m.field, "forge.") && strings.HasSuffix(m.field, ".post_script"): + // Same as forge pre_script above. + case strings.HasPrefix(m.field, "forge.") && strings.HasSuffix(m.field, ".validation_loop.script"): + // Same as forge pre_script above. default: var idx int if _, err := fmt.Sscanf(m.field, "skills[%d]", &idx); err == nil && idx >= 0 && idx < len(h.Skills) { diff --git a/internal/harness/compose.go b/internal/harness/compose.go index a8441e2db1..c56270a39a 100644 --- a/internal/harness/compose.go +++ b/internal/harness/compose.go @@ -2,7 +2,11 @@ package harness import ( "context" + "encoding/json" "fmt" + "net/url" + "os" + "path" "path/filepath" "strings" "time" @@ -180,8 +184,21 @@ func loadBaseChain( return nil, nil, fmt.Errorf("parsing base harness from %s: %w", cleanURL, err) } - // For URL bases, relative paths in the base resolve against the child's directory - // (scripts are always local per ADR-0038's "no remote executables" rule) + // Resolve script fields in the base by fetching them from the base's + // source URL. This extends ADR-0038: standalone script URL references + // (pre_script: https://...) remain rejected, but scripts inherited + // through base: composition are fetched using the same integrity and + // allowlist infrastructure. After resolution, all script paths are + // local cache paths, so ValidateResourceTypes still passes. + scriptDeps, err := resolveBaseScripts(ctx, base, baseRef, allowlist, opts) + if err != nil { + return nil, nil, fmt.Errorf("resolving base scripts from %s: %w", cleanURL, err) + } + deps = append(deps, scriptDeps...) + + // Non-script relative paths in the base still resolve against the + // child's directory (agent, skills, policy are handled separately by + // ResolveHarness which processes URL fields). baseDir = childDir } else { // Local path base @@ -459,6 +476,304 @@ func mergeBaseIntoChild(base, child *Harness) { } } +// resolveBaseScripts fetches script fields from a URL-referenced base harness. +// For each script field (pre_script, post_script, validation_loop.script) that +// is a non-empty relative path, the script is fetched from the base URL's +// directory, cached content-addressed, and the field is rewritten to the local +// cache path. Forge-level scripts are also resolved. agent_input is excluded +// because runtime treats it as a directory (uploaded recursively). +// Returns additional dependencies for the fetched scripts. +func resolveBaseScripts(ctx context.Context, base *Harness, baseURL string, allowlist []string, opts ComposeOpts) ([]Dependency, error) { + baseURLDir := urlDirPrefix(baseURL) + if baseURLDir == "" { + return nil, fmt.Errorf("cannot determine directory from base URL") + } + + var deps []Dependency + + // agent_input is excluded: runtime treats it as a directory (uploaded + // recursively), so single-file fetch is not appropriate. + scriptFields := []struct { + name string + ptr *string + }{ + {"pre_script", &base.PreScript}, + {"post_script", &base.PostScript}, + } + + for _, f := range scriptFields { + if *f.ptr == "" { + continue + } + if err := validateBaseScriptPath(f.name, *f.ptr); err != nil { + return nil, err + } + dep, cachePath, err := fetchBaseScript(ctx, f.name, baseURLDir, *f.ptr, allowlist, opts) + if err != nil { + return nil, err + } + *f.ptr = cachePath + deps = append(deps, dep) + } + + if base.ValidationLoop != nil && base.ValidationLoop.Script != "" { + if err := validateBaseScriptPath("validation_loop.script", base.ValidationLoop.Script); err != nil { + return nil, err + } + dep, cachePath, err := fetchBaseScript(ctx, "validation_loop.script", baseURLDir, base.ValidationLoop.Script, allowlist, opts) + if err != nil { + return nil, err + } + base.ValidationLoop.Script = cachePath + deps = append(deps, dep) + } + + for platform, fc := range base.Forge { + if fc == nil { + continue + } + forgeScripts := []struct { + name string + ptr *string + }{ + {fmt.Sprintf("forge.%s.pre_script", platform), &fc.PreScript}, + {fmt.Sprintf("forge.%s.post_script", platform), &fc.PostScript}, + } + for _, f := range forgeScripts { + if *f.ptr == "" { + continue + } + if err := validateBaseScriptPath(f.name, *f.ptr); err != nil { + return nil, err + } + dep, cachePath, err := fetchBaseScript(ctx, f.name, baseURLDir, *f.ptr, allowlist, opts) + if err != nil { + return nil, err + } + *f.ptr = cachePath + deps = append(deps, dep) + } + if fc.ValidationLoop != nil && fc.ValidationLoop.Script != "" { + fieldName := fmt.Sprintf("forge.%s.validation_loop.script", platform) + if err := validateBaseScriptPath(fieldName, fc.ValidationLoop.Script); err != nil { + return nil, err + } + dep, cachePath, err := fetchBaseScript(ctx, fieldName, baseURLDir, fc.ValidationLoop.Script, allowlist, opts) + if err != nil { + return nil, err + } + fc.ValidationLoop.Script = cachePath + deps = append(deps, dep) + } + } + + // agent_input is a directory at runtime (uploaded recursively) and cannot + // be fetched as a single file from a URL. Clear it so it doesn't resolve + // against the child's local directory where it won't exist. + if base.AgentInput != "" { + base.AgentInput = "" + } + + return deps, nil +} + +func validateBaseScriptPath(field, val string) error { + if strings.ContainsRune(val, 0) { + return fmt.Errorf("base script %s must not contain null bytes (got %q)", field, val) + } + if strings.ContainsAny(val, "?#") { + return fmt.Errorf("base script %s must not contain query or fragment markers (got %q)", field, val) + } + if IsURL(val) { + return fmt.Errorf("base script %s must be a relative path, not a URL (got %q)", field, val) + } + if filepath.IsAbs(val) { + return fmt.Errorf("base script %s must be a relative path, not an absolute path (got %q)", field, val) + } + for _, seg := range strings.Split(val, "/") { + if seg == ".." { + return fmt.Errorf("base script %s must not contain path traversal segments (got %q)", field, val) + } + } + return nil +} + +// fetchBaseScript fetches a single script file from a URL derived from the +// base harness's directory and the script's relative path. The script is +// cached content-addressed and the local cache path is returned. +func fetchBaseScript(ctx context.Context, field, baseURLDir, relPath string, allowlist []string, opts ComposeOpts) (Dependency, string, error) { + scriptURL := baseURLDir + relPath + + allowedBy := matchingAllowedPrefix(scriptURL, allowlist) + if allowedBy == "" { + return Dependency{}, "", fmt.Errorf("base script %s: URL %q is not in allowed_remote_resources", field, scriptURL) + } + + // Check URL-to-hash index for cached content (supports offline mode). + hash, indexHit := urlIndexLookup(opts.WorkspaceRoot, scriptURL) + if indexHit { + content, entry, err := fetch.CacheGet(opts.WorkspaceRoot, hash) + if err == nil && content != nil { + cachePath, cpErr := fetch.CachePath(opts.WorkspaceRoot, hash) + if cpErr != nil { + return Dependency{}, "", fmt.Errorf("base script %s: computing cache path: %w", field, cpErr) + } + contentPath := filepath.Join(cachePath, "content") + + if chErr := os.Chmod(contentPath, 0o755); chErr != nil { + return Dependency{}, "", fmt.Errorf("base script %s: setting executable permission on cached script: %w", field, chErr) + } + + if aErr := auditScriptFetch(opts, scriptURL, hash, allowedBy, true, entry.FetchTime); aErr != nil { + return Dependency{}, "", aErr + } + + return Dependency{ + Field: field, + URL: scriptURL, + LocalPath: contentPath, + SHA256: hash, + FetchedAt: entry.FetchTime, + CacheHit: true, + Type: "script", + }, contentPath, nil + } + } + + if opts.FetchPolicy.Offline { + return Dependency{}, "", fmt.Errorf("base script %s: URL %s not in cache and offline mode is enabled (run 'fullsend lock' first)", field, scriptURL) + } + + content, err := fetch.FetchURL(ctx, scriptURL, opts.FetchPolicy) + if err != nil { + return Dependency{}, "", fmt.Errorf("base script %s: fetching %s: %w", field, scriptURL, err) + } + + if err := fetch.CachePut(opts.WorkspaceRoot, scriptURL, content); err != nil { + return Dependency{}, "", fmt.Errorf("base script %s: caching: %w", field, err) + } + + hash = fetch.ComputeSHA256(content) + cachePath, err := fetch.CachePath(opts.WorkspaceRoot, hash) + if err != nil { + return Dependency{}, "", fmt.Errorf("base script %s: computing cache path: %w", field, err) + } + contentPath := filepath.Join(cachePath, "content") + + // Make cached script executable. + if chErr := os.Chmod(contentPath, 0o755); chErr != nil { + return Dependency{}, "", fmt.Errorf("base script %s: setting executable permission: %w", field, chErr) + } + + // Store URL→hash mapping for future offline lookups. + if iErr := urlIndexPut(opts.WorkspaceRoot, scriptURL, hash); iErr != nil { + return Dependency{}, "", fmt.Errorf("base script %s: updating URL index: %w", field, iErr) + } + + fetchedAt := time.Now().UTC() + if aErr := auditScriptFetch(opts, scriptURL, hash, allowedBy, false, fetchedAt); aErr != nil { + return Dependency{}, "", aErr + } + + return Dependency{ + Field: field, + URL: scriptURL, + LocalPath: contentPath, + SHA256: hash, + FetchedAt: fetchedAt, + CacheHit: false, + Type: "script", + }, contentPath, nil +} + +// auditScriptFetch appends a fetch audit log entry for a script fetch. +func auditScriptFetch(opts ComposeOpts, scriptURL, hash, allowedBy string, cacheHit bool, fetchedAt time.Time) error { + if opts.AuditLogPath == "" { + return nil + } + return fetch.AppendFetchAudit(opts.AuditLogPath, fetch.FetchAuditEntry{ + TraceID: opts.TraceID, + FetchTime: fetchedAt, + URL: scriptURL, + SHA256: hash, + FetchType: "base_script", + AllowedBy: allowedBy, + CacheHit: cacheHit, + }) +} + +// urlDirPrefix returns the directory portion of a URL (everything up to and +// including the last "/" before the filename). The integrity hash fragment +// is stripped first. Returns "" if the URL cannot be parsed. +func urlDirPrefix(rawURL string) string { + cleanURL, _, _ := ParseIntegrityHash(rawURL) + parsed, err := url.Parse(cleanURL) + if err != nil { + return "" + } + dir := path.Dir(parsed.Path) + if dir == "." || dir == "" { + return "" + } + if !strings.HasSuffix(dir, "/") { + dir += "/" + } + parsed.Path = dir + parsed.RawPath = "" + parsed.Fragment = "" + return parsed.String() +} + +// urlIndexPath returns the path to the URL-to-hash index file. +func urlIndexPath(workspaceRoot string) string { + return filepath.Join(workspaceRoot, ".fullsend-cache", "url-index.json") +} + +// urlIndexLookup reads the URL-to-hash index and returns the SHA256 for the +// given URL. Returns ("", false) on miss or read error. +func urlIndexLookup(workspaceRoot, rawURL string) (string, bool) { + if workspaceRoot == "" { + return "", false + } + data, err := os.ReadFile(urlIndexPath(workspaceRoot)) + if err != nil { + return "", false + } + var index map[string]string + if err := json.Unmarshal(data, &index); err != nil { + return "", false + } + hash, ok := index[rawURL] + return hash, ok +} + +// urlIndexPut records a URL→SHA256 mapping in the index file. +func urlIndexPut(workspaceRoot, rawURL, hash string) error { + if workspaceRoot == "" { + return nil + } + idxPath := urlIndexPath(workspaceRoot) + if err := os.MkdirAll(filepath.Dir(idxPath), 0o700); err != nil { + return err + } + + var index map[string]string + data, err := os.ReadFile(idxPath) + if err == nil { + _ = json.Unmarshal(data, &index) + } + if index == nil { + index = make(map[string]string) + } + index[rawURL] = hash + + out, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + return os.WriteFile(idxPath, out, 0o600) +} + // mergeHostFiles concatenates base and child host files, with child entries // overriding base entries that have the same Dest path. func mergeHostFiles(base, child []HostFile) []HostFile { diff --git a/internal/harness/compose_test.go b/internal/harness/compose_test.go index b020a1b017..3f69026897 100644 --- a/internal/harness/compose_test.go +++ b/internal/harness/compose_test.go @@ -1140,6 +1140,824 @@ runner_env: assert.Equal(t, map[string]string{"KEY1": "value1"}, h.RunnerEnv) } +func TestURLDirPrefix(t *testing.T) { + tests := []struct { + input string + want string + }{ + { + "https://raw.githubusercontent.com/org/repo/sha/harness/triage.yaml#sha256=abc123", + "https://raw.githubusercontent.com/org/repo/sha/harness/", + }, + { + "https://example.com/path/to/file.yaml", + "https://example.com/path/to/", + }, + { + "https://example.com/file.yaml#sha256=0000000000000000000000000000000000000000000000000000000000000000", + "https://example.com/", + }, + { + "not-a-url", + "", + }, + } + for _, tt := range tests { + got := urlDirPrefix(tt.input) + assert.Equal(t, tt.want, got, "urlDirPrefix(%q)", tt.input) + } +} + +func setupScriptTestServer(t *testing.T, harnessContent []byte, scripts map[string][]byte) (*httptest.Server, fetch.FetchPolicy) { + t.Helper() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/harness/triage.yaml" { + w.WriteHeader(http.StatusOK) + w.Write(harnessContent) + return + } + if content, ok := scripts[r.URL.Path]; ok { + w.WriteHeader(http.StatusOK) + w.Write(content) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + policy := fetch.NewTestPolicy( + server.Client().Transport.(*http.Transport).TLSClientConfig, + []string{"127.0.0.1"}, + []string{server.Listener.Addr().String()[len("127.0.0.1:"):]}, + ) + return server, policy +} + +func TestLoadWithBase_URLBase_ScriptsFetched(t *testing.T) { + preScript := []byte("#!/bin/bash\necho pre") + postScript := []byte("#!/bin/bash\necho post") + + baseContent := []byte(` +agent: agents/triage.md +role: test +model: opus +pre_script: scripts/pre.sh +post_script: scripts/post.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/pre.sh": preScript, + "/harness/scripts/post.sh": postScript, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + assert.Equal(t, "agents/child.md", h.Agent) + assert.Equal(t, "opus", h.Model) + + // Scripts resolved to local cache paths + assert.NotEmpty(t, h.PreScript) + assert.NotEmpty(t, h.PostScript) + assert.True(t, filepath.IsAbs(h.PreScript), "pre_script should be absolute cache path") + assert.True(t, filepath.IsAbs(h.PostScript), "post_script should be absolute cache path") + assert.False(t, IsURL(h.PreScript), "pre_script should not be a URL") + assert.False(t, IsURL(h.PostScript), "post_script should not be a URL") + + // Verify cached content + preContent, err := os.ReadFile(h.PreScript) + require.NoError(t, err) + assert.Equal(t, preScript, preContent) + + postContent, err := os.ReadFile(h.PostScript) + require.NoError(t, err) + assert.Equal(t, postScript, postContent) + + // Dependencies: 1 for base harness + 2 for scripts + require.Len(t, deps, 3) + assert.Equal(t, "base", deps[0].Field) + scriptDeps := deps[1:] + scriptFields := map[string]bool{} + for _, d := range scriptDeps { + scriptFields[d.Field] = true + assert.Equal(t, "script", d.Type) + assert.False(t, d.CacheHit) + } + assert.True(t, scriptFields["pre_script"]) + assert.True(t, scriptFields["post_script"]) +} + +func TestLoadWithBase_URLBase_ValidationLoopScriptFetched(t *testing.T) { + validateScript := []byte("#!/bin/bash\necho validate") + + baseContent := []byte(` +agent: agents/triage.md +role: test +validation_loop: + script: scripts/validate.sh + max_iterations: 3 +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/validate.sh": validateScript, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + require.NotNil(t, h.ValidationLoop) + assert.True(t, filepath.IsAbs(h.ValidationLoop.Script)) + assert.Equal(t, 3, h.ValidationLoop.MaxIterations) + + content, err := os.ReadFile(h.ValidationLoop.Script) + require.NoError(t, err) + assert.Equal(t, validateScript, content) + + // 1 base + 1 validation script + require.Len(t, deps, 2) + assert.Equal(t, "validation_loop.script", deps[1].Field) + assert.Equal(t, "script", deps[1].Type) +} + +func TestLoadWithBase_URLBase_ForgeScriptsFetched(t *testing.T) { + forgePre := []byte("#!/bin/bash\necho forge-pre") + forgePost := []byte("#!/bin/bash\necho forge-post") + + baseContent := []byte(` +agent: agents/triage.md +role: test +forge: + github: + pre_script: scripts/gh-pre.sh + post_script: scripts/gh-post.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/gh-pre.sh": forgePre, + "/harness/scripts/gh-post.sh": forgePost, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + ForgePlatform: "github", + }) + require.NoError(t, err) + + // After forge resolution, scripts are promoted to top level + assert.True(t, filepath.IsAbs(h.PreScript)) + assert.True(t, filepath.IsAbs(h.PostScript)) + + preContent, err := os.ReadFile(h.PreScript) + require.NoError(t, err) + assert.Equal(t, forgePre, preContent) + + // 1 base + 2 forge scripts + require.Len(t, deps, 3) + forgeScriptDeps := deps[1:] + for _, d := range forgeScriptDeps { + assert.Equal(t, "script", d.Type) + assert.Contains(t, d.Field, "forge.github.") + } +} + +func TestLoadWithBase_URLBase_ChildOverridesScript(t *testing.T) { + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/base-pre.sh +post_script: scripts/base-post.sh +`) + preScript := []byte("#!/bin/bash\necho base-pre") + postScript := []byte("#!/bin/bash\necho base-post") + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/base-pre.sh": preScript, + "/harness/scripts/base-post.sh": postScript, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + // Child overrides pre_script; both base scripts are still fetched + // before merge (we can't know which fields the child overrides yet). + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +pre_script: local-pre.sh +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + // Child's pre_script wins + assert.Equal(t, "local-pre.sh", h.PreScript) + // Base's post_script fetched from remote + assert.True(t, filepath.IsAbs(h.PostScript)) + + // 1 base + 2 scripts: both are fetched BEFORE merge, so pre_script is + // fetched even though the child overrides it afterward. + require.Len(t, deps, 3) +} + +func TestLoadWithBase_URLBase_ScriptNotInAllowlist(t *testing.T) { + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/pre.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/pre.sh": []byte("#!/bin/bash"), + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + // Allowlist only covers /harness/triage.yaml, not /harness/scripts/ + _, _, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/harness/triage.yaml"}, + }) + // The allowlist check is prefix-based, so /harness/triage.yaml as prefix + // does NOT cover /harness/scripts/pre.sh + require.Error(t, err) + assert.Contains(t, err.Error(), "not in allowed_remote_resources") +} + +func TestLoadWithBase_URLBase_ScriptFetchFails(t *testing.T) { + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/missing.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{}) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + _, _, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "pre_script") +} + +func TestLoadWithBase_URLBase_ScriptsOffline_NoCacheError(t *testing.T) { + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/pre.sh +`) + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + + // Pre-populate base harness in cache so it can be loaded offline + require.NoError(t, fetch.CachePut(cacheDir, "https://example.com/harness/triage.yaml", baseContent)) + + baseURL := "https://example.com/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + _, _, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: fetch.FetchPolicy{Offline: true}, + OrgAllowlist: []string{"https://example.com/"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "offline mode") + assert.Contains(t, err.Error(), "fullsend lock") +} + +func TestLoadWithBase_URLBase_ScriptsOffline_CacheHit(t *testing.T) { + preScript := []byte("#!/bin/bash\necho cached-pre") + + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/pre.sh +`) + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + + // Pre-populate base harness in cache + require.NoError(t, fetch.CachePut(cacheDir, "https://example.com/harness/triage.yaml", baseContent)) + // Pre-populate script in cache + require.NoError(t, fetch.CachePut(cacheDir, "https://example.com/harness/scripts/pre.sh", preScript)) + // Add URL index entry + scriptHash := fetch.ComputeSHA256(preScript) + require.NoError(t, urlIndexPut(cacheDir, "https://example.com/harness/scripts/pre.sh", scriptHash)) + + baseURL := "https://example.com/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: fetch.FetchPolicy{Offline: true}, + OrgAllowlist: []string{"https://example.com/"}, + }) + require.NoError(t, err) + + assert.True(t, filepath.IsAbs(h.PreScript)) + content, err := os.ReadFile(h.PreScript) + require.NoError(t, err) + assert.Equal(t, preScript, content) + + // Both deps should be cache hits + require.Len(t, deps, 2) + assert.True(t, deps[0].CacheHit, "base should be cache hit") + assert.True(t, deps[1].CacheHit, "script should be cache hit") +} + +func TestLoadWithBase_URLBase_ScriptExecutablePermission(t *testing.T) { + scriptContent := []byte("#!/bin/bash\necho executable") + + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/pre.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/pre.sh": scriptContent, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, _, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + // Verify the cached script is executable + info, err := os.Stat(h.PreScript) + require.NoError(t, err) + assert.True(t, info.Mode()&0o111 != 0, "cached script should be executable, got mode %o", info.Mode()) +} + +func TestLoadWithBase_URLBase_NoScripts_NoExtraFetches(t *testing.T) { + baseContent := []byte(` +agent: agents/remote.md +role: test +model: sonnet +`) + hash := computeHash(baseContent) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{}) + + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + _, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + // Only 1 dep for the base itself — no scripts + require.Len(t, deps, 1) + assert.Equal(t, "base", deps[0].Field) +} + +func TestLoadWithBase_URLBase_AuditLogForScripts(t *testing.T) { + preScript := []byte("#!/bin/bash\necho pre") + + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/pre.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/pre.sh": preScript, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + auditLog := filepath.Join(dir, "audit.jsonl") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + _, _, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + AuditLogPath: auditLog, + TraceID: "test-trace-123", + }) + require.NoError(t, err) + + // Verify audit log was written + auditData, err := os.ReadFile(auditLog) + require.NoError(t, err) + auditStr := string(auditData) + assert.Contains(t, auditStr, "base_script") + assert.Contains(t, auditStr, "test-trace-123") + assert.Contains(t, auditStr, "scripts/pre.sh") +} + +func TestLoadWithBase_URLBase_ForgeValidationLoopScriptFetched(t *testing.T) { + forgeValidate := []byte("#!/bin/bash\necho forge-validate") + + baseContent := []byte(` +agent: agents/triage.md +role: test +forge: + github: + validation_loop: + script: scripts/gh-validate.sh + max_iterations: 2 +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/gh-validate.sh": forgeValidate, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + ForgePlatform: "github", + }) + require.NoError(t, err) + + require.NotNil(t, h.ValidationLoop) + assert.True(t, filepath.IsAbs(h.ValidationLoop.Script)) + assert.Equal(t, 2, h.ValidationLoop.MaxIterations) + + content, err := os.ReadFile(h.ValidationLoop.Script) + require.NoError(t, err) + assert.Equal(t, forgeValidate, content) + + // 1 base + 1 forge validation_loop script + require.Len(t, deps, 2) + assert.Equal(t, "forge.github.validation_loop.script", deps[1].Field) +} + +func TestLoadWithBase_URLBase_AgentInputNotFetched(t *testing.T) { + baseContent := []byte(` +agent: agents/triage.md +role: test +agent_input: data/input +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{}) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + // agent_input is a directory at runtime — it is cleared from URL bases + // to prevent the relative path resolving against the child's directory + // where it won't exist. + assert.Empty(t, h.AgentInput) + + // Only 1 dep for the base harness, no agent_input dep + require.Len(t, deps, 1) + assert.Equal(t, "base", deps[0].Field) +} + +func TestLoadWithBase_URLBase_ForgeScriptFetchError(t *testing.T) { + baseContent := []byte(` +agent: agents/triage.md +role: test +forge: + github: + pre_script: scripts/missing-forge.sh +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{}) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + _, _, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "forge.github.pre_script") +} + +func TestLoadWithBase_URLBase_AllScriptTypes(t *testing.T) { + preScript := []byte("#!/bin/bash\necho pre") + postScript := []byte("#!/bin/bash\necho post") + validateScript := []byte("#!/bin/bash\necho validate") + + baseContent := []byte(` +agent: agents/triage.md +role: test +pre_script: scripts/pre.sh +post_script: scripts/post.sh +validation_loop: + script: scripts/validate.sh + max_iterations: 3 +`) + + server, policy := setupScriptTestServer(t, baseContent, map[string][]byte{ + "/harness/scripts/pre.sh": preScript, + "/harness/scripts/post.sh": postScript, + "/harness/scripts/validate.sh": validateScript, + }) + + hash := computeHash(baseContent) + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + baseURL := server.URL + "/harness/triage.yaml#sha256=" + hash + + path := writeTestHarness(t, dir, "child.yaml", ` +agent: agents/child.md +role: test +base: `+baseURL+` +`) + + h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{ + WorkspaceRoot: cacheDir, + FetchPolicy: policy, + OrgAllowlist: []string{server.URL + "/"}, + }) + require.NoError(t, err) + + assert.True(t, filepath.IsAbs(h.PreScript)) + assert.True(t, filepath.IsAbs(h.PostScript)) + require.NotNil(t, h.ValidationLoop) + assert.True(t, filepath.IsAbs(h.ValidationLoop.Script)) + + // 1 base + 3 scripts (agent_input excluded — it's a directory) + require.Len(t, deps, 4) + depFields := map[string]bool{} + for _, d := range deps[1:] { + depFields[d.Field] = true + assert.Equal(t, "script", d.Type) + } + assert.True(t, depFields["pre_script"]) + assert.True(t, depFields["post_script"]) + assert.True(t, depFields["validation_loop.script"]) +} + +func TestResolveBaseScripts_RejectsAbsolutePath(t *testing.T) { + base := &Harness{PreScript: "/etc/passwd"} + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a relative path, not an absolute path") + assert.Contains(t, err.Error(), "pre_script") +} + +func TestResolveBaseScripts_RejectsPathTraversal(t *testing.T) { + base := &Harness{PostScript: "../../../etc/passwd"} + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain path traversal") + assert.Contains(t, err.Error(), "post_script") +} + +func TestResolveBaseScripts_RejectsURLInScriptField(t *testing.T) { + base := &Harness{PreScript: "https://evil.com/malware.sh"} + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a relative path, not a URL") +} + +func TestResolveBaseScripts_RejectsAbsoluteValidationLoopScript(t *testing.T) { + base := &Harness{ + ValidationLoop: &ValidationLoop{Script: "/usr/bin/evil"}, + } + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a relative path, not an absolute path") + assert.Contains(t, err.Error(), "validation_loop.script") +} + +func TestResolveBaseScripts_RejectsAbsoluteForgeScript(t *testing.T) { + base := &Harness{ + Forge: map[string]*ForgeConfig{ + "github": {PreScript: "/usr/bin/evil"}, + }, + } + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a relative path, not an absolute path") + assert.Contains(t, err.Error(), "forge.github.pre_script") +} + +func TestResolveBaseScripts_RejectsTraversalInForgeScript(t *testing.T) { + base := &Harness{ + Forge: map[string]*ForgeConfig{ + "github": {PostScript: "../escape.sh"}, + }, + } + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain path traversal") + assert.Contains(t, err.Error(), "forge.github.post_script") +} + +func TestResolveBaseScripts_RejectsAbsoluteForgeValidationLoop(t *testing.T) { + base := &Harness{ + Forge: map[string]*ForgeConfig{ + "gitlab": { + ValidationLoop: &ValidationLoop{Script: "/usr/bin/evil"}, + }, + }, + } + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a relative path, not an absolute path") + assert.Contains(t, err.Error(), "forge.gitlab.validation_loop.script") +} + +func TestResolveBaseScripts_RejectsNullBytes(t *testing.T) { + base := &Harness{PreScript: "scripts/pre\x00.sh"} + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain null bytes") +} + +func TestResolveBaseScripts_RejectsQueryMarker(t *testing.T) { + base := &Harness{PreScript: "scripts/pre.sh?param=1"} + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain query or fragment markers") +} + +func TestResolveBaseScripts_RejectsFragmentMarker(t *testing.T) { + base := &Harness{PostScript: "scripts/post.sh#anchor"} + _, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain query or fragment markers") +} + +func TestResolveBaseScripts_ClearsAgentInput(t *testing.T) { + base := &Harness{AgentInput: "data/input"} + deps, err := resolveBaseScripts(context.Background(), base, "https://example.com/harness/triage.yaml#sha256=abc", nil, ComposeOpts{}) + require.NoError(t, err) + assert.Empty(t, base.AgentInput) + assert.Empty(t, deps) +} + +func TestValidateBaseScriptPath_AllowsDotsInFilename(t *testing.T) { + err := validateBaseScriptPath("pre_script", "scripts/foo..bar.sh") + assert.NoError(t, err) +} + +func TestResolveBaseScripts_InvalidBaseURL(t *testing.T) { + base := &Harness{PreScript: "scripts/pre.sh"} + _, err := resolveBaseScripts(context.Background(), base, "not-a-valid-url", nil, ComposeOpts{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot determine directory") +} + +func TestURLIndexPut_EmptyWorkspaceRoot(t *testing.T) { + err := urlIndexPut("", "https://example.com/script.sh", "abc123") + assert.NoError(t, err) +} + +func TestURLIndexLookup_EmptyWorkspaceRoot(t *testing.T) { + hash, ok := urlIndexLookup("", "https://example.com/script.sh") + assert.False(t, ok) + assert.Empty(t, hash) +} + func TestLoadWithBase_RuntimeFetchFieldsNotInherited(t *testing.T) { dir := t.TempDir()