From 5a45485534c6c7f8ac7a02a5c33704d061a6d82d Mon Sep 17 00:00:00 2001 From: alex289 Date: Thu, 29 Oct 2026 19:48:56 +0100 Subject: [PATCH 1/2] fix(agent): grant DAC_READ_SEARCH to read restricted deployment dirs The agent container could fail to read deployment files when an application's own containers restrict permissions on their mounted directories, since the agent dropped all Linux capabilities and had no way to bypass directory access checks owned by another user. Add the DAC_READ_SEARCH capability back to the agent container so it can inspect deployment paths regardless of directory permissions set by application containers, and update e2e coverage and the generated compose snippet to match. --- docker-compose.yml | 3 ++ e2e/docker-compose.e2e.yml | 6 ++++ e2e/tests/agent.test.ts | 2 ++ e2e/tests/applications.test.ts | 34 +++++++++++++++++-- e2e/tests/helpers/docker.ts | 2 +- .../components/dialogs/agent-setup-dialog.tsx | 3 ++ 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f0f8b953..df32644a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,9 @@ services: - no-new-privileges:true cap_drop: - ALL + cap_add: + # Inspect deployment paths even when application containers restrict directory permissions. + - DAC_READ_SEARCH use_api_socket: true # Allows container to access Docker API on host volumes: - ./deployments:/deployments diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 1057c116..932f0a13 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -29,6 +29,12 @@ services: profiles: - agent container_name: orca-agent-e2e-test + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - DAC_READ_SEARCH use_api_socket: true volumes: - ./data/agent/deployments:/deployments diff --git a/e2e/tests/agent.test.ts b/e2e/tests/agent.test.ts index 6c2fcf09..a34c848f 100644 --- a/e2e/tests/agent.test.ts +++ b/e2e/tests/agent.test.ts @@ -136,6 +136,8 @@ test.describe("Agent setup", () => { const compose = await setup.locator("pre").innerText(); expect(compose).toContain(`HUB_URL: ${APP_URL}`); expect(compose).toMatch(/image: ghcr\.io\/orcacd\/agent:\S+/); + expect(compose).toMatch(/cap_drop:\s+- ALL/); + expect(compose).toMatch(/cap_add:\s+(?:#[^\n]*\n\s+)?- DAC_READ_SEARCH/); authToken = compose.match(/AUTH_TOKEN: (\S+)/)?.[1] ?? ""; expect(authToken).not.toBe(""); await setup.getByRole("button", { name: "Close" }).click(); diff --git a/e2e/tests/applications.test.ts b/e2e/tests/applications.test.ts index 3ec3437d..78820ee0 100644 --- a/e2e/tests/applications.test.ts +++ b/e2e/tests/applications.test.ts @@ -15,6 +15,7 @@ import { pullImage, removeApplicationContainers, removeVolumes, + runWithAgentVolumes, startContainer, stopContainer, } from "./helpers/docker.ts"; @@ -130,7 +131,7 @@ const SAFE_VOLUME = `${SAFE_PROJECT}_data`; const safeCompose = policyComposeFile( ` volumes: - data:/named - - ./bound:/bound + - ./bound/data:/bound `, `volumes: data: @@ -944,7 +945,7 @@ test.describe("Compose security policy", () => { const bind = mounts.find((mount) => mount.Destination === "/bound"); expect(bind?.Type).toBe("bind"); // The agent translates the path inside its container to the one on the Docker host - expect(bind?.Source).toMatch(new RegExp(`/${SAFE_PROJECT}/bound$`)); + expect(bind?.Source).toMatch(new RegExp(`/${SAFE_PROJECT}/bound/data$`)); await expect .poll(() => execInContainer(container.id, ["cat", "/named/file"]), { timeout: 15_000 }) @@ -955,6 +956,35 @@ test.describe("Compose security policy", () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ type: "deployment", status: "succeeded" }); }); + + test("redeploys through a directory restricted to an application user", async ({ page }) => { + const id = policyAppIds.get(SAFE_NAME)!; + const parent = `/deployments/${SAFE_PROJECT}/bound`; + const restrict = runWithAgentVolumes([ + "sh", + "-ec", + `chown 1234:1234 ${parent}; chmod 700 ${parent}`, + ]); + expect(restrict.status, restrict.stderr).toBe(0); + try { + const before = (await applicationEvents(page, id)).length; + expect((await api(page, "POST", `/applications/${id}/deploy`)).status).toBe(202); + await expect + .poll(async () => (await applicationEvents(page, id)).length, { timeout: 60_000 }) + .toBe(before + 1); + await expect + .poll(async () => (await applicationEvents(page, id))[0].status, { timeout: 60_000 }) + .toBe("succeeded"); + expect((await getApplication(page, id)).lastSyncError).toBeUndefined(); + } finally { + const restore = runWithAgentVolumes([ + "sh", + "-ec", + `chmod 755 ${parent}; chown 0:0 ${parent}`, + ]); + expect(restore.status, restore.stderr).toBe(0); + } + }); }); test.describe("Deployment notifications", () => { diff --git a/e2e/tests/helpers/docker.ts b/e2e/tests/helpers/docker.ts index 45bed630..41125abc 100644 --- a/e2e/tests/helpers/docker.ts +++ b/e2e/tests/helpers/docker.ts @@ -125,7 +125,7 @@ export function inspectContainer(id: string) { // The deployments directory is owned by root on Linux hosts and the agent image has no shell, // so a throwaway container with the agent's volumes reads it -function runWithAgentVolumes(command: string[]) { +export function runWithAgentVolumes(command: string[]) { return spawnSync( "docker", ["run", "--rm", "--volumes-from", AGENT_CONTAINER, BUSYBOX_IMAGE, ...command], diff --git a/frontend/src/components/dialogs/agent-setup-dialog.tsx b/frontend/src/components/dialogs/agent-setup-dialog.tsx index ebdcf65d..410eaba2 100644 --- a/frontend/src/components/dialogs/agent-setup-dialog.tsx +++ b/frontend/src/components/dialogs/agent-setup-dialog.tsx @@ -49,6 +49,9 @@ export function buildAgentComposeFile({ - no-new-privileges:true cap_drop: - ALL + cap_add: + # Inspect deployment paths even when application containers restrict directory permissions. + - DAC_READ_SEARCH volumes: - ./deployments:/deployments `; From bedc9efc4455f482d759fcd260c2358204671be4 Mon Sep 17 00:00:00 2001 From: alex289 Date: Fri, 30 Oct 2026 12:01:03 +0100 Subject: [PATCH 2/2] fix(agent): make restricted directory access opt-in --- README.md | 6 ++++ .../internal/agent/docker/deployment_paths.go | 3 ++ .../agent/docker/deployment_paths_test.go | 31 +++++++++++++++++++ docker-compose.yml | 7 +++-- e2e/docker-compose.e2e.yml | 1 + e2e/tests/agent.test.ts | 3 +- .../components/dialogs/agent-setup-dialog.tsx | 7 +++-- 7 files changed, 51 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 211d872d..ad0f0791 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ OrcaCD is a simple GitOps tool for Docker. It allows you to deploy your applicat You can get started with OrcaCD by following the guide in the [documentation](https://orcacd.dev). +### Restricted deployment directories + +The agent checks deployment paths for symlinks before deploying. A container can create a symlink in a writable bind mount and use it as a mount source on a later deployment to access paths outside its application directory. If directory permissions prevent the agent from inspecting a path, the deployment is rejected. + +Prefer granting the agent search permission on the affected parent directories. If that is impractical, you can uncomment the `cap_add` block for `DAC_READ_SEARCH` in the agent's Compose configuration and recreate the agent container. This optional Linux capability bypasses file read and directory read/search permissions for **all files accessible to the agent**, not just the deployments directory. It does not bypass write permissions. It is disabled by default; symlink checks remain enabled. + ## Contribute You're very welcome to contribute to OrcaCD! Please follow the [contribution guide](https://github.com/OrcaCD/orca-cd/blob/main/CONTRIBUTING.md) to get started. If you wish to contribute to the documentation head over to the [repository](https://github.com/OrcaCD/docs). diff --git a/backend/internal/agent/docker/deployment_paths.go b/backend/internal/agent/docker/deployment_paths.go index 94bfe2e5..f5c410de 100644 --- a/backend/internal/agent/docker/deployment_paths.go +++ b/backend/internal/agent/docker/deployment_paths.go @@ -28,6 +28,9 @@ func deploymentPathViolation(path, deploymentsDir, applicationDir string) string } link, err := firstSymlink(deploymentsDir, path) if err != nil { + if errors.Is(err, os.ErrPermission) { + return fmt.Sprintf("could not be checked for symlinks: %v; allow the agent to search the parent directories, or opt in to the agent's DAC_READ_SEARCH capability (bypasses read/search permissions for all files accessible to the agent)", err) + } return fmt.Sprintf("could not be checked for symlinks: %v", err) } if link != "" { diff --git a/backend/internal/agent/docker/deployment_paths_test.go b/backend/internal/agent/docker/deployment_paths_test.go index acb2b29c..5c5b09c0 100644 --- a/backend/internal/agent/docker/deployment_paths_test.go +++ b/backend/internal/agent/docker/deployment_paths_test.go @@ -1,6 +1,7 @@ package docker import ( + "errors" "os" "path/filepath" "strings" @@ -9,6 +10,36 @@ import ( composetypes "github.com/compose-spec/compose-go/v2/types" ) +func TestDeploymentPaths_RejectsInaccessiblePathsWithGuidance(t *testing.T) { + rules := setupDeploymentDirs(t) + parent := filepath.Join(rules.applicationDir, "data") + source := filepath.Join(parent, "private") + if err := os.Mkdir(source, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Chmod(parent, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + //nolint:gosec // Restore directory traversal so t.TempDir can clean up. + if err := os.Chmod(parent, 0o750); err != nil { + t.Error(err) + } + }) + if _, err := os.Lstat(source); !errors.Is(err, os.ErrPermission) { + if err == nil { + t.Skip("process can bypass directory permissions") + } + t.Fatal(err) + } + + err := checkDeployPolicy(bindMountProject(source), rules) + requireErrorContains(t, err, "could not be checked for symlinks") + requireErrorContains(t, err, "allow the agent to search the parent directories") + requireErrorContains(t, err, "opt in to the agent's DAC_READ_SEARCH capability") + requireErrorContains(t, err, "bypasses read/search permissions for all files accessible to the agent") +} + func setupDeploymentDirs(t *testing.T) pathRules { t.Helper() deploymentsDir := t.TempDir() diff --git a/docker-compose.yml b/docker-compose.yml index df32644a..5c7b11bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,9 +22,10 @@ services: - no-new-privileges:true cap_drop: - ALL - cap_add: - # Inspect deployment paths even when application containers restrict directory permissions. - - DAC_READ_SEARCH + # Optional: inspect deployment paths inside directories restricted by application containers. + # This bypasses read/search permissions for all files accessible to the agent. + # cap_add: + # - DAC_READ_SEARCH use_api_socket: true # Allows container to access Docker API on host volumes: - ./deployments:/deployments diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 932f0a13..2788f016 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -34,6 +34,7 @@ services: cap_drop: - ALL cap_add: + # Exercise the optional capability for restricted deployment directories. - DAC_READ_SEARCH use_api_socket: true volumes: diff --git a/e2e/tests/agent.test.ts b/e2e/tests/agent.test.ts index a34c848f..0e477a36 100644 --- a/e2e/tests/agent.test.ts +++ b/e2e/tests/agent.test.ts @@ -137,7 +137,8 @@ test.describe("Agent setup", () => { expect(compose).toContain(`HUB_URL: ${APP_URL}`); expect(compose).toMatch(/image: ghcr\.io\/orcacd\/agent:\S+/); expect(compose).toMatch(/cap_drop:\s+- ALL/); - expect(compose).toMatch(/cap_add:\s+(?:#[^\n]*\n\s+)?- DAC_READ_SEARCH/); + expect(compose).not.toMatch(/^\s*cap_add:/m); + expect(compose).toMatch(/# cap_add:\s+#\s+- DAC_READ_SEARCH/); authToken = compose.match(/AUTH_TOKEN: (\S+)/)?.[1] ?? ""; expect(authToken).not.toBe(""); await setup.getByRole("button", { name: "Close" }).click(); diff --git a/frontend/src/components/dialogs/agent-setup-dialog.tsx b/frontend/src/components/dialogs/agent-setup-dialog.tsx index 410eaba2..1d85bbe9 100644 --- a/frontend/src/components/dialogs/agent-setup-dialog.tsx +++ b/frontend/src/components/dialogs/agent-setup-dialog.tsx @@ -49,9 +49,10 @@ export function buildAgentComposeFile({ - no-new-privileges:true cap_drop: - ALL - cap_add: - # Inspect deployment paths even when application containers restrict directory permissions. - - DAC_READ_SEARCH + # Optional: inspect deployment paths inside directories restricted by application containers. + # This bypasses read/search permissions for all files accessible to the agent. + # cap_add: + # - DAC_READ_SEARCH volumes: - ./deployments:/deployments `;