diff --git a/docs/contributing/runtime-implementation.md b/docs/contributing/runtime-implementation.md index 975297c126..abf6185a90 100644 --- a/docs/contributing/runtime-implementation.md +++ b/docs/contributing/runtime-implementation.md @@ -58,7 +58,7 @@ flowchart TB | **Host-side context injection scan** (unicode, SSRF patterns on repo context files) | Host + sandbox `scan context` | ✓ | N/A — stub | ✓ (runner-level, runtime-agnostic) | Harness `security.host_scanners`; heuristic scanners only — DeBERTa ML model removed from sandbox in #6522 (its only consumer is the host-side `scan input`, not `scan context`) | | **Host-side runtime content scan** (agent def, SKILL.md, plugin JSON before upload) | Host (`scanRuntimeContent`) | ✓ | N/A — stub | ✓ (runner-level, runtime-agnostic) | Uses `security.InputPipeline()`; not part of `Runtime` interface — runner responsibility | | **Tirith** (Bash command scanning) | Sandbox PreToolUse hook | ✓ (loaded via `--settings`, #6358) | N/A — stub | ✓ via `fullsend-hooks.js` (pi `tool_call` → `HookPlan` PreToolUse scripts) | `tirith_check.py`; harness `security.sandbox_hooks.tirith`; fails open on missing binary/timeout unless `TIRITH_REQUIRED=1` | -| **SSRF pre-tool** | Sandbox PreToolUse hook | ✓ (`hooks-loaded.feature` runs under the dummy runtime, which installs no hooks — it guards the sandbox egress boundary; the hook itself is unit-tested) | N/A — stub | ✓ via `fullsend-hooks.js` (pi `tool_call` → `HookPlan` PreToolUse scripts) | `ssrf_pretool.py`; default on; when DNS resolution fails for a host on the `FULLSEND_EGRESS_ALLOWLIST`, the hook defers to the L7 egress proxy instead of failing closed — all other SSRF checks (scheme, hostname blocklist, IP blocklist, DNS rebinding) still apply | +| **SSRF pre-tool** | Sandbox PreToolUse hook | ✓ (`hooks-loaded.feature` runs under the dummy runtime, which installs no hooks — it guards the sandbox egress boundary; the hook itself is unit-tested) | N/A — stub | ✓ via `fullsend-hooks.js` (pi `tool_call` → `HookPlan` PreToolUse scripts) | `ssrf_pretool.py`; default on; when DNS resolution fails for a host on the `FULLSEND_EGRESS_ALLOWLIST`, the hook defers to the L7 egress proxy instead of failing closed — all other SSRF checks (scheme, hostname blocklist, IP blocklist, DNS rebinding) still apply. On GitLab CI, the forge host is also covered by the auto-generated `fullsend-gitlab-forge` provider profile (#6615), which opens the L7 proxy for the forge API | | **Canary token detection** | Sandbox Pre/PostToolUse hooks | pre ✓; post-tool via `posttool_chain.py` on successful tool calls (`tool_response` / `updatedToolOutput`, #6357); failed calls: the same driver on `PostToolUseFailure` (detect + halt; the error text cannot be rewritten) | N/A — stub | ✓ pre via `fullsend-hooks.js` `tool_call`; post via `tool_result` (sequential chain, block withholds the result) | `canary_pretool.py` / `canary_posttool.py`; both inert unless `FULLSEND_CANARY_TOKEN` is set. Post-tool canary is an in-process chain stage so it cannot race sanitizer rewrites. Claude Code `decision:block` does not hide PostToolUse output, so the chain also redacts the token in `updatedToolOutput`. | | **Secret redaction** | Sandbox PostToolUse hook | ✓ via `posttool_chain.py` on successful tool calls (#6357); on failed calls the same driver detects, logs to `findings.jsonl` and warns the agent via `additionalContext` — Claude Code does not let a hook rewrite a failed call's output | N/A — stub | ✓ via `fullsend-hooks.js` `tool_result` → the same `posttool_chain.py` (sent `tool_response` + `tool_result`; `updatedToolOutput` applied to the result the model sees) | `secret_redact_posttool.py` | | **Unicode normalization** | Sandbox PostToolUse hook | ✓ via `posttool_chain.py` on successful tool calls (#6357); on failed calls the same driver detects, logs to `findings.jsonl` and warns the agent via `additionalContext` — Claude Code does not let a hook rewrite a failed call's output | N/A — stub | ✓ via `fullsend-hooks.js` `tool_result` → the same `posttool_chain.py` (sent `tool_response` + `tool_result`; `updatedToolOutput` applied to the result the model sees) | `unicode_posttool.py` | @@ -97,7 +97,7 @@ The hook scripts in `internal/security/hooks/*.py` are plain programs with no Cl - **Wire protocol (per script):** JSON on stdin — `{"tool_name": ..., "tool_input": {...}}` for PreToolUse. PostToolUse payloads include the tool output as `tool_response` (Claude Code; string or structured object such as Bash `{stdout, stderr, interrupted, isImage}`) with `tool_result` accepted as a fallback. Exit `0` = allow. *Blocking* scripts (all PreToolUse scripts, standalone `canary_posttool.py`, and `posttool_chain.py` when its canary stage fires) exit `1` and print `{"decision":"block","reason":"..."}` on stdout; the adapter must stop the tool call (or, post-tool, drop the result) and surface the reason. *Sanitizing* stages (suppress/unicode/redact) always exit `0` and, when they changed something, print `{"hookSpecificOutput":{"hookEventName":"PostToolUse","updatedToolOutput": }, "tool_result": }`. Empty stdout = unchanged. `updatedToolOutput` must match the tool's output shape — a bare string is ignored for built-in Claude Code tools. `scan_text` flattens every string field (including `stderr`), newline-joined so a needle cannot match across a field boundary (such a match would be unredactable, since the redactors rewrite each field independently); `apply_text` writes a replacement into the first text slot and blanks the rest, or leaves unrecognized structured shapes unchanged. Unicode normalization skips identifier fields (`hook_io.IDENTIFIER_KEYS`: paths, URLs, commands, exact-match edit strings) — NFKC would hand Claude a path that does not exist on disk; secret redaction still walks them, since it only replaces matched patterns. - **Sanitizer scope (what is rewritten, and what is not):** the PostToolUse stages exist to remove *controls-relevant* content and nothing else, because an agent edits against what it reads — a rewritten `Read` result means `Edit.old_string` no longer matches the file, and a `Write` of what it saw persists the rewrite. *Secret redaction* masks credential-shaped values only: the prefix patterns (`ghp_…`, `sk-…`, `AKIA…`, bearer headers, private-key blocks, database URLs) plus env/JSON shapes that need both a secret-bearing name (`…_TOKEN`, `api_key`, `accessToken`, not `TOKEN_URL`/`KEY_ID`/`publicKey`) and a value that is not an identifier, member path (`request.headers.authorization`), URL, path, placeholder or word phrase (`test-secret`, `ghs_policy_token`); a source-style `name = expr` counts only when the value is a quoted literal. A sweep of 900 fullsend files through the chain rewrites only test files holding token-shaped fakes. *Context suppression* condenses the output of exactly one verification command (`go test`, `pytest`, `npm test`, `make test`, `pre-commit run`, `gitleaks detect`, `scan-secrets`) with optional setup prefixes (`cd`, `export`, `source`), and only from positive evidence the tool printed (`ok `, `N passed`, `…Passed`, `no leaks`) — silence is never condensed into "passed", because a hook whose interpreter is missing is silent too and Claude Code's Bash result carries no exit code (so linters and `go vet`/`go build`, whose clean run prints nothing, are never condensed); the command must *start* with the tool (after wrappers that run it: `VAR=…`, `sudo`, `nice`, `timeout `, `env VAR=…`, `uvx`, `npx`, `uv run`, `mise exec --`, stacked; `python3.12 -m pytest` counts) — a command that merely mentions it, such as `grep -n scan-secrets hooks.py`, keeps its output; pipelines (`| tail` can cut the `FAIL` line; a `|` inside quotes such as `-run 'A|B'` is not a pipeline), `$(…)`, chains of two tools (`pytest; go test`, and deliberately also `go test && go vet` — one summary cannot speak for two), a trailing `echo $?`, and any output carrying a failure marker (`FAIL`, `panic:`, `Traceback`, `3 failed`) pass through untouched; comment lines and backslash continuations are tolerated. *Unicode* strips invisible, bidi, tag, NUL and ANSI/OSC characters and runs of variation selectors, but keeps compatibility characters (fullwidth, ligatures, CJK punctuation) and single emoji/CJK selectors — NFKC is applied to a *detection copy* (canary, secret patterns); a field is emitted normalized only when the normalized copy reveals an escape sequence or a secret the original hid. Every rewrite attaches `hookSpecificOutput.additionalContext` so the agent knows the output was changed and why, and every hook entry carries `timeout: 30` (Claude Code's 600 s default fails open — so does the 30 s one, for PreToolUse blockers included; the scripts finish in milliseconds and `tirith_check.py` bounds its own scan at 5 s, so the budget is headroom, not a ceiling the scripts approach). - **Fail modes:** blocking scripts fail **closed** on malformed JSON or oversized input (> 10 × 1024 × 1024 characters, read from text-mode stdin) — they block. Empty/whitespace-only stdin is treated as "no tool call" and allowed by every script; a payload without `tool_name` blocks only in the allowlist hook. `tirith_check.py` fails **open** when the `tirith` binary is missing, times out or errors, unless `TIRITH_REQUIRED=1` (which `appendHookEnv` writes when Tirith is enabled — adapters must make sure it reaches the script). Sanitizing scripts and each `posttool_chain.py` sanitizer stage fail **open** — malformed input or a stage exception is passed through unchanged (exit 0; the unicode hook logs an `input_truncated` finding), and a stage failure is recorded in `findings.jsonl` as `_stage_error`. Adapters must not treat a sanitizer's empty stdout as an error. The **canary stage fails closed**: a scan that raises is treated as a hit, a hit whose redaction cannot be verified clean withholds the output entirely rather than emitting it, and `exit 1` is unconditional. Because `posttool_chain.py` is the only PostToolUse entry point Claude Code schedules, input the driver cannot read — malformed JSON, or more than the 10 MB limit — also blocks (`exit 1`, `continue: false`) whenever `FULLSEND_CANARY_TOKEN` is set, instead of skipping detection; with no canary token configured it stays fail-open. Detection and redaction share one case-insensitive matcher (`hook_io.canary_pattern`), so a token that is detected is always one that can be redacted. -- **Environment:** `runtime.appendHookEnv` writes `TIRITH_FAIL_ON` / `TIRITH_REQUIRED` / `FULLSEND_EGRESS_ALLOWLIST` into `/sandbox/workspace/.env`; the runtime must launch the scripts with that file sourced (Claude's run command does). Scripts also read `FULLSEND_TRACE_ID`, `FULLSEND_TOOL_ALLOWLIST` (allowlist hook, fail-closed when unset), `FULLSEND_EGRESS_ALLOWLIST` (SSRF hook, comma-separated host:port entries, exact hostnames only — wildcards are skipped with a warning on stderr; on DNS failure the hook defers to the L7 proxy for allowlisted hosts instead of failing closed; if DNS succeeds but resolves to a blocked IP, the allowlist is not consulted) and `FULLSEND_CANARY_TOKEN` (both canary hooks are no-ops when it is empty; supply it via harness `env.sandbox`/`host_files`), and write findings to `/sandbox/workspace/.security/findings.jsonl`. +- **Environment:** `runtime.appendHookEnv` writes `TIRITH_FAIL_ON` / `TIRITH_REQUIRED` / `FULLSEND_EGRESS_ALLOWLIST` into `/sandbox/workspace/.env`; the runtime must launch the scripts with that file sourced (Claude's run command does). When the CLI layer resolves a forge egress entry (e.g. the GitLab forge host:port via `gitlab.ResolveForgeHostPort()`), it passes it through `SandboxHookConfig.WithForgeEgressEntry()` and `appendHookEnv` auto-merges it into `FULLSEND_EGRESS_ALLOWLIST` so the SSRF hook defers to the L7 proxy for the forge API (#6615). Scripts also read `FULLSEND_TRACE_ID`, `FULLSEND_TOOL_ALLOWLIST` (allowlist hook, fail-closed when unset), `FULLSEND_EGRESS_ALLOWLIST` (SSRF hook, comma-separated host:port entries, exact hostnames only — wildcards are skipped with a warning on stderr; on DNS failure the hook defers to the L7 proxy for allowlisted hosts instead of failing closed; if DNS succeeds but resolves to a blocked IP, the allowlist is not consulted) and `FULLSEND_CANARY_TOKEN` (both canary hooks are no-ops when it is empty; supply it via harness `env.sandbox`/`host_files`), and write findings to `/sandbox/workspace/.security/findings.jsonl`. - **Suppression reachability:** under Claude Code a non-zero-exit command never reaches `PostToolUse` at all, so the suppressors only ever see zero-exit output; a tool that exits 0 with nothing to say is the case that used to be summarized as "passed". Adapters whose post-tool event also fires for failures (pi's `tool_result`) do deliver failed calls to the same chain, which is why the positive-evidence rule matters on both. - **Claude Code caveats (#6358, #6357):** (1) *Loading* — fixed by #6358: the hook wiring is written to the runner-owned `/sandbox/claude-config/hooks.json` and passed explicitly via `--settings`, so it loads regardless of the CLI's working directory (previously it sat unread in `/sandbox/workspace/.claude/`); the `hooks-loaded.feature` behaviour scenario guards the "silently not loaded" regression class. Note Claude Code still auto-loads a target repo's own `/.claude/settings.json` hooks from `` — a separate exposure to assess. (2) *Payload (fixed in #6357, contract v2)* — scripts read `tool_response` (fallback `tool_result`) and replace output via `hookSpecificOutput.updatedToolOutput` with the original shape preserved. Sanitizer order and canary detection share `posttool_chain.py` so two PostToolUse hooks cannot race. `scan_text` inspects every string field (including `stderr`). (3) *Failed tool calls* — Claude Code fires `PostToolUse` only when a tool **succeeds**; a failed call (non-zero-exit Bash included) fires `PostToolUseFailure`, which delivers the error text but supports no output rewrite. `HookPlan` wires the same `posttool_chain.py` there, where it runs canary detection (halt) plus detection-only secret and unicode passes that log to `findings.jsonl` and return an `additionalContext` warning — `additionalContext` is the only output the event accepts, so a credential or an ANSI/zero-width sequence in a failed command's output still reaches the transcript unmasked and the agent is told not to copy or obey it. Scanning covers every string in the payload rather than one named key (the documented field is `error`; doc versions differ), halting via `continue: false` (the only decision control the event honours), also on a detection copy — NFKC-normalized with combining marks, format characters (zero-width, bidi, tag), line/paragraph separators, control characters and whole ANSI/OSC sequences removed, i.e. everything the unicode stage strips from a successful call, so detection sees through the same obfuscation on both paths; suppression, unicode normalization and redaction cannot apply to a failed call under Claude Code — pi sanitizes those too, because its `tool_result` event fires for failures. `interrupted` on a Bash `tool_response` marks a cancelled tool, not an exit code — the `Exit code` prefix check in `looks_failed` therefore serves the v1 adapter path only. (4) *Blocking* — Claude Code keys on the stdout JSON on any exit code (`decision:"block"` is deprecated for PreToolUse but still maps to `deny`) and treats a bare exit `1` as non-blocking (exit `2` is its own blocking code); a local control run confirmed the scripts' "exit 1 + `{"decision":"block"}`" convention does block once the settings are loaded. For PostToolUse, `decision:"block"` **only appends `reason` next to the tool result — Claude still sees the original output**. `canary_posttool.py` therefore also emits `updatedToolOutput` with the token redacted to `[CANARY_REDACTED]`, and sets the universal `continue: false` field — the documented control that actually halts the session — so a leak still terminates the run. Net: after #6358 and #6357, both PreToolUse and PostToolUse halves of the contract are effective under Claude Code. diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 0512901718..4786425b93 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -382,7 +382,10 @@ Vendoring commit messages use title + body (upload and stale delete). `github st │ ▼ │ │ ┌──────────────────┐ │ │ │ ImportProfile() │ Import openshell provider profiles │ -│ │ │ (from resolved openshell.profiles) │ +│ │ │ (from resolved openshell.profiles; │ +│ │ │ on GitLab, a fullsend-gitlab-forge │ +│ │ │ profile is auto-generated from the │ +│ │ │ forge host URL — see #6615) │ │ └──────┬───────────┘ │ │ ▼ │ │ ┌──────────────────┐ │ diff --git a/internal/cli/bootstrap_input.go b/internal/cli/bootstrap_input.go index c01f0e22f6..4b05638169 100644 --- a/internal/cli/bootstrap_input.go +++ b/internal/cli/bootstrap_input.go @@ -29,7 +29,7 @@ func (b *harnessBootstrapWithHooks) SandboxHookConfig() security.SandboxHookConf return b.hooks } -func newHarnessBootstrap(h *harness.Harness, sandboxName, agentName string) runtime.BootstrapInput { +func newHarnessBootstrap(h *harness.Harness, sandboxName, agentName, forgeEgressEntry string) runtime.BootstrapInput { base := &harnessBootstrap{ sandboxName: sandboxName, agentPath: h.Agent, @@ -40,8 +40,12 @@ func newHarnessBootstrap(h *harness.Harness, sandboxName, agentName string) runt if !h.SecurityEnabled() { return base } + hooks := security.SandboxHookConfigFromHarness(h) + if forgeEgressEntry != "" { + hooks = hooks.WithForgeEgressEntry(forgeEgressEntry) + } return &harnessBootstrapWithHooks{ harnessBootstrap: base, - hooks: security.SandboxHookConfigFromHarness(h), + hooks: hooks, } } diff --git a/internal/cli/bootstrap_input_test.go b/internal/cli/bootstrap_input_test.go index f5eb28a444..cff05d074f 100644 --- a/internal/cli/bootstrap_input_test.go +++ b/internal/cli/bootstrap_input_test.go @@ -18,7 +18,7 @@ func TestNewHarnessBootstrap_WithoutSecurity(t *testing.T) { Enabled: &disabled, }, } - boot := newHarnessBootstrap(h, "sandbox-1", "test") + boot := newHarnessBootstrap(h, "sandbox-1", "test", "") _, ok := boot.(agentruntime.SandboxHooksBootstrap) assert.False(t, ok) @@ -38,7 +38,7 @@ func TestNewHarnessBootstrap_WithSecurity(t *testing.T) { }, }, } - boot := newHarnessBootstrap(h, "sandbox-1", "test") + boot := newHarnessBootstrap(h, "sandbox-1", "test", "") hooksBoot, ok := boot.(agentruntime.SandboxHooksBootstrap) require.True(t, ok) @@ -48,3 +48,17 @@ func TestNewHarnessBootstrap_WithSecurity(t *testing.T) { assert.Equal(t, []string{"plugins/p"}, boot.PluginDirs()) assert.Equal(t, harness.SkillSources(h.Skills), boot.SkillDirs()) } + +func TestNewHarnessBootstrap_WithForgeEgressEntry(t *testing.T) { + h := &harness.Harness{ + Agent: "agents/test.md", + Security: &harness.SecurityConfig{ + SandboxHooks: &harness.SandboxHooks{}, + }, + } + boot := newHarnessBootstrap(h, "sandbox-1", "test", "gitlab.company.com:443") + + hooksBoot, ok := boot.(agentruntime.SandboxHooksBootstrap) + require.True(t, ok) + assert.Equal(t, "gitlab.company.com:443", hooksBoot.SandboxHookConfig().ForgeEgressEntry()) +} diff --git a/internal/cli/forge_client.go b/internal/cli/forge_client.go index bbe286ebcc..b6555073a7 100644 --- a/internal/cli/forge_client.go +++ b/internal/cli/forge_client.go @@ -46,16 +46,18 @@ func newForgeClient(forgeName, gitlabToken, baseURL string, glOpts ...gl.Option) return nil, err } } - // Base URL precedence: explicit arg > FULLSEND_GITLAB_URL > GITLAB_API_URL > CI_SERVER_URL + // Base URL precedence: explicit arg > env vars (via gl.URLEnvVars). + // The env-var precedence is shared with gl.ResolveForgeHostPort(). var opts []gl.Option if baseURL != "" { opts = append(opts, gl.WithBaseURL(baseURL)) - } else if envURL := strings.TrimSpace(os.Getenv("FULLSEND_GITLAB_URL")); envURL != "" { - opts = append(opts, gl.WithBaseURL(envURL)) - } else if envURL := strings.TrimSpace(os.Getenv("GITLAB_API_URL")); envURL != "" { - opts = append(opts, gl.WithBaseURL(envURL)) - } else if envURL := strings.TrimSpace(os.Getenv("CI_SERVER_URL")); envURL != "" { - opts = append(opts, gl.WithBaseURL(envURL)) + } else { + for _, env := range gl.URLEnvVars { + if envURL := strings.TrimSpace(os.Getenv(env)); envURL != "" { + opts = append(opts, gl.WithBaseURL(envURL)) + break + } + } } opts = append(opts, glOpts...) return gl.New(token, opts...) diff --git a/internal/cli/gitlab_profile.go b/internal/cli/gitlab_profile.go new file mode 100644 index 0000000000..92ed40c64c --- /dev/null +++ b/internal/cli/gitlab_profile.go @@ -0,0 +1,79 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strconv" + + "gopkg.in/yaml.v3" + + gl "github.com/fullsend-ai/fullsend/internal/forge/gitlab" +) + +// gitlabForgeEndpoint is the YAML shape of a single provider endpoint. +type gitlabForgeEndpoint struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Protocol string `yaml:"protocol"` + Access string `yaml:"access"` + Enforcement string `yaml:"enforcement"` +} + +// gitlabForgeProfileSpec is the YAML shape of a provider profile. +type gitlabForgeProfileSpec struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description"` + Category string `yaml:"category"` + Endpoints []gitlabForgeEndpoint `yaml:"endpoints"` + Binaries []string `yaml:"binaries"` +} + +// generateGitLabForgeProfile creates a temporary provider profile YAML +// for the GitLab forge host, analogous to the scaffold's +// fullsend-github.yaml. Returns the temp file path and a cleanup +// function, or ("", nil, nil) when no GitLab host can be resolved. +func generateGitLabForgeProfile() (string, func(), error) { + host, port := gl.ResolveForgeHostPort() + if host == "" { + return "", nil, nil + } + portNum, err := strconv.Atoi(port) + if err != nil { + return "", nil, fmt.Errorf("GitLab port %q is not a valid integer", port) + } + + profile := gitlabForgeProfileSpec{ + ID: "fullsend-gitlab-forge", + DisplayName: "Fullsend GitLab (auto)", + Description: "GitLab API and Git operations for fullsend agents (auto-generated from forge host)", + Category: "source_control", + Endpoints: []gitlabForgeEndpoint{ + {Host: host, Port: portNum, Protocol: "rest", Access: "read-write", Enforcement: "enforce"}, + }, + Binaries: []string{"**/git", "**/glab", "**/node", "**/pre-commit"}, + } + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&profile); err != nil { + return "", nil, fmt.Errorf("marshaling GitLab profile YAML: %w", err) + } + if err := enc.Close(); err != nil { + return "", nil, fmt.Errorf("closing YAML encoder: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "fullsend-gitlab-profile-*") + if err != nil { + return "", nil, fmt.Errorf("creating temp dir for GitLab profile: %w", err) + } + profilePath := filepath.Join(tmpDir, "fullsend-gitlab-forge.yaml") + if err := os.WriteFile(profilePath, buf.Bytes(), 0o644); err != nil { + os.RemoveAll(tmpDir) + return "", nil, fmt.Errorf("writing GitLab profile: %w", err) + } + return profilePath, func() { os.RemoveAll(tmpDir) }, nil +} diff --git a/internal/cli/gitlab_profile_test.go b/internal/cli/gitlab_profile_test.go new file mode 100644 index 0000000000..d3e7370be5 --- /dev/null +++ b/internal/cli/gitlab_profile_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateGitLabForgeProfile(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantEmpty bool + wantErr string + wantHost string + wantPort string + notContains string + }{ + { + name: "default port from CI_SERVER_URL", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.cee.redhat.com"}, + wantHost: "host: gitlab.cee.redhat.com", + wantPort: "port: 443", + }, + { + name: "non-standard port", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.company.com:8443"}, + wantHost: "host: gitlab.company.com", + wantPort: "port: 8443", + }, + { + name: "no env vars", + env: map[string]string{}, + wantEmpty: true, + }, + { + name: "FULLSEND_GITLAB_URL takes precedence", + env: map[string]string{"FULLSEND_GITLAB_URL": "https://gitlab.company.com", "CI_SERVER_URL": "https://gitlab.other.com"}, + wantHost: "host: gitlab.company.com", + wantPort: "port: 443", + notContains: "gitlab.other.com", + }, + { + name: "gitlab.com", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.com"}, + wantHost: "host: gitlab.com", + wantPort: "port: 443", + }, + { + name: "http scheme defaults to port 80", + env: map[string]string{"CI_SERVER_URL": "http://gitlab.internal"}, + wantHost: "host: gitlab.internal", + wantPort: "port: 80", + }, + { + name: "invalid URL yields empty result", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.company.com:notaport"}, + wantEmpty: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, key := range []string{"FULLSEND_GITLAB_URL", "GITLAB_API_URL", "CI_SERVER_URL"} { + t.Setenv(key, "") + } + for k, v := range tc.env { + t.Setenv(k, v) + } + + profilePath, cleanup, err := generateGitLabForgeProfile() + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + + if tc.wantEmpty { + assert.Empty(t, profilePath) + assert.Nil(t, cleanup) + return + } + + require.NotEmpty(t, profilePath) + defer cleanup() + + data, err := os.ReadFile(profilePath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "id: fullsend-gitlab-forge") + assert.Contains(t, content, tc.wantHost) + assert.Contains(t, content, tc.wantPort) + assert.Contains(t, content, "category: source_control") + assert.Contains(t, content, "**/node") + if tc.notContains != "" { + assert.NotContains(t, content, tc.notContains) + } + }) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index a0769bc7c7..cd9ba1966b 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -35,6 +35,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/fetchsvc" "github.com/fullsend-ai/fullsend/internal/forge" gh "github.com/fullsend-ai/fullsend/internal/forge/github" + gl "github.com/fullsend-ai/fullsend/internal/forge/gitlab" "github.com/fullsend-ai/fullsend/internal/gitfetch" "github.com/fullsend-ai/fullsend/internal/harness" "github.com/fullsend-ai/fullsend/internal/lock" @@ -1090,6 +1091,23 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep // Dedupe URL-resolved providers (last-wins) so shadowed entries from // base composition don't trigger false integrity errors. result.Providers = dedupResolvedProviders(result.Providers) + + // Auto-generate a GitLab provider profile when running on a self-hosted + // GitLab instance (#6615). Prepended so that a user-defined profile + // with the same ID wins via last-wins dedup. Inserted before the + // integrity check so providers referencing this ID are valid. + if forgePlatform == "gitlab" { + if profilePath, cleanupProfile, err := generateGitLabForgeProfile(); err != nil { + printer.StepWarn("Failed to auto-generate GitLab forge profile: " + err.Error()) + } else if profilePath != "" { + defer cleanupProfile() + result.Profiles = append([]resolve.ResolvedProfile{{ + ID: "fullsend-gitlab-forge", + LocalPath: profilePath, + }}, result.Profiles...) + } + } + dirProfileIDs, err := resolve.CollectProfileIDs(filepath.Join(absFullsendDir, "profiles")) if err != nil { return fmt.Errorf("scanning profiles directory: %w", err) @@ -1527,7 +1545,19 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep // 7. Bootstrap sandbox. bootstrapStart := time.Now() printer.StepStart("Bootstrapping sandbox") - boot := newHarnessBootstrap(h, sandboxName, agentName) + // Resolve the forge egress entry for the sandbox SSRF allowlist. + // The runtime layer consumes this via SandboxHookConfig without + // importing forge-specific packages (#6615). + // NOTE: gl.ResolveForgeHostPort() is also called in + // generateGitLabForgeProfile() for the L7 proxy profile; both + // calls are deterministic env-var reads. + var forgeEgressEntry string + if forgePlatform == "gitlab" { + if host, port := gl.ResolveForgeHostPort(); host != "" { + forgeEgressEntry = host + ":" + port + } + } + boot := newHarnessBootstrap(h, sandboxName, agentName, forgeEgressEntry) if h.SecurityEnabled() { // Scan all runtime content before upload so warnings surface together. // Host files could change between scan and upload; the runner owns the host FS here. diff --git a/internal/forge/gitlab/env.go b/internal/forge/gitlab/env.go new file mode 100644 index 0000000000..e77752b328 --- /dev/null +++ b/internal/forge/gitlab/env.go @@ -0,0 +1,40 @@ +package gitlab + +import ( + "net/url" + "os" + "strings" +) + +// URLEnvVars is the ordered list of environment variables consulted +// to resolve the GitLab instance URL. Precedence matches forge_client.go. +var URLEnvVars = []string{"FULLSEND_GITLAB_URL", "GITLAB_API_URL", "CI_SERVER_URL"} + +// ResolveForgeHostPort returns the GitLab forge hostname and port from +// environment variables. URL precedence matches forge_client.go: +// FULLSEND_GITLAB_URL > GITLAB_API_URL > CI_SERVER_URL. +// When the URL does not include an explicit port, the default is +// derived from the scheme ("80" for http, "443" otherwise). +// Returns ("", "") when no URL can be resolved. +func ResolveForgeHostPort() (host, port string) { + for _, env := range URLEnvVars { + raw := strings.TrimSpace(os.Getenv(env)) + if raw == "" { + continue + } + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + continue + } + p := u.Port() + if p == "" { + if u.Scheme == "http" { + p = "80" + } else { + p = "443" + } + } + return u.Hostname(), p + } + return "", "" +} diff --git a/internal/forge/gitlab/env_test.go b/internal/forge/gitlab/env_test.go new file mode 100644 index 0000000000..f082a9988d --- /dev/null +++ b/internal/forge/gitlab/env_test.go @@ -0,0 +1,84 @@ +package gitlab + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveForgeHostPort(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantHost string + wantPort string + }{ + { + name: "CI_SERVER_URL default port", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.cee.redhat.com"}, + wantHost: "gitlab.cee.redhat.com", + wantPort: "443", + }, + { + name: "non-standard port", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.company.com:8443"}, + wantHost: "gitlab.company.com", + wantPort: "8443", + }, + { + name: "FULLSEND_GITLAB_URL takes precedence", + env: map[string]string{"FULLSEND_GITLAB_URL": "https://gitlab.company.com", "CI_SERVER_URL": "https://gitlab.other.com"}, + wantHost: "gitlab.company.com", + wantPort: "443", + }, + { + name: "GITLAB_API_URL takes precedence over CI_SERVER_URL", + env: map[string]string{"GITLAB_API_URL": "https://gitlab.api.example.com", "CI_SERVER_URL": "https://gitlab.other.com"}, + wantHost: "gitlab.api.example.com", + wantPort: "443", + }, + { + name: "no env vars", + env: map[string]string{}, + wantHost: "", + wantPort: "", + }, + { + name: "gitlab.com", + env: map[string]string{"CI_SERVER_URL": "https://gitlab.com"}, + wantHost: "gitlab.com", + wantPort: "443", + }, + { + name: "non-standard port from FULLSEND_GITLAB_URL", + env: map[string]string{"FULLSEND_GITLAB_URL": "https://gitlab.internal:9443"}, + wantHost: "gitlab.internal", + wantPort: "9443", + }, + { + name: "http scheme defaults to port 80", + env: map[string]string{"CI_SERVER_URL": "http://gitlab.internal"}, + wantHost: "gitlab.internal", + wantPort: "80", + }, + { + name: "http scheme explicit port", + env: map[string]string{"CI_SERVER_URL": "http://gitlab.internal:3000"}, + wantHost: "gitlab.internal", + wantPort: "3000", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for _, key := range URLEnvVars { + t.Setenv(key, "") + } + for k, v := range tc.env { + t.Setenv(k, v) + } + host, port := ResolveForgeHostPort() + assert.Equal(t, tc.wantHost, host) + assert.Equal(t, tc.wantPort, port) + }) + } +} diff --git a/internal/runtime/sandbox_hooks_bootstrap.go b/internal/runtime/sandbox_hooks_bootstrap.go index dfeb2fb853..5c3b246ae9 100644 --- a/internal/runtime/sandbox_hooks_bootstrap.go +++ b/internal/runtime/sandbox_hooks_bootstrap.go @@ -71,6 +71,11 @@ func appendEnvVar(buf *strings.Builder, key, value string) { // TIRITH_REQUIRED, FULLSEND_EGRESS_ALLOWLIST) to the sandbox workspace // .env so the scripts see it regardless of which runtime invokes them. // All env vars are written in a single sandbox exec call. +// +// When the CLI layer resolves a forge egress entry (e.g. the GitLab +// forge host:port), it is automatically merged into the egress allowlist +// so the SSRF pre-tool hook defers to the L7 proxy for the forge API +// (internal DNS is unavailable inside the sandbox). See #6615. func appendHookEnv(sandboxName string, hooks security.SandboxHookConfig) error { var buf strings.Builder if failOn := hooks.TirithFailOn(); failOn != "" { @@ -79,7 +84,20 @@ func appendHookEnv(sandboxName string, hooks security.SandboxHookConfig) error { if hooks.TirithRequired() { appendEnvVar(&buf, "TIRITH_REQUIRED", "1") } - if allowlist := hooks.SSRFEgressAllowlist(); allowlist != "" { + allowlist := hooks.SSRFEgressAllowlist() + // Merge the forge egress entry (resolved by the CLI layer) into the + // allowlist so the SSRF pre-tool hook defers to the L7 proxy for the + // forge's API when DNS resolution fails inside the sandbox (#6615). + if entry := hooks.ForgeEgressEntry(); entry != "" { + if allowlist == "" { + allowlist = entry + } else { + // ParseEgressAllowlist deduplicates via map, so appending + // a duplicate entry from the user config is harmless. + allowlist = allowlist + "," + entry + } + } + if allowlist != "" { appendEnvVar(&buf, "FULLSEND_EGRESS_ALLOWLIST", allowlist) } if buf.Len() == 0 { diff --git a/internal/runtime/sandbox_hooks_bootstrap_test.go b/internal/runtime/sandbox_hooks_bootstrap_test.go index 197015b02d..2c4596e610 100644 --- a/internal/runtime/sandbox_hooks_bootstrap_test.go +++ b/internal/runtime/sandbox_hooks_bootstrap_test.go @@ -174,6 +174,69 @@ func TestAppendHookEnv_EgressAllowlistEmpty(t *testing.T) { assert.NotContains(t, log, "FULLSEND_EGRESS_ALLOWLIST") } +func TestAppendHookEnv_AutoAddsForgeEgressEntry(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "openshell.log") + sentinelPath := filepath.Join(t.TempDir(), "unused.tar.gz") + fakeOpenshellBootstrap(t, logPath, sentinelPath) + + h := &harness.Harness{Security: &harness.SecurityConfig{SandboxHooks: &harness.SandboxHooks{}}} + hooks := security.SandboxHookConfigFromHarness(h).WithForgeEgressEntry("gitlab.cee.redhat.com:443") + require.NoError(t, appendHookEnv("sb", hooks)) + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err) + log := string(logBytes) + assert.Contains(t, log, "FULLSEND_EGRESS_ALLOWLIST=gitlab.cee.redhat.com:443") +} + +func TestAppendHookEnv_MergesForgeEgressEntryWithUserAllowlist(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "openshell.log") + sentinelPath := filepath.Join(t.TempDir(), "unused.tar.gz") + fakeOpenshellBootstrap(t, logPath, sentinelPath) + + h := &harness.Harness{Security: &harness.SecurityConfig{SandboxHooks: &harness.SandboxHooks{ + SSRFEgressAllowlist: "registry.internal:443", + }}} + hooks := security.SandboxHookConfigFromHarness(h).WithForgeEgressEntry("gitlab.cee.redhat.com:443") + require.NoError(t, appendHookEnv("sb", hooks)) + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err) + log := string(logBytes) + assert.Contains(t, log, "FULLSEND_EGRESS_ALLOWLIST=registry.internal:443,gitlab.cee.redhat.com:443") +} + +func TestAppendHookEnv_NoForgeEgressEntryByDefault(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "openshell.log") + sentinelPath := filepath.Join(t.TempDir(), "unused.tar.gz") + fakeOpenshellBootstrap(t, logPath, sentinelPath) + + // No forge egress entry → no auto-append. + h := &harness.Harness{Security: &harness.SecurityConfig{SandboxHooks: &harness.SandboxHooks{}}} + require.NoError(t, appendHookEnv("sb", security.SandboxHookConfigFromHarness(h))) + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err) + log := string(logBytes) + // Without a forge egress entry, only tirith env should be set. + assert.NotContains(t, log, "FULLSEND_EGRESS_ALLOWLIST") +} + +func TestAppendHookEnv_ForgeEgressEntryNonStandardPort(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "openshell.log") + sentinelPath := filepath.Join(t.TempDir(), "unused.tar.gz") + fakeOpenshellBootstrap(t, logPath, sentinelPath) + + h := &harness.Harness{Security: &harness.SecurityConfig{SandboxHooks: &harness.SandboxHooks{}}} + hooks := security.SandboxHookConfigFromHarness(h).WithForgeEgressEntry("gitlab.company.com:8443") + require.NoError(t, appendHookEnv("sb", hooks)) + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err) + log := string(logBytes) + assert.Contains(t, log, "FULLSEND_EGRESS_ALLOWLIST=gitlab.company.com:8443") +} + func TestClaudeRuntime_Bootstrap_HooksChmodFailure(t *testing.T) { // Agent upload happens first, so make only later steps fail: time out // (exit 124, the only exec failure sandbox.Exec reports) on the chmod diff --git a/internal/security/hooks_test.go b/internal/security/hooks_test.go index 29391a4803..4aaae9dc14 100644 --- a/internal/security/hooks_test.go +++ b/internal/security/hooks_test.go @@ -570,6 +570,14 @@ func TestSandboxHookConfig_SSRFEgressAllowlist(t *testing.T) { assert.Equal(t, "gitlab.internal:443,other.host:8443", cfg.SSRFEgressAllowlist()) } +func TestSandboxHookConfig_ForgeEgressEntry(t *testing.T) { + cfg := SandboxHookConfigFromHarness(nil) + assert.Empty(t, cfg.ForgeEgressEntry()) + + cfg = cfg.WithForgeEgressEntry("gitlab.cee.redhat.com:443") + assert.Equal(t, "gitlab.cee.redhat.com:443", cfg.ForgeEgressEntry()) +} + func countPhase(plan []HookGroup, phase HookPhase) int { n := 0 for _, g := range plan { diff --git a/internal/security/sandbox_hooks.go b/internal/security/sandbox_hooks.go index b4c3309dff..1b09b54f77 100644 --- a/internal/security/sandbox_hooks.go +++ b/internal/security/sandbox_hooks.go @@ -12,7 +12,8 @@ import ( // mechanism. A nil internal hook config uses the same defaults as an unset // harness security block. type SandboxHookConfig struct { - hooks *harness.SandboxHooks + hooks *harness.SandboxHooks + forgeEgressEntry string // optional "host:port" to auto-merge into the egress allowlist } // SandboxHookConfigFromHarness extracts sandbox hook settings from a harness. @@ -56,3 +57,17 @@ func (c SandboxHookConfig) SSRFEgressAllowlist() string { } return sh.SSRFEgressAllowlist } + +// WithForgeEgressEntry returns a copy of c with the forge egress entry +// set. The entry is a "host:port" string merged into the egress +// allowlist at bootstrap time. +func (c SandboxHookConfig) WithForgeEgressEntry(entry string) SandboxHookConfig { + c.forgeEgressEntry = entry + return c +} + +// ForgeEgressEntry returns the forge-specific "host:port" egress entry +// set by the CLI layer, or empty when none was configured. +func (c SandboxHookConfig) ForgeEgressEntry() string { + return c.forgeEgressEntry +}