Skip to content

feat(compose): resolve local profiles and providers in base harness (#5240) - #5461

Merged
maruiz93 merged 17 commits into
mainfrom
fix/5240-resolve-profiles-providers
Aug 5, 2026
Merged

feat(compose): resolve local profiles and providers in base harness (#5240)#5461
maruiz93 merged 17 commits into
mainfrom
fix/5240-resolve-profiles-providers

Conversation

@maruiz93

@maruiz93 maruiz93 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add resolveBaseProfiles and resolveBaseProviders to harness composition so local paths in base harnesses are fetched and cached, matching existing behavior for skills, agent, policy, and scripts
  • Extract IsProviderPath helper to distinguish bare provider names (e.g. fullsend-github) from file paths, consolidating a heuristic previously duplicated in 3 locations
  • Extract parseProviderDef helper to deduplicate provider YAML validation between local-path and URL branches
  • Add Warnings field to ResolveResult so credential warnings from local providers surface to the user
  • Wire local-only resolution path in run.go with result merging to preserve lock-file deps/providers
  • Add FromURL origin marker to ResolvedProvider/ResolvedProfile so checkProviderProfileIntegrity can distinguish URL-resolved from local-path providers
  • Add .yaml/.yml extension validation for local profile paths in ValidateResourceTypes

Closes #5240

Test plan

  • TestResolveBaseProfiles — 5 cases: URL, relative, absolute, mixed, empty
  • TestResolveBaseProviders — 6 cases: URL, bare name, relative path, absolute, mixed, empty
  • TestIsProviderPath — 9 cases covering bare names, slashes, YAML extensions
  • TestParseProviderDef — 7 cases: valid, missing name/type, invalid chars, credential warning
  • TestResolveHarness_LocalProviderWarnings — end-to-end warning propagation
  • TestValidateFilesExist_BareProviderNameSkipped — bare provider names not file-checked
  • TestResolveHarness_LocalProfileReadError / TestResolveHarness_LocalProviderReadError — missing-file errors from ResolveHarness
  • TestCheckProviderProfileIntegrity — local-path providers skipped, mixed URL+local, mismatches
  • TestValidateResourceTypes_ProfilesRequireYAMLExtension — extensionless profile paths rejected
  • Consolidated overlapping traversal/unchanged tests into table-driven patterns
  • All harness and resolve tests pass (go test ./internal/harness/... ./internal/resolve/...)

🤖 Generated with Claude Code

@maruiz93
maruiz93 requested a review from a team as a code owner July 22, 2026 13:38
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Resolve local profiles/providers when composing and running base harnesses

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Fetch and cache relative profile/provider paths from URL base harnesses.
• Treat local profiles and absolute provider paths as resolvable resources.
• Surface provider credential warnings and strengthen path/file validation.
Diagram

graph TD
  A["CLI runAgent"] --> B["compose.LoadWithBase"] --> C["resolveBaseProfiles/Providers"] --> D["fetchBaseFile"] --> E[("Cache")]
  A --> F["resolve.ResolveHarness"] --> G["Warn + merge results"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Require file:// URLs (with optional integrity) for local profiles/providers
  • ➕ Single URL-based codepath; fewer special cases for local paths
  • ➕ Could reuse existing integrity-hash validation model for all resources
  • ➖ More verbose authoring and a breaking UX change for existing local configs
  • ➖ Still needs careful handling of relative paths in base composition
2. Resolve all local resources during composition only (no CLI fallback)
  • ➕ Keeps resolution concerns in one layer (compose), simplifying run-time logic
  • ➕ Would avoid the local-only ResolveHarness invocation/merge in CLI
  • ➖ Composition doesn’t naturally parse provider YAML/profile IDs (resolve currently does)
  • ➖ Risk of duplicating parsing/validation logic between compose and resolve
3. Extend lockfile to capture local provider/profile digests and warnings

Recommendation: Keep the PR’s approach: it aligns profiles/providers with existing base-resource behavior (fetch+cache on URL bases) while minimally expanding ResolveHarness to support local paths. The added CLI fallback and result merging is pragmatic to preserve lockfile/URL-resolved data without requiring a larger lockfile redesign.

Files changed (9) +751 / -40

Enhancement (2) +160 / -29
compose.goFetch/cache base profiles and provider file paths from URL bases +100/-0

Fetch/cache base profiles and provider file paths from URL bases

• Introduces resolveBaseProfiles and resolveBaseProviders to fetch relative paths referenced by URL base harnesses, caching them content-addressed and rewriting harness entries to local cache paths. Wires this into both LoadWithBase and base-chain loading to match existing agent/skill/script/host_file behavior.

internal/harness/compose.go

resolve.goResolve local profiles and absolute provider paths; propagate warnings +60/-29

Resolve local profiles and absolute provider paths; propagate warnings

• Updates ResolveHarness to accept local profile paths (read directly) alongside URL-based profiles (fetch+cache). Adds absolute-path provider resolution by parsing local YAML into ProviderDef while preserving bare names for later provider loading; introduces parseProviderDef helper and surfaces local-provider credential warnings via a new ResolveResult.Warnings field.

internal/resolve/resolve.go

Bug fix (2) +68 / -9
run.goRun local-only profile/provider resolution and print warnings +31/-0

Run local-only profile/provider resolution and print warnings

• Adds a local-only resolution path when profiles/providers are present as local paths without URL references, ensuring they are parsed into resolved structures. Merges newly resolved deps/providers into any existing resolution result and emits ResolveResult warnings to the user.

internal/cli/run.go

harness.goAllow local profiles, resolve provider/profile relpaths, and validate file existence +37/-9

Allow local profiles, resolve provider/profile relpaths, and validate file existence

• Extends relative-path resolution to openshell profiles and provider file paths (while leaving bare provider names unchanged). Updates validation to accept local profile paths (URLs still require integrity hashes), improves HasURLReferences to check profiles individually, and adds ValidateFilesExist checks for profiles and provider paths.

internal/harness/harness.go

Refactor (1) +8 / -0
url.goExtract IsProviderPath helper for name-vs-path detection +8/-0

Extract IsProviderPath helper for name-vs-path detection

• Adds IsProviderPath heuristic to distinguish bare provider names from file-path-like provider entries (slash or .yaml/.yml suffix). Centralizes logic previously duplicated across resolution and validation paths.

internal/harness/url.go

Tests (4) +515 / -2
compose_test.goAdd URL-base composition tests for profile/provider resolution +230/-0

Add URL-base composition tests for profile/provider resolution

• Adds end-to-end tests verifying relative profile/provider fetching, cache rewrites, and dependency recording when base harnesses are URL-sourced. Includes a regression test ensuring bare provider names are skipped (not fetched as relative paths).

internal/harness/compose_test.go

harness_test.goCover profile/provider relative resolution and new file-existence checks +123/-2

Cover profile/provider relative resolution and new file-existence checks

• Adds tests for profile relpath resolution, provider relpath vs bare-name handling, and traversal rejection. Adds ValidateFilesExist tests for missing profile/provider paths and verifies bare provider names are skipped; updates URL-reference and profile validation expectations.

internal/harness/harness_test.go

url_test.goAdd IsProviderPath unit tests +23/-0

Add IsProviderPath unit tests

• Introduces table-driven coverage for bare names, slashes, YAML extensions, absolute paths, and empty string behavior.

internal/harness/url_test.go

resolve_test.goAdd ResolveHarness tests for local profiles/providers and warning propagation +139/-0

Add ResolveHarness tests for local profiles/providers and warning propagation

• Adds coverage for resolving a local profile path, resolving an absolute provider YAML while retaining bare provider names, parsing/validating provider YAML via parseProviderDef, and ensuring literal-credential warnings from local providers surface in ResolveResult.Warnings.

internal/resolve/resolve_test.go

@fullsend-ai-review

fullsend-ai-review Bot commented Jul 22, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 1:39 PM UTC · Ended 1:41 PM UTC
Commit: 84d4a79 · View workflow run →

@maruiz93
maruiz93 force-pushed the fix/5240-resolve-profiles-providers branch from 84d4a79 to 1daf9b1 Compare July 22, 2026 13:41
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 22, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:43 PM UTC · Completed 2:01 PM UTC
Commit: 1daf9b1 · View workflow run →

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Site preview

Preview: https://e7dd77dc-site.fullsend-ai.workers.dev

Commit: c23cdcb333b04ee969a1db82b6cbb39d7cc8773a

@qodo-code-review

qodo-code-review Bot commented Jul 22, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Lock skips local providers ✓ Resolved 🐞 Bug ≡ Correctness
Description
In runAgent, the local-only ResolveHarness fallback is gated by len(result.Profiles)==0 and only
merges deps/providers, so when lock-file resolution already produced profiles, any remaining local
provider file paths in h.Providers are never parsed into result.Providers. This can prevent provider
creation because LoadProviderDefs filters by provider name (def.Name), not file paths, and it
suppresses local-provider credential warnings.
Code

internal/cli/run.go[R481-498]

+	// When profiles or providers use local paths (from ResolveRelativeTo or
+	// base composition), ResolveHarness must still run to parse them into
+	// ResolvedProfile/ResolvedProvider — even without URL references.
+	// Merge into any existing result to avoid discarding deps/providers
+	// already resolved by the lock-file or URL-resolution path.
+	if len(result.Profiles) == 0 && (len(h.OpenShellProfiles()) > 0 || hasLocalProviders(h)) {
+		prevDeps := result.Deps
+		prevProviders := result.Providers
+		var resolveErr error
+		result, resolveErr = resolve.ResolveHarness(ctx, h, resolve.ResolveOpts{
+			WorkspaceRoot: absFullsendDir,
+		})
+		if resolveErr != nil {
+			return fmt.Errorf("resolving local profiles/providers: %w", resolveErr)
+		}
+		result.Deps = append(prevDeps, result.Deps...)
+		result.Providers = append(prevProviders, result.Providers...)
+	}
Relevance

●● Moderate

No prior review evidence on ResolveHarness fallback gating with lock results; related lock/resolve
work in #2082/#3062.

PR-#2082
PR-#3062

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new fallback in runAgent is skipped when lock resolution produces any profiles, even if local
provider paths still exist in h.Providers; later provider loading filters by provider *name*
(def.Name), so those path entries won’t load any provider YAMLs unless ResolveHarness converts them
to ResolvedProvider first and removes them from h.Providers.

internal/cli/run.go[481-501]
internal/cli/run.go[711-819]
internal/cli/lock.go[656-785]
internal/harness/harness.go[53-102]
internal/resolve/resolve.go[293-347]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`runAgent`’s local-only resolution block is guarded by `len(result.Profiles) == 0` and only merges `Deps`/`Providers`. This means that when the lock-file path already populated `result.Profiles`, local provider file paths (absolute cache paths from base composition or ResolveRelativeTo) can remain in `h.Providers` and never get converted into `ResolvedProvider` entries.

### Issue Context
- Lock resolution (`resolveFromLock`) strips URL providers from `h.Providers` but keeps non-URL entries, so absolute provider YAML paths can remain in `h.Providers`.
- Provider creation later relies on `result.Providers` (resolved defs) + `LoadProviderDefs(providersDir, declaredNames)` where `declaredNames` is built from `h.Providers` entries; passing absolute paths there will not match `def.Name` and can lead to providers not being loaded/created.

### Fix Focus Areas
- internal/cli/run.go[481-501]
- internal/cli/run.go[728-819]
- internal/cli/lock.go[656-785]
- internal/harness/harness.go[53-102]
- internal/resolve/resolve.go[266-347]

### What to change
1. Change the fallback condition to run whenever *unresolved local* profiles/providers may exist (e.g., `len(h.OpenShellProfiles()) > 0 || hasLocalProviders(h)`), not based on `len(result.Profiles)`.
2. When invoking `ResolveHarness` for local-only parsing, **merge all fields**, not just deps/providers:
  - Preserve `prevProfiles := result.Profiles` and `prevWarnings := result.Warnings`.
  - After local resolve, do `result.Profiles = append(prevProfiles, result.Profiles...)`, `result.Warnings = append(prevWarnings, result.Warnings...)`, and keep the existing deps/providers merge.
3. Update `resolveFromLock` to avoid wiping non-URL local profiles:
  - Instead of unconditional `h.OpenShell.Profiles = nil`, filter out only URL profile entries and keep local-path entries so the local-only `ResolveHarness` pass can parse them into `ResolvedProfile`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Windows provider paths misdetected ✗ Dismissed 🐞 Bug ☼ Reliability
Description
IsProviderPath only checks for "/" separators, so Windows-style relative paths using "\\" can be
misclassified as bare provider names, skipping ResolveRelativeTo/ValidateFilesExist path handling.
This can leave provider file paths unresolved/unchecked and later treated as provider names.
Code

internal/harness/url.go[R25-30]

+// IsProviderPath returns true if s looks like a file path rather than a bare
+// provider name. A provider string is a path if it contains a directory
+// separator or ends with a YAML extension.
+func IsProviderPath(s string) bool {
+	return strings.Contains(s, "/") || strings.HasSuffix(s, ".yaml") || strings.HasSuffix(s, ".yml")
+}
Relevance

●● Moderate

No historical suggestions about Windows '\\' path handling in IsProviderPath; related URL helper
work exists but not OS-specific.

PR-#1095

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The heuristic only checks for forward slashes, but it’s used as the gate for resolving provider
paths and for skipping provider-name validation; on Windows, backslash-separated paths may not be
handled as intended.

internal/harness/url.go[25-30]
internal/harness/harness.go[415-422]
internal/harness/harness.go[585-592]
internal/harness/harness.go[710-715]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`IsProviderPath` detects paths by checking for `"/"` or a `.yaml/.yml` suffix. On Windows, relative paths can be written/constructed with `"\\"`, which may not contain `"/"`.

### Issue Context
`IsProviderPath` is now used to decide whether a provider entry should be treated as a file path (resolved/checked) or as a bare provider name.

### Fix Focus Areas
- internal/harness/url.go[25-30]
- internal/harness/harness.go[415-422]
- internal/harness/harness.go[585-592]
- internal/harness/harness.go[710-715]

### What to change
- Make `IsProviderPath` recognize both separators, e.g. `strings.ContainsAny(s, "/\\")` (and keep the `.yaml/.yml` suffix checks).
- Consider adding/adjusting tests to include a Windows-style path like `providers\\custom.yaml` and/or `providers\\custom` (if extensionless paths should be treated as paths).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Profile errors lose URL ✓ Resolved 🐞 Bug ◔ Observability
Description
ResolveHarness now reports profile parse failures as coming from the local cache path even for URL
profiles, losing the original remote URL context. This makes malformed remote profile failures
harder to diagnose and trace to the actual source reference.
Code

internal/resolve/resolve.go[R266-289]

+	// Resolve profiles: URL entries are fetched and cached; local paths
+	// (from ResolveRelativeTo or base composition cache) are used directly.
	var profiles []ResolvedProfile
	for i, p := range h.OpenShellProfiles() {
-		if !harness.IsURL(p) {
-			return ResolveResult{}, fmt.Errorf("openshell.profiles[%d]: expected URL, got local path %q", i, p)
-		}
-		dep, localPath, err := resolveFileURL(ctx, fmt.Sprintf("openshell.profiles[%d]", i), p, h, opts, state)
-		if err != nil {
-			return ResolveResult{}, fmt.Errorf("resolving openshell.profiles[%d]: %w", i, err)
+		var localPath string
+		if harness.IsURL(p) {
+			dep, lp, err := resolveFileURL(ctx, fmt.Sprintf("openshell.profiles[%d]", i), p, h, opts, state)
+			if err != nil {
+				return ResolveResult{}, fmt.Errorf("resolving openshell.profiles[%d]: %w", i, err)
+			}
+			localPath = lp
+			state.appendDependency(dep)
+		} else {
+			localPath = p
		}

		content, err := os.ReadFile(localPath)
		if err != nil {
-			return ResolveResult{}, fmt.Errorf("reading resolved profile %s: %w", localPath, err)
+			return ResolveResult{}, fmt.Errorf("reading profile %s: %w", localPath, err)
		}
		id, err := ParseProfileID(content)
		if err != nil {
-			return ResolveResult{}, fmt.Errorf("openshell.profiles[%d]: %w (from %s)", i, err, dep.URL)
+			return ResolveResult{}, fmt.Errorf("openshell.profiles[%d]: %w (from %s)", i, err, localPath)
		}
-		state.appendDependency(dep)
Relevance

●● Moderate

No direct historical evidence on preserving remote URL context in profile parse errors; related
profile-resolution changes in #3062/#5397.

PR-#3062
PR-#5397

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
In the URL branch, resolveFileURL returns dep and lp, but the error message always references
localPath, which is the cache path for URL profiles.

internal/resolve/resolve.go[266-290]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
For URL-backed profiles, `ResolveHarness` fetches the profile via `resolveFileURL` (which returns `dep.URL`) but then reports `ParseProfileID` errors as `(from %s)` where `%s` is the local cache path.

### Issue Context
The resolver has both the cache path and the source URL; retaining the URL in the error is more actionable.

### Fix Focus Areas
- internal/resolve/resolve.go[266-290]

### What to change
- Track a `source` string for error reporting:
 - If `harness.IsURL(p)`, set `source = dep.URL` (or the original `p`).
 - Else set `source = localPath`.
- Use `source` in the `ParseProfileID` error message, optionally including both URL and cache path if desired.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context used
✅ Compliance rules (platform): 54 rules

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread internal/cli/run.go
Comment thread internal/harness/url.go Outdated
Comment thread internal/resolve/resolve.go
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [documentation-correctness] docs/ADRs/0075-local-path-profiles-providers.md:143 — ADR 0075 states that checkProviderProfileIntegrity excludes local-path providers from the integrity check via the FromURL origin marker on ResolvedProvider. However, the actual implementation in run.go checks ALL providers (both URL-resolved and local-path) against the union of all profile sources (harness-resolved profiles + directory profiles). The FromURL field is set on ResolvedProfile and ResolvedProvider but is never read anywhere in the codebase. The code behavior is stricter than documented, which is safer, but the ADR creates a false expectation that local providers skip the integrity check.
    Remediation: Update ADR 0075's "Content and referential integrity" section to match the actual implementation, or remove the unused FromURL field.

  • [bidirectional-invariant] internal/harness/harness.go — Per docs/contributing/harness-composition.md, changes to path-handling in harness composition must be mirrored in rewriteCustomizedPaths in internal/cli/migrate.go. This PR adds ResolveRelativeTo handling for OpenShell.Profiles and Providers (path-bearing fields), making them subject to relative path resolution. However, rewriteCustomizedPaths does not strip the customized/ prefix for these fields. While the gap for providers pre-dates this PR, profiles are newly path-bearing due to this PR's ResolveRelativeTo extension.
    Remediation: Add profile and provider path stripping to rewriteCustomizedPaths in internal/cli/migrate.go, and add corresponding test coverage.

Low

  • [dead-code] internal/resolve/resolve.go:46 — The FromURL field on ResolvedProfile and ResolvedProvider is set in multiple locations but never read anywhere in the codebase. The field was presumably added for checkProviderProfileIntegrity to filter by origin, but the implementation checks all providers instead. See also: [documentation-correctness] finding above.

  • [stale-doc] docs/guides/user/bring-your-own-agent.md:129 — States "reference a remote provider definition" as the way to define custom provider types. With this PR, providers and profiles now also accept local file paths, but this guidance only mentions remote definitions. The linked customizing-agents.md section was updated to cover both, but this sentence's framing remains remote-only.

  • [path-traversal] internal/resolve/resolve.go:186isContainedPath falls back to syntactic check when filepath.EvalSymlinks fails for non-existent paths. TOCTOU window exists but requires workspace write access; upstream guards prevent attacker-controlled path construction.

  • [fail-open-validation] internal/harness/harness.go:2993Validate() skips provider name-format validation for entries matching IsProviderPath(p) or filepath.IsAbs(p). Trust boundary maintained by downstream parseProviderDef validating name/type against validIdentifier.

  • [path-traversal] internal/harness/harness.go:3155ResolveRelativeTo now resolves profile and provider paths via the resolve() closure. Containment check prevents directory escape. Low-risk extension of existing resolution surface.

  • [validation-relaxation] internal/harness/harness.go:3465ValidateResourceTypes no longer rejects non-URL profiles outright. Requires .yaml/.yml extension for relative paths; absolute paths from cache have no extension check. Bounded by downstream isContainedPath and ParseProfileID checks.

  • [comment-whitespace] internal/harness/compose_test.go:1333 — Blank line removed between test functions, deviating from the file's convention of separating top-level test functions with a blank line.

Previous run

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:398 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:428 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [integrity-check-bypass] internal/cli/run.go:3757checkProviderProfileIntegrity skips all providers where FromURL is false. Local-path providers are not subject to the referential integrity check. Intentional per ADR 0075 (local providers may reference gateway-resident profile types), but a misconfigured local provider referencing a non-existent profile type won't be caught at resolution time. The failure mode is fail-closed (gateway rejects unknown profile types at registration time).

  • [missing-audit] internal/cli/run.go:494 — The second ResolveHarness pass (for local profiles/providers) is called with a minimal ResolveOpts that omits AuditLogPath. Since this pass only processes local file paths (not URLs), this is benign in practice. The guard conditions prevent URL entries from reaching this code path.

  • [fail-open-validation] internal/harness/harness.go:901ValidateResourceTypes now accepts absolute-path profile entries with no extension check and no integrity hash. Intentional — absolute paths originate from cache resolution (ResolveRelativeTo or base composition). Mitigated by downstream isContainedPath and ParseProfileID checks in ResolveHarness.

  • [fail-open] internal/harness/harness.go:424Validate() now skips provider name-format validation for entries matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. The trust boundary is maintained by downstream parseProviderDef, which validates the parsed name and type fields against validIdentifier.

  • [path-traversal] internal/harness/harness.go:590ResolveRelativeTo now resolves profile paths and provider paths (gated by IsProviderPath(p)) via the resolve() closure. The closure's containment check prevents directory escape. Low-risk extension of the existing resolution surface.

  • [path-traversal] internal/resolve/resolve.go:148isContainedPath returns true when filepath.EvalSymlinks fails for a non-existent path, falling back to the syntactic check only. TOCTOU window exists if an attacker creates a symlink between the isContainedPath check and os.ReadFile, but this requires write access to the workspace directory. Upstream guards prevent attacker-controlled path construction.

  • [error-message-consistency] internal/resolve/resolve.go:125 — Error message in parseProviderDef uses format parsing provider from %s which differs from the pattern used for profiles.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — References "Extended by ADR 0070" without noting that ADR 0070 has been superseded by ADR 0075. Readers following the ADR evolution will not be directed to the current authoritative decision.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0075.

Previous run (2)

Review

Reason: stale-head

The review agent reviewed commit 7b399c5c1824e891dd6f2466955840e6df255d79 but the PR HEAD is now 86b2cdff825edd51583a1a08c16e36f702a7d9ca. This review was discarded to avoid approving unreviewed code.

Previous run (3)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:344 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:374 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [integrity-check-bypass] internal/cli/run.go:3753checkProviderProfileIntegrity skips all providers where FromURL is false. Local-path providers are not subject to the referential integrity check. Intentional per ADR 0074 (local providers may reference gateway-resident profile types), but a misconfigured local provider referencing a non-existent profile type won't be caught at resolution time.

  • [fail-open-validation] internal/harness/harness.go:862ValidateResourceTypes now accepts absolute-path profile entries with no validation — neither extension check nor integrity hash. Absolute paths skip both the URL integrity check and the extension check. Intentional (absolute paths from cache/ResolveRelativeTo), but reduces defense-in-depth at the validation layer. Mitigated by downstream isContainedPath checks in ResolveHarness.

  • [fail-open] internal/harness/harness.go:416Validate() now skips provider name-format validation for entries matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. The trust boundary is maintained by downstream parseProviderDef, which validates the parsed name and type fields against validIdentifier.

  • [path-traversal] internal/harness/harness.go:579ResolveRelativeTo now resolves profile paths and provider paths (gated by IsProviderPath(p)) via the resolve() closure. The closure's containment check prevents directory escape. Low-risk extension of the existing resolution surface.

  • [path-traversal] internal/resolve/resolve.go:155isContainedPath returns true when filepath.EvalSymlinks fails for a non-existent path, falling back to the syntactic check only. TOCTOU window exists if an attacker creates a symlink between the isContainedPath check and os.ReadFile, but this requires write access to the workspace directory (which implies compromise). Upstream guards also prevent attacker-controlled path construction.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references ADR 0070 without mentioning that ADR 0070 has been superseded by ADR 0074. Readers following the ADR evolution will not be directed to the current authoritative decision.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.

Previous run (4)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:348 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:378 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [fail-open-validation] internal/harness/harness.go:860ValidateResourceTypes now accepts absolute-path profile entries with no validation — neither extension check nor integrity hash. Absolute paths skip both the URL integrity check and the extension check. Intentional (absolute paths from cache/ResolveRelativeTo), but reduces defense-in-depth at the validation layer. Mitigated by downstream isContainedPath checks in ResolveHarness.

  • [fail-open] internal/harness/harness.go:814Validate() now skips provider name-format validation for entries matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. The trust boundary is maintained by downstream parseProviderDef, which validates the parsed name and type fields against validIdentifier.

  • [path-traversal] internal/harness/harness.go:576ResolveRelativeTo now resolves provider paths via IsProviderPath(p), which returns true for any string containing / or ending with .yaml/.yml. The resolve() closure's containment check prevents directory escape, and parseProviderDef validates ProviderDef YAML structure downstream. Low-risk extension of the parse surface.

  • [path-traversal] internal/resolve/resolve.go:158isContainedPath returns true when filepath.EvalSymlinks fails for a non-existent path, falling back to the syntactic check only. TOCTOU window exists if an attacker creates a symlink between the isContainedPath check and os.ReadFile, but this requires write access to the workspace directory (which implies compromise). Upstream guards also prevent attacker-controlled path construction.

  • [integrity-check-bypass] internal/cli/run.go:3657checkProviderProfileIntegrity skips all providers where FromURL is false. Local-path providers are not subject to the referential integrity check. Intentional per ADR 0074 (local providers may reference gateway-resident profile types), but a misconfigured local provider referencing a non-existent profile type won't be caught at resolution time.

  • [naming-convention] internal/resolve/resolve.go:170isCachePath in resolve.go duplicates the logic of isFullsendCachePath in compose.go. The comment explicitly states this is intentional to avoid an import cycle, but the naming inconsistency could cause confusion.

  • [error-message-inconsistency] internal/resolve/resolve.go:353 — Error messages for reading profile/provider files use inconsistent phrasing: local errors say reading profile %s / reading provider %s, while URL errors say reading resolved profile %s / reading resolved provider %s.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references ADR 0070 without mentioning that ADR 0070 has been superseded by ADR 0074. Readers following the ADR evolution will not be directed to the current authoritative decision.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.

Previous run (5)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:348 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:378 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [pattern-inconsistency] internal/resolve/resolve.go:378 — Local absolute-path providers gated on filepath.IsAbs(p) rather than harness.IsAbsPath(p). The resolve package uses harness.IsURL and harness.IsProviderPath elsewhere in the same function.

  • [fail-open-validation] internal/harness/harness.go:860ValidateResourceTypes now accepts any non-URL, non-absolute-path string as a valid openshell.profiles entry, gated only by a .yaml/.yml extension check. Absolute paths bypass this entirely. The defense-in-depth reduction at the validation layer is mitigated by downstream isContainedPath checks in ResolveHarness. Not directly exploitable.

  • [fail-open] internal/harness/harness.go:814Validate() now skips provider name-format validation for entries matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. The trust boundary is maintained by downstream parseProviderDef, which validates the parsed name and type fields against validIdentifier.

  • [path-traversal] internal/harness/harness.go:576ResolveRelativeTo now resolves provider paths via IsProviderPath(p), which returns true for any string containing / or ending with .yaml/.yml. The resolve() closure's containment check prevents directory escape, and parseProviderDef validates ProviderDef YAML structure downstream. Low-risk extension of the parse surface.

  • [naming-convention] internal/resolve/resolve.go:170isCachePath in resolve.go duplicates the logic of isFullsendCachePath in compose.go. The comment explicitly states this is intentional to avoid an import cycle, but the naming inconsistency could cause confusion.

  • [error-message-inconsistency] internal/resolve/resolve.go:353 — Error messages for reading profile/provider files use inconsistent phrasing: local errors say reading profile %s / reading provider %s, while URL errors say reading resolved profile %s / reading resolved provider %s.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references ADR 0070 without mentioning that ADR 0070 has been superseded by ADR 0074. Readers following the ADR evolution will not be directed to the current authoritative decision.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.

Previous run (6)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:342 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:372 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [pattern-inconsistency] internal/resolve/resolve.go:372 — Local absolute-path providers gated on filepath.IsAbs(p) rather than harness.IsAbsPath(p). The resolve package uses harness.IsURL and harness.IsProviderPath elsewhere in the same function.

  • [fail-open-validation] internal/harness/harness.go:866ValidateResourceTypes now accepts any non-URL, non-absolute-path string as a valid openshell.profiles entry, gated only by a .yaml/.yml extension check. Absolute paths bypass this entirely. The defense-in-depth reduction at the validation layer is mitigated by downstream isContainedPath checks in ResolveHarness. Not directly exploitable.

  • [fail-open] internal/harness/harness.go:831Validate() now skips provider name-format validation for entries matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. The trust boundary is maintained by downstream parseProviderDef, which validates the parsed name and type fields against validIdentifier.

  • [path-traversal] internal/harness/harness.go:592ResolveRelativeTo now resolves provider paths via IsProviderPath(p), which returns true for any string containing / or ending with .yaml/.yml. A bare provider name like evil.yaml would be treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, and parseProviderDef validates ProviderDef YAML structure downstream. Low-risk extension of the parse surface.

  • [naming-convention] internal/resolve/resolve.go:166isCachePath in resolve.go duplicates the logic of isFullsendCachePath in compose.go. The comment explicitly states this is intentional to avoid an import cycle, but the naming inconsistency could cause confusion.

  • [pattern-inconsistency] internal/resolve/resolve.go:385 — Warning channel asymmetry: URL-path providers store credential warnings on dep.Warning (persisted in lock file) while local-path providers store on state.warnings (ephemeral). Both consumed downstream — the asymmetry is by-design since URL provider warnings need lock-file persistence for reproducibility.

  • [error-message-inconsistency] internal/resolve/resolve.go:347 — Error messages for reading profile/provider files use inconsistent phrasing: local errors say reading profile %s / reading provider %s, while URL errors say reading resolved profile %s / reading resolved provider %s.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references ADR 0070 without mentioning that ADR 0070 has been superseded by ADR 0074. Readers following the ADR evolution will not be directed to the current authoritative decision.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.

Previous run (7)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:342 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:372 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [fail-open-validation] internal/harness/harness.go:862ValidateResourceTypes now accepts any non-URL string as a valid openshell.profiles entry without further validation. Previously, non-URL profiles were rejected outright. Downstream ResolveHarness validates these by reading, parsing (ParseProfileID), and checking workspace containment (isContainedPath). Defense-in-depth reduction at the validation layer, not exploitable.

  • [fail-open] internal/harness/harness.go:417Validate() now skips name-format validation for providers matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. Downstream parseProviderDef validates the parsed name/type fields with validIdentifier, so the trust boundary is maintained.

  • [path-traversal] internal/harness/harness.go:586ResolveRelativeTo resolves provider paths via IsProviderPath(p), which returns true when a string contains / or ends with .yaml/.yml. A bare provider name like evil.yaml is treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, and ResolveHarness requires valid ProviderDef YAML. Low-risk extension of parse surface.

  • [naming-convention] internal/resolve/resolve.go:166isCachePath in resolve.go duplicates the logic of isFullsendCachePath in compose.go. The comment explicitly states this is intentional to avoid an import cycle, but the naming inconsistency (isCachePath vs isFullsendCachePath) could cause confusion when reading cross-package.

  • [pattern-inconsistency] internal/resolve/resolve.go:385 — Warning channel asymmetry: URL-path providers store credential warnings on dep.Warning (persisted in lock file) while local-path providers store on state.warnings (ephemeral, surfaced via StepWarn). Both channels are properly consumed downstream — the asymmetry is by-design since URL provider warnings need lock-file persistence for reproducibility.

  • [error-message-inconsistency] internal/resolve/resolve.go:347 — Error messages for reading profile/provider files use inconsistent phrasing: local errors say reading profile %s / reading provider %s, while URL errors say reading resolved profile %s / reading resolved provider %s. The resolved qualifier distinguishes URL-fetched from local resources, which is useful diagnostic context, but the inconsistency is worth standardizing.

  • [naming-convention] internal/harness/harness.go:417 — The Validate() guard for providers uses filepath.IsAbs(p) directly instead of the package's own IsAbsPath(p) helper. Within the harness package itself, filepath.IsAbs is the established convention (also used in ResolveRelativeTo's resolve closure), so this is consistent within the file.

  • [pattern-inconsistency] internal/resolve/resolve.go:372 — In the provider resolution loop, local absolute-path providers are gated on filepath.IsAbs(p) rather than the imported harness.IsAbsPath(p). The resolve package uses harness.IsURL and harness.IsProviderPath elsewhere in the same function.
    Remediation: Replace filepath.IsAbs(p) with harness.IsAbsPath(p).

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references ADR 0070 without mentioning that ADR 0070 has been superseded by ADR 0074. Readers following the ADR evolution will not be directed to the current authoritative decision.
    Remediation: Update the note to reference ADR 0074.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.
    Remediation: Update the note to reference ADR 0074.

Previous run (8)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:342 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading for a relative path. Safe in production since ResolveRelativeTo always absolutizes paths before ResolveHarness runs.

  • [edge-case] internal/resolve/resolve.go:372 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently kept in h.Providers. Safe in production since ResolveRelativeTo always runs first.

  • [fail-open-validation] internal/harness/harness.go:862ValidateResourceTypes now accepts any non-URL string as a valid openshell.profiles entry without further validation. Previously, non-URL profiles were rejected outright. Downstream ResolveHarness validates these by reading, parsing (ParseProfileID), and checking workspace containment (isContainedPath). Defense-in-depth reduction at the validation layer, not exploitable.

  • [fail-open] internal/harness/harness.go:417Validate() now skips name-format validation for providers matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. Downstream parseProviderDef validates the parsed name/type fields with validIdentifier, so the trust boundary is maintained.

  • [path-traversal] internal/harness/harness.go:586ResolveRelativeTo resolves provider paths via IsProviderPath(p), which returns true when a string contains / or ends with .yaml/.yml. A bare provider name like evil.yaml is treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, and ResolveHarness requires valid ProviderDef YAML. Low-risk extension of parse surface.

  • [naming-convention] internal/resolve/resolve.go:164isCachePath in resolve.go duplicates the logic of isFullsendCachePath in compose.go. The comment explicitly states this is intentional to avoid an import cycle, but the naming inconsistency (isCachePath vs isFullsendCachePath) could cause confusion when reading cross-package.
    Remediation: Consider renaming to isFullsendCachePath to match compose.go, or add a comment referencing the original function name.

  • [pattern-inconsistency] internal/resolve/resolve.go:385 — Warning channel asymmetry: URL-path providers store credential warnings on dep.Warning (persisted in lock file) while local-path providers store on state.warnings (ephemeral, surfaced via StepWarn). Both channels are properly consumed downstream — the asymmetry is by-design since URL provider warnings need lock-file persistence for reproducibility.

  • [error-message-inconsistency] internal/resolve/resolve.go:347 — Error messages for reading profile/provider files use inconsistent phrasing: local errors say reading profile %s / reading provider %s, while URL errors say reading resolved profile %s / reading resolved provider %s. The resolved qualifier distinguishes URL-fetched from local resources, which is useful diagnostic context, but the inconsistency is worth standardizing.
    Remediation: Unify error messages to either always or never include "resolved".

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references ADR 0070 without mentioning that ADR 0070 has been superseded by ADR 0074. Readers following the ADR evolution will not be directed to the current authoritative decision.
    Remediation: Update the note to reference ADR 0074.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.
    Remediation: Update the note to reference ADR 0074.

Previous run (9)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:338 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. A relative path passed to isContainedPath is correctly rejected (safe failure mode), but the error message ("outside workspace root") is misleading — the real issue would be that ResolveRelativeTo did not run. Safe in production since ResolveRelativeTo always runs before ResolveHarness.

  • [edge-case] internal/resolve/resolve.go:368 — The provider local-path branch gates on filepath.IsAbs(p). A relative provider path matching IsProviderPath but not absolutized by ResolveRelativeTo would fall through to the bare-name bucket, then be silently dropped by the post-resolution strip in run.go. Safe in production since ResolveRelativeTo always runs first.

  • [fail-open-validation] internal/harness/harness.go:862ValidateResourceTypes now accepts any non-URL string as a valid openshell.profiles entry without further validation. Previously, non-URL profiles were rejected outright. Downstream consumers (ResolveHarness's ParseProfileID, isContainedPath) handle malformed paths safely, so this is defense-in-depth reduction rather than an exploitable gap.

  • [fail-open] internal/harness/harness.go:417Validate() now skips name-format validation for providers matching IsProviderPath(p) or filepath.IsAbs(p). A provider entry like evil.yaml bypasses the validProviderName regex. Downstream parseProviderDef validates the parsed name/type fields with validIdentifier, so the trust boundary is maintained.

  • [path-traversal] internal/harness/harness.go:586ResolveRelativeTo resolves provider paths via IsProviderPath(p), which returns true when a string contains / or ends with .yaml/.yml. A bare provider name like evil.yaml is treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, and ResolveHarness requires valid ProviderDef YAML. Low-risk extension of parse surface.

  • [pattern-inconsistency] internal/resolve/resolve.go:116 — Warning channel asymmetry: URL-path providers store credential warnings on dep.Warning (persisted in lock file) while local-path providers store on state.warnings (ephemeral, surfaced via StepWarn). Callers consuming warnings from both sources need to check two places.
    Remediation: Unify warning channels by also appending URL-provider credential warnings to state.warnings.

  • [naming-convention] internal/resolve/resolve.go:162isCachePath in resolve.go duplicates the logic of isFullsendCachePath in compose.go (both check whether a path is under workspaceRoot/.fullsend-cache/). The compose.go version has a doc comment explaining the security reasoning; the resolve.go version is an unexplained near-duplicate with a different name.
    Remediation: Consolidate into a shared helper or document why the duplication is necessary (import cycle avoidance).

  • [error-message-inconsistency] internal/resolve/resolve.go:314 — Error message for reading a URL-resolved profile says reading profile %s using the original URL, while the provider branch says reading resolved provider %s using the local cache path. Inconsistent between the two parallel code paths.
    Remediation: Use fmt.Errorf("reading resolved profile %s: ...", localPath, ...) to match the provider pattern.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references the now-superseded ADR 0070 for openshell.profiles and URL-based providers without mentioning that ADR 0070 has been superseded by ADR 0074.
    Remediation: Update the note to reference ADR 0074.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.
    Remediation: Update the note to reference ADR 0074.

Previous run (10)

Review

Findings

Low

  • [fail-open] internal/resolve/resolve.go:142isContainedPath returns true unconditionally when root is empty, disabling the workspace-root containment check for local profiles and providers. The caller in run.go always passes absFullsendDir as WorkspaceRoot, so the containment check is active in the production code path. However, the function's design is still fail-open: any future caller that constructs ResolveOpts{} without WorkspaceRoot (or passes empty string) silently bypasses the containment check, allowing reads of arbitrary absolute paths on the host. The function's doc comment describes it as "defense-in-depth" — a fail-open design undermines that purpose.
    Remediation: Return false (deny) when root is empty instead of true. If root is genuinely optional in some contexts, require callers to explicitly opt out via a separate flag rather than relying on empty-string semantics.

  • [edge-case] internal/resolve/resolve.go:314 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. The provider branch gates on filepath.IsAbs(p) before entering the absolute-path code path, creating an asymmetry. If a relative-path profile reaches ResolveHarness without being absolutized by ResolveRelativeTo, isContainedPath rejects it (safe failure mode), but the error message ("outside workspace root") would be confusing.

  • [edge-case] internal/resolve/resolve.go:347 — The provider local-path branch only processes providers passing filepath.IsAbs(p). If ResolveHarness is called without a prior ResolveRelativeTo, a relative provider path would fall through to bare-name handling and be silently dropped by the post-resolution strip in run.go. Safe in production since ResolveRelativeTo always runs first.

  • [path-traversal] internal/harness/harness.go:589ResolveRelativeTo resolves provider paths via IsProviderPath(p), which returns true when a string contains / or ends with .yaml/.yml. A bare provider name like evil.yaml is treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, and ResolveHarness requires valid ProviderDef YAML with non-empty name and type fields matching validIdentifier. Low-risk extension of parse surface.

  • [fail-open-validation] internal/harness/harness.go:871ValidateResourceTypes now accepts any non-URL string as a valid openshell.profiles entry without further validation. Previously, non-URL profiles were rejected outright. Downstream consumers (ResolveHarness's ParseProfileID, ValidateFilesExist) handle malformed paths safely, so this is defense-in-depth reduction rather than an exploitable gap.

  • [pattern-inconsistency] internal/resolve/resolve.go:116 — Warning channel asymmetry: URL-path providers store credential warnings on dep.Warning (persisted in lock file) while local-path providers store on state.warnings (ephemeral, surfaced via StepWarn). Callers consuming warnings from both sources need to check two places.
    Remediation: Unify warning channels by also appending URL-provider credential warnings to state.warnings.

  • [error-handling-idiom] internal/resolve/resolve.go:299 — URL profile branch error message uses the URL string instead of the local/cache path, deviating from the existing pattern where reading errors reference the local path (e.g., "reading resolved provider %s").
    Remediation: Use lp (local path) in the error message.

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:27 — The "Extended by ADR 0070" note references the now-superseded ADR 0070 for openshell.profiles and URL-based providers without mentioning that ADR 0070 has been superseded by ADR 0074.
    Remediation: Update the note to reference ADR 0074.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:25 — Same pattern — references ADR 0070 without noting supersession by ADR 0074.
    Remediation: Update the note to reference ADR 0074.

Previous run (11)

Review

Findings

Medium

  • [fail-open] internal/resolve/resolve.go:142isContainedPath returns true unconditionally when root is empty, disabling the workspace-root containment check for local profiles and providers. The caller in run.go always passes absFullsendDir as WorkspaceRoot, so the containment check is active in the production code path. However, the function's design is still fail-open: any future caller that constructs ResolveOpts{} without WorkspaceRoot (or passes empty string) silently bypasses the containment check, allowing reads of arbitrary absolute paths on the host. The function's doc comment describes it as "defense-in-depth" — a fail-open design undermines that purpose.
    Remediation: Return false (deny) when root is empty instead of true. If root is genuinely optional in some contexts, require callers to explicitly opt out via a separate flag rather than relying on empty-string semantics.

Low

  • [edge-case] internal/resolve/resolve.go:314 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. The provider branch gates on filepath.IsAbs(p) before entering the absolute-path code path. If a relative-path profile reaches ResolveHarness without being absolutized by ResolveRelativeTo, isContainedPath rejects it (safe failure mode). No runtime bug, but adding a filepath.IsAbs guard would make the intent clearer and symmetric with the provider path.

  • [edge-case] internal/resolve/resolve.go:347 — The provider local-path branch only processes providers passing filepath.IsAbs(p). After ResolveRelativeTo, provider paths matching IsProviderPath are absolutized. If ResolveHarness is called without a prior ResolveRelativeTo, a relative provider path would fall through to bare-name handling. Safe failure mode — the provider will be treated as a bare name, not silently dropped.

  • [path-traversal] internal/harness/harness.go:589ResolveRelativeTo resolves provider paths via IsProviderPath(p), which returns true when a string contains / or ends with .yaml/.yml. A bare provider name like evil.yaml is treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, and ResolveHarness requires valid ProviderDef YAML with non-empty name and type fields matching validIdentifier. Low-risk extension of parse surface.

  • [data-exposure] internal/resolve/resolve.go:133parseProviderDef applies WarnLiteralCredentials to local-path provider definitions, but the warning is appended to state.warnings (ephemeral, surfaced via StepWarn). For URL-fetched providers, the same warning is attached to dep.Warning and persisted in the lock file's dependency entry. Hardcoded secrets in local provider files produce only ephemeral console output, making them less visible in audit trails.

  • [fail-open-validation] internal/harness/harness.go:871ValidateResourceTypes now accepts any non-URL string as a valid openshell.profiles entry without further validation. Previously, non-URL profiles were rejected outright. Downstream consumers (ResolveHarness's ParseProfileID, ValidateFilesExist) handle malformed paths safely, so this is defense-in-depth reduction rather than an exploitable gap.

  • [stale-reference] docs/guides/user/customizing-agents.md:132 — References ADR 0070 for "full details" on portable provider and profile resolution. This PR supersedes ADR 0070 with ADR 0074, but this reference was not updated despite the file being modified in the PR.
    Remediation: Update the reference to point to ADR 0074.

  • [stale-reference] docs/guides/user/bring-your-own-agent.md:129 — References ADR 0070 when discussing remote provider definitions and openshell.profiles. Since ADR 0074 supersedes ADR 0070, this reference should be updated.
    Remediation: Update the reference to point to ADR 0074.

Previous run (12)

Review

Findings

Medium

  • [path-containment-bypass] internal/resolve/resolve.go:142isContainedPath returns true unconditionally when root is empty, disabling the workspace-root containment check for local profiles and providers. While the current caller in run.go always passes absFullsendDir as WorkspaceRoot, there is no compile-time enforcement of this invariant. Any future caller that omits WorkspaceRoot (or passes empty string) would silently bypass the containment check, allowing reads of arbitrary absolute paths on the host. The function's own doc comment describes it as "defense-in-depth" — a fail-open design undermines that purpose.
    Remediation: Change isContainedPath to return false (fail-closed) when root is empty, or require WorkspaceRoot to be non-empty at the ResolveHarness entry point with an explicit error.

Low

  • [edge-case] internal/resolve/resolve.go:317 — When ResolveHarness processes local profile entries (the else branch at line 314), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. The provider branch gates on filepath.IsAbs(p) before entering the absolute-path code path (line 347). If a relative-path profile reaches ResolveHarness without being absolutized by ResolveRelativeTo, isContainedPath rejects it (safe failure mode — filepath.Clean on a relative path never produces a prefix match against an absolute root). No runtime bug, but adding a filepath.IsAbs guard would make the intent clearer and parallel the provider branch.

  • [edge-case] internal/resolve/resolve.go:347 — The provider local-path branch only processes providers passing filepath.IsAbs(p). After ResolveRelativeTo, provider paths matching IsProviderPath are absolutized. If ResolveHarness is called without a prior ResolveRelativeTo, a relative provider path would fall through to the bare-name handling. Safe failure mode — the provider would later fail at LoadProviderDefs.

  • [logic-error] internal/cli/run.go:493 — The second ResolveHarness pass (for local profiles/providers) is invoked with a minimal ResolveOpts that omits FetchPolicy and AuditLogPath. This is intentional since local paths don't need network access. By this point, h.Agent, h.Policy, and h.Skills are already resolved to local paths, so the URL branches in ResolveHarness are not entered. However, using the full ResolveOpts would make the second pass more resilient to upstream changes.

  • [path-traversal] internal/harness/harness.go:586ResolveRelativeTo guards provider path resolution with IsProviderPath(p), which returns true when p contains / or ends with .yaml/.yml. A bare provider name like evil.yaml (no slash, but ends in .yaml) is treated as a path and resolved relative to baseDir. The resolve() closure's containment check prevents directory escape, but this extends the parse surface: a file named evil.yaml in baseDir would be read and YAML-parsed. This is documented in ADR 0074 and constrained by existing guards.

  • [data-exposure] internal/resolve/resolve.go:133parseProviderDef applies WarnLiteralCredentials to local-path provider definitions, but the warning is appended to state.warnings (ephemeral, surfaced via StepWarn). For URL-fetched providers, the same warning is attached to dep.Warning and persisted in the lock file's dependency entry. Hardcoded secrets in local provider files are less visible in audit trails than those in URL-fetched providers.

  • [fail-open-validation] internal/harness/harness.go:871ValidateResourceTypes now accepts any non-URL string as a valid openshell.profiles entry without further validation. Previously, non-URL profiles were rejected outright. Downstream consumers (ValidateFilesExist, ResolveHarness) handle malformed paths safely, so this is defense-in-depth, not exploitable.

  • [documentation-comment-format] internal/cli/run.go:385 — Function hasLocalProviders lacks a documentation comment. Sibling functions in this file have doc comments.

  • [documentation-comment-format] internal/resolve/resolve.go:116 — Function parseProviderDef lacks a documentation comment. Other unexported helper functions in this file (e.g., isContainedPath) have doc comments.

  • [stale-reference] docs/guides/user/customizing-agents.md:132 — References ADR 0070 for "full details" on portable provider and profile resolution. This PR supersedes ADR 0070 with ADR 0074, but this reference was not updated despite the file being modified in the PR.
    Remediation: Update the reference to point to ADR 0074.

  • [stale-reference] docs/guides/user/bring-your-own-agent.md:129 — References ADR 0070 when discussing remote provider definitions and openshell.profiles. Since ADR 0074 supersedes ADR 0070, this reference should be updated.
    Remediation: Update the reference to point to ADR 0074.

Previous run (13)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:317 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. The provider branch gates on filepath.IsAbs(p) before entering the absolute-path code path. If a relative-path profile reaches ResolveHarness without being absolutized by ResolveRelativeTo, isContainedPath rejects it (safe failure mode — filepath.Clean on a relative path never produces a prefix match against an absolute root). No runtime bug, but adding a filepath.IsAbs guard would make the intent clearer and parallel the provider branch.

  • [stale-reference] docs/guides/user/customizing-agents.md:134 — References superseded ADR 0070 for "full details" on portable provider and profile resolution. This PR supersedes ADR 0070 with ADR 0074, but the reference at this line was not updated.
    Remediation: Update the reference to point to ADR 0074.

Previous run (14)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:317 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. The provider branch gates on filepath.IsAbs(p) before entering the absolute-path code path. If a relative-path profile reaches ResolveHarness without being absolutized by ResolveRelativeTo, isContainedPath rejects it (safe failure mode — filepath.Clean on a relative path never produces a prefix match against an absolute root). No runtime bug, but adding a filepath.IsAbs guard would make the intent clearer and parallel the provider branch.

  • [stale-reference] docs/guides/user/customizing-agents.md:134 — References superseded ADR 0070 for "full details" on portable provider and profile resolution. This PR supersedes ADR 0070 with ADR 0074, but the reference at this line was not updated.
    Remediation: Update the reference to point to ADR 0074.

Previous run (15)

Review

Findings

Low

  • [edge-case] internal/resolve/resolve.go:317 — When ResolveHarness processes local profile entries (the else branch), it checks isContainedPath(localPath, opts.WorkspaceRoot) but does not verify that localPath is absolute first. The provider branch gates on filepath.IsAbs(p) before entering the absolute-path code path. If a relative-path profile reaches ResolveHarness without being absolutized by ResolveRelativeTo, isContainedPath rejects it (safe failure mode — filepath.Clean on a relative path never produces a prefix match against an absolute root). No runtime bug, but adding a filepath.IsAbs guard would make the intent clearer and parallel the provider branch.

  • [stale-doc] docs/guides/dev/cli-internals.md:402 — The flowchart describes ImportProfile() as importing profiles "(from URL-resolved openshell.profiles)". Profiles can now also come from local paths.
    Remediation: Update to "(from resolved openshell.profiles)" or "(from URL-resolved or local openshell.profiles)".

  • [stale-doc] docs/architecture.md:132 — States provider and profile definitions can be "URL-referenced in the harness (sha256-pinned)". They can now also be referenced via local file paths. Also references ADR 0070 without noting its supersession by ADR 0074.
    Remediation: Update the entry to reference ADR 0074 and note that local paths are now supported.

Previous run (16)

Review

Findings

Low

  • [stale-doc] docs/guides/dev/cli-internals.md:402 — The flowchart describes ImportProfile() as importing profiles "(from URL-resolved openshell.profiles)". Profiles can now also come from local paths.
    Remediation: Update to "(from resolved openshell.profiles)" or "(from URL-resolved or local openshell.profiles)".

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:28 — Extension note says ADR 0070 "adds openshell.profiles and URL-based providers fields". Now also supports local paths.
    Remediation: Update to reflect local path support and note ADR 0074 supersession.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:26 — Extension note says ADR 0070 "adds URL support to providers and introduces openshell.profiles". Omits local path capability.
    Remediation: Update to reflect local path support and note ADR 0074 supersession.

  • [stale-doc] docs/architecture.md:132 — States provider and profile definitions can be "URL-referenced in the harness (sha256-pinned)". They can now also be referenced via local file paths. Also references ADR 0070 without noting its supersession by ADR 0074.
    Remediation: Update the entry to reference ADR 0074 and note that local paths are now supported.

Previous run (17)

Review

Findings

Low

  • [validation-gap] internal/harness/harness.go:417Validate() skips provider name validation for entries matching filepath.IsAbs(p) || IsProviderPath(p) with comment "validated by ValidateResourceTypes below", but ValidateResourceTypes does not validate path entries — it only checks URL entries for integrity hashes. Path-type provider entries pass through both Validate() and ValidateResourceTypes() with no format validation. Actual content validation occurs downstream in ResolveHarness via parseProviderDef.
    Remediation: Update the comment to "validated downstream by ResolveHarness/parseProviderDef" to accurately document the validation boundary.

  • [naming-convention] internal/harness/url.go:28IsProviderPath checks whether a provider list entry looks like a file path rather than a bare provider name. The name is adequate but could be marginally clearer as IsProviderFilePath to distinguish from bare names, consistent with how it is used across Validate(), ResolveRelativeTo, and resolveBaseProviders.

  • [arbitrary-file-read] internal/resolve/resolve.go:325 — In ResolveHarness, os.ReadFile(p) is called for absolute-path providers and local profiles without a containment check against WorkspaceRoot. Upstream guards prevent untrusted paths (harness YAML is admin-controlled, URL-fetched bases use validateBaseRelPath, ResolveRelativeTo uses containment check) but defense-in-depth is absent at this layer.
    Remediation: Add a containment check verifying the path is under WorkspaceRoot or the fullsend cache directory before calling os.ReadFile.

  • [stale-doc] docs/guides/dev/cli-internals.md:402 — The flowchart describes ImportProfile() as importing profiles "(from URL-resolved openshell.profiles)". Profiles can now also come from local paths.
    Remediation: Update to "(from resolved openshell.profiles)" or "(from URL-resolved or local openshell.profiles)".

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:28 — Extension note says ADR 0070 "adds openshell.profiles and URL-based providers fields". Now also supports local paths.
    Remediation: Update to reflect local path support and note ADR 0074 supersession.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:26 — Extension note says ADR 0070 "adds URL support to providers and introduces openshell.profiles". Omits local path capability.
    Remediation: Update to reflect local path support and note ADR 0074 supersession.

  • [stale-doc] docs/architecture.md:132 — States provider and profile definitions can be "URL-referenced in the harness (sha256-pinned)". They can now also be referenced via local file paths. Also references ADR 0070 without noting its supersession by ADR 0074.
    Remediation: Update the entry to reference ADR 0074 and note that local paths are now supported.

Previous run (18)

Review

Findings

High

  • [ADR immutability violation] docs/ADRs/0070-portable-provider-profile-resolution.md:64 — ADR 0070 has status Accepted on main and this PR substantively rewrites the Decision section — changing profiles from "URL-only, no local-path form" to accepting local file paths, and changing providers to also accept local file paths. The "Amended" annotation added to the Status section since the prior review is a welcome cross-reference, but it does not make the in-place Decision rewrite compliant with docs/contributing/adrs.md policy: "Do not substantially rewrite its Context, Decision, or Consequences sections. When circumstances change, write a new ADR that supersedes the old one."
    Remediation: Create a new ADR (next available number) that supersedes ADR 0070, documenting the expanded Decision that includes local file paths for profiles and providers. Update ADR 0070's status to Superseded with a cross-reference to the new ADR.

Medium

  • [logic-error] internal/harness/compose.go:203 — The post-merge SourceURL block (which resolves relative paths from a URL-fetched child harness after merging with a base) calls resolveBaseScripts, resolveBaseResources, and resolveBaseHostFiles, but does NOT call the newly added resolveBaseProfiles or resolveBaseProviders. If a URL-sourced child harness composed with a base declares relative profile or provider paths, those paths will not be resolved against the source URL after composition. The no-base SourceURL block and loadBaseChain both correctly include these calls. This finding was raised in the prior review and remains unfixed.
    Remediation: Add resolveBaseProfiles and resolveBaseProviders calls inside the post-merge SourceURL block (after resolveBaseHostFiles), following the same pattern as the other two insertion points.

Low

  • [skip-guard-inconsistency] internal/harness/compose.go:896 — The new resolveBaseProfiles and resolveBaseProviders use filepath.IsAbs(p) as their skip guard for absolute paths, while sibling functions (resolveBaseResources, resolveBaseScripts, resolveBaseHostFiles) use isFullsendCachePath(p, opts.WorkspaceRoot). The new functions skip ALL absolute paths, while existing functions only skip cache paths and reject other absolute paths via validateBaseRelPath.
    Remediation: Use isFullsendCachePath as the skip guard (matching the sibling functions) to maintain defense-in-depth.

  • [arbitrary-file-read] internal/resolve/resolve.go:325 — In ResolveHarness, when a provider entry is an absolute path, os.ReadFile(p) is called without a containment check against WorkspaceRoot. Same pattern for local profiles. Upstream guards prevent untrusted paths from reaching here, but defense-in-depth is absent.
    Remediation: Add a containment check verifying the path starts with WorkspaceRoot or the cache directory before calling os.ReadFile.

  • [stale-doc] docs/guides/dev/cli-internals.md:402 — The flowchart describes ImportProfile() as importing profiles "(from URL-resolved openshell.profiles)". Profiles can now also come from local paths.
    Remediation: Update to "(from resolved openshell.profiles)" or "(from URL-resolved or local openshell.profiles)".

  • [stale-doc] docs/ADRs/0024-harness-definitions.md:28 — Extension note says ADR 0070 "adds openshell.profiles and URL-based providers fields". Now also supports local paths.
    Remediation: Update to reflect local path support.

  • [stale-doc] docs/ADRs/0038-universal-harness-access.md:26 — Extension note says ADR 0070 "adds URL support to providers and introduces openshell.profiles". Omits local path capability.
    Remediation: Update to reflect local path support.

  • [stale-doc] docs/architecture.md:132 — States "provider and profile definitions can be URL-referenced in the harness (sha256-pinned)". They can now also be referenced via local file paths.
    Remediation: Update to include local path references.

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added component/harness Agent harness, config, and skills loading go Pull requests that update go code labels Jul 22, 2026
@rh-hemartin

Copy link
Copy Markdown
Member

Didn't read anything yet: make sure you allow for overriding by the same name. We detected that problem with skills, in which if you wanted to override an existing skill you got an error.

@maruiz93

maruiz93 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

make sure you allow for overriding by the same name. We detected that problem with skills, in which if you wanted to override an existing skill you got an error.

Already handled — child entries override base entries by name/ID using last-wins semantics:

Tests covering the compose override scenario:

@maruiz93 maruiz93 changed the title fix(compose): resolve local profiles and providers in base harness (#5240) feat(compose): resolve local profiles and providers in base harness (#5240) Jul 22, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 22, 2026

Copy link
Copy Markdown

🤖 Finished Review · ❌ Failure · Started 2:45 PM UTC · Completed 3:26 PM UTC
Commit: 25258f1 · View workflow run →

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-agent review — MEDIUM+ findings

Parallel review (Claude + Gemini agents). Posting only MEDIUM-and-above findings that are not already covered by existing comments. The already-flagged local providers dropped in the lock path issue (qodo bot at run.go:481) is still unresolved — note that the mechanism it describes (gated by len(result.Profiles)==0) no longer matches the current code, but the underlying bug persists in the reworked gate; see the inline notes below for the current form.

Inline comments cover: duplicate base-composed profiles (High), the Accepted-ADR rewrite policy violation (High), the untested merge design (Medium), and the hasLocalProviders absolute-path assumption (Medium).

[MEDIUM] test-coverage — No internal/cli test for the lock ↔ second-pass merge seam

The added tests exercise ResolveHarness, IsProviderPath, parseProviderDef, and ValidateFilesExist in isolation, but the diff touches no internal/cli/run_test.go or internal/cli/lock_test.go. The correctness bugs in the inline comments all live specifically in the resolveFromLockhasLocalProviders → merge composition, which has zero coverage. Please add internal/cli integration tests over the matrix {lock, no-lock} × {base-composed, child-local} × {profile, provider}, asserting exactly one resolved entry per resource and no dropped local providers — these would fail today and pin the fixes.

Comment thread internal/cli/lock.go
Comment thread docs/ADRs/0070-portable-provider-profile-resolution.md Outdated
Comment thread internal/cli/run.go
Comment thread internal/cli/run.go Outdated
@maruiz93
maruiz93 force-pushed the fix/5240-resolve-profiles-providers branch from 25258f1 to 893ec29 Compare July 22, 2026 15:50
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 22, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 3:52 PM UTC · Completed 4:12 PM UTC
Commit: 893ec29 · View workflow run →

@fullsend-ai-review
fullsend-ai-review Bot dismissed their stale review July 22, 2026 16:11

Superseded by updated review

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added the requires-manual-review Review requires human judgment label Jul 22, 2026

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 893ec298 — the round of fixes doesn't close the blockers

Verified the pushed fixes against the current code (multi-agent, Claude + Gemini). One new top-level finding below; I've also replied in-thread to the ADR, profile-strip, and second-pass comments where the fix is incomplete or the premise is contested. Out of scope but worth noting: dedupResolvedProfiles/Providers (pre-existing, not in this diff) silently collapse two distinct entries sharing an id/name with no warning — unlike mergeProviderDefs, which surfaces a shadowed list. Consider a warning on content mismatch in a follow-up.

Comment thread internal/cli/lock.go Outdated
@maruiz93
maruiz93 force-pushed the fix/5240-resolve-profiles-providers branch from 893ec29 to afce4d8 Compare July 27, 2026 14:51
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 27, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 2:53 PM UTC · Completed 3:11 PM UTC
Commit: afce4d8 · View workflow run →

fullsend-ai-review[bot]

This comment was marked as outdated.

Comment thread docs/ADRs/0075-local-path-profiles-providers.md

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No major issues beyond the ADR number collision (0074 duplicates the already-merged repos-command-consolidation ADR — confirmed live via git merge-base, and CI's own lint-adr-numbers check is failing on it right now). That's a required check, so the merge queue already blocks this from landing until it's renumbered (suggest 0082, next free slot) — not gating approval on it separately. Renumber-adr skill covers the workflow: update the file, its title, ADR 0070's cross-reference, and any doc mentions.

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 2:40 PM UTC · Completed 2:56 PM UTC
Commit: 7b399c5 · View workflow run →

maruiz93 and others added 16 commits August 5, 2026 16:49
Extend base composition to resolve local profile and provider paths
relative to the base URL, matching existing behavior for agent, policy,
skills, scripts, and host_files.

- Add resolveBaseProfiles() to fetch and cache profiles from URL bases
- Add resolveBaseProviders() to fetch and cache providers from URL bases
- Wire both functions into loadBaseChain and LoadWithBase SourceURL path
- Update validation to allow local profile paths (not just URLs)
- Update provider validation to skip path validation for file paths
- Fix HasRemoteResources to check profile URLs individually
- Add comprehensive tests for profile and provider resolution

Issue #5240

Signed-off-by: Marta Anon <manon@redhat.com>
Add profiles and providers to relative path resolution. Bare provider
names (without "/" or .yaml/.yml suffix) are left unchanged.

Issue #5240

Signed-off-by: Marta Anon <manon@redhat.com>
Update ResolveHarness to resolve local profile paths (reading content
and extracting ID) and absolute-path providers (parsing ProviderDef).
Bare provider names are kept for LoadProviderDefs. Issue #5240.

Signed-off-by: Marta Anon <manon@redhat.com>
Fix two issues in harness resolution:

1. Local-only profiles were silently dropped when ResolveHarness wasn't
   called. Added a post-URL-references block in run.go to call
   ResolveHarness for local profiles/providers even when no URL
   references exist. Added hasLocalProviders helper to detect absolute
   path providers.

2. resolveBaseProviders in compose.go lacked bare-name heuristic, which
   could cause 404s when URL-fetched bases had bare provider names like
   "fullsend-github". Added skip logic matching the heuristic in
   ResolveRelativeTo (no "/" and no .yaml/.yml extension).

Added TestLoadWithBase_URLBase_BareProviderNameSkipped to verify bare
provider names are preserved while relative paths are fetched.

Signed-off-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
The local-only ResolveHarness fallback was gated on
len(result.Profiles)==0, skipping it when the lock-file already
produced profiles, leaving absolute-path providers unresolved.

- Remove the profile-count guard; run whenever local profiles or
  providers exist, merge all result fields
- Strip absolute-path provider entries in resolveFromLock
- Preserve local-path profiles in resolveFromLock instead of
  unconditionally niling them

Signed-off-by: Marta Anon <manon@redhat.com>
Signed-off-by: Marta Anon <manon@redhat.com>
Signed-off-by: Marta Anon <manon@redhat.com>
The lock strip was removing all path entries (absolute paths and
provider paths), which silently dropped local-path profiles/providers
that had no corresponding lock deps. Now only URL entries are stripped
in resolveFromLock; path entries survive for the second ResolveHarness
pass. Path providers are stripped from h.Providers after resolution to
keep sandboxProviderNames clean.

Signed-off-by: Marta Anon <maruiz93@users.noreply.github.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
ADR 0070 restricted profiles to URL-only. ADR 0074 extends both
profiles and providers to accept local file paths, matching all other
harness resource fields. Marks 0070 as superseded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
- Add defense-in-depth containment check for local profile/provider
  file reads in ResolveHarness, verifying paths are under WorkspaceRoot
  before calling os.ReadFile
- Fix misleading comment in Validate() that claimed provider paths are
  validated by ValidateResourceTypes (they are actually validated
  downstream by ResolveHarness/parseProviderDef)

Addresses review feedback on #5461
Add tests for resolveBaseProfiles and resolveBaseProviders in compose.go
covering all code paths: empty/nil inputs, URL/cache/empty skips, path
traversal and null byte rejection, invalid base URL errors, and
integration through LoadWithBase for both URL base and SourceURL flows.

Add error-path tests for local profile/provider resolution in
resolve.go: read failures, parse errors, and missing profile ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
Update cli-internals flowchart and architecture overview to reflect
that profiles and providers can now be local file paths, not just
URLs. Update ADR reference from 0070 to 0074.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
Local-path profiles from base composition lose the .yaml extension
because fetchBaseFile stores cached files as extensionless "content"
entries. The URL branch already applied CacheNamedSymlink, but the
local-path branch did not. Add extension check and symlink creation
for extensionless cache paths, with regression tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
- Resolve symlinks in isContainedPath to prevent escape via symlinked
  profile/provider paths; change empty-root from fail-open to fail-closed
- Gate CacheNamedSymlink on cache-path check so extensionless local
  profiles don't get stray symlinks written into the user's repo
- Remove unreachable ValidateFilesExist profile/provider loops (existence
  errors already surface via ResolveHarness's os.ReadFile)
- Add local file-path examples to providers documentation
- Make openshell test stub extension-aware (rejects --file without
  .yaml/.yml extension)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
- Add FromURL field to ResolvedProvider/ResolvedProfile to distinguish
  URL-resolved from local-path entries
- Update checkProviderProfileIntegrity to skip local-path providers,
  preventing misleading errors for gateway-resident provider types
- Add .yaml/.yml extension validation for local profile paths in
  ValidateResourceTypes (user-authored relative paths only)
- Update ADR 0074 validation/security sections to reflect actual
  enforcement via isContainedPath and ResolveHarness

Signed-off-by: Marta Anon <marta@fullsend.ai>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Marta Anon <manon@redhat.com>
ADR 0074 was taken by repos-command-consolidation on main while this
branch was in flight. Renumber our local-path-profiles-providers ADR
to 0075 and update all cross-references.

Signed-off-by: Marta Anon <manon@redhat.com>
@fullsend-ai-review

Copy link
Copy Markdown

/fs-review

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 2:57 PM UTC · Ended 3:03 PM UTC
Commit: 86b2cdf · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 3:04 PM UTC · Completed 3:25 PM UTC
Commit: ceccf05 · View workflow run →

fullsend-ai-review[bot]

This comment was marked as outdated.

@maruiz93

maruiz93 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Re: validation relaxations (fail-open-validation profiles, fail-open providers)

Intentional — Validate() runs after base composition, at which point cache-derived profile/provider entries are extensionless absolute paths (e.g. .fullsend-cache/.../content). The .yaml symlink is created later in ResolveHarness via CacheNamedSymlink. Adding extension or name-format checks here would reject valid cache paths. Downstream parseProviderDef, ParseProfileID, and isContainedPath in ResolveHarness catch malformed or out-of-bounds entries.

@maruiz93

maruiz93 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Re: security defense-in-depth (TOCTOU in isContainedPath, resolve closure surface)

Acknowledged. The TOCTOU window in isContainedPath requires write access to the workspace directory to exploit (symlink creation between check and os.ReadFile), which implies the workspace is already compromised. Upstream guards (ResolveRelativeTo, validateBaseRelPath) prevent attacker-controlled path construction from reaching this code path. The ResolveRelativeTo extension for profiles/providers reuses the same resolve() closure and containment check as the existing skill/resource resolution — no new attack surface beyond what was already accepted.

With local-path profile support, profiles are no longer always URLs.
The len() > 0 check from main's plugin merge incorrectly treated
local-path profiles as URL references, breaking HasURLReferences
for harnesses with only local profiles.

Signed-off-by: Marta Anon <manon@redhat.com>
@maruiz93

maruiz93 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed review findings in c23cdcb:

  • [stale-doc] ADR 0024 and 0038: Added supersession note linking to ADR 0075.
  • [edge-case] resolve.go: Added filepath.IsAbs guard for profile local-path branch, paralleling the provider branch pattern.
  • [integrity-check-bypass] Extracted resolve.CollectProfileIDs to scan the profiles/ directory before the integrity check. checkProviderProfileIntegrity now validates ALL providers (not just URL) against all known profile sources (harness-resolved + directory). Refactored sandbox.ImportProfiles to reuse the same function (DRY).
  • [error-message-consistency] Fixed parseProviderDef error format: "parsing provider from %s""parsing provider %s" for consistency.

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 4:23 PM UTC · Completed 4:42 PM UTC
Commit: c23cdcb · View workflow run →

@fullsend-ai-retro

fullsend-ai-retro Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 5:20 PM UTC · Completed 5:38 PM UTC
Commit: c23cdcb · View workflow run →

@fullsend-ai-retro

Copy link
Copy Markdown

PR #5461 added local file-path support for profiles and providers in harness resolution (issue #5240). Human-authored (maruiz93), reviewed over 14 days by waynesun09 (human) and fullsend-ai-review[bot]. 22 files changed, +1,726/−786 lines. Agents repo: fullsend-ai/agents. 30 review runs dispatched (22 success, 2 failure, 5 cancelled), 1 fix agent run.

Review quality

The human reviewer found 5 HIGH-severity bugs the review agent missed across 22 successful passes: (1) symlink containment bypass in isContainedPathfilepath.Clean + HasPrefix without filepath.EvalSymlinks; (2) local-path profiles losing .yaml extension — URL branch called CacheNamedSymlink, local branch did not; (3) duplicate profiles in lock-file path; (4) FromURL provenance gap — base-composed cache-fetched providers indistinguishable from local providers, bypassing integrity checks; (5) ValidateFilesExist dead code — loops unreachable because ResolveHarness clears those fields first.

The review agent contributed findings the human did not raise: PR title fix→feat convention, migrate.go bidirectional-invariant violation, and FromURL dead-code detection. The same ~8 LOW findings were repeated across 18+ passes, creating significant noise.

Fix agent

Ran once (Jul 28), correctly addressed 2 review agent findings and declined 1 cosmetic rename. Its isContainedPath check used syntactic-only comparison — the symlink hardening was added only after the human reviewer identified the bypass.

Token cost

30 review runs over 14 days, triggered by every push (8+), every human review event, and manual /fs-review commands.

Existing issue evidence (no new proposals — all gaps have existing coverage)

Autonomy readiness

This PR demonstrates the review agent should not be trusted for autonomous approval on complex Go PRs involving path operations, multi-pass resolution with field lifecycle management, or base composition with provenance tracking. The human reviewer was essential — 5 HIGH-severity bugs (including a security-relevant symlink bypass) would have shipped without human review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/docs User-facing documentation component/harness Agent harness, config, and skills loading go Pull requests that update go code requires-manual-review Review requires human judgment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(compose): support local-path resolution for profiles and providers during base composition

3 participants