Skip to content

Commit 1ab2de3

Browse files
authored
feat(cli): --allow-path / --allow-read-path for per-session filesystem grants (#100)
* feat(cli): --allow-path / --allow-read-path for per-session filesystem grants Add two repeatable flags that grant filesystem access to an extra directory or file for a single session, without authoring a profile: --allow-path read+write (appended to AllowRead + AllowWrite) --allow-read-path read-only (appended to AllowRead only) Both accept a directory or a file and reuse the existing per-session AllowRead/AllowWrite plumbing, so they work on Linux (bubblewrap + Landlock) and macOS (Seatbelt) with no sandbox-layer changes. Grants are applied after profile merge and watch overrides, and nothing is persisted. Non-existent paths are tolerated, matching --allow. Tests: unit test for the flag-merge helper, macOS Seatbelt rule test (rw vs read-only, incl. a file case), Linux bind-mount test, and smoke tests covering write-allowed, read-only (write denied), and a negative control. * fix(linux): correct session allow-path test + extract writableBindArgs The Linux test asserted buildDenyByDefaultMounts emits a writable --bind for --allow-path, but that function only does read-only binds; the writable --bind for AllowWrite lives in WrapCommandLinuxWithOptions. Extract that inline logic into writableBindArgs so the test can verify both layers (read --ro-bind for all grants, writable --bind only for --allow-path). Also fix gosec G301 by tightening test dir perms to 0o750. * docs(readme): document --allow-path / --allow-read-path session grants * docs(readme): show --allow-path / --allow-read-path in basic commands
1 parent 6a95565 commit 1ab2de3

8 files changed

Lines changed: 345 additions & 33 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ greywall --proxy socks5://localhost:1080 -- npm install
124124
# Expose a port for inbound connections (e.g., dev servers)
125125
greywall -p 3000 -c "npm run dev"
126126

127+
# Grant an extra directory/file for this run (read+write, or read-only)
128+
greywall --allow-path /tmp/work -- mytool
129+
greywall --allow-read-path /data/refs -- mytool
130+
127131
# Enable debug logging
128132
greywall -d -- curl https://example.com
129133

cmd/greywall/main.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ var (
5555
ignoreVars []string
5656
skipVersionCheck bool
5757
allowDests []string
58+
allowPaths []string
59+
allowReadPaths []string
5860
blankProfile bool
5961
watch bool
6062
)
@@ -99,6 +101,8 @@ Examples:
99101
greywall -p 3000 -c "npm run dev" # Expose port 3000
100102
greywall -f 5432 -- psql -h localhost # Forward host port into sandbox
101103
greywall --learning -- opencode # Learn filesystem needs
104+
greywall --allow-path /tmp/work -- mytool # Grant rw to an extra dir/file for this run
105+
greywall --allow-read-path /data/refs -- mytool # Grant read-only to a dir/file for this run
102106
greywall --secret MY_VAR -- command # Protect a custom env var
103107
greywall --inject ANTHROPIC_API_KEY -- command # Inject from proxy dashboard
104108
@@ -146,6 +150,8 @@ Configuration file format:
146150
rootCmd.Flags().BoolVar(&skipVersionCheck, "skip-version-check", false, "Skip greyproxy version check (for testing)")
147151
_ = rootCmd.Flags().MarkHidden("skip-version-check")
148152
rootCmd.Flags().StringArrayVar(&allowDests, "allow", nil, "Allow a network destination for this session (e.g. --allow api.example.com:443)")
153+
rootCmd.Flags().StringArrayVar(&allowPaths, "allow-path", nil, "Allow read+write access to a directory or file for this session (repeatable, e.g. --allow-path /tmp/work)")
154+
rootCmd.Flags().StringArrayVar(&allowReadPaths, "allow-read-path", nil, "Allow read-only access to a directory or file for this session (repeatable, e.g. --allow-read-path /data/refs)")
149155
rootCmd.Flags().BoolVar(&blankProfile, "blank", false, "With --learning, skip default profile network rules (start from scratch)")
150156
rootCmd.Flags().BoolVar(&watch, "watch", false, "Watch mode: skip profile loading, allow all network (logged on dashboard), permissive filesystem — observability only, no deny-by-default")
151157

@@ -409,6 +415,16 @@ func runCommand(cmd *cobra.Command, args []string) error {
409415
fmt.Fprintf(os.Stderr, "[greywall] Watch mode: no profile, all network allowed (logged on dashboard), permissive filesystem\n")
410416
}
411417

418+
// Session-scoped filesystem grants from --allow-path / --allow-read-path.
419+
// Applied after profile merge and watch overrides so they always take effect.
420+
// Nothing is persisted; the sandbox resolves and binds these paths for this
421+
// run only. Non-existent paths are tolerated (Linux skips them, macOS Seatbelt
422+
// ignores them), matching the lenient behaviour of --allow.
423+
applySessionAllowPaths(cfg, allowPaths, allowReadPaths)
424+
if debug && (len(allowPaths) > 0 || len(allowReadPaths) > 0) {
425+
fmt.Fprintf(os.Stderr, "[greywall] Session allow: %d rw, %d read-only path(s)\n", len(allowPaths), len(allowReadPaths))
426+
}
427+
412428
// Learning mode setup
413429
if learning {
414430
if err := sandbox.CheckLearningAvailable(); err != nil {
@@ -1428,6 +1444,21 @@ parseCommand:
14281444
}
14291445
}
14301446

1447+
// applySessionAllowPaths grants session-scoped filesystem access from the
1448+
// --allow-path (read+write) and --allow-read-path (read-only) CLI flags.
1449+
// Read-write paths are appended to both AllowRead and AllowWrite; read-only
1450+
// paths are appended to AllowRead only. The underlying sandbox plumbing
1451+
// (NormalizePath + per-platform binding) handles directories and files alike.
1452+
func applySessionAllowPaths(cfg *config.Config, rwPaths, roPaths []string) {
1453+
if len(roPaths) > 0 {
1454+
cfg.Filesystem.AllowRead = append(cfg.Filesystem.AllowRead, roPaths...)
1455+
}
1456+
if len(rwPaths) > 0 {
1457+
cfg.Filesystem.AllowRead = append(cfg.Filesystem.AllowRead, rwPaths...)
1458+
cfg.Filesystem.AllowWrite = append(cfg.Filesystem.AllowWrite, rwPaths...)
1459+
}
1460+
}
1461+
14311462
// mergeUnique combines two string slices, removing duplicates while preserving order.
14321463
func mergeUnique(a, b []string) []string {
14331464
if len(a) == 0 {

cmd/greywall/main_test.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,85 @@
11
// Package main implements the greywall CLI.
22
package main
33

4-
import "testing"
4+
import (
5+
"slices"
6+
"testing"
7+
8+
"github.com/GreyhavenHQ/greywall/internal/config"
9+
)
10+
11+
// TestApplySessionAllowPaths verifies that --allow-path grants read+write while
12+
// --allow-read-path grants read-only, both appended to the session config.
13+
func TestApplySessionAllowPaths(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
rwPaths []string
17+
roPaths []string
18+
baseRead []string
19+
baseWrite []string
20+
wantRead []string
21+
wantWrite []string
22+
}{
23+
{
24+
name: "no flags leaves config untouched",
25+
wantRead: nil,
26+
wantWrite: nil,
27+
},
28+
{
29+
name: "allow-path adds to both read and write",
30+
rwPaths: []string{"/tmp/work"},
31+
wantRead: []string{"/tmp/work"},
32+
wantWrite: []string{"/tmp/work"},
33+
},
34+
{
35+
name: "allow-read-path adds to read only",
36+
roPaths: []string{"/data/refs"},
37+
wantRead: []string{"/data/refs"},
38+
wantWrite: nil,
39+
},
40+
{
41+
name: "both flags combine: rw in both, ro in read only",
42+
rwPaths: []string{"/tmp/out"},
43+
roPaths: []string{"/data/refs", "/data/reference.csv"},
44+
wantRead: []string{"/data/refs", "/data/reference.csv", "/tmp/out"},
45+
wantWrite: []string{"/tmp/out"},
46+
},
47+
{
48+
name: "appends to existing config paths",
49+
rwPaths: []string{"/tmp/work"},
50+
baseRead: []string{"/existing/read"},
51+
baseWrite: []string{"/existing/write"},
52+
wantRead: []string{"/existing/read", "/tmp/work"},
53+
wantWrite: []string{"/existing/write", "/tmp/work"},
54+
},
55+
{
56+
name: "multiple rw paths repeatable",
57+
rwPaths: []string{"/tmp/a", "/tmp/b"},
58+
wantRead: []string{"/tmp/a", "/tmp/b"},
59+
wantWrite: []string{"/tmp/a", "/tmp/b"},
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
cfg := &config.Config{
66+
Filesystem: config.FilesystemConfig{
67+
AllowRead: tt.baseRead,
68+
AllowWrite: tt.baseWrite,
69+
},
70+
}
71+
72+
applySessionAllowPaths(cfg, tt.rwPaths, tt.roPaths)
73+
74+
if !slices.Equal(cfg.Filesystem.AllowRead, tt.wantRead) {
75+
t.Errorf("AllowRead = %v, want %v", cfg.Filesystem.AllowRead, tt.wantRead)
76+
}
77+
if !slices.Equal(cfg.Filesystem.AllowWrite, tt.wantWrite) {
78+
t.Errorf("AllowWrite = %v, want %v", cfg.Filesystem.AllowWrite, tt.wantWrite)
79+
}
80+
})
81+
}
82+
}
583

684
// TestProxyIdentity is the regression test for #96. The greyproxy session
785
// container name and the SOCKS5 login are both derived from proxyIdentity, so

docs/cli-reference.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ greywall <subcommand> [args...]
2929
| `--debug` | `-d` | Verbose output: proxy activity, filter decisions, sandbox command |
3030
| `--monitor` | `-m` | Show only violations and blocked requests (audit mode) |
3131
| `--learning` | | Trace filesystem access with strace/eslogger and auto-generate a profile |
32+
| `--allow-path <path>` | | Grant read+write access to a directory **or** file for this session only (repeatable). Nothing is persisted. See [below](#--allow-path-and---allow-read-path). |
33+
| `--allow-read-path <path>` | | Grant read-only access to a directory **or** file for this session only (repeatable). Nothing is persisted. |
3234
| `--secret <VAR>` | | Treat an environment variable as a credential even if it doesn't match the auto-detection rules (repeatable). See [Credential Protection](./credential-protection). |
3335
| `--inject <LABEL>` | | Inject a credential stored in the greyproxy dashboard into the sandbox by label (repeatable) |
3436
| `--ignore-secret <VAR>` | | Exclude a variable from credential detection even if it matches the heuristics (repeatable) |
@@ -74,6 +76,31 @@ greywall -f 5432 -f 6379 -- make test
7476

7577
See [Concepts](./concepts#port-forwarding-platform-differences) for the full explanation of the platform difference.
7678

79+
### `--allow-path` and `--allow-read-path`
80+
81+
Greywall is deny-by-default for the filesystem: a sandboxed command can only touch the current working directory (plus system paths). When you just need one extra directory or file for a single run — a scratch/temp dir, a sibling project, a reference dataset — these flags grant it **for that session only**. Nothing is written to disk and no profile is created or modified; for a persistent grant, use `filesystem.allowRead` / `filesystem.allowWrite` in your [config](./configuration).
82+
83+
- `--allow-path` grants **read+write**.
84+
- `--allow-read-path` grants **read-only** (writes stay blocked).
85+
86+
Both are repeatable and accept either a **directory or a file**. Paths may be absolute, relative (resolved against the CWD), or `~`-prefixed.
87+
88+
```bash
89+
# Read+write scratch directory for this run
90+
greywall --allow-path /tmp/work -- mytool
91+
92+
# Several extra paths at once
93+
greywall --allow-path /tmp/work --allow-path ~/.cache/foo -- mytool
94+
95+
# Read-only reference data (a single file here); writes to it are denied
96+
greywall --allow-read-path /data/reference.csv -- mytool
97+
98+
# Mix: read-only inputs, read+write output
99+
greywall --allow-read-path /data/refs --allow-path /tmp/out -- mytool
100+
```
101+
102+
These compose with profiles — they are added on top of whatever a profile already allows.
103+
77104
## Subcommands
78105

79106
### `greywall check`

internal/sandbox/linux.go

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,49 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, dbusBridge *DbusBr
10061006
return args
10071007
}
10081008

1009+
// writableBindArgs returns bwrap --bind args that make the configured writable
1010+
// paths actually writable, overriding the read-only root. It covers the default
1011+
// system write paths plus cfg.Filesystem.AllowWrite (which includes the
1012+
// read+write grants from --allow-path). These binds are appended after the
1013+
// read-only binds from buildDenyByDefaultMounts, so for a path that is both
1014+
// readable and writable the later --bind wins. Only existing paths are bound;
1015+
// bwrap rejects a missing source.
1016+
func writableBindArgs(cfg *config.Config) []string {
1017+
writablePaths := make(map[string]bool)
1018+
1019+
// Default write paths (system paths needed for operation).
1020+
for _, p := range GetDefaultWritePaths() {
1021+
// Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs).
1022+
if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
1023+
continue
1024+
}
1025+
writablePaths[p] = true
1026+
}
1027+
1028+
// User-specified allowWrite paths.
1029+
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
1030+
for _, p := range ExpandGlobPatterns(cfg.Filesystem.AllowWrite) {
1031+
writablePaths[p] = true
1032+
}
1033+
1034+
// Non-glob paths, normalized.
1035+
for _, p := range cfg.Filesystem.AllowWrite {
1036+
normalized := NormalizePath(p)
1037+
if !ContainsGlobChars(normalized) {
1038+
writablePaths[normalized] = true
1039+
}
1040+
}
1041+
}
1042+
1043+
var args []string
1044+
for p := range writablePaths {
1045+
if fileExists(p) {
1046+
args = append(args, "--bind", p, p)
1047+
}
1048+
}
1049+
return args
1050+
}
1051+
10091052
// isSystemMountPoint returns true if the path is a top-level system directory
10101053
// that gets mounted directly under --tmpfs / (bwrap auto-creates these).
10111054
func isSystemMountPoint(path string) bool {
@@ -1171,39 +1214,8 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
11711214
// deny (the sandbox is already permissive with home + cwd writable).
11721215
if !opts.Learning && !opts.Watch {
11731216

1174-
writablePaths := make(map[string]bool)
1175-
1176-
// Add default write paths (system paths needed for operation)
1177-
for _, p := range GetDefaultWritePaths() {
1178-
// Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs)
1179-
if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
1180-
continue
1181-
}
1182-
writablePaths[p] = true
1183-
}
1184-
1185-
// Add user-specified allowWrite paths
1186-
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
1187-
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
1188-
for _, p := range expandedPaths {
1189-
writablePaths[p] = true
1190-
}
1191-
1192-
// Add non-glob paths
1193-
for _, p := range cfg.Filesystem.AllowWrite {
1194-
normalized := NormalizePath(p)
1195-
if !ContainsGlobChars(normalized) {
1196-
writablePaths[normalized] = true
1197-
}
1198-
}
1199-
}
1200-
12011217
// Make writable paths actually writable (override read-only root)
1202-
for p := range writablePaths {
1203-
if fileExists(p) {
1204-
bwrapArgs = append(bwrapArgs, "--bind", p, p)
1205-
}
1206-
}
1218+
bwrapArgs = append(bwrapArgs, writableBindArgs(cfg)...)
12071219

12081220
// Handle denyRead paths - hide them
12091221
// For directories: use --tmpfs to replace with empty tmpfs

internal/sandbox/macos_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,48 @@ func TestMacOS_DenyReadUserPaths(t *testing.T) {
440440
}
441441
}
442442

443+
// TestMacOS_SessionAllowPaths verifies the Seatbelt profile distinguishes
444+
// read+write grants (--allow-path) from read-only grants (--allow-read-path):
445+
// a read-write path gets both a read-data and a file-write* allow, while a
446+
// read-only path gets a read-data allow but NO file-write* allow. Covers both
447+
// a directory and a single file (the file case must not get a write rule).
448+
func TestMacOS_SessionAllowPaths(t *testing.T) {
449+
rwDir := "/home/user/scratch"
450+
roDir := "/home/user/reference"
451+
roFile := "/home/user/reference.csv"
452+
453+
params := MacOSSandboxParams{
454+
Command: "echo test",
455+
DefaultDenyRead: true,
456+
Cwd: "/home/user/project",
457+
// --allow-path appends to both read and write; --allow-read-path to read only.
458+
ReadAllowPaths: []string{roDir, roFile, rwDir},
459+
WriteAllowPaths: []string{rwDir},
460+
}
461+
462+
profile := GenerateSandboxProfile(params)
463+
464+
// Read-write path: present as both a read allow and a write allow.
465+
if !strings.Contains(profile, fmt.Sprintf(`(subpath %q)`, rwDir)) {
466+
t.Errorf("rw path %q missing read allow\nProfile:\n%s", rwDir, profile)
467+
}
468+
wantWrite := fmt.Sprintf("(allow file-write*\n (subpath %q)", rwDir)
469+
if !strings.Contains(profile, wantWrite) {
470+
t.Errorf("rw path %q missing file-write* allow\nExpected: %s\nProfile:\n%s", rwDir, wantWrite, profile)
471+
}
472+
473+
// Read-only directory and file: read allow present, write allow absent.
474+
for _, ro := range []string{roDir, roFile} {
475+
if !strings.Contains(profile, fmt.Sprintf(`(subpath %q)`, ro)) {
476+
t.Errorf("read-only path %q missing read allow\nProfile:\n%s", ro, profile)
477+
}
478+
unwantedWrite := fmt.Sprintf("(allow file-write*\n (subpath %q)", ro)
479+
if strings.Contains(profile, unwantedWrite) {
480+
t.Errorf("read-only path %q must NOT have a file-write* allow\nProfile:\n%s", ro, profile)
481+
}
482+
}
483+
}
484+
443485
// TestExpandMacOSTmpPaths verifies that /tmp and /private/tmp paths are properly mirrored.
444486
func TestExpandMacOSTmpPaths(t *testing.T) {
445487
tests := []struct {

0 commit comments

Comments
 (0)