Skip to content

Commit 6cf981b

Browse files
authored
Merge pull request #59 from VibePod/issue-58
Implement explicit directory permissions
2 parents 2c6b362 + 74320cd commit 6cf981b

5 files changed

Lines changed: 381 additions & 0 deletions

File tree

src/vibepod/commands/config.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
import yaml
1212

1313
from vibepod.constants import SUPPORTED_AGENTS
14+
from vibepod.core.allowed_dirs import (
15+
add_allowed_dir,
16+
is_protected_dir,
17+
load_allowed_dirs,
18+
remove_allowed_dir,
19+
)
1420
from vibepod.core.config import get_config, get_global_config_path, get_project_config_path
1521
from vibepod.utils.console import console, error, success
1622

@@ -142,3 +148,69 @@ def path(
142148
print(f"Global: {global_path}")
143149
print(f"Project: {project_path}")
144150
print(f"Logs: {logs_path}")
151+
152+
153+
@app.command("allow-dir")
154+
def allow_dir(
155+
directory: Annotated[
156+
Path | None,
157+
typer.Argument(help="Directory to allow (defaults to current directory)"),
158+
] = None,
159+
) -> None:
160+
"""Add a directory to the vp run allow list."""
161+
try:
162+
target = (directory or Path.cwd()).expanduser().resolve()
163+
except (OSError, ValueError) as exc:
164+
error(f"Could not resolve directory path: {exc}")
165+
raise typer.Exit(1) from exc
166+
if not target.exists() or not target.is_dir():
167+
error(f"Not a valid directory: {target}")
168+
raise typer.Exit(1)
169+
if is_protected_dir(target):
170+
error(
171+
f"'{target}' is a protected directory (home or root) and cannot be added "
172+
"to the allow list."
173+
)
174+
raise typer.Exit(1)
175+
try:
176+
add_allowed_dir(target)
177+
except OSError as exc:
178+
error(f"Could not update allow list: {exc}")
179+
raise typer.Exit(1) from exc
180+
success(f"Allowed: {target}")
181+
182+
183+
@app.command("remove-dir")
184+
def remove_dir(
185+
directory: Annotated[
186+
Path | None,
187+
typer.Argument(help="Directory to remove (defaults to current directory)"),
188+
] = None,
189+
) -> None:
190+
"""Remove a directory from the vp run allow list."""
191+
try:
192+
target = (directory or Path.cwd()).expanduser().resolve()
193+
except (OSError, ValueError) as exc:
194+
error(f"Could not resolve directory path: {exc}")
195+
raise typer.Exit(1) from exc
196+
try:
197+
removed = remove_allowed_dir(target)
198+
except OSError as exc:
199+
error(f"Could not update allow list: {exc}")
200+
raise typer.Exit(1) from exc
201+
if removed:
202+
success(f"Removed: {target}")
203+
else:
204+
error(f"Directory not in allow list: {target}")
205+
raise typer.Exit(1)
206+
207+
208+
@app.command("list-allowed-dirs")
209+
def list_allowed_dirs() -> None:
210+
"""List all directories in the vp run allow list."""
211+
dirs = load_allowed_dirs()
212+
if not dirs:
213+
console.print("No directories in the allow list.")
214+
return
215+
for d in dirs:
216+
console.print(d)

src/vibepod/commands/run.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_agent_spec,
2323
resolve_agent_name,
2424
)
25+
from vibepod.core.allowed_dirs import add_allowed_dir, is_dir_allowed, is_protected_dir
2526
from vibepod.core.config import get_config
2627
from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag
2728
from vibepod.core.session_logger import SessionLogger
@@ -262,6 +263,32 @@ def run(
262263
if not workspace_path.exists() or not workspace_path.is_dir():
263264
raise typer.BadParameter(f"Workspace not found: {workspace_path}")
264265

266+
if is_protected_dir(workspace_path):
267+
error(
268+
f"'{workspace_path}' is a protected directory (home or root) and cannot be "
269+
"added to the allow list. Change to a project directory first."
270+
)
271+
raise typer.Exit(1)
272+
273+
if not is_dir_allowed(workspace_path):
274+
if not sys.stdin.isatty():
275+
error(
276+
f"'{workspace_path}' is not in the allowed directories list. "
277+
"Run `vp config allow-dir` to add it."
278+
)
279+
raise typer.Exit(1)
280+
if not Confirm.ask(
281+
f"'{workspace_path}' is not allowed for `vp run`. Would you like to allow it?",
282+
default=True,
283+
):
284+
error("Directory not allowed. Aborting.")
285+
raise typer.Exit(1)
286+
try:
287+
add_allowed_dir(workspace_path)
288+
except OSError as exc:
289+
error(f"Could not update allow list for '{workspace_path}': {exc}")
290+
raise typer.Exit(1) from exc
291+
265292
agent_cfg = config.get("agents", {}).get(selected_agent, {})
266293
spec = get_agent_spec(selected_agent)
267294
init_commands = _agent_init_commands(selected_agent, agent_cfg)

src/vibepod/core/allowed_dirs.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Allowed directories management for vp run."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from pathlib import Path
8+
9+
from vibepod.core.config import get_config_root
10+
11+
12+
def get_allowed_dirs_path() -> Path:
13+
"""Return path to the allowed-directories JSON file."""
14+
return get_config_root() / "allowed_dirs.json"
15+
16+
17+
def load_allowed_dirs() -> list[str]:
18+
"""Load and return the list of allowed directory paths."""
19+
path = get_allowed_dirs_path()
20+
if not path.exists():
21+
return []
22+
try:
23+
data = json.loads(path.read_text(encoding="utf-8"))
24+
if isinstance(data, list):
25+
return [str(d) for d in data if isinstance(d, str)]
26+
except (json.JSONDecodeError, OSError):
27+
pass
28+
return []
29+
30+
31+
def save_allowed_dirs(dirs: list[str]) -> None:
32+
"""Persist the list of allowed directory paths."""
33+
path = get_allowed_dirs_path()
34+
path.parent.mkdir(parents=True, exist_ok=True)
35+
tmp = path.with_suffix(".tmp")
36+
tmp.write_text(json.dumps(sorted(set(dirs)), indent=2), encoding="utf-8")
37+
os.replace(tmp, path)
38+
39+
40+
def is_protected_dir(path: Path) -> bool:
41+
"""Return True if *path* is the filesystem root or the current user's home directory."""
42+
try:
43+
resolved = path.expanduser().resolve()
44+
except OSError:
45+
return False
46+
home = Path.home().resolve()
47+
root = Path("/").resolve()
48+
return resolved == home or resolved == root
49+
50+
51+
def is_dir_allowed(path: Path) -> bool:
52+
"""Return True if *path* is in the allow list."""
53+
try:
54+
resolved = str(path.expanduser().resolve())
55+
except OSError:
56+
return False
57+
return resolved in load_allowed_dirs()
58+
59+
60+
def add_allowed_dir(path: Path) -> None:
61+
"""Add *path* to the allow list (no-op if already present)."""
62+
resolved = str(path.expanduser().resolve())
63+
dirs = load_allowed_dirs()
64+
if resolved not in dirs:
65+
dirs.append(resolved)
66+
save_allowed_dirs(dirs)
67+
68+
69+
def remove_allowed_dir(path: Path) -> bool:
70+
"""Remove *path* from the allow list. Returns True if it was present."""
71+
resolved = str(path.expanduser().resolve())
72+
dirs = load_allowed_dirs()
73+
if resolved in dirs:
74+
dirs.remove(resolved)
75+
save_allowed_dirs(dirs)
76+
return True
77+
return False

tests/test_config.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,91 @@ def test_llm_env_overrides(monkeypatch, tmp_path: Path) -> None:
163163
assert llm["base_url"] == "http://localhost:11434/v1"
164164
assert llm["api_key"] == "sk-test"
165165
assert llm["model"] == "llama3"
166+
167+
168+
169+
# ---------------------------------------------------------------------------
170+
# allow-dir / remove-dir / list-allowed-dirs subcommand tests
171+
# ---------------------------------------------------------------------------
172+
173+
174+
def test_allow_dir_adds_current_directory(monkeypatch, tmp_path: Path) -> None:
175+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
176+
monkeypatch.chdir(tmp_path)
177+
178+
result = runner.invoke(app, ["config", "allow-dir"])
179+
assert result.exit_code == 0, result.stdout
180+
assert str(tmp_path) in result.stdout
181+
182+
183+
def test_allow_dir_accepts_explicit_path(monkeypatch, tmp_path: Path) -> None:
184+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
185+
target = tmp_path / "myproject"
186+
target.mkdir()
187+
188+
result = runner.invoke(app, ["config", "allow-dir", str(target)])
189+
assert result.exit_code == 0, result.stdout
190+
assert str(target) in result.stdout
191+
192+
193+
def test_allow_dir_rejects_nonexistent_path(monkeypatch, tmp_path: Path) -> None:
194+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
195+
196+
result = runner.invoke(app, ["config", "allow-dir", str(tmp_path / "nonexistent")])
197+
assert result.exit_code == 1
198+
199+
200+
def test_allow_dir_rejects_home_directory(monkeypatch, tmp_path: Path) -> None:
201+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
202+
home = str(Path.home())
203+
204+
result = runner.invoke(app, ["config", "allow-dir", home])
205+
assert result.exit_code == 1
206+
assert "protected" in result.stdout
207+
208+
209+
def test_allow_dir_rejects_root_directory(monkeypatch, tmp_path: Path) -> None:
210+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
211+
212+
result = runner.invoke(app, ["config", "allow-dir", "/"])
213+
assert result.exit_code == 1
214+
assert "protected" in result.stdout
215+
216+
217+
def test_remove_dir_removes_allowed_directory(monkeypatch, tmp_path: Path) -> None:
218+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
219+
target = tmp_path / "myproject"
220+
target.mkdir()
221+
222+
runner.invoke(app, ["config", "allow-dir", str(target)])
223+
result = runner.invoke(app, ["config", "remove-dir", str(target)])
224+
assert result.exit_code == 0, result.stdout
225+
assert str(target) in result.stdout
226+
227+
228+
def test_remove_dir_fails_when_not_in_list(monkeypatch, tmp_path: Path) -> None:
229+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
230+
target = tmp_path / "myproject"
231+
target.mkdir()
232+
233+
result = runner.invoke(app, ["config", "remove-dir", str(target)])
234+
assert result.exit_code == 1
235+
236+
237+
def test_list_allowed_dirs_shows_added_directories(monkeypatch, tmp_path: Path) -> None:
238+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
239+
target = tmp_path / "myproject"
240+
target.mkdir()
241+
242+
runner.invoke(app, ["config", "allow-dir", str(target)])
243+
result = runner.invoke(app, ["config", "list-allowed-dirs"])
244+
assert result.exit_code == 0, result.stdout
245+
assert str(target) in result.stdout
246+
247+
248+
def test_list_allowed_dirs_empty(monkeypatch, tmp_path: Path) -> None:
249+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "cfg"))
250+
251+
result = runner.invoke(app, ["config", "list-allowed-dirs"])
252+
assert result.exit_code == 0
253+
assert "No directories" in result.stdout

0 commit comments

Comments
 (0)