Skip to content

Commit 93dce52

Browse files
committed
Populate Writable/Readable in node/nix toolchain guards for Landlock
Same root cause as the git-integration fix: both guards emitted file-read* file-write* Seatbelt rules but never populated the structured Writable/Readable fields that DeriveGrantedPathSet reads to build the Landlock allow-list. Under Landlock, npm install, pnpm, yarn, corepack, and nix writes all hit EACCES. node-toolchain: mirror every file-write* grant (version managers, npm, pnpm, yarn, corepack, browser testing, Prisma, Turborepo dirs) into result.Writable. macOS-only Library/... paths are included but harmless on Linux — applyLandlock stats each path and skips non-existent ones. nix-toolchain: ~/.nix-profile, ~/.local/state/nix, ~/.cache/nix go into Writable; ~/.nix-defexpr and ~/.config/nix (read-only in the Seatbelt rule) go into Readable. The daemon unix-socket rule has no Landlock equivalent and is left as-is.
1 parent be19fa4 commit 93dce52

5 files changed

Lines changed: 228 additions & 7 deletions

File tree

pkg/seatbelt/guards/export_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ var TestDirExists = dirExists
55

66
// TestPathExists exposes pathExists for testing.
77
var TestPathExists = pathExists
8+
9+
// TestNixStoreDir allows tests to override the nix store path without nix installed.
10+
var TestNixStoreDir = &nixStoreDir

pkg/seatbelt/guards/guard_nix_toolchain.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ package guards
66

77
import (
88
"fmt"
9+
"path/filepath"
910

1011
"github.com/jskswamy/aide/pkg/seatbelt"
1112
)
1213

14+
// nixStoreDir is the expected location of the Nix store. Overridable in tests.
15+
var nixStoreDir = "/nix/store"
16+
1317
type nixToolchainGuard struct{}
1418

1519
// NixToolchainGuard returns a Guard with Nix package manager sandbox rules.
@@ -23,15 +27,17 @@ func (g *nixToolchainGuard) Rules(ctx *seatbelt.Context) seatbelt.GuardResult {
2327
if ctx == nil || ctx.HomeDir == "" {
2428
return seatbelt.GuardResult{}
2529
}
26-
if !dirExists("/nix/store") {
30+
if !dirExists(nixStoreDir) {
2731
return seatbelt.GuardResult{
28-
Skipped: []string{"/nix/store not found — nix not installed"},
32+
Skipped: []string{nixStoreDir + " not found — nix not installed"},
2933
}
3034
}
3135

3236
home := ctx.HomeDir
3337

34-
return seatbelt.GuardResult{Rules: []seatbelt.Rule{
38+
var result seatbelt.GuardResult
39+
40+
result.Rules = []seatbelt.Rule{
3541
// Nix daemon socket
3642
seatbelt.SectionAllow("Nix daemon socket"),
3743
seatbelt.AllowRule(`(allow network-outbound
@@ -55,5 +61,19 @@ func (g *nixToolchainGuard) Rules(ctx *seatbelt.Context) seatbelt.GuardResult {
5561
%s
5662
)`, seatbelt.HomeSubpath(home, ".nix-defexpr"),
5763
seatbelt.HomeSubpath(home, ".config/nix"))),
58-
}}
64+
}
65+
66+
// Linux Landlock equivalents: mirror the read+write and read-only grants
67+
// into the structured fields so DeriveGrantedPathSet picks them up.
68+
result.Writable = []string{
69+
filepath.Join(home, ".nix-profile"),
70+
filepath.Join(home, ".local", "state", "nix"),
71+
filepath.Join(home, ".cache", "nix"),
72+
}
73+
result.Readable = []string{
74+
filepath.Join(home, ".nix-defexpr"),
75+
filepath.Join(home, ".config", "nix"),
76+
}
77+
78+
return result
5979
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package guards_test
2+
3+
import (
4+
"path/filepath"
5+
"slices"
6+
"strings"
7+
"testing"
8+
9+
"github.com/jskswamy/aide/pkg/seatbelt"
10+
"github.com/jskswamy/aide/pkg/seatbelt/guards"
11+
)
12+
13+
// nixStoreOverride sets TestNixStoreDir to a fake store dir for the duration
14+
// of the test and restores it on cleanup.
15+
func nixStoreOverride(t *testing.T, dir string) {
16+
t.Helper()
17+
orig := *guards.TestNixStoreDir
18+
*guards.TestNixStoreDir = dir
19+
t.Cleanup(func() { *guards.TestNixStoreDir = orig })
20+
}
21+
22+
// TestGuard_NixToolchain_PopulatesWritableAndReadableForLandlock pins the
23+
// cross-platform grant split: read+write nix user dirs must be in Writable,
24+
// and channel/config dirs must be in Readable (not Writable). Both must be
25+
// absent from the other slice.
26+
func TestGuard_NixToolchain_PopulatesWritableAndReadableForLandlock(t *testing.T) {
27+
nixStoreOverride(t, t.TempDir())
28+
29+
home := t.TempDir()
30+
g := guards.NixToolchainGuard()
31+
result := g.Rules(&seatbelt.Context{HomeDir: home})
32+
33+
mustBeWritable := []string{
34+
filepath.Join(home, ".nix-profile"),
35+
filepath.Join(home, ".local", "state", "nix"),
36+
filepath.Join(home, ".cache", "nix"),
37+
}
38+
for _, p := range mustBeWritable {
39+
if !slices.Contains(result.Writable, p) {
40+
t.Errorf("expected %q in result.Writable; got %v", p, result.Writable)
41+
}
42+
if slices.Contains(result.Readable, p) {
43+
t.Errorf("writable path must not be in Readable: %q", p)
44+
}
45+
}
46+
47+
mustBeReadable := []string{
48+
filepath.Join(home, ".nix-defexpr"),
49+
filepath.Join(home, ".config", "nix"),
50+
}
51+
for _, p := range mustBeReadable {
52+
if !slices.Contains(result.Readable, p) {
53+
t.Errorf("expected %q in result.Readable; got %v", p, result.Readable)
54+
}
55+
if slices.Contains(result.Writable, p) {
56+
t.Errorf("read-only path must not be in Writable: %q", p)
57+
}
58+
}
59+
}
60+
61+
// TestGuard_NixToolchain_SkippedWhenNixNotInstalled verifies the guard returns
62+
// a Skipped entry and no rules when the nix store is absent.
63+
func TestGuard_NixToolchain_SkippedWhenNixNotInstalled(t *testing.T) {
64+
nixStoreOverride(t, "/nonexistent/nix/store")
65+
66+
g := guards.NixToolchainGuard()
67+
result := g.Rules(&seatbelt.Context{HomeDir: t.TempDir()})
68+
69+
if len(result.Rules) != 0 {
70+
t.Error("expected no Rules when nix store absent")
71+
}
72+
if len(result.Skipped) == 0 {
73+
t.Error("expected Skipped entry when nix store absent")
74+
}
75+
if len(result.Writable) != 0 || len(result.Readable) != 0 {
76+
t.Error("expected no Writable/Readable when nix not installed")
77+
}
78+
}
79+
80+
// TestGuard_NixToolchain_RulesContent verifies the Seatbelt rule content when
81+
// nix is present: daemon socket, read-write user paths, read-only config paths.
82+
func TestGuard_NixToolchain_RulesContent(t *testing.T) {
83+
nixStoreOverride(t, t.TempDir())
84+
85+
home := t.TempDir()
86+
g := guards.NixToolchainGuard()
87+
result := g.Rules(&seatbelt.Context{HomeDir: home})
88+
89+
if len(result.Rules) == 0 {
90+
t.Fatal("expected rules when nix store present")
91+
}
92+
output := renderTestRules(result.Rules)
93+
for _, want := range []string{
94+
"network-outbound",
95+
"/nix/var/nix/daemon-socket/socket",
96+
".nix-profile",
97+
".local/state/nix",
98+
".cache/nix",
99+
".nix-defexpr",
100+
".config/nix",
101+
"file-read* file-write*",
102+
} {
103+
if !strings.Contains(output, want) {
104+
t.Errorf("expected rules to contain %q", want)
105+
}
106+
}
107+
}

pkg/seatbelt/guards/guard_node_toolchain.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
package guards
88

9-
import "github.com/jskswamy/aide/pkg/seatbelt"
9+
import (
10+
"path/filepath"
11+
12+
"github.com/jskswamy/aide/pkg/seatbelt"
13+
)
1014

1115
type nodeToolchainGuard struct{}
1216

@@ -25,7 +29,9 @@ func (g *nodeToolchainGuard) Rules(ctx *seatbelt.Context) seatbelt.GuardResult {
2529
}
2630
home := ctx.HomeDir
2731

28-
return seatbelt.GuardResult{Rules: []seatbelt.Rule{
32+
var result seatbelt.GuardResult
33+
34+
result.Rules = []seatbelt.Rule{
2935
// Node version managers
3036
seatbelt.SectionAllow("Node version managers"),
3137
seatbelt.AllowRule(`(allow file-read* file-write*
@@ -103,5 +109,44 @@ func (g *nodeToolchainGuard) Rules(ctx *seatbelt.Context) seatbelt.GuardResult {
103109
` + seatbelt.HomeSubpath(home, "Library/Caches/turbo") + `
104110
` + seatbelt.HomeSubpath(home, "Library/Application Support/turborepo") + `
105111
)`),
106-
}}
112+
}
113+
114+
// Linux Landlock equivalents: XDG/dotfile paths only — Library/... paths
115+
// are macOS-specific and belong only in Rules above.
116+
result.Writable = []string{
117+
// version managers
118+
filepath.Join(home, ".nvm"),
119+
filepath.Join(home, ".fnm"),
120+
// npm
121+
filepath.Join(home, ".npm"),
122+
filepath.Join(home, ".config", "npm"),
123+
filepath.Join(home, ".cache", "npm"),
124+
filepath.Join(home, ".cache", "node"),
125+
filepath.Join(home, ".npmrc"),
126+
filepath.Join(home, ".config", "configstore"),
127+
filepath.Join(home, ".node-gyp"),
128+
filepath.Join(home, ".cache", "node-gyp"),
129+
// pnpm
130+
filepath.Join(home, ".config", "pnpm"),
131+
filepath.Join(home, ".pnpm-state"),
132+
filepath.Join(home, ".pnpm-store"),
133+
filepath.Join(home, ".local", "share", "pnpm"),
134+
filepath.Join(home, ".local", "state", "pnpm"),
135+
// yarn
136+
filepath.Join(home, ".yarn"),
137+
filepath.Join(home, ".yarnrc"),
138+
filepath.Join(home, ".yarnrc.yml"),
139+
filepath.Join(home, ".config", "yarn"),
140+
filepath.Join(home, ".cache", "yarn"),
141+
// corepack
142+
filepath.Join(home, ".cache", "node", "corepack"),
143+
// browser testing
144+
filepath.Join(home, ".cache", "puppeteer"),
145+
// Prisma
146+
filepath.Join(home, ".cache", "prisma"),
147+
// Turborepo
148+
filepath.Join(home, ".cache", "turbo"),
149+
}
150+
151+
return result
107152
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package guards_test
2+
3+
import (
4+
"path/filepath"
5+
"slices"
6+
"testing"
7+
8+
"github.com/jskswamy/aide/pkg/seatbelt"
9+
"github.com/jskswamy/aide/pkg/seatbelt/guards"
10+
)
11+
12+
// TestGuard_NodeToolchain_PopulatesWritableForLandlock pins the cross-platform
13+
// grant: npm/pnpm/yarn dirs must appear in result.Writable, not only in the
14+
// Seatbelt Rules s-expression. The Linux Landlock backend reads the structured
15+
// Writable field; if the guard populated only Rules, npm install inside the
16+
// sandbox would fail with EACCES on writes to ~/.npm, ~/.cache/npm, etc.
17+
func TestGuard_NodeToolchain_PopulatesWritableForLandlock(t *testing.T) {
18+
home := t.TempDir()
19+
g := guards.NodeToolchainGuard()
20+
result := g.Rules(&seatbelt.Context{HomeDir: home})
21+
22+
mustBeWritable := []string{
23+
filepath.Join(home, ".nvm"),
24+
filepath.Join(home, ".fnm"),
25+
filepath.Join(home, ".npm"),
26+
filepath.Join(home, ".cache", "npm"),
27+
filepath.Join(home, ".config", "pnpm"),
28+
filepath.Join(home, ".pnpm-store"),
29+
filepath.Join(home, ".yarn"),
30+
filepath.Join(home, ".cache", "yarn"),
31+
filepath.Join(home, ".cache", "node", "corepack"),
32+
filepath.Join(home, ".cache", "puppeteer"),
33+
filepath.Join(home, ".cache", "turbo"),
34+
}
35+
for _, p := range mustBeWritable {
36+
if !slices.Contains(result.Writable, p) {
37+
t.Errorf("expected %q in result.Writable (Landlock write grant); got %v", p, result.Writable)
38+
}
39+
}
40+
// Writable paths must not leak into Readable.
41+
for _, p := range mustBeWritable {
42+
if slices.Contains(result.Readable, p) {
43+
t.Errorf("writable path must not be in Readable: %q", p)
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)