Skip to content

Commit 094191b

Browse files
authored
Merge pull request #6975 from fullsend-ai/agent/6971-scaffold-vertex-binary-allowlist
fix(#6971): allow claude.exe and pi on scaffold Vertex profile, warn on profile shadowing
2 parents 48eac1a + e75bc41 commit 094191b

10 files changed

Lines changed: 303 additions & 2 deletions

File tree

‎docs/contributing/runtime-implementation.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ On this page:
3434
5. If the runtime's binary ships in the sandbox image, pin it the way the
3535
existing ones are pinned and prove the pin at build time
3636
([Pinned runtime binaries](#pinned-runtime-binaries-in-the-sandbox-image)).
37+
6. Name the process that opens the model connection in `runtimeEgressBinaries`
38+
(`internal/cli/runtime_binaries_test.go`) and make sure the inference
39+
profiles' `binaries:` globs match it — a runtime exec'd directly (no node
40+
wrapper) is not covered by `**/node`
41+
([Egress binary identity](#egress-binary-identity-per-runtime)).
3742

3843
## Security feature matrix
3944

@@ -55,7 +60,7 @@ flowchart TB
5560
end
5661
subgraph SB["Sandbox boundary — OpenShell + L7 egress policy (containment)"]
5762
direction TB
58-
EG["egress allowlist: *.googleapis.com · api.anthropic.com\n(+ api.openai.com POST /v1/responses with the openai provider)\nbinaries: **/claude · **/node (pi runs via node) · **/codex"]
63+
EG["egress allowlist: *.googleapis.com · api.anthropic.com\n(+ api.openai.com POST /v1/responses with the openai provider)\nbinaries: **/claude · **/claude.exe · **/node (pi runs via node) · **/pi (fleet-profile parity) · **/codex"]
5964
subgraph PROC["Runtime process — steering, defense in depth"]
6065
direction LR
6166
PRE["PreToolUse\nTirith · SSRF\ncanary · allowlist"]
@@ -269,6 +274,22 @@ The run log's `Agent: <model> (vX.Y.Z)` line is the ground truth for which Claud
269274

270275
The image also creates each runtime's config directory when the binary refuses to start without one: `CODEX_HOME` (`sandbox.SandboxCodexConfig`) is created owned by the sandbox user and baked as an `ENV` default, so an ad-hoc `codex` invocation from Bash behaves like the runtime's own `EnvExports()`. `TestSandboxImageCodexDefaults` keeps the path in the image and the Go constant in step (the pi equivalent is `TestSandboxImagePiDefaults`).
271276

277+
## Egress binary identity per runtime
278+
279+
The inference profiles (`profiles/fullsend-vertex-ai.yaml` in this repo and in fullsend-ai/agents; `profiles/fullsend-openai.yaml`, which has no fleet copy — the runner imports the embedded scaffold) carry a `binaries:` list. OpenShell's OPA (`sandbox-policy.rego`, `binary_allowed`) matches each glob against the kernel-resolved `/proc/<pid>/exe` of the process that opens the connection **or of any of its ancestors**. That gives two kinds of runtime:
280+
281+
- **Wrapped by node** (pi, codex): `**/node` admits them through the ancestor, so a pin bump cannot take them off the allowlist unless the wrapper disappears.
282+
- **Exec'd directly** (Claude Code): `claude` on PATH is a symlink to the npm package's `bin/claude.exe`, so the connecting process has no wrapper ancestor and only a glob on the real file name admits it. A renamed native binary breaks every run of that runtime on its first model call with `API Error: Error code policy_denied`, 0 tokens, and the only explanation is the gateway's `NET:OPEN … DENIED … binary '…' not allowed in policy '_provider_<name>'` line (#6971).
283+
284+
`TestScaffoldProfilesAllowRuntimeBinaries` (`internal/cli/runtime_binaries_test.go`) fails for any selectable runtime without a mapping and pins the ones below.
285+
286+
| Runtime | Process that opens the connection | Profile glob(s) | Evidence |
287+
|---------|-----------------------------------|-----------------|----------|
288+
| Claude Code | `bin/claude.exe` in the npm package — 2.1.2xx's `install.cjs` places the native binary there ("Always write to bin/claude.exe"); the Containerfile installs it *with* scripts so that runs | `**/claude`, `**/claude.exe` | gateway deny log in #6971; `npm view @anthropic-ai/claude-code@<pin> bin` |
289+
| pi | `node` (`bin = dist/bundle/cli.js`; no native network path in the package) | `**/node` | `images/sandbox/Containerfile`, `npm view @earendil-works/pi-coding-agent@<pin> bin` |
290+
| Codex | `vendor/<triple>/bin/codex`, spawned by the npm launcher `bin/codex.js` under node; codex also spawns `codex-code-mode-host` (default-enabled in 0.152.1), covered by ancestor matching, not by name | `**/node` (ancestor), `**/codex` (the process) | `npm pack --dry-run "@openai/codex@<pin>-linux-x64"` |
291+
| OpenCode (stub) | exec'd directly, like Claude Code: `opencode-ai` ships a stub `bin/opencode.exe` that `postinstall.mjs` replaces with `opencode-linux-x64/bin/opencode`; with `--ignore-scripts` the stub stays | to be decided against the Containerfile install (`**/opencode.exe` or `**/opencode`); the test fails the moment `opencode` joins `config.ValidRuntimes` until a mapping exists | `npm view opencode-ai bin optionalDependencies`, `postinstall.mjs` |
292+
272293
## Sandbox workspace layout
273294

274295
The sandbox has two key directories that map to Claude Code's config levels (plus a runner-owned config directory per additional runtime, e.g. `pi-config/` for pi and `codex-config/` for codex):
@@ -916,6 +937,7 @@ Two artefacts of the run are worth knowing about:
916937
| `auth.command` semantics (trimmed stdout, non-zero exit fails, no env fallback) | the whole credential path | `codex-rs/login/src/auth/external_bearer.rs` |
917938
| `supports_websockets` default for custom providers | a true default would take traffic off `POST /v1/responses` and break the egress profile | `codex-rs/model-provider-info/src/lib.rs` |
918939
| `[skills.bundled]` and skill discovery | the bundled skills are disabled by the runner-owned config; a renamed key would silently bring `skill-installer` and friends back into the agent's roster | `codex-rs/config/src/skills_config.rs` |
940+
| The native binary's path inside the platform package (`vendor/<triple>/bin/codex` at 0.152.1) | the `fullsend-openai` profile names it as `**/codex`; the node ancestor still admits a renamed file, but the pin in `runtimeEgressBinaries` should follow the rename | `npm pack --dry-run "@openai/codex@<pin>-linux-x64"` |
919941
| Whether a custom provider still issues `GET /v1/models` at startup | the `fullsend-openai` egress profile denies it; if the request ever became fatal or retried, it would delay or fail every first turn | `codex-rs/models-manager/` |
920942
| `ConfigToml` keys and the `ReasoningEffort` enum | a renamed or removed key silently changes behaviour; `--strict-config` reports it | `codex-rs/config/src/config_toml.rs`, `codex-rs/protocol/src/openai_models.rs` |
921943
| JSONL event structs and rollout file naming | the stream parser and transcript extraction | `codex-rs/exec/src/exec_events.rs`, `codex-rs/thread-store/src/local/helpers.rs` |

‎docs/guides/user/bring-your-own-agent.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ endpoints:
132132
enforcement: enforce
133133
binaries:
134134
- "**/claude"
135+
- "**/claude.exe" # Claude Code 2.1.2xx runs as claude.exe, even on Linux
135136
- "**/node"
137+
- "**/pi"
136138
```
137139

138140
> **Note (CI only):** the provider profile above controls network access only; real credentials are delivered via `host_files` (see [real-world example](#real-world-example-the-triage-agent)). Make sure you've completed the GCP prerequisites in [Before you begin](#before-you-begin).
@@ -355,6 +357,7 @@ allowed_remote_resources:
355357

356358
| Symptom | Fix |
357359
|---------|-----|
360+
| `API Error: Error code policy_denied` on the first model call (agent exits after ~2 s, 0 tokens) | The sandbox gateway denied the agent's *binary*, not the model. Check your profile's `binaries:` list has both `**/claude` and `**/claude.exe` (Claude Code 2.1.2xx runs as `claude.exe`). To see exactly which binary was denied: `grep DENIED <run-dir>/logs/openshell-sandbox.log` — see [Debugging network policies locally](running-agents-locally.md#debugging-network-policies-locally) |
358361
| Agent crashes at 0s | Sandbox can't reach Vertex AI — verify that `providers/vertex-ai.yaml` is listed in your harness `providers:` and that `ANTHROPIC_VERTEX_PROJECT_ID`/`CLOUD_ML_REGION` are set (in your `--env-file` for local runs, or in the workflow `env` block for CI) |
359362
| "role field is required" | Add `role:` to harness |
360363
| `403` / "role not allowed" from the mint | Your `role:` is not one the mint serves. On the hosted mint use a built-in role (`triage`, `coder`, `review`, `retro`, `prioritize`, `fullsend`); for a custom role, point `FULLSEND_MINT_URL` at your own mint — see [Custom Agent Identity](custom-agent-identity.md) |

‎docs/guides/user/customizing-agents.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ When using `base:` composition, the base harness can declare its own providers a
135135
- **Profiles:** base + child lists are concatenated; deduplicated by profile `id` (child wins)
136136
- **Providers:** base + child lists are concatenated; local names shadow URL-resolved names of the same `name`
137137

138+
If the `profiles/` directory next to the harness also contains a file with the same `id` as a profile the harness already resolves, `fullsend run` warns:
139+
140+
```text
141+
! Profile "fullsend-vertex-ai" is defined both in /work/.fullsend/profiles and by the harness (/work/.fullsend/.fullsend-cache/resources/sha256/fe4f748d…/content); whichever copy was imported most recently is live — delete the directory copy or keep it in sync
142+
```
143+
144+
Delete the directory copy unless you mean to override the harness's. A stale copy is how a fix that already landed in the harness (for example the `**/claude.exe` entry on the Vertex profile) silently stops applying.
145+
138146
Remote URLs must include a `#sha256=...` integrity hash and match an `allowed_remote_resources` prefix in the same config. The integrity hash is checked on every resolution to ensure the content hasn't been tampered with since it was pinned.
139147

140148
### Tuning agents with augmentation skills

‎docs/guides/user/running-agents-locally.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,10 @@ to the server (gateway). It is likely that you need to bind the gateway to `0.0.
444444
**`Syntax error: "(" unexpected` inside sandbox**
445445
- The macOS Mach-O binary was injected instead of a Linux ELF. Update to fullsend 0.4.0+ which auto-resolves the correct binary, or provide one explicitly with `--fullsend-binary`
446446

447+
**`API Error: Error code policy_denied` on the first model call (agent exits after ~2 s, 0 tokens)**
448+
- The gateway denied the agent's binary, not the model. Run `grep DENIED <run-dir>/logs/openshell-sandbox.log`; a line ending in `binary '…/claude.exe' not allowed in policy '_provider_vertex_ai'` means the Vertex profile lacks `**/claude.exe` (Claude Code 2.1.2xx runs as `claude.exe`, even on Linux)
449+
- If `--fullsend-dir` contains a `profiles/` directory, its copy of the profile is imported after the harness's and is the one to fix; `fullsend run` prints a `Profile "…" is defined both in … and by the harness` warning when that happens
450+
447451
**Agent fails with missing environment variable**
448452
- Check your env file contains all variables listed in the agent's harness YAML (`harness/{agent}.yaml` in the `.fullsend` config directory)
449453

‎internal/cli/run.go‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,10 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
12111211
// GitLab instance (#6615). Prepended so that a user-defined profile
12121212
// with the same ID wins via last-wins dedup. Inserted before the
12131213
// integrity check so providers referencing this ID are valid.
1214+
// generatedProfileIDs records profiles the runner synthesized itself;
1215+
// a profiles/ directory copy overriding one of these is the documented
1216+
// path, not a shadowing worth warning about.
1217+
generatedProfileIDs := map[string]bool{}
12141218
if forgePlatform == "gitlab" {
12151219
if profilePath, cleanupProfile, err := generateGitLabForgeProfile(); err != nil {
12161220
printer.StepWarn("Failed to auto-generate GitLab forge profile: " + err.Error())
@@ -1220,6 +1224,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
12201224
ID: "fullsend-gitlab-forge",
12211225
LocalPath: profilePath,
12221226
}}, result.Profiles...)
1227+
generatedProfileIDs["fullsend-gitlab-forge"] = true
12231228
}
12241229
}
12251230

@@ -1283,8 +1288,18 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
12831288
printer.StepDone(fmt.Sprintf("Profile imported: %s (%.1fs)", rp.ID, time.Since(profileStart).Seconds()))
12841289
}
12851290

1286-
// Import provider profiles (if profiles/ directory exists).
1291+
// Warn when a profiles/ directory copy and a harness-resolved profile
1292+
// share an id. The directory import below runs after the harness
1293+
// import, but each has its own hash cache, so either copy can end up
1294+
// live on the gateway; a stale directory copy can silently undo a fix
1295+
// the harness already carries (#6971). Per-repo customization relies
1296+
// on the override, so this only makes it visible.
12871297
profilesDir := filepath.Join(absFullsendDir, "profiles")
1298+
for _, sp := range shadowedProfiles(dirProfileIDs, result.Profiles, profilesDir, generatedProfileIDs) {
1299+
printer.StepWarn(fmt.Sprintf("Profile %q is defined both in %s and by the harness (%s); whichever copy was imported most recently is live — delete the directory copy or keep it in sync", sp.ID, profilesDir, sp.LocalPath))
1300+
}
1301+
1302+
// Import provider profiles (if profiles/ directory exists).
12881303
dirProfileStart := time.Now()
12891304
printer.StepStart("Importing provider profiles")
12901305
if err := sandbox.ImportProfiles(profilesDir); err != nil {
@@ -5282,6 +5297,34 @@ func dedupResolvedProfiles(profiles []resolve.ResolvedProfile) []resolve.Resolve
52825297
return deduped
52835298
}
52845299

5300+
// shadowedProfiles returns, sorted by ID, the harness-resolved profiles
5301+
// whose ID also appears in profilesDir. ImportProfiles(profilesDir) runs
5302+
// after the harness-resolved imports, but the two imports keep independent
5303+
// hash caches, so the copy imported most recently is the live one. A
5304+
// resolved profile that already lives in profilesDir (a local-path entry,
5305+
// ADR 0075) is the same file, not a shadow, and runner-generated profiles
5306+
// (generatedIDs) are meant to be overridden, so both are skipped. Duplicate
5307+
// IDs in the directory are reported once.
5308+
func shadowedProfiles(dirIDs []string, resolved []resolve.ResolvedProfile, profilesDir string, generatedIDs map[string]bool) []resolve.ResolvedProfile {
5309+
byID := make(map[string]resolve.ResolvedProfile, len(resolved))
5310+
for _, rp := range resolved {
5311+
if generatedIDs[rp.ID] || (!rp.FromURL && filepath.Dir(rp.LocalPath) == profilesDir) {
5312+
continue
5313+
}
5314+
byID[rp.ID] = rp
5315+
}
5316+
seen := make(map[string]bool, len(dirIDs))
5317+
var shadowed []resolve.ResolvedProfile
5318+
for _, id := range dirIDs {
5319+
if rp, ok := byID[id]; ok && !seen[id] {
5320+
seen[id] = true
5321+
shadowed = append(shadowed, rp)
5322+
}
5323+
}
5324+
sort.Slice(shadowed, func(i, j int) bool { return shadowed[i].ID < shadowed[j].ID })
5325+
return shadowed
5326+
}
5327+
52855328
// mergeProviderDefs merges local and URL-resolved provider definitions.
52865329
// Local defs have highest precedence; among URL-resolved defs, last
52875330
// occurrence wins (child over base). The returned slice is deterministically

‎internal/cli/run_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5830,6 +5830,102 @@ func TestDedupResolvedProfiles(t *testing.T) {
58305830
}
58315831
}
58325832

5833+
func TestShadowedProfiles(t *testing.T) {
5834+
const profilesDir = "/ws/.fullsend/profiles"
5835+
url := func(id string) resolve.ResolvedProfile {
5836+
return resolve.ResolvedProfile{ID: id, LocalPath: "/cache/sha256/" + id + "/content.yaml", FromURL: true}
5837+
}
5838+
ids := func(profiles []resolve.ResolvedProfile) []string {
5839+
var out []string
5840+
for _, p := range profiles {
5841+
out = append(out, p.ID)
5842+
}
5843+
return out
5844+
}
5845+
tests := []struct {
5846+
name string
5847+
dirIDs []string
5848+
resolved []resolve.ResolvedProfile
5849+
generated map[string]bool
5850+
want []string
5851+
}{
5852+
{
5853+
name: "no overlap",
5854+
dirIDs: []string{"local-only"},
5855+
resolved: []resolve.ResolvedProfile{url("remote-only")},
5856+
want: nil,
5857+
},
5858+
{
5859+
name: "empty dir",
5860+
dirIDs: nil,
5861+
resolved: []resolve.ResolvedProfile{url("a")},
5862+
want: nil,
5863+
},
5864+
{
5865+
name: "empty resolved",
5866+
dirIDs: []string{"a"},
5867+
resolved: nil,
5868+
want: nil,
5869+
},
5870+
{
5871+
name: "one shadow",
5872+
dirIDs: []string{"fullsend-vertex-ai"},
5873+
resolved: []resolve.ResolvedProfile{url("fullsend-vertex-ai")},
5874+
want: []string{"fullsend-vertex-ai"},
5875+
},
5876+
{
5877+
name: "multiple shadows sorted",
5878+
dirIDs: []string{"z-profile", "a-profile", "local-only"},
5879+
resolved: []resolve.ResolvedProfile{url("z-profile"), url("a-profile"), url("remote-only")},
5880+
want: []string{"a-profile", "z-profile"},
5881+
},
5882+
{
5883+
name: "local-path profile in the same directory is not a shadow",
5884+
dirIDs: []string{"byo"},
5885+
resolved: []resolve.ResolvedProfile{
5886+
{ID: "byo", LocalPath: profilesDir + "/byo.yaml", FromURL: false},
5887+
},
5888+
want: nil,
5889+
},
5890+
{
5891+
name: "local-path profile elsewhere in the workspace is a shadow",
5892+
dirIDs: []string{"byo"},
5893+
resolved: []resolve.ResolvedProfile{
5894+
{ID: "byo", LocalPath: "/ws/custom/byo.yaml", FromURL: false},
5895+
},
5896+
want: []string{"byo"},
5897+
},
5898+
{
5899+
name: "duplicate directory ids reported once",
5900+
dirIDs: []string{"dup", "dup"},
5901+
resolved: []resolve.ResolvedProfile{url("dup")},
5902+
want: []string{"dup"},
5903+
},
5904+
{
5905+
name: "runner-generated gitlab forge profile is not a shadow",
5906+
dirIDs: []string{"fullsend-gitlab-forge"},
5907+
resolved: []resolve.ResolvedProfile{
5908+
{ID: "fullsend-gitlab-forge", LocalPath: "/tmp/fullsend-gitlab-profile-123/fullsend-gitlab-forge.yaml"},
5909+
},
5910+
generated: map[string]bool{"fullsend-gitlab-forge": true},
5911+
want: nil,
5912+
},
5913+
}
5914+
for _, tt := range tests {
5915+
t.Run(tt.name, func(t *testing.T) {
5916+
got := shadowedProfiles(tt.dirIDs, tt.resolved, profilesDir, tt.generated)
5917+
assert.Equal(t, tt.want, ids(got))
5918+
})
5919+
}
5920+
}
5921+
5922+
func TestShadowedProfiles_ReturnsResolvedCopy(t *testing.T) {
5923+
rp := resolve.ResolvedProfile{ID: "fullsend-vertex-ai", LocalPath: "/cache/x/content.yaml", FromURL: true}
5924+
got := shadowedProfiles([]string{"fullsend-vertex-ai"}, []resolve.ResolvedProfile{rp}, "/ws/profiles", nil)
5925+
require.Len(t, got, 1)
5926+
assert.Equal(t, rp, got[0], "the returned entry must carry the shadowed copy's path so the warning can name it")
5927+
}
5928+
58335929
func TestDedupResolvedProviders(t *testing.T) {
58345930
tests := []struct {
58355931
name string

0 commit comments

Comments
 (0)