From 5b57527a83af546129404650ec4bff5695be8fec Mon Sep 17 00:00:00 2001 From: JY Tan Date: Wed, 21 Jan 2026 12:35:35 -0800 Subject: [PATCH] fix: filter directory-only Landlock rights for non-directory paths (#17) --- internal/sandbox/integration_linux_test.go | 48 ++++++++++++++++------ internal/sandbox/linux_landlock.go | 39 ++++++++++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/internal/sandbox/integration_linux_test.go b/internal/sandbox/integration_linux_test.go index b72ed3d..43d8948 100644 --- a/internal/sandbox/integration_linux_test.go +++ b/internal/sandbox/integration_linux_test.go @@ -246,19 +246,6 @@ func TestLinux_NetworkBlocksCurl(t *testing.T) { assertNetworkBlocked(t, result) } -// TestLinux_NetworkBlocksWget verifies that wget cannot reach the network. -func TestLinux_NetworkBlocksWget(t *testing.T) { - skipIfAlreadySandboxed(t) - skipIfCommandNotFound(t, "wget") - - workspace := createTempWorkspace(t) - cfg := testConfigWithWorkspace(workspace) - - result := runUnderSandboxWithTimeout(t, cfg, "wget -q --timeout=2 -O /dev/null http://example.com", workspace, 10*time.Second) - - assertBlocked(t, result) -} - // TestLinux_NetworkBlocksPing verifies that ping cannot reach the network. func TestLinux_NetworkBlocksPing(t *testing.T) { skipIfAlreadySandboxed(t) @@ -482,6 +469,41 @@ func TestLinux_ProcSelfEnvReadable(t *testing.T) { assertAllowed(t, result) } +// TestLinux_GlobPatternAllowsWriteToMatchingFile verifies that glob patterns +// like "~/.claude*" correctly allow writes to matching files (not just directories). +// The bug was that Landlock rules for files were silently failing because +// directory-only access rights (MAKE_*, REFER, etc.) were being applied. +func TestLinux_GlobPatternAllowsWriteToMatchingFile(t *testing.T) { + skipIfAlreadySandboxed(t) + + workspace := createTempWorkspace(t) + + testFile := filepath.Join(workspace, ".testglob.json") + if err := os.WriteFile(testFile, []byte(`{"initial": true}`), 0o600); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + // Configure allowWrite with a glob pattern that matches the file + cfg := testConfigWithWorkspace(workspace) + cfg.Filesystem.AllowWrite = []string{ + workspace, + filepath.Join(workspace, ".testglob*"), + } + + // Try to append to the file (shouldn't fail) + result := runUnderSandbox(t, cfg, "echo 'appended' >> "+testFile, workspace) + + assertAllowed(t, result) + + content, err := os.ReadFile(testFile) //nolint:gosec + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + if !strings.Contains(string(content), "appended") { + t.Errorf("expected file to contain 'appended', got: %s", string(content)) + } +} + // ============================================================================ // Helper functions // ============================================================================ diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go index 5b54eb9..73f9bc9 100644 --- a/internal/sandbox/linux_landlock.go +++ b/internal/sandbox/linux_landlock.go @@ -323,9 +323,48 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error { } defer func() { _ = unix.Close(fd) }() + // Use fstat on the fd to avoid TOCTOU race between stat and open + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + if l.debug { + fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to fstat path %s: %v\n", absPath, err) + } + return nil + } + isDir := (stat.Mode & unix.S_IFMT) == unix.S_IFDIR + // Intersect with handled access to avoid invalid combinations access &= l.getHandledAccessFS() + // Filter out directory-only access rights for non-directory paths (files, sockets, devices, etc.). + // Landlock returns EINVAL if you try to add MAKE_*, REMOVE_*, READ_DIR, or REFER rights to a non-directory. + // See: https://docs.kernel.org/userspace-api/landlock.html + // File-compatible rights: EXECUTE, WRITE_FILE, READ_FILE, TRUNCATE + // Directory-only rights: READ_DIR, REMOVE_*, MAKE_*, REFER + if !isDir { + dirOnlyRights := uint64( + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_REFER, + ) + access &^= dirOnlyRights + } + + if access == 0 { + if l.debug { + fmt.Fprintf(os.Stderr, "[fence:landlock] Skipping %s: no applicable access rights\n", absPath) + } + return nil + } + attr := landlockPathBeneathAttr{ allowedAccess: access, parentFd: int32(fd), //nolint:gosec // fd from unix.Open fits in int32