From 0a4f8824789d01b8c70b70a1268cda7f37912790 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sat, 22 Aug 2026 09:42:14 +0000 Subject: [PATCH 1/8] Add proxy filter core: validation, config wiring, materialization --- src/vibepod/core/config.py | 6 ++ src/vibepod/core/proxy_filter.py | 123 ++++++++++++++++++++++++ tests/test_proxy_filter.py | 158 +++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 src/vibepod/core/proxy_filter.py create mode 100644 tests/test_proxy_filter.py diff --git a/src/vibepod/core/config.py b/src/vibepod/core/config.py index e41feb1..82a64c7 100644 --- a/src/vibepod/core/config.py +++ b/src/vibepod/core/config.py @@ -179,6 +179,11 @@ def _default_config() -> dict[str, Any]: "db_path": str(config_root / "proxy" / "proxy.db"), "ca_dir": str(config_root / "proxy" / "mitmproxy"), "ca_path": str(config_root / "proxy" / "mitmproxy" / "mitmproxy-ca-cert.pem"), + "filter": { + "mode": "open", + "allow": [], + "deny": [], + }, }, "llm": { "enabled": False, @@ -228,6 +233,7 @@ def _apply_env(config: dict[str, Any]) -> dict[str, Any]: "VP_NO_COLOR": ("no_color", lambda x: x.lower() == "true"), "VP_DATASETTE_PORT": ("logging.ui_port", int), "VP_PROXY_ENABLED": ("proxy.enabled", lambda x: x.lower() == "true"), + "VP_PROXY_FILTER_MODE": ("proxy.filter.mode", str), "VP_LLM_ENABLED": ("llm.enabled", lambda x: x.lower() == "true"), "VP_LLM_BASE_URL": ("llm.base_url", str), "VP_LLM_API_KEY": ("llm.api_key", str), diff --git a/src/vibepod/core/proxy_filter.py b/src/vibepod/core/proxy_filter.py new file mode 100644 index 0000000..fdc4477 --- /dev/null +++ b/src/vibepod/core/proxy_filter.py @@ -0,0 +1,123 @@ +"""Proxy allow/deny filter: validation, config mutation, materialization.""" + +from __future__ import annotations + +import json +import os +import re +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import yaml + +from vibepod.core.config import _load_yaml, get_global_config_path +from vibepod.utils.console import warning + +VALID_MODES = ("open", "allow", "deny") + +_PATTERN_RE = re.compile( + r"^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", +) + + +def normalize_pattern(raw: str) -> str: + """Validate and normalize a host pattern; raise ValueError if invalid.""" + pattern = raw.strip().lower().rstrip(".") + if not _PATTERN_RE.match(pattern): + raise ValueError( + f"Invalid host pattern '{raw}'. Use a hostname like 'example.com' " + "or a subdomain wildcard like '*.example.com'.", + ) + return pattern + + +def get_filter_settings(config: dict[str, Any]) -> dict[str, Any]: + """Return normalized filter settings from an effective config.""" + proxy_cfg = config.get("proxy", {}) + filter_cfg = proxy_cfg.get("filter", {}) if isinstance(proxy_cfg, dict) else {} + if not isinstance(filter_cfg, dict): + filter_cfg = {} + + mode = str(filter_cfg.get("mode", "open")).strip().lower() + if mode not in VALID_MODES: + mode = "open" + + def _patterns(raw: Any) -> list[str]: + if not isinstance(raw, list): + return [] + return [str(p).strip().lower().rstrip(".") for p in raw if str(p).strip()] + + return { + "mode": mode, + "allow": _patterns(filter_cfg.get("allow")), + "deny": _patterns(filter_cfg.get("deny")), + } + + +def raw_configured_mode(config: dict[str, Any]) -> str: + """Return the configured mode as written, before fail-open coercion.""" + proxy_cfg = config.get("proxy", {}) + filter_cfg = proxy_cfg.get("filter", {}) if isinstance(proxy_cfg, dict) else {} + if not isinstance(filter_cfg, dict): + return "open" + return str(filter_cfg.get("mode", "open")) + + +def get_filter_file_path(config: dict[str, Any]) -> Path: + proxy_cfg = config.get("proxy", {}) + db_path = ( + Path(str(proxy_cfg.get("db_path", "~/.config/vibepod/proxy/proxy.db"))) + .expanduser() + .resolve() + ) + return db_path.parent / "filter.json" + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write via a sibling temp file + rename so readers never see partials.""" + # Follow symlinks: replacing the link itself would disconnect e.g. a + # dotfiles-managed config.yaml from its target. + path = path.resolve() + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + os.replace(tmp_name, path) + except BaseException: + os.unlink(tmp_name) + raise + + +def write_filter_file(config: dict[str, Any]) -> Path: + """Materialize filter settings into the proxy data dir (hot-reloaded by the proxy).""" + raw_mode = raw_configured_mode(config) + if raw_mode.strip().lower() not in VALID_MODES: + warning(f"Invalid proxy.filter.mode '{raw_mode}' in config; treating as 'open'") + path = get_filter_file_path(config) + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(path, json.dumps(get_filter_settings(config), indent=2) + "\n") + return path + + +def update_global_filter(mutate: Callable[[dict[str, Any]], None]) -> dict[str, Any]: + """Apply *mutate* to proxy.filter in the global config.yaml and save it.""" + path = get_global_config_path() + if path.exists(): + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if raw is not None and not isinstance(raw, dict): + raise ValueError( + f"Global config at {path} is not a YAML mapping; refusing to rewrite it", + ) + data = _load_yaml(path) + proxy_cfg = data.setdefault("proxy", {}) + if not isinstance(proxy_cfg, dict): + raise ValueError("Config key 'proxy' must be a mapping") + filter_cfg = proxy_cfg.setdefault("filter", {}) + if not isinstance(filter_cfg, dict): + raise ValueError("Config key 'proxy.filter' must be a mapping") + mutate(filter_cfg) + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(path, yaml.safe_dump(data, sort_keys=False)) + return filter_cfg diff --git a/tests/test_proxy_filter.py b/tests/test_proxy_filter.py new file mode 100644 index 0000000..ecf398b --- /dev/null +++ b/tests/test_proxy_filter.py @@ -0,0 +1,158 @@ +"""Tests for proxy filter rule management and materialization.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from vibepod.core import proxy_filter as pf +from vibepod.core.config import get_config + + +def test_normalize_pattern_lowercases_and_strips() -> None: + assert pf.normalize_pattern(" Example.COM. ") == "example.com" + + +def test_normalize_pattern_accepts_wildcard() -> None: + assert pf.normalize_pattern("*.GitHub.com") == "*.github.com" + + +@pytest.mark.parametrize( + "raw", + ["", "https://example.com", "example.com/path", "*example.com", "a b.com", "*."], +) +def test_normalize_pattern_rejects_garbage(raw: str) -> None: + with pytest.raises(ValueError): + pf.normalize_pattern(raw) + + +def test_get_filter_settings_defaults_open() -> None: + assert pf.get_filter_settings({}) == {"mode": "open", "allow": [], "deny": []} + + +def test_get_filter_settings_coerces_invalid_mode() -> None: + config = {"proxy": {"filter": {"mode": "strict", "allow": ["a.com"], "deny": []}}} + assert pf.get_filter_settings(config)["mode"] == "open" + + +def test_get_filter_settings_passes_valid_config() -> None: + config = {"proxy": {"filter": {"mode": "allow", "allow": ["a.com"], "deny": ["b.com"]}}} + assert pf.get_filter_settings(config) == { + "mode": "allow", + "allow": ["a.com"], + "deny": ["b.com"], + } + + +def test_write_filter_file_materializes_next_to_db(tmp_path: Path) -> None: + config = { + "proxy": { + "db_path": str(tmp_path / "proxy" / "proxy.db"), + "filter": {"mode": "deny", "allow": [], "deny": ["example.com"]}, + }, + } + path = pf.write_filter_file(config) + assert path == tmp_path / "proxy" / "filter.json" + assert json.loads(path.read_text()) == { + "mode": "deny", + "allow": [], + "deny": ["example.com"], + } + + +def test_update_global_filter_writes_config_yaml(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + pf.update_global_filter(lambda f: f.update(mode="allow")) + data = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert data["proxy"]["filter"]["mode"] == "allow" + + +def test_update_global_filter_preserves_other_keys(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text("default_agent: gemini\nproxy:\n enabled: true\n") + pf.update_global_filter(lambda f: f.setdefault("allow", []).append("a.com")) + data = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert data["default_agent"] == "gemini" + assert data["proxy"]["enabled"] is True + assert data["proxy"]["filter"]["allow"] == ["a.com"] + + +def test_default_config_has_open_filter(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + config = get_config() + assert config["proxy"]["filter"] == {"mode": "open", "allow": [], "deny": []} + + +def test_env_overrides_filter_mode(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "deny") + config = get_config() + assert config["proxy"]["filter"]["mode"] == "deny" + + +def test_write_filter_file_is_atomic(monkeypatch, tmp_path: Path) -> None: + """No truncate-then-write: replace so hot-reload readers never see partials.""" + config = { + "proxy": { + "db_path": str(tmp_path / "proxy" / "proxy.db"), + "filter": {"mode": "deny", "allow": [], "deny": ["example.com"]}, + }, + } + pf.write_filter_file(config) + + calls: list[tuple[Path, Path]] = [] + real_replace = pf.os.replace + + def spy_replace(src, dst): + calls.append((Path(src), Path(dst))) + real_replace(src, dst) + + monkeypatch.setattr(pf.os, "replace", spy_replace) + path = pf.write_filter_file(config) + assert calls and calls[-1][1] == path + assert json.loads(path.read_text())["mode"] == "deny" + assert list(path.parent.glob(f".{path.name}.*")) == [] + + +def test_update_global_filter_refuses_non_mapping_config(monkeypatch, tmp_path: Path) -> None: + """A list-root config.yaml must not be silently replaced (data loss).""" + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text("- just\n- a\n- list\n") + with pytest.raises(ValueError): + pf.update_global_filter(lambda f: f.update(mode="allow")) + assert yaml.safe_load((tmp_path / "config.yaml").read_text()) == ["just", "a", "list"] + + +def test_write_filter_file_warns_on_invalid_mode(tmp_path: Path, capsys) -> None: + """Every startup path materializes; the fail-open coercion must be visible.""" + config = { + "proxy": { + "db_path": str(tmp_path / "proxy" / "proxy.db"), + "filter": {"mode": "alow", "allow": [], "deny": []}, + }, + } + path = pf.write_filter_file(config) + assert json.loads(path.read_text())["mode"] == "open" + out = capsys.readouterr().out + assert "alow" in out + assert "open" in out + + +def test_atomic_write_preserves_symlinked_config(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + target = tmp_path / "dotfiles" / "vibepod.yaml" + target.parent.mkdir() + target.write_text("default_agent: gemini\n") + (tmp_path / "config.yaml").symlink_to(target) + + pf.update_global_filter(lambda f: f.update(mode="allow")) + + assert (tmp_path / "config.yaml").is_symlink() + data = yaml.safe_load(target.read_text()) + assert data["default_agent"] == "gemini" + assert data["proxy"]["filter"]["mode"] == "allow" From 21af33542738da5898bec1faec4acf63541e02e0 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sat, 22 Aug 2026 09:42:14 +0000 Subject: [PATCH 2/8] Add vp proxy filter command group --- src/vibepod/commands/proxy.py | 170 ++++++++++++++++++++++++++++++++- tests/test_proxy_cmd.py | 8 ++ tests/test_proxy_filter_cmd.py | 161 +++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 tests/test_proxy_filter_cmd.py diff --git a/src/vibepod/commands/proxy.py b/src/vibepod/commands/proxy.py index 894aaa9..c05782f 100644 --- a/src/vibepod/commands/proxy.py +++ b/src/vibepod/commands/proxy.py @@ -3,17 +3,180 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer from vibepod.constants import EXIT_DOCKER_NOT_RUNNING from vibepod.core.config import get_config from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag +from vibepod.core.proxy_filter import ( + VALID_MODES, + get_filter_settings, + normalize_pattern, + raw_configured_mode, + update_global_filter, + write_filter_file, +) from vibepod.utils.console import error, info, success, warning app = typer.Typer(help="Manage the HTTP(S) proxy") +filter_app = typer.Typer(help="Manage proxy allow/deny filtering") +allow_app = typer.Typer(help="Manage the allow list") +deny_app = typer.Typer(help="Manage the deny list") +filter_app.add_typer(allow_app, name="allow") +filter_app.add_typer(deny_app, name="deny") +app.add_typer(filter_app, name="filter") + + +def _sync_filter_file() -> None: + write_filter_file(get_config()) + + +def _normalized(entry: object) -> str: + return str(entry).strip().lower().rstrip(".") + + +_OVERRIDE_HINT = "overridden by project config (.vibepod/config.yaml) or VP_PROXY_FILTER_MODE" + + +def _warn_invalid_configured_mode(config: dict[str, Any]) -> None: + raw = raw_configured_mode(config) + if raw.strip().lower() not in VALID_MODES: + warning(f"Invalid proxy.filter.mode '{raw}' in config; treating as 'open'") + + +@filter_app.command("status") +def filter_status() -> None: + """Show filter mode, lists, and proxy state.""" + config = get_config() + settings = get_filter_settings(config) + _warn_invalid_configured_mode(config) + info(f"Mode: {settings['mode']}") + info(f"Allow list ({len(settings['allow'])}): {', '.join(settings['allow']) or '—'}") + info(f"Deny list ({len(settings['deny'])}): {', '.join(settings['deny']) or '—'}") + try: + manager = DockerManager() + except DockerClientError: + info("Proxy: unknown (Docker is not running)") + return + existing = manager.find_proxy() + if existing is None: + info("Proxy: not running") + else: + existing.reload() + info(f"Proxy: {existing.name} ({existing.status})") + + +@filter_app.command("mode") +def filter_mode( + value: Annotated[str, typer.Argument(help="open, allow, or deny")], +) -> None: + """Set the filter mode (open = no filtering).""" + normalized = value.strip().lower() + if normalized not in VALID_MODES: + error(f"Unknown mode '{value}'. Valid modes: {', '.join(VALID_MODES)}") + raise typer.Exit(1) + update_global_filter(lambda f: f.update(mode=normalized)) + _sync_filter_file() + success(f"Proxy filter mode set to '{normalized}'") + effective = get_filter_settings(get_config()) + if effective["mode"] != normalized: + warning( + f"Effective mode stays '{effective['mode']}' — {_OVERRIDE_HINT}", + ) + + +def _list_add(list_name: str, host: str) -> None: + try: + pattern = normalize_pattern(host) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + added = True + + def mutate(filter_cfg: dict[str, Any]) -> None: + nonlocal added + entries = filter_cfg.setdefault(list_name, []) + if not isinstance(entries, list): + entries = [] + filter_cfg[list_name] = entries + # Hand-authored config entries may be unnormalized; compare normalized. + if pattern in {_normalized(e) for e in entries}: + added = False + return + entries.append(pattern) + + update_global_filter(mutate) + if not added: + warning(f"'{pattern}' is already in the {list_name} list") + return + _sync_filter_file() + success(f"Added '{pattern}' to the {list_name} list") + if pattern not in get_filter_settings(get_config())[list_name]: + warning( + f"'{pattern}' saved globally but absent from the effective " + f"{list_name} list — {_OVERRIDE_HINT}", + ) + + +def _list_remove(list_name: str, host: str) -> None: + try: + pattern = normalize_pattern(host) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + removed = False + + def mutate(filter_cfg: dict[str, Any]) -> None: + nonlocal removed + entries = filter_cfg.setdefault(list_name, []) + if not isinstance(entries, list): + return + kept = [e for e in entries if _normalized(e) != pattern] + if len(kept) != len(entries): + filter_cfg[list_name] = kept + removed = True + + update_global_filter(mutate) + if not removed: + warning(f"'{pattern}' is not in the {list_name} list") + return + _sync_filter_file() + success(f"Removed '{pattern}' from the {list_name} list") + if pattern in get_filter_settings(get_config())[list_name]: + warning( + f"'{pattern}' removed globally but still in the effective " + f"{list_name} list — {_OVERRIDE_HINT}", + ) + + +@allow_app.command("add") +def allow_add(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: + """Add a host pattern to the allow list.""" + _list_add("allow", host) + + +@allow_app.command("remove") +def allow_remove(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: + """Remove a host pattern from the allow list.""" + _list_remove("allow", host) + + +@deny_app.command("add") +def deny_add(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: + """Add a host pattern to the deny list.""" + _list_add("deny", host) + + +@deny_app.command("remove") +def deny_remove(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: + """Remove a host pattern from the deny list.""" + _list_remove("deny", host) + @app.command("start") def proxy_start() -> None: @@ -53,6 +216,11 @@ def proxy_start() -> None: if existing: existing.remove(force=True) + # Materialize filter rules so the proxy picks them up (and hand-edits to + # config.yaml are synced on start); write_filter_file warns on an invalid + # configured mode. + write_filter_file(config) + info("Starting proxy") manager.ensure_proxy( image=proxy_image, diff --git a/tests/test_proxy_cmd.py b/tests/test_proxy_cmd.py index 0e9d04d..93f20e9 100644 --- a/tests/test_proxy_cmd.py +++ b/tests/test_proxy_cmd.py @@ -41,6 +41,11 @@ def ensure_proxy(self, **kwargs) -> None: def _patch_common(monkeypatch, events: list[str], config: dict, updated: bool = True) -> None: monkeypatch.setattr(proxy_cmd, "DockerManager", lambda: _FakeManager(events, updated)) monkeypatch.setattr(proxy_cmd, "get_config", lambda: config) + monkeypatch.setattr( + proxy_cmd, + "write_filter_file", + lambda config: events.append("write_filter_file"), + ) def test_proxy_start_recreates_container_before_cleanup(monkeypatch) -> None: @@ -54,6 +59,7 @@ def test_proxy_start_recreates_container_before_cleanup(monkeypatch) -> None: "ensure_network", "pull_if_newer(auto_clean=False)", "container.remove", + "write_filter_file", "ensure_proxy", "clean_untagged_images", ] @@ -68,6 +74,7 @@ def test_proxy_start_keeps_container_without_update(monkeypatch) -> None: assert events == [ "ensure_network", "pull_if_newer(auto_clean=False)", + "write_filter_file", "ensure_proxy", "clean_untagged_images", ] @@ -83,5 +90,6 @@ def test_proxy_start_skips_cleanup_without_auto_clean(monkeypatch) -> None: "ensure_network", "pull_if_newer(auto_clean=False)", "container.remove", + "write_filter_file", "ensure_proxy", ] diff --git a/tests/test_proxy_filter_cmd.py b/tests/test_proxy_filter_cmd.py new file mode 100644 index 0000000..056dce5 --- /dev/null +++ b/tests/test_proxy_filter_cmd.py @@ -0,0 +1,161 @@ +"""Tests for vp proxy filter commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from vibepod.cli import app + +runner = CliRunner() + + +@pytest.fixture() +def config_dir(monkeypatch, tmp_path: Path) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("VP_PROXY_ENABLED", "true") + monkeypatch.delenv("VP_PROXY_FILTER_MODE", raising=False) + monkeypatch.chdir(tmp_path) + # Point the proxy db under the temp dir so filter.json lands there too. + (tmp_path / "config.yaml").write_text( + f"proxy:\n db_path: {tmp_path / 'proxy' / 'proxy.db'}\n", + ) + return tmp_path + + +def _filter_json(config_dir: Path) -> dict: + return json.loads((config_dir / "proxy" / "filter.json").read_text()) + + +def _config_yaml(config_dir: Path) -> dict: + return yaml.safe_load((config_dir / "config.yaml").read_text()) + + +def test_filter_status_shows_defaults(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "status"]) + assert result.exit_code == 0 + assert "open" in result.stdout + + +def test_filter_mode_switch_updates_config_and_file(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["mode"] == "allow" + assert _filter_json(config_dir)["mode"] == "allow" + + +def test_filter_mode_rejects_unknown(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "strict"]) + assert result.exit_code == 1 + + +def test_allow_add_and_remove(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "API.Anthropic.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["allow"] == ["api.anthropic.com"] + assert _filter_json(config_dir)["allow"] == ["api.anthropic.com"] + + result = runner.invoke(app, ["proxy", "filter", "allow", "remove", "api.anthropic.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["allow"] == [] + assert _filter_json(config_dir)["allow"] == [] + + +def test_deny_add_wildcard(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "deny", "add", "*.example.com"]) + assert result.exit_code == 0 + assert _filter_json(config_dir)["deny"] == ["*.example.com"] + + +def test_add_rejects_invalid_pattern(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "https://example.com"]) + assert result.exit_code == 1 + + +def test_add_duplicate_is_noop(config_dir: Path) -> None: + runner.invoke(app, ["proxy", "filter", "allow", "add", "a.com"]) + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "a.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["allow"] == ["a.com"] + + +def test_remove_absent_is_noop(config_dir: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "allow", "remove", "missing.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"].get("allow", []) == [] + + +def test_mode_switch_preserves_lists(config_dir: Path) -> None: + runner.invoke(app, ["proxy", "filter", "deny", "add", "example.com"]) + runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + data = _config_yaml(config_dir)["proxy"]["filter"] + assert data["mode"] == "allow" + assert data["deny"] == ["example.com"] + + +def test_remove_matches_hand_authored_unnormalized_entry(config_dir: Path) -> None: + (config_dir / "config.yaml").write_text( + f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n" + " filter:\n mode: deny\n deny:\n - Example.COM.\n", + ) + result = runner.invoke(app, ["proxy", "filter", "deny", "remove", "example.com"]) + assert result.exit_code == 0 + assert _config_yaml(config_dir)["proxy"]["filter"]["deny"] == [] + + +def test_add_does_not_duplicate_unnormalized_entry(config_dir: Path) -> None: + (config_dir / "config.yaml").write_text( + f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n" + " filter:\n mode: deny\n deny:\n - Example.COM.\n", + ) + result = runner.invoke(app, ["proxy", "filter", "deny", "add", "example.com"]) + assert result.exit_code == 0 + assert len(_config_yaml(config_dir)["proxy"]["filter"]["deny"]) == 1 + + +def test_mode_warns_when_project_config_overrides(config_dir: Path) -> None: + project = config_dir / ".vibepod" + project.mkdir() + (project / "config.yaml").write_text("proxy:\n filter:\n mode: deny\n") + + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + + assert result.exit_code == 0 + assert "overridden" in result.stdout + assert _filter_json(config_dir)["mode"] == "deny" + + +def test_mode_warns_when_env_overrides(config_dir: Path, monkeypatch) -> None: + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "deny") + + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + + assert result.exit_code == 0 + assert "overridden" in result.stdout + assert _filter_json(config_dir)["mode"] == "deny" + + +def test_add_warns_when_project_config_overrides(config_dir: Path) -> None: + project = config_dir / ".vibepod" + project.mkdir() + (project / "config.yaml").write_text("proxy:\n filter:\n allow: []\n") + + result = runner.invoke(app, ["proxy", "filter", "allow", "add", "a.com"]) + + assert result.exit_code == 0 + assert "overridden" in result.stdout + assert _filter_json(config_dir)["allow"] == [] + + +def test_status_warns_on_invalid_configured_mode(config_dir: Path) -> None: + (config_dir / "config.yaml").write_text( + f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n filter:\n mode: strict\n", + ) + result = runner.invoke(app, ["proxy", "filter", "status"]) + assert result.exit_code == 0 + assert "strict" in result.stdout + assert "open" in result.stdout From 01ef45c10a2b4c1b74b055e6ecd268ce653d69bf Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sat, 22 Aug 2026 09:42:14 +0000 Subject: [PATCH 3/8] Materialize filter and refresh proxy on run and task startup --- src/vibepod/commands/run.py | 16 +++++- src/vibepod/commands/task.py | 16 +++++- tests/test_run.py | 105 +++++++++++++++++++++++++++++++++++ tests/test_task_cmd.py | 22 ++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 339d724..72ecfc3 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -92,6 +92,7 @@ x11_volumes_and_env as _x11_volumes_and_env, ) from vibepod.core.profiles import resolve_profile +from vibepod.core.proxy_filter import write_filter_file from vibepod.core.resume import show_resume_hint from vibepod.core.session_logger import SessionLogger from vibepod.utils.console import error, info, success, warning @@ -640,7 +641,20 @@ def run( ) if _is_latest_tag(proxy_image): - manager.pull_if_newer(proxy_image, auto_clean=bool(config.get("auto_clean", True))) + updated = manager.pull_if_newer( + proxy_image, + auto_clean=bool(config.get("auto_clean", True)), + ) + if updated: + # ensure_proxy reuses a running container; replace it so the + # freshly pulled image (and its features) actually serve. + existing_proxy = manager.find_proxy() + if existing_proxy: + existing_proxy.remove(force=True) + + # Materialize filter rules for the proxy; `vp proxy start` is not the + # only path that brings the proxy up. + write_filter_file(config) manager.ensure_proxy( image=proxy_image, diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index fe426cf..f55427c 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -45,6 +45,7 @@ update_container_mapping, ) from vibepod.core.profiles import resolve_profile +from vibepod.core.proxy_filter import write_filter_file from vibepod.core.tasks import ( TASK_STATUS_CANCELLED, TASK_STATUS_COMPLETED, @@ -717,7 +718,20 @@ def task_create( ) if _is_latest_tag(proxy_image): - manager.pull_if_newer(proxy_image, auto_clean=bool(config.get("auto_clean", True))) + updated = manager.pull_if_newer( + proxy_image, + auto_clean=bool(config.get("auto_clean", True)), + ) + if updated: + # ensure_proxy reuses a running container; replace it so the + # freshly pulled image (and its features) actually serve. + existing_proxy = manager.find_proxy() + if existing_proxy: + existing_proxy.remove(force=True) + + # Materialize filter rules for the proxy; `vp proxy start` is not the + # only path that brings the proxy up. + write_filter_file(config) actual_ca_dir = proxy_ca_dir or proxy_db_path.parent / "mitmproxy" manager.ensure_proxy( diff --git a/tests/test_run.py b/tests/test_run.py index fbbbaa2..4596d5b 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2596,3 +2596,108 @@ def test_run_dsh_prints_preview_warning_and_web_ui_url(monkeypatch, tmp_path: Pa assert any("developer preview" in msg for msg in warnings) assert any("http://127.0.0.1:3080" in msg for msg in successes) + + +def test_run_materializes_proxy_filter_file(monkeypatch, tmp_path: Path) -> None: + """Configured filter rules must reach the proxy even without vp proxy start.""" + filter_calls: list[dict] = [] + + class _ProxyDockerManager: + def ensure_network(self, name: str) -> None: + pass + + def networks_with_running_containers(self) -> list[str]: + return [] + + def pull_image(self, image: str, auto_clean: bool = False) -> None: + pass + + def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] + pass + + def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] + return type( + "_Container", + (), + { + "name": "vibepod-claude-test", + "id": "abc123", + "status": "running", + "attrs": {"NetworkSettings": {"Networks": {}}}, + "reload": lambda self: None, + "labels": {}, + "logs": lambda self, **kw: b"", + }, + )() + + config = _make_config() + config["proxy"] = { + "enabled": True, + "image": "vibepod/proxy:0.1", + "db_path": str(tmp_path / "proxy" / "proxy.db"), + } + monkeypatch.setattr(run_cmd, "get_config", lambda: config) + monkeypatch.setattr(run_cmd, "DockerManager", _ProxyDockerManager) + monkeypatch.setattr(run_cmd, "write_filter_file", lambda cfg: filter_calls.append(cfg)) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + + assert filter_calls == [config] + + +def test_run_recreates_proxy_when_image_updated(monkeypatch, tmp_path: Path) -> None: + """A pulled newer proxy image must replace the running pre-update container.""" + events: list[str] = [] + + class _OldProxyContainer: + def remove(self, force: bool = False) -> None: + events.append("proxy.remove") + + class _UpdatingDockerManager: + def ensure_network(self, name: str) -> None: + pass + + def networks_with_running_containers(self) -> list[str]: + return [] + + def pull_image(self, image: str, auto_clean: bool = False) -> None: + pass + + def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: + events.append("pull_if_newer") + return True + + def find_proxy(self) -> object: + return _OldProxyContainer() + + def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] + events.append("ensure_proxy") + + def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] + return type( + "_Container", + (), + { + "name": "vibepod-claude-test", + "id": "abc123", + "status": "running", + "attrs": {"NetworkSettings": {"Networks": {}}}, + "reload": lambda self: None, + "labels": {}, + "logs": lambda self, **kw: b"", + }, + )() + + config = _make_config() + config["proxy"] = { + "enabled": True, + "image": "vibepod/proxy:latest", + "db_path": str(tmp_path / "proxy" / "proxy.db"), + } + monkeypatch.setattr(run_cmd, "get_config", lambda: config) + monkeypatch.setattr(run_cmd, "DockerManager", _UpdatingDockerManager) + monkeypatch.setattr(run_cmd, "write_filter_file", lambda cfg: None) + + run_cmd.run(agent="claude", workspace=tmp_path, detach=True) + + assert events == ["pull_if_newer", "proxy.remove", "ensure_proxy"] diff --git a/tests/test_task_cmd.py b/tests/test_task_cmd.py index d20e1c0..763c4e4 100644 --- a/tests/test_task_cmd.py +++ b/tests/test_task_cmd.py @@ -1203,3 +1203,25 @@ def test_task_create_dsh_keeps_user_extra_ports(monkeypatch, tmp_path, tmp_task_ assert ports is not None assert all(key.split("/", 1)[0] != "3081" for key in ports) assert any(key.split("/", 1)[0] == "9999" for key in ports) + + +def test_task_create_materializes_proxy_filter_file( + monkeypatch, + tmp_path, + tmp_task_store, +) -> None: + """Configured filter rules must reach the proxy even without vp proxy start.""" + filter_calls: list[dict] = [] + config = _make_config() + config["proxy"] = { + "enabled": True, + "image": "vibepod/proxy:0.1", + "db_path": str(tmp_path / "proxy" / "proxy.db"), + } + monkeypatch.setattr(task_cmd, "get_config", lambda: config) + monkeypatch.setattr(task_cmd, "DockerManager", _CapturingDockerManager) + monkeypatch.setattr(task_cmd, "write_filter_file", lambda cfg: filter_calls.append(cfg)) + + task_cmd.task_create(agent="claude", prompt="hi", workspace=tmp_path) + + assert filter_calls == [config] From 5765c35c89dbff71022d698a01b29ab8b7c3abc2 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sat, 22 Aug 2026 09:42:14 +0000 Subject: [PATCH 4/8] Document proxy filter configuration and commands --- docs/configuration.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 96bb05e..5c6835f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -178,6 +178,10 @@ proxy: db_path: ~/.config/vibepod/proxy/proxy.db ca_dir: ~/.config/vibepod/proxy/mitmproxy ca_path: ~/.config/vibepod/proxy/mitmproxy/mitmproxy-ca-cert.pem + filter: + mode: open # open | allow | deny + allow: [] + deny: [] ``` ## Environment variables @@ -193,6 +197,7 @@ These variables override the corresponding config keys without editing any file: | `VP_NO_COLOR` | `no_color` | `VP_NO_COLOR=true` | | `VP_DATASETTE_PORT` | `logging.ui_port` | `VP_DATASETTE_PORT=9001` | | `VP_PROXY_ENABLED` | `proxy.enabled` | `VP_PROXY_ENABLED=false` | +| `VP_PROXY_FILTER_MODE` | `proxy.filter.mode` | `VP_PROXY_FILTER_MODE=allow` | | `VP_LLM_ENABLED` | `llm.enabled` | `VP_LLM_ENABLED=true` | | `VP_LLM_BASE_URL` | `llm.base_url` | `VP_LLM_BASE_URL=http://localhost:11434` | | `VP_LLM_API_KEY` | `llm.api_key` | `VP_LLM_API_KEY=ollama` | @@ -332,3 +337,28 @@ VP_PROXY_ENABLED=false vp run claude network. If you use Podman with the CNI backend, install the `dnsname` plugin (e.g. `podman-plugins` on Fedora, `golang-github-containernetworking-plugin-dnsname` on Debian/Ubuntu) and recreate the network. See [Quickstart — Using Podman](quickstart.md#using-podman-instead-of-docker) for full instructions. + +### Allow/deny filtering + +Filtering is opt-in; the default mode `open` passes and logs everything +(today's behavior). In `allow` mode only listed hosts pass; in `deny` mode +everything passes except listed hosts. Patterns: `example.com` matches that +host exactly, `*.example.com` matches subdomains (not the apex). + +```bash +vp proxy filter status +vp proxy filter mode allow +vp proxy filter allow add api.anthropic.com +vp proxy filter allow add "*.github.com" +vp proxy filter deny add example.com +vp proxy filter mode open # back to no filtering; lists are kept +``` + +Changes apply immediately — the running proxy hot-reloads the rules, no +restart needed. Blocked requests return `403` (HTTPS tunnels are refused at +`CONNECT`) and are logged with `blocked = 1` in the proxy database. + +The `vp proxy filter` commands write the **global** config. A project-level +`.vibepod/config.yaml` filter section or `VP_PROXY_FILTER_MODE` takes +precedence over the global values; when that masks a change the command warns +and the materialized rules keep the effective (overriding) settings. From 5bf398c6c6288f50a81227003ff305a8fded2b9a Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Sun, 23 Aug 2026 17:00:48 +0000 Subject: [PATCH 5/8] Add per-profile proxy filter settings --- docs/configuration.md | 15 +++-- docs/profiles.md | 31 ++++++++-- src/vibepod/commands/proxy.py | 102 +++++++++++++++++++++++-------- src/vibepod/commands/run.py | 2 +- src/vibepod/commands/task.py | 2 +- src/vibepod/core/proxy_filter.py | 76 +++++++++++++++++++++-- tests/test_proxy_cmd.py | 2 +- tests/test_proxy_filter.py | 93 ++++++++++++++++++++++++++++ tests/test_proxy_filter_cmd.py | 57 +++++++++++++++++ tests/test_run.py | 8 ++- tests/test_task_cmd.py | 6 +- 11 files changed, 348 insertions(+), 46 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 5c6835f..affef15 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -358,7 +358,14 @@ Changes apply immediately — the running proxy hot-reloads the rules, no restart needed. Blocked requests return `403` (HTTPS tunnels are refused at `CONNECT`) and are logged with `blocked = 1` in the proxy database. -The `vp proxy filter` commands write the **global** config. A project-level -`.vibepod/config.yaml` filter section or `VP_PROXY_FILTER_MODE` takes -precedence over the global values; when that masks a change the command warns -and the materialized rules keep the effective (overriding) settings. +The `vp proxy filter` commands act on the **active profile**. For the +`default` profile they write the global config; for a named profile they write +`profiles//filter.yaml` (seeded from the global settings on first +write), so switching profiles also switches the filter mode and lists. Pass +`--profile ` to manage another profile's filter without switching. + +A project-level `.vibepod/config.yaml` filter section or +`VP_PROXY_FILTER_MODE` takes precedence over the global values (for a profile +with its own `filter.yaml`, only `VP_PROXY_FILTER_MODE` still overrides the +mode); when that masks a change the command warns and the materialized rules +keep the effective (overriding) settings. diff --git a/docs/profiles.md b/docs/profiles.md index 1906386..1f8ff06 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -4,11 +4,12 @@ Profiles let you keep multiple credential sets per agent and switch between them at run time — for example a Claude subscription login, a separate API-key setup, and an environment prepared for Ollama. -A profile only switches the **credential directories** that get mounted into the -agent container. Everything else (skills, allowed directories, proxy, logging) -stays shared. Environment variables such as `ANTHROPIC_API_KEY` are still -configured via `agents..env` or `-e` flags — combine them with a profile -via a project config (see below). +A profile switches the **credential directories** that get mounted into the +agent container and, when the profile has its own filter settings, the +**proxy allow/deny filter** (see below). Everything else (skills, allowed +directories, proxy, logging) stays shared. Environment variables such as +`ANTHROPIC_API_KEY` are still configured via `agents..env` or `-e` +flags — combine them with a profile via a project config (see below). ## Layout @@ -16,6 +17,7 @@ via a project config (see below). ~/.config/vibepod/ agents// # the built-in "default" profile profiles//agents// # named profiles + profiles//filter.yaml # optional per-profile proxy filter ``` Your existing credentials in `~/.config/vibepod/agents/` are the `default` @@ -68,3 +70,22 @@ agents: Referencing a profile that does not exist is a hard error — create it first with `vp profile create `. + +## Per-profile proxy filter + +Each named profile can carry its own proxy filter mode and allow/deny lists in +`profiles//filter.yaml`. The `vp proxy filter` commands act on the +active profile, or on an explicit one via `--profile`: + +```bash +vp proxy filter mode allow --profile work +vp proxy filter allow add api.anthropic.com --profile work +vp proxy filter status --profile work +``` + +The file is created on first write, seeded from the global filter settings. +A profile without `filter.yaml` uses the global `proxy.filter` config. On +`vp run`, `vp task create`, and `vp proxy start` the active profile's filter +is materialized for the proxy, so switching profiles switches the agent +credentials *and* the filter policy. The `default` profile keeps its filter in +the global config, as before. diff --git a/src/vibepod/commands/proxy.py b/src/vibepod/commands/proxy.py index c05782f..f3f4732 100644 --- a/src/vibepod/commands/proxy.py +++ b/src/vibepod/commands/proxy.py @@ -10,12 +10,14 @@ from vibepod.constants import EXIT_DOCKER_NOT_RUNNING from vibepod.core.config import get_config from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag +from vibepod.core.profiles import DEFAULT_PROFILE, resolve_profile from vibepod.core.proxy_filter import ( VALID_MODES, - get_filter_settings, + effective_filter_settings, normalize_pattern, + profile_filter_path, raw_configured_mode, - update_global_filter, + update_profile_filter, write_filter_file, ) from vibepod.utils.console import error, info, success, warning @@ -30,8 +32,33 @@ app.add_typer(filter_app, name="filter") +_PROFILE_OPTION = typer.Option( + "--profile", + help="Profile whose filter to manage (default: the active profile)", +) + + +def _target_profile(flag: str | None) -> str: + try: + return resolve_profile(flag, get_config()) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + def _sync_filter_file() -> None: - write_filter_file(get_config()) + """Rematerialize filter.json for the active profile (the one the proxy serves).""" + config = get_config() + try: + active = resolve_profile(None, config) + except ValueError: + active = DEFAULT_PROFILE + write_filter_file(config, active) + + +def _uses_profile_file(profile: str) -> bool: + path = profile_filter_path(profile) + return path is not None and path.exists() def _normalized(entry: object) -> str: @@ -48,11 +75,18 @@ def _warn_invalid_configured_mode(config: dict[str, Any]) -> None: @filter_app.command("status") -def filter_status() -> None: +def filter_status( + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: """Show filter mode, lists, and proxy state.""" config = get_config() - settings = get_filter_settings(config) - _warn_invalid_configured_mode(config) + target = _target_profile(profile) + settings = effective_filter_settings(config, target) + if _uses_profile_file(target): + info(f"Profile: {target} (profile-specific filter)") + else: + info(f"Profile: {target} (global filter settings)") + _warn_invalid_configured_mode(config) info(f"Mode: {settings['mode']}") info(f"Allow list ({len(settings['allow'])}): {', '.join(settings['allow']) or '—'}") info(f"Deny list ({len(settings['deny'])}): {', '.join(settings['deny']) or '—'}") @@ -72,29 +106,32 @@ def filter_status() -> None: @filter_app.command("mode") def filter_mode( value: Annotated[str, typer.Argument(help="open, allow, or deny")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, ) -> None: """Set the filter mode (open = no filtering).""" normalized = value.strip().lower() if normalized not in VALID_MODES: error(f"Unknown mode '{value}'. Valid modes: {', '.join(VALID_MODES)}") raise typer.Exit(1) - update_global_filter(lambda f: f.update(mode=normalized)) + target = _target_profile(profile) + update_profile_filter(target, lambda f: f.update(mode=normalized)) _sync_filter_file() - success(f"Proxy filter mode set to '{normalized}'") - effective = get_filter_settings(get_config()) + success(f"Proxy filter mode set to '{normalized}' for profile '{target}'") + effective = effective_filter_settings(get_config(), target) if effective["mode"] != normalized: warning( f"Effective mode stays '{effective['mode']}' — {_OVERRIDE_HINT}", ) -def _list_add(list_name: str, host: str) -> None: +def _list_add(list_name: str, host: str, profile: str | None) -> None: try: pattern = normalize_pattern(host) except ValueError as exc: error(str(exc)) raise typer.Exit(1) from exc + target = _target_profile(profile) added = True def mutate(filter_cfg: dict[str, Any]) -> None: @@ -109,26 +146,27 @@ def mutate(filter_cfg: dict[str, Any]) -> None: return entries.append(pattern) - update_global_filter(mutate) + update_profile_filter(target, mutate) if not added: warning(f"'{pattern}' is already in the {list_name} list") return _sync_filter_file() - success(f"Added '{pattern}' to the {list_name} list") - if pattern not in get_filter_settings(get_config())[list_name]: + success(f"Added '{pattern}' to the {list_name} list of profile '{target}'") + if pattern not in effective_filter_settings(get_config(), target)[list_name]: warning( f"'{pattern}' saved globally but absent from the effective " f"{list_name} list — {_OVERRIDE_HINT}", ) -def _list_remove(list_name: str, host: str) -> None: +def _list_remove(list_name: str, host: str, profile: str | None) -> None: try: pattern = normalize_pattern(host) except ValueError as exc: error(str(exc)) raise typer.Exit(1) from exc + target = _target_profile(profile) removed = False def mutate(filter_cfg: dict[str, Any]) -> None: @@ -141,13 +179,13 @@ def mutate(filter_cfg: dict[str, Any]) -> None: filter_cfg[list_name] = kept removed = True - update_global_filter(mutate) + update_profile_filter(target, mutate) if not removed: warning(f"'{pattern}' is not in the {list_name} list") return _sync_filter_file() - success(f"Removed '{pattern}' from the {list_name} list") - if pattern in get_filter_settings(get_config())[list_name]: + success(f"Removed '{pattern}' from the {list_name} list of profile '{target}'") + if pattern in effective_filter_settings(get_config(), target)[list_name]: warning( f"'{pattern}' removed globally but still in the effective " f"{list_name} list — {_OVERRIDE_HINT}", @@ -155,27 +193,39 @@ def mutate(filter_cfg: dict[str, Any]) -> None: @allow_app.command("add") -def allow_add(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: +def allow_add( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: """Add a host pattern to the allow list.""" - _list_add("allow", host) + _list_add("allow", host, profile) @allow_app.command("remove") -def allow_remove(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: +def allow_remove( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: """Remove a host pattern from the allow list.""" - _list_remove("allow", host) + _list_remove("allow", host, profile) @deny_app.command("add") -def deny_add(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: +def deny_add( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: """Add a host pattern to the deny list.""" - _list_add("deny", host) + _list_add("deny", host, profile) @deny_app.command("remove") -def deny_remove(host: Annotated[str, typer.Argument(help="Host pattern")]) -> None: +def deny_remove( + host: Annotated[str, typer.Argument(help="Host pattern")], + profile: Annotated[str | None, _PROFILE_OPTION] = None, +) -> None: """Remove a host pattern from the deny list.""" - _list_remove("deny", host) + _list_remove("deny", host, profile) @app.command("start") @@ -219,7 +269,7 @@ def proxy_start() -> None: # Materialize filter rules so the proxy picks them up (and hand-edits to # config.yaml are synced on start); write_filter_file warns on an invalid # configured mode. - write_filter_file(config) + _sync_filter_file() info("Starting proxy") manager.ensure_proxy( diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 72ecfc3..3dd5273 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -654,7 +654,7 @@ def run( # Materialize filter rules for the proxy; `vp proxy start` is not the # only path that brings the proxy up. - write_filter_file(config) + write_filter_file(config, active_profile) manager.ensure_proxy( image=proxy_image, diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index f55427c..fe5ee28 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -731,7 +731,7 @@ def task_create( # Materialize filter rules for the proxy; `vp proxy start` is not the # only path that brings the proxy up. - write_filter_file(config) + write_filter_file(config, active_profile) actual_ca_dir = proxy_ca_dir or proxy_db_path.parent / "mitmproxy" manager.ensure_proxy( diff --git a/src/vibepod/core/proxy_filter.py b/src/vibepod/core/proxy_filter.py index fdc4477..a5e6a19 100644 --- a/src/vibepod/core/proxy_filter.py +++ b/src/vibepod/core/proxy_filter.py @@ -13,6 +13,7 @@ import yaml from vibepod.core.config import _load_yaml, get_global_config_path +from vibepod.core.profiles import DEFAULT_PROFILE, profiles_root from vibepod.utils.console import warning VALID_MODES = ("open", "allow", "deny") @@ -90,14 +91,79 @@ def _atomic_write_text(path: Path, content: str) -> None: raise -def write_filter_file(config: dict[str, Any]) -> Path: +def profile_filter_path(profile: str) -> Path | None: + """Per-profile filter file; None for the default profile (uses the global config).""" + if profile == DEFAULT_PROFILE: + return None + return profiles_root() / profile / "filter.yaml" + + +def _load_profile_filter(profile: str) -> dict[str, Any] | None: + """Return the profile's raw filter mapping, or None when it has no own filter.""" + path = profile_filter_path(profile) + if path is None or not path.exists(): + return None + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + warning(f"Profile filter at {path} is not a YAML mapping; ignoring it") + return None + return data + + +def effective_filter_settings( + config: dict[str, Any], + profile: str = DEFAULT_PROFILE, +) -> dict[str, Any]: + """Filter settings for *profile*: its filter.yaml when present, else the config. + + An explicit VP_PROXY_FILTER_MODE always wins, matching its precedence over + the config files. + """ + override = _load_profile_filter(profile) + if override is None: + return get_filter_settings(config) + settings = get_filter_settings({"proxy": {"filter": override}}) + env_mode = os.environ.get("VP_PROXY_FILTER_MODE", "").strip().lower() + if env_mode in VALID_MODES: + settings["mode"] = env_mode + return settings + + +def update_profile_filter( + profile: str, + mutate: Callable[[dict[str, Any]], None], +) -> dict[str, Any]: + """Apply *mutate* to the profile's filter settings and save them. + + The default profile keeps its settings in the global config.yaml; named + profiles get a filter.yaml in their profile dir, seeded from the global + settings on first write. + """ + path = profile_filter_path(profile) + if path is None: + return update_global_filter(mutate) + data: dict[str, Any] + if path.exists(): + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + data = raw if isinstance(raw, dict) else {} + else: + data = get_filter_settings(_load_yaml(get_global_config_path())) + mutate(data) + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(path, yaml.safe_dump(data, sort_keys=False)) + return data + + +def write_filter_file(config: dict[str, Any], profile: str = DEFAULT_PROFILE) -> Path: """Materialize filter settings into the proxy data dir (hot-reloaded by the proxy).""" - raw_mode = raw_configured_mode(config) - if raw_mode.strip().lower() not in VALID_MODES: - warning(f"Invalid proxy.filter.mode '{raw_mode}' in config; treating as 'open'") + if _load_profile_filter(profile) is None: + raw_mode = raw_configured_mode(config) + if raw_mode.strip().lower() not in VALID_MODES: + warning(f"Invalid proxy.filter.mode '{raw_mode}' in config; treating as 'open'") path = get_filter_file_path(config) path.parent.mkdir(parents=True, exist_ok=True) - _atomic_write_text(path, json.dumps(get_filter_settings(config), indent=2) + "\n") + settings = effective_filter_settings(config, profile) + _atomic_write_text(path, json.dumps(settings, indent=2) + "\n") return path diff --git a/tests/test_proxy_cmd.py b/tests/test_proxy_cmd.py index 93f20e9..0346fc2 100644 --- a/tests/test_proxy_cmd.py +++ b/tests/test_proxy_cmd.py @@ -44,7 +44,7 @@ def _patch_common(monkeypatch, events: list[str], config: dict, updated: bool = monkeypatch.setattr( proxy_cmd, "write_filter_file", - lambda config: events.append("write_filter_file"), + lambda config, profile=None: events.append("write_filter_file"), ) diff --git a/tests/test_proxy_filter.py b/tests/test_proxy_filter.py index ecf398b..bad9bd1 100644 --- a/tests/test_proxy_filter.py +++ b/tests/test_proxy_filter.py @@ -156,3 +156,96 @@ def test_atomic_write_preserves_symlinked_config(monkeypatch, tmp_path: Path) -> data = yaml.safe_load(target.read_text()) assert data["default_agent"] == "gemini" assert data["proxy"]["filter"]["mode"] == "allow" + + +# --- per-profile filter settings --- + + +@pytest.fixture() +def profile_env(monkeypatch, tmp_path: Path) -> Path: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("VP_PROXY_FILTER_MODE", raising=False) + (tmp_path / "profiles" / "work" / "agents").mkdir(parents=True) + return tmp_path + + +def test_profile_filter_path_default_is_none(profile_env: Path) -> None: + assert pf.profile_filter_path("default") is None + + +def test_profile_filter_path_named_profile(profile_env: Path) -> None: + assert pf.profile_filter_path("work") == profile_env / "profiles" / "work" / "filter.yaml" + + +def test_effective_settings_fall_back_to_global_without_profile_file(profile_env: Path) -> None: + config = {"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}} + settings = pf.effective_filter_settings(config, "work") + assert settings == {"mode": "deny", "allow": [], "deny": ["example.com"]} + + +def test_effective_settings_use_profile_file_when_present(profile_env: Path) -> None: + (profile_env / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "allow", "allow": ["api.anthropic.com"], "deny": []}), + ) + config = {"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}} + settings = pf.effective_filter_settings(config, "work") + assert settings == {"mode": "allow", "allow": ["api.anthropic.com"], "deny": []} + + +def test_effective_settings_default_profile_ignores_profile_files(profile_env: Path) -> None: + config = {"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}} + assert pf.effective_filter_settings(config, "default") == { + "mode": "deny", + "allow": [], + "deny": ["example.com"], + } + + +def test_env_mode_override_beats_profile_file(profile_env: Path, monkeypatch) -> None: + (profile_env / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "allow", "allow": ["api.anthropic.com"]}), + ) + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "open") + assert pf.effective_filter_settings({}, "work")["mode"] == "open" + + +def test_update_profile_filter_seeds_from_global_config(profile_env: Path) -> None: + (profile_env / "config.yaml").write_text( + yaml.safe_dump({"proxy": {"filter": {"mode": "deny", "deny": ["example.com"]}}}), + ) + pf.update_profile_filter("work", lambda f: f.update(mode="allow")) + data = yaml.safe_load((profile_env / "profiles" / "work" / "filter.yaml").read_text()) + assert data["mode"] == "allow" + assert data["deny"] == ["example.com"] + + +def test_update_profile_filter_mutates_existing_file(profile_env: Path) -> None: + path = profile_env / "profiles" / "work" / "filter.yaml" + path.write_text(yaml.safe_dump({"mode": "allow", "allow": ["a.com"], "deny": []})) + pf.update_profile_filter("work", lambda f: f["allow"].append("b.com")) + data = yaml.safe_load(path.read_text()) + assert data["allow"] == ["a.com", "b.com"] + + +def test_update_profile_filter_default_updates_global(profile_env: Path) -> None: + pf.update_profile_filter("default", lambda f: f.update(mode="deny")) + data = yaml.safe_load((profile_env / "config.yaml").read_text()) + assert data["proxy"]["filter"]["mode"] == "deny" + + +def test_write_filter_file_materializes_profile_settings(profile_env: Path) -> None: + (profile_env / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "allow", "allow": ["api.anthropic.com"], "deny": []}), + ) + config = { + "proxy": { + "db_path": str(profile_env / "proxy" / "proxy.db"), + "filter": {"mode": "deny", "deny": ["example.com"]}, + }, + } + path = pf.write_filter_file(config, profile="work") + assert json.loads(path.read_text()) == { + "mode": "allow", + "allow": ["api.anthropic.com"], + "deny": [], + } diff --git a/tests/test_proxy_filter_cmd.py b/tests/test_proxy_filter_cmd.py index 056dce5..b551824 100644 --- a/tests/test_proxy_filter_cmd.py +++ b/tests/test_proxy_filter_cmd.py @@ -159,3 +159,60 @@ def test_status_warns_on_invalid_configured_mode(config_dir: Path) -> None: assert result.exit_code == 0 assert "strict" in result.stdout assert "open" in result.stdout + + +# --- per-profile filter commands --- + + +@pytest.fixture() +def work_profile(config_dir: Path) -> Path: + (config_dir / "profiles" / "work" / "agents").mkdir(parents=True) + return config_dir + + +def _work_filter(config_dir: Path) -> dict: + return yaml.safe_load((config_dir / "profiles" / "work" / "filter.yaml").read_text()) + + +def test_filter_mode_with_profile_writes_profile_file(work_profile: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "allow", "--profile", "work"]) + assert result.exit_code == 0 + assert _work_filter(work_profile)["mode"] == "allow" + # Global config and the materialized file of the active (default) profile stay put. + assert "filter" not in _config_yaml(work_profile).get("proxy", {}) + assert _filter_json(work_profile)["mode"] == "open" + + +def test_filter_mode_targets_active_profile(work_profile: Path) -> None: + config = _config_yaml(work_profile) + config["profile"] = "work" + (work_profile / "config.yaml").write_text(yaml.safe_dump(config)) + + result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) + assert result.exit_code == 0 + assert _work_filter(work_profile)["mode"] == "allow" + assert _filter_json(work_profile)["mode"] == "allow" + + +def test_allow_add_with_profile(work_profile: Path) -> None: + result = runner.invoke( + app, + ["proxy", "filter", "allow", "add", "api.anthropic.com", "--profile", "work"], + ) + assert result.exit_code == 0 + assert _work_filter(work_profile)["allow"] == ["api.anthropic.com"] + + +def test_filter_status_with_profile_shows_profile_settings(work_profile: Path) -> None: + (work_profile / "profiles" / "work" / "filter.yaml").write_text( + yaml.safe_dump({"mode": "deny", "allow": [], "deny": ["example.com"]}), + ) + result = runner.invoke(app, ["proxy", "filter", "status", "--profile", "work"]) + assert result.exit_code == 0 + assert "deny" in result.output + assert "example.com" in result.output + + +def test_filter_with_unknown_profile_errors(work_profile: Path) -> None: + result = runner.invoke(app, ["proxy", "filter", "mode", "allow", "--profile", "nope"]) + assert result.exit_code == 1 diff --git a/tests/test_run.py b/tests/test_run.py index 4596d5b..d964966 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2638,7 +2638,11 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] } monkeypatch.setattr(run_cmd, "get_config", lambda: config) monkeypatch.setattr(run_cmd, "DockerManager", _ProxyDockerManager) - monkeypatch.setattr(run_cmd, "write_filter_file", lambda cfg: filter_calls.append(cfg)) + monkeypatch.setattr( + run_cmd, + "write_filter_file", + lambda cfg, profile=None: filter_calls.append(cfg), + ) run_cmd.run(agent="claude", workspace=tmp_path, detach=True) @@ -2696,7 +2700,7 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] } monkeypatch.setattr(run_cmd, "get_config", lambda: config) monkeypatch.setattr(run_cmd, "DockerManager", _UpdatingDockerManager) - monkeypatch.setattr(run_cmd, "write_filter_file", lambda cfg: None) + monkeypatch.setattr(run_cmd, "write_filter_file", lambda cfg, profile=None: None) run_cmd.run(agent="claude", workspace=tmp_path, detach=True) diff --git a/tests/test_task_cmd.py b/tests/test_task_cmd.py index 763c4e4..8881e92 100644 --- a/tests/test_task_cmd.py +++ b/tests/test_task_cmd.py @@ -1220,7 +1220,11 @@ def test_task_create_materializes_proxy_filter_file( } monkeypatch.setattr(task_cmd, "get_config", lambda: config) monkeypatch.setattr(task_cmd, "DockerManager", _CapturingDockerManager) - monkeypatch.setattr(task_cmd, "write_filter_file", lambda cfg: filter_calls.append(cfg)) + monkeypatch.setattr( + task_cmd, + "write_filter_file", + lambda cfg, profile=None: filter_calls.append(cfg), + ) task_cmd.task_create(agent="claude", prompt="hi", workspace=tmp_path) From db06010d9248ce6994d12b682c375834cf94926c Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Wed, 26 Aug 2026 09:12:16 +0200 Subject: [PATCH 6/8] Use proxy policies with running containers and selected profiles --- docs/configuration.md | 41 ++++- docs/profiles.md | 20 ++- src/vibepod/commands/list_cmd.py | 28 ++- src/vibepod/commands/profile.py | 25 ++- src/vibepod/commands/proxy.py | 37 ++-- src/vibepod/commands/run.py | 91 +++++++--- src/vibepod/commands/task.py | 89 +++++++--- src/vibepod/core/docker.py | 36 +++- src/vibepod/core/launch.py | 31 +++- src/vibepod/core/proxy_filter.py | 265 ++++++++++++++++++++++++++--- src/vibepod/core/proxy_identity.py | 26 +++ tests/test_docker.py | 47 +++++ tests/test_list.py | 51 +++++- tests/test_profile_cmd.py | 67 ++++++++ tests/test_proxy_cmd.py | 10 +- tests/test_proxy_filter.py | 154 ++++++++++++++++- tests/test_proxy_filter_cmd.py | 20 ++- tests/test_proxy_permissions.py | 4 + tests/test_run.py | 39 +++-- tests/test_task_cmd.py | 28 +-- 20 files changed, 952 insertions(+), 157 deletions(-) create mode 100644 src/vibepod/core/proxy_identity.py diff --git a/docs/configuration.md b/docs/configuration.md index affef15..ee95bdf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -354,9 +354,23 @@ vp proxy filter deny add example.com vp proxy filter mode open # back to no filtering; lists are kept ``` -Changes apply immediately — the running proxy hot-reloads the rules, no -restart needed. Blocked requests return `403` (HTTPS tunnels are refused at -`CONNECT`) and are logged with `blocked = 1` in the proxy database. +One shared proxy evaluates a separate policy for every VibePod agent container. +Each launch receives an opaque policy identity and is also bound to its source +container after startup. This prevents a later launch from replacing the rules +of an already-running agent. Unidentified clients continue to use the global +filter. + +Profile changes apply immediately to every running container using that +profile—no proxy restart is needed. Project filter settings and +`VP_PROXY_FILTER_MODE` are captured when each container starts, so changing +either affects new launches only. The effective profile and live mode are shown +by `vp list` in the `PROFILE` and `PROXY MODE` columns and in its JSON rows as +`profile` and `proxy_mode`. + +Blocked requests return `403` (HTTPS tunnels are refused at `CONNECT`) and are +logged with `blocked = 1` in the proxy database. Invalid policy configuration +is rejected instead of silently falling back to `open`; an identified launch +whose policy files are missing or malformed fails closed. The `vp proxy filter` commands act on the **active profile**. For the `default` profile they write the global config; for a named profile they write @@ -367,5 +381,22 @@ write), so switching profiles also switches the filter mode and lists. Pass A project-level `.vibepod/config.yaml` filter section or `VP_PROXY_FILTER_MODE` takes precedence over the global values (for a profile with its own `filter.yaml`, only `VP_PROXY_FILTER_MODE` still overrides the -mode); when that masks a change the command warns and the materialized rules -keep the effective (overriding) settings. +mode). Project and environment overrides are launch-specific and never replace +the shared global fallback. + +Custom proxy images must implement per-source policies and expose this exact +OCI image label: + +```dockerfile +LABEL io.vibepod.proxy.policy-schema="2" +``` + +VibePod checks the configured image—and an already-running proxy—before +launching a proxied agent. An image with a missing or different schema label is +rejected with an upgrade error. + +!!! warning "Filtering is not a network sandbox" + The filter controls requests that use the injected proxy. A container can + override its proxy environment or attempt a direct connection unless a + separate network-control layer prevents that. VibePod warns when explicit + `HTTP_PROXY` or `HTTPS_PROXY` values bypass its identified proxy URL. diff --git a/docs/profiles.md b/docs/profiles.md index 1f8ff06..e6af573 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -84,8 +84,18 @@ vp proxy filter status --profile work ``` The file is created on first write, seeded from the global filter settings. -A profile without `filter.yaml` uses the global `proxy.filter` config. On -`vp run`, `vp task create`, and `vp proxy start` the active profile's filter -is materialized for the proxy, so switching profiles switches the agent -credentials *and* the filter policy. The `default` profile keeps its filter in -the global config, as before. +A profile without `filter.yaml` inherits the global `proxy.filter` config and +then any project filter captured for that launch. An explicit profile file is +a complete replacement for the global/project base. In both cases, the +launch-time `VP_PROXY_FILTER_MODE` value wins last. + +The proxy keeps one materialized profile base and a small record for each +container. Editing a profile filter hot-reloads every running container that +uses it, while each container retains the project and environment overrides it +started with. This means two agents using different profiles—or different +projects with the same inherited profile—can safely share one proxy. + +`vp list` shows the selected profile and live effective proxy mode for each +running container. Removing a profile deletes its credentials, but VibePod +retains its materialized filter while any existing container (including a +stopped container) still references it. diff --git a/src/vibepod/commands/list_cmd.py b/src/vibepod/commands/list_cmd.py index 18aeebc..1e9ac18 100644 --- a/src/vibepod/commands/list_cmd.py +++ b/src/vibepod/commands/list_cmd.py @@ -14,6 +14,7 @@ from vibepod.core.config import get_config from vibepod.core.docker import DockerClientError, DockerManager from vibepod.core.launch import overlay_enabled +from vibepod.core.proxy_filter import resolve_container_policy from vibepod.utils.console import console, error @@ -30,7 +31,7 @@ def _configured_agent_rows() -> list[dict[str, str]]: return rows -def _running_rows(containers: list[Any]) -> list[dict[str, str]]: +def _running_rows(containers: list[Any], config: dict[str, Any]) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for container in containers: labels = getattr(container, "labels", {}) or {} @@ -38,10 +39,22 @@ def _running_rows(containers: list[Any]) -> list[dict[str, str]]: status = getattr(container, "status", "-") if not agent or status != "running": continue + profile = labels.get("vibepod.profile", "-") + policy_id = labels.get("vibepod.proxy-policy") + proxy_mode = "-" + if policy_id: + try: + settings = resolve_container_policy(config, policy_id) + mode = settings.get("mode") + proxy_mode = mode if isinstance(mode, str) else "unavailable" + except (OSError, ValueError): + proxy_mode = "unavailable" rows.append( { "agent": agent, "container": getattr(container, "name", "-"), + "profile": profile, + "proxy_mode": proxy_mode, "context": labels.get("vibepod.workspace", "-"), }, ) @@ -124,7 +137,8 @@ def list_agents( manager = None containers = [] - running_rows = _running_rows(containers) + config = get_config() + running_rows = _running_rows(containers, config) configured_rows = _configured_agent_rows() overlay_rows = [] if running else _overlay_rows(manager) @@ -141,11 +155,19 @@ def list_agents( running_table = Table(title="Running Agents", title_justify="left") running_table.add_column("AGENT", style="cyan") running_table.add_column("CONTAINER", style="magenta") + running_table.add_column("PROFILE") + running_table.add_column("PROXY MODE") running_table.add_column("CONTEXT") if running_rows: for row in running_rows: - running_table.add_row(row["agent"], row["container"], row["context"]) + running_table.add_row( + row["agent"], + row["container"], + row["profile"], + row["proxy_mode"], + row["context"], + ) console.print(running_table) else: console.print("No running agents.") diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py index 03b96be..3c09176 100644 --- a/src/vibepod/commands/profile.py +++ b/src/vibepod/commands/profile.py @@ -3,12 +3,13 @@ from __future__ import annotations import os -from typing import Annotated +from typing import Annotated, Any import typer from vibepod.constants import SUPPORTED_AGENTS from vibepod.core.config import get_config +from vibepod.core.docker import DockerClientError, DockerManager from vibepod.core.profiles import ( DEFAULT_PROFILE, create_profile, @@ -19,6 +20,7 @@ resolve_profile, validate_profile_name, ) +from vibepod.core.proxy_filter import cleanup_orphan_policies from vibepod.utils.console import console, error, success, warning app = typer.Typer(help="Manage credential profiles (separate agent logins per environment)") @@ -44,6 +46,25 @@ def _active_profile() -> str | None: return None +def _cleanup_removed_profile_policy(config: dict[str, Any]) -> None: + """Sweep only when the complete managed-container set is available.""" + try: + containers = DockerManager().list_managed(all_containers=True) + except DockerClientError: + return + policy_ids = { + policy_id + for container in containers + if isinstance( + policy_id := (getattr(container, "labels", {}) or {}).get( + "vibepod.proxy-policy", + ), + str, + ) + } + cleanup_orphan_policies(config, policy_ids) + + @app.command("list") def list_() -> None: """List profiles, the active one, and which agents have stored data.""" @@ -90,6 +111,7 @@ def remove( raise typer.Exit(code=1) if not yes: typer.confirm(f"Remove profile '{name}' and all credentials stored in it?", abort=True) + config = get_config() try: remove_profile(name) except ValueError as exc: @@ -104,4 +126,5 @@ def remove( raise typer.Exit(code=1) from exc if os.environ.get("VP_PROFILE") == name: console.print(f"Note: VP_PROFILE still points at removed profile '{name}'.") + _cleanup_removed_profile_policy(config) success(f"Removed profile '{name}'") diff --git a/src/vibepod/commands/proxy.py b/src/vibepod/commands/proxy.py index f3f4732..8d34ab0 100644 --- a/src/vibepod/commands/proxy.py +++ b/src/vibepod/commands/proxy.py @@ -14,11 +14,10 @@ from vibepod.core.proxy_filter import ( VALID_MODES, effective_filter_settings, + materialize_policy_bases, normalize_pattern, profile_filter_path, - raw_configured_mode, update_profile_filter, - write_filter_file, ) from vibepod.utils.console import error, info, success, warning @@ -46,14 +45,16 @@ def _target_profile(flag: str | None) -> str: raise typer.Exit(1) from exc -def _sync_filter_file() -> None: - """Rematerialize filter.json for the active profile (the one the proxy serves).""" +def _sync_filter_file(profile: str | None = None) -> None: + """Rematerialize the global and selected-profile schema-2 bases.""" config = get_config() - try: - active = resolve_profile(None, config) - except ValueError: - active = DEFAULT_PROFILE - write_filter_file(config, active) + target = profile + if target is None: + try: + target = resolve_profile(None, config) + except ValueError: + target = DEFAULT_PROFILE + materialize_policy_bases(config, target) def _uses_profile_file(profile: str) -> bool: @@ -68,12 +69,6 @@ def _normalized(entry: object) -> str: _OVERRIDE_HINT = "overridden by project config (.vibepod/config.yaml) or VP_PROXY_FILTER_MODE" -def _warn_invalid_configured_mode(config: dict[str, Any]) -> None: - raw = raw_configured_mode(config) - if raw.strip().lower() not in VALID_MODES: - warning(f"Invalid proxy.filter.mode '{raw}' in config; treating as 'open'") - - @filter_app.command("status") def filter_status( profile: Annotated[str | None, _PROFILE_OPTION] = None, @@ -86,7 +81,6 @@ def filter_status( info(f"Profile: {target} (profile-specific filter)") else: info(f"Profile: {target} (global filter settings)") - _warn_invalid_configured_mode(config) info(f"Mode: {settings['mode']}") info(f"Allow list ({len(settings['allow'])}): {', '.join(settings['allow']) or '—'}") info(f"Deny list ({len(settings['deny'])}): {', '.join(settings['deny']) or '—'}") @@ -115,7 +109,7 @@ def filter_mode( raise typer.Exit(1) target = _target_profile(profile) update_profile_filter(target, lambda f: f.update(mode=normalized)) - _sync_filter_file() + _sync_filter_file(target) success(f"Proxy filter mode set to '{normalized}' for profile '{target}'") effective = effective_filter_settings(get_config(), target) if effective["mode"] != normalized: @@ -150,7 +144,7 @@ def mutate(filter_cfg: dict[str, Any]) -> None: if not added: warning(f"'{pattern}' is already in the {list_name} list") return - _sync_filter_file() + _sync_filter_file(target) success(f"Added '{pattern}' to the {list_name} list of profile '{target}'") if pattern not in effective_filter_settings(get_config(), target)[list_name]: warning( @@ -183,7 +177,7 @@ def mutate(filter_cfg: dict[str, Any]) -> None: if not removed: warning(f"'{pattern}' is not in the {list_name} list") return - _sync_filter_file() + _sync_filter_file(target) success(f"Removed '{pattern}' from the {list_name} list of profile '{target}'") if pattern in effective_filter_settings(get_config(), target)[list_name]: warning( @@ -266,9 +260,7 @@ def proxy_start() -> None: if existing: existing.remove(force=True) - # Materialize filter rules so the proxy picks them up (and hand-edits to - # config.yaml are synced on start); write_filter_file warns on an invalid - # configured mode. + # Materialize the shared global and active-profile policy bases. _sync_filter_file() info("Starting proxy") @@ -277,6 +269,7 @@ def proxy_start() -> None: db_path=db_path, ca_dir=ca_dir, network=network_name, + policy_schema="2", ) if auto_clean: # Swept last: the replaced image is only removable once the proxy diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 3dd5273..4b0fe91 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -67,6 +67,9 @@ from vibepod.core.launch import ( init_entrypoint as _init_entrypoint, ) +from vibepod.core.launch import ( + managed_proxy_policy_ids as _managed_proxy_policy_ids, +) from vibepod.core.launch import ( parse_env_pairs as _parse_env_pairs, ) @@ -92,7 +95,13 @@ x11_volumes_and_env as _x11_volumes_and_env, ) from vibepod.core.profiles import resolve_profile -from vibepod.core.proxy_filter import write_filter_file +from vibepod.core.proxy_filter import ( + cleanup_orphan_policies, + materialize_container_policy, + materialize_policy_bases, + remove_container_policy, +) +from vibepod.core.proxy_identity import identified_proxy_url, new_policy_id from vibepod.core.resume import show_resume_hint from vibepod.core.session_logger import SessionLogger from vibepod.utils.console import error, info, success, warning @@ -592,6 +601,7 @@ def run( Path(proxy_ca_path_value).expanduser().resolve() if proxy_ca_path_value else None ) proxy_db_path: Path | None = None + proxy_policy_id: str | None = None extra_volumes = _agent_extra_volumes(selected_agent, config_dir) for host_path, _, _ in extra_volumes: @@ -652,15 +662,24 @@ def run( if existing_proxy: existing_proxy.remove(force=True) - # Materialize filter rules for the proxy; `vp proxy start` is not the - # only path that brings the proxy up. - write_filter_file(config, active_profile) - manager.ensure_proxy( image=proxy_image, db_path=proxy_db_path, ca_dir=proxy_ca_dir or proxy_db_path.parent / "mitmproxy", network=network_name, + policy_schema="2", + ) + + materialize_policy_bases(config, active_profile) + referenced_policy_ids = _managed_proxy_policy_ids(manager) + if referenced_policy_ids is not None: + cleanup_orphan_policies(config, referenced_policy_ids) + proxy_policy_id = new_policy_id() + materialize_container_policy( + config, + profile=active_profile, + workspace=workspace_path, + policy_id=proxy_policy_id, ) if proxy_ca_path: @@ -674,9 +693,14 @@ def run( if not ca_ready: warning(f"Proxy CA not found yet at {proxy_ca_path}") - proxy_url = "http://vibepod-proxy:8080" + proxy_url = identified_proxy_url(proxy_policy_id) merged_env.setdefault("HTTP_PROXY", proxy_url) merged_env.setdefault("HTTPS_PROXY", proxy_url) + if any(merged_env[key] != proxy_url for key in ("HTTP_PROXY", "HTTPS_PROXY")): + warning( + "Explicit HTTP_PROXY or HTTPS_PROXY overrides the identified VibePod proxy; " + "this launch may bypass its per-container filter policy.", + ) merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") _ca = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" merged_env.setdefault("NODE_EXTRA_CA_CERTS", _ca) @@ -691,26 +715,39 @@ def run( container_user = None if not rootless_podman and spec.run_as_host_user: container_user = _host_user() - container = manager.run_agent( - agent=selected_agent, - image=image, - workspace=workspace_path, - config_dir=config_dir, - config_mount_path=spec.config_mount_path, - env=merged_env, - command=command, - auto_remove=bool(config.get("auto_remove", True)), - name=name, - version=__version__, - network=network_name, - ports=agent_ports, - extra_volumes=extra_volumes, - platform=spec.platform, - user=container_user, - entrypoint=entrypoint, - userns_mode=agent_userns_mode, - extra_labels=herdr_labels, - ) + launch_labels = dict(herdr_labels) + if proxy_policy_id is not None: + launch_labels.update( + { + "vibepod.profile": active_profile, + "vibepod.proxy-policy": proxy_policy_id, + }, + ) + try: + container = manager.run_agent( + agent=selected_agent, + image=image, + workspace=workspace_path, + config_dir=config_dir, + config_mount_path=spec.config_mount_path, + env=merged_env, + command=command, + auto_remove=bool(config.get("auto_remove", True)), + name=name, + version=__version__, + network=network_name, + ports=agent_ports, + extra_volumes=extra_volumes, + platform=spec.platform, + user=container_user, + entrypoint=entrypoint, + userns_mode=agent_userns_mode, + extra_labels=launch_labels, + ) + except Exception: + if proxy_policy_id is not None: + remove_container_policy(config, proxy_policy_id) + raise container.reload() if container.status != "running": @@ -749,6 +786,8 @@ def run( container.id, container.name, selected_agent, + policy_id=proxy_policy_id, + profile=active_profile, ) if not mapping_updated: warning( diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index fe5ee28..bc86987 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -39,13 +39,20 @@ host_identity_env, host_user, init_entrypoint, + managed_proxy_policy_ids, parse_env_pairs, read_claude_stored_token, terminal_env_defaults, update_container_mapping, ) from vibepod.core.profiles import resolve_profile -from vibepod.core.proxy_filter import write_filter_file +from vibepod.core.proxy_filter import ( + cleanup_orphan_policies, + materialize_container_policy, + materialize_policy_bases, + remove_container_policy, +) +from vibepod.core.proxy_identity import identified_proxy_url, new_policy_id from vibepod.core.tasks import ( TASK_STATUS_CANCELLED, TASK_STATUS_COMPLETED, @@ -708,6 +715,7 @@ def task_create( Path(proxy_ca_path_value).expanduser().resolve() if proxy_ca_path_value else None ) proxy_db_path: Path | None = None + proxy_policy_id: str | None = None if proxy_enabled: proxy_image = str(proxy_cfg.get("image", "vibepod/proxy:latest")) @@ -729,16 +737,25 @@ def task_create( if existing_proxy: existing_proxy.remove(force=True) - # Materialize filter rules for the proxy; `vp proxy start` is not the - # only path that brings the proxy up. - write_filter_file(config, active_profile) - actual_ca_dir = proxy_ca_dir or proxy_db_path.parent / "mitmproxy" manager.ensure_proxy( image=proxy_image, db_path=proxy_db_path, ca_dir=actual_ca_dir, network=network_name, + policy_schema="2", + ) + + materialize_policy_bases(config, active_profile) + referenced_policy_ids = managed_proxy_policy_ids(manager) + if referenced_policy_ids is not None: + cleanup_orphan_policies(config, referenced_policy_ids) + proxy_policy_id = new_policy_id() + materialize_container_policy( + config, + profile=active_profile, + workspace=workspace_path, + policy_id=proxy_policy_id, ) if proxy_ca_path: @@ -748,9 +765,14 @@ def task_create( break time.sleep(0.25) - proxy_url = "http://vibepod-proxy:8080" + proxy_url = identified_proxy_url(proxy_policy_id) merged_env.setdefault("HTTP_PROXY", proxy_url) merged_env.setdefault("HTTPS_PROXY", proxy_url) + if any(merged_env[key] != proxy_url for key in ("HTTP_PROXY", "HTTPS_PROXY")): + warning( + "Explicit HTTP_PROXY or HTTPS_PROXY overrides the identified VibePod proxy; " + "this launch may bypass its per-container filter policy.", + ) merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") _ca = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" merged_env.setdefault("NODE_EXTRA_CA_CERTS", _ca) @@ -764,26 +786,39 @@ def task_create( container_user = None if not rootless_podman and spec.run_as_host_user: container_user = host_user() - container = manager.run_agent( - agent=selected, - image=image, - workspace=workspace_path, - config_dir=config_dir, - config_mount_path=spec.config_mount_path, - env=merged_env, - command=command, - auto_remove=False, # tasks keep the container so logs/exit survive - name=name, - version=__version__, - network=network_name, - ports=agent_ports, - extra_volumes=extra_volumes, - platform=spec.platform, - user=container_user, - entrypoint=entrypoint, - userns_mode=agent_userns_mode, - extra_labels=herdr_labels, - ) + launch_labels = dict(herdr_labels) + if proxy_policy_id is not None: + launch_labels.update( + { + "vibepod.profile": active_profile, + "vibepod.proxy-policy": proxy_policy_id, + }, + ) + try: + container = manager.run_agent( + agent=selected, + image=image, + workspace=workspace_path, + config_dir=config_dir, + config_mount_path=spec.config_mount_path, + env=merged_env, + command=command, + auto_remove=False, # tasks keep the container so logs/exit survive + name=name, + version=__version__, + network=network_name, + ports=agent_ports, + extra_volumes=extra_volumes, + platform=spec.platform, + user=container_user, + entrypoint=entrypoint, + userns_mode=agent_userns_mode, + extra_labels=launch_labels, + ) + except Exception: + if proxy_policy_id is not None: + remove_container_policy(config, proxy_policy_id) + raise container.reload() if container.status not in {"running", "created"}: @@ -810,6 +845,8 @@ def task_create( container.id, container.name, selected, + policy_id=proxy_policy_id, + profile=active_profile, ) state = container.attrs.get("State", {}) or {} diff --git a/src/vibepod/core/docker.py b/src/vibepod/core/docker.py index 3973b93..db3afa2 100644 --- a/src/vibepod/core/docker.py +++ b/src/vibepod/core/docker.py @@ -72,6 +72,7 @@ class DockerClientError(RuntimeError): # How much trailing container output attach_interactive keeps for post-exit # inspection (resume hints appear in the last few lines of a session). ATTACH_TAIL_LIMIT = 64 * 1024 +PROXY_POLICY_SCHEMA_LABEL = "io.vibepod.proxy.policy-schema" def _run_podman(podman: str, args: list[str]) -> str | None: @@ -403,6 +404,27 @@ def image_id(self, image: str) -> str | None: # missing id as "no confirmed image", never as a failure to report. return None + def require_proxy_policy_schema(self, image: str | Any, required: str = "2") -> None: + """Require an exact per-source policy schema label on a proxy image.""" + image_name = image if isinstance(image, str) else "the running proxy image" + try: + inspected = self.client.images.get(image) if isinstance(image, str) else image + attrs = inspected.attrs + config = attrs.get("Config", {}) if isinstance(attrs, dict) else {} + labels = config.get("Labels", {}) if isinstance(config, dict) else {} + actual = labels.get(PROXY_POLICY_SCHEMA_LABEL) if isinstance(labels, dict) else None + except (AttributeError, DockerException) as exc: + raise DockerClientError( + f"Proxy image {image_name} could not be inspected for policy schema {required}", + ) from exc + if actual != required: + shown = actual if isinstance(actual, str) and actual else "missing" + raise DockerClientError( + f"Proxy image {image_name} is incompatible: required policy schema {required}, " + f"found {shown}. Use an updated VibePod proxy image or add " + f"the label {PROXY_POLICY_SCHEMA_LABEL}={required} to a compatible custom image.", + ) + def build_image( self, context_tar: Any, @@ -813,10 +835,19 @@ def find_proxy(self) -> Any | None: ) return containers[0] if containers else None - def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) -> Any: + def ensure_proxy( + self, + image: str, + db_path: Path, + ca_dir: Path, + network: str, + policy_schema: str | None = None, + ) -> Any: existing = self.find_proxy() if existing: if existing.status == "running": + if policy_schema is not None: + self.require_proxy_policy_schema(existing.image, policy_schema) return existing existing.remove(force=True) @@ -826,6 +857,9 @@ def ensure_proxy(self, image: str, db_path: Path, ca_dir: Path, network: str) -> except NotFound: self.pull_image(image) + if policy_schema is not None: + self.require_proxy_policy_schema(image, policy_schema) + db_path.parent.mkdir(parents=True, exist_ok=True) ca_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/vibepod/core/launch.py b/src/vibepod/core/launch.py index 0ab0ad3..deafc02 100644 --- a/src/vibepod/core/launch.py +++ b/src/vibepod/core/launch.py @@ -347,12 +347,36 @@ def get_container_ip(container: Any, network: str) -> str | None: return None +def managed_proxy_policy_ids(manager: Any) -> set[str] | None: + """Return policy IDs on all existing managed containers, or None if unknowable.""" + lister = getattr(manager, "list_managed", None) + if not callable(lister): + return None + try: + containers = lister(all_containers=True) + except DockerClientError: + return None + return { + policy_id + for container in containers + if isinstance( + policy_id := (getattr(container, "labels", {}) or {}).get( + "vibepod.proxy-policy", + ), + str, + ) + } + + def update_container_mapping( mapping_path: Path, ip: str, container_id: str, container_name: str, agent: str, + *, + policy_id: str | None = None, + profile: str | None = None, ) -> bool: """Merge a new IP→container entry into containers.json atomically.""" mapping: dict[str, dict[str, str]] = {} @@ -363,12 +387,17 @@ def update_container_mapping( except (json.JSONDecodeError, OSError): pass - mapping[ip] = { + entry = { "container_id": container_id, "container_name": container_name, "agent": agent, "started_at": datetime.now(timezone.utc).isoformat(), } + if policy_id is not None: + entry["policy_id"] = policy_id + if profile is not None: + entry["profile"] = profile + mapping[ip] = entry tmp_path = mapping_path.with_suffix(".tmp") tmp_path.write_text(json.dumps(mapping, indent=2)) diff --git a/src/vibepod/core/proxy_filter.py b/src/vibepod/core/proxy_filter.py index a5e6a19..ea139b8 100644 --- a/src/vibepod/core/proxy_filter.py +++ b/src/vibepod/core/proxy_filter.py @@ -12,10 +12,17 @@ import yaml -from vibepod.core.config import _load_yaml, get_global_config_path -from vibepod.core.profiles import DEFAULT_PROFILE, profiles_root -from vibepod.utils.console import warning +from vibepod.core.config import ( + _default_config, + _load_yaml, + deep_merge, + get_global_config_path, + load_project_config, +) +from vibepod.core.profiles import DEFAULT_PROFILE, profiles_root, validate_profile_name +from vibepod.core.proxy_identity import validate_policy_id +POLICY_SCHEMA = 2 VALID_MODES = ("open", "allow", "deny") _PATTERN_RE = re.compile( @@ -37,23 +44,32 @@ def normalize_pattern(raw: str) -> str: def get_filter_settings(config: dict[str, Any]) -> dict[str, Any]: """Return normalized filter settings from an effective config.""" proxy_cfg = config.get("proxy", {}) - filter_cfg = proxy_cfg.get("filter", {}) if isinstance(proxy_cfg, dict) else {} + if not isinstance(proxy_cfg, dict): + raise ValueError("Config key 'proxy' must be a mapping") + filter_cfg = proxy_cfg.get("filter", {}) if not isinstance(filter_cfg, dict): - filter_cfg = {} + raise ValueError("Config key 'proxy.filter' must be a mapping") - mode = str(filter_cfg.get("mode", "open")).strip().lower() + raw_mode = filter_cfg.get("mode", "open") + if not isinstance(raw_mode, str): + raise ValueError("Config key 'proxy.filter.mode' must be a string") + mode = raw_mode.strip().lower() if mode not in VALID_MODES: - mode = "open" + raise ValueError( + f"Invalid proxy.filter.mode '{raw_mode}'. Choose one of: {', '.join(VALID_MODES)}", + ) - def _patterns(raw: Any) -> list[str]: + def _patterns(raw: Any, key: str) -> list[str]: if not isinstance(raw, list): - return [] - return [str(p).strip().lower().rstrip(".") for p in raw if str(p).strip()] + raise ValueError(f"Config key 'proxy.filter.{key}' must be a list of strings") + if any(not isinstance(pattern, str) for pattern in raw): + raise ValueError(f"Config key 'proxy.filter.{key}' must contain only strings") + return [normalize_pattern(pattern) for pattern in raw] return { "mode": mode, - "allow": _patterns(filter_cfg.get("allow")), - "deny": _patterns(filter_cfg.get("deny")), + "allow": _patterns(filter_cfg.get("allow", []), "allow"), + "deny": _patterns(filter_cfg.get("deny", []), "deny"), } @@ -105,8 +121,7 @@ def _load_profile_filter(profile: str) -> dict[str, Any] | None: return None data = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(data, dict): - warning(f"Profile filter at {path} is not a YAML mapping; ignoring it") - return None + raise ValueError(f"Profile filter at {path} must be a YAML mapping") return data @@ -123,9 +138,9 @@ def effective_filter_settings( if override is None: return get_filter_settings(config) settings = get_filter_settings({"proxy": {"filter": override}}) - env_mode = os.environ.get("VP_PROXY_FILTER_MODE", "").strip().lower() - if env_mode in VALID_MODES: - settings["mode"] = env_mode + env_mode = os.environ.get("VP_PROXY_FILTER_MODE") + if env_mode is not None: + settings["mode"] = _normalize_mode(env_mode, "VP_PROXY_FILTER_MODE") return settings @@ -156,10 +171,6 @@ def update_profile_filter( def write_filter_file(config: dict[str, Any], profile: str = DEFAULT_PROFILE) -> Path: """Materialize filter settings into the proxy data dir (hot-reloaded by the proxy).""" - if _load_profile_filter(profile) is None: - raw_mode = raw_configured_mode(config) - if raw_mode.strip().lower() not in VALID_MODES: - warning(f"Invalid proxy.filter.mode '{raw_mode}' in config; treating as 'open'") path = get_filter_file_path(config) path.parent.mkdir(parents=True, exist_ok=True) settings = effective_filter_settings(config, profile) @@ -167,6 +178,218 @@ def write_filter_file(config: dict[str, Any], profile: str = DEFAULT_PROFILE) -> return path +def _normalize_mode(raw: Any, source: str = "proxy.filter.mode") -> str: + if not isinstance(raw, str): + raise ValueError(f"{source} must be a string") + mode = raw.strip().lower() + if mode not in VALID_MODES: + raise ValueError(f"Invalid {source} '{raw}'. Choose one of: {', '.join(VALID_MODES)}") + return mode + + +def _normalize_filter_mapping(raw: Any) -> dict[str, Any]: + """Validate a partial filter mapping while retaining only its explicit keys.""" + if not isinstance(raw, dict): + raise ValueError("Config key 'proxy.filter' must be a mapping") + unsupported = set(raw) - {"mode", "allow", "deny"} + if unsupported: + names = ", ".join(sorted(str(key) for key in unsupported)) + raise ValueError(f"Unsupported proxy.filter key(s): {names}") + normalized = get_filter_settings({"proxy": {"filter": raw}}) + return {key: normalized[key] for key in ("mode", "allow", "deny") if key in raw} + + +def get_proxy_data_dir(config: dict[str, Any]) -> Path: + """Return the host directory mounted at ``/data`` in the proxy container.""" + return get_filter_file_path(config).parent + + +def materialized_profile_path(config: dict[str, Any], profile: str) -> Path: + validate_profile_name(profile) + return get_proxy_data_dir(config) / "policies" / "profiles" / f"{profile}.json" + + +def container_policy_path(config: dict[str, Any], policy_id: str) -> Path: + return ( + get_proxy_data_dir(config) + / "policies" + / "containers" + / f"{validate_policy_id(policy_id)}.json" + ) + + +def _global_filter_settings() -> dict[str, Any]: + global_config = deep_merge(_default_config(), _load_yaml(get_global_config_path())) + return get_filter_settings(global_config) + + +def materialize_policy_bases(config: dict[str, Any], profile: str) -> list[Path]: + """Atomically materialize the global and selected explicit-profile policy bases.""" + global_path = get_filter_file_path(config) + global_path.parent.mkdir(parents=True, exist_ok=True) + global_document = {"version": POLICY_SCHEMA, **_global_filter_settings()} + _atomic_write_text(global_path, json.dumps(global_document, indent=2) + "\n") + written = [global_path] + + if profile == DEFAULT_PROFILE: + return written + + target = materialized_profile_path(config, profile) + profile_filter = _load_profile_filter(profile) + if profile_filter is None: + if target.exists(): + target.unlink() + return written + + target.parent.mkdir(parents=True, exist_ok=True) + profile_document = { + "version": POLICY_SCHEMA, + "profile": profile, + **get_filter_settings({"proxy": {"filter": profile_filter}}), + } + _atomic_write_text(target, json.dumps(profile_document, indent=2) + "\n") + written.append(target) + return written + + +def _project_filter(workspace: Path) -> dict[str, Any] | None: + project = load_project_config(workspace) + if "proxy" not in project: + return None + proxy = project["proxy"] + if not isinstance(proxy, dict): + raise ValueError("Project config key 'proxy' must be a mapping") + if "filter" not in proxy: + return None + return _normalize_filter_mapping(proxy["filter"]) + + +def materialize_container_policy( + config: dict[str, Any], + *, + profile: str, + workspace: Path, + policy_id: str, +) -> Path: + """Capture launch-specific project and environment policy inputs.""" + validated_id = validate_policy_id(policy_id) + env_value = os.environ.get("VP_PROXY_FILTER_MODE") + env_mode = _normalize_mode(env_value, "VP_PROXY_FILTER_MODE") if env_value is not None else None + document = { + "version": POLICY_SCHEMA, + "policy_id": validated_id, + "profile": profile, + "project_filter": _project_filter(workspace), + "env_mode": env_mode, + } + path = container_policy_path(config, validated_id) + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(path, json.dumps(document, indent=2) + "\n") + return path + + +def _load_policy_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Unable to read proxy policy at {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"Proxy policy at {path} must be a JSON object") + if data.get("version") != POLICY_SCHEMA: + raise ValueError(f"Proxy policy at {path} does not use schema {POLICY_SCHEMA}") + return data + + +def _settings_from_document(document: dict[str, Any]) -> dict[str, Any]: + raw = { + key: document.get(key, default) + for key, default in ( + ("mode", "open"), + ("allow", []), + ("deny", []), + ) + } + return get_filter_settings({"proxy": {"filter": raw}}) + + +def resolve_container_policy(config: dict[str, Any], policy_id: str) -> dict[str, Any]: + """Resolve the live effective settings for one materialized launch policy.""" + record = _load_policy_json(container_policy_path(config, policy_id)) + if record.get("policy_id") != policy_id: + raise ValueError("Container proxy policy id does not match its filename") + profile = record.get("profile") + if not isinstance(profile, str) or not profile: + raise ValueError("Container proxy policy profile must be a non-empty string") + validate_profile_name(profile) + + global_settings = _settings_from_document(_load_policy_json(get_filter_file_path(config))) + profile_path = materialized_profile_path(config, profile) + if profile != DEFAULT_PROFILE and profile_path.exists(): + settings = _settings_from_document(_load_policy_json(profile_path)) + else: + merged = dict(global_settings) + project_filter = record.get("project_filter") + if project_filter is not None: + merged.update(_normalize_filter_mapping(project_filter)) + settings = get_filter_settings({"proxy": {"filter": merged}}) + + env_mode = record.get("env_mode") + if env_mode is not None: + settings["mode"] = _normalize_mode(env_mode, "container env_mode") + return settings + + +def remove_container_policy(config: dict[str, Any], policy_id: str) -> None: + """Remove one launch policy record if it exists.""" + container_policy_path(config, policy_id).unlink(missing_ok=True) + + +def cleanup_orphan_policies( + config: dict[str, Any], + referenced_policy_ids: set[str], +) -> dict[str, int]: + """Remove records not referenced by any existing managed container.""" + root = get_proxy_data_dir(config) / "policies" + containers_dir = root / "containers" + profiles_dir = root / "profiles" + removed_containers = 0 + referenced_profiles: set[str] = set() + unknown_reference = False + + if containers_dir.exists(): + for path in containers_dir.glob("*.json"): + policy_id = path.stem + if policy_id not in referenced_policy_ids: + path.unlink(missing_ok=True) + removed_containers += 1 + continue + try: + record = _load_policy_json(path) + except ValueError: + unknown_reference = True + continue + profile = record.get("profile") + if isinstance(profile, str) and profile: + referenced_profiles.add(profile) + else: + unknown_reference = True + + removed_profiles = 0 + if profiles_dir.exists() and not unknown_reference: + for path in profiles_dir.glob("*.json"): + profile = path.stem + try: + validate_profile_name(profile) + except ValueError: + continue + source = profile_filter_path(profile) + if profile not in referenced_profiles and (source is None or not source.exists()): + path.unlink(missing_ok=True) + removed_profiles += 1 + + return {"containers": removed_containers, "profiles": removed_profiles} + + def update_global_filter(mutate: Callable[[dict[str, Any]], None]) -> dict[str, Any]: """Apply *mutate* to proxy.filter in the global config.yaml and save it.""" path = get_global_config_path() diff --git a/src/vibepod/core/proxy_identity.py b/src/vibepod/core/proxy_identity.py new file mode 100644 index 0000000..938ba84 --- /dev/null +++ b/src/vibepod/core/proxy_identity.py @@ -0,0 +1,26 @@ +"""Per-launch proxy policy identity helpers.""" + +from __future__ import annotations + +import re +from uuid import uuid4 + +_POLICY_ID_RE = re.compile(r"^[0-9a-f]{32}$") + + +def validate_policy_id(policy_id: str) -> str: + """Return a valid policy identifier or raise ``ValueError``.""" + if not _POLICY_ID_RE.fullmatch(policy_id): + raise ValueError(f"Invalid proxy policy id '{policy_id}'") + return policy_id + + +def new_policy_id() -> str: + """Generate an opaque identifier for one agent launch policy.""" + return uuid4().hex + + +def identified_proxy_url(policy_id: str) -> str: + """Return the shared proxy URL carrying one launch policy identity.""" + validated = validate_policy_id(policy_id) + return f"http://vp-{validated}:vibepod@vibepod-proxy:8080" diff --git a/tests/test_docker.py b/tests/test_docker.py index d587c4e..b91696a 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -412,6 +412,53 @@ def test_ensure_proxy_pulls_image_when_missing(mock_docker, tmp_path: Path) -> N mock_client.containers.run.assert_called_once() +@pytest.mark.parametrize("label", [None, "", "two", "3"]) +@patch("vibepod.core.docker.docker") +def test_require_proxy_policy_schema_rejects_incompatible_image( + mock_docker, + label: str | None, +) -> None: + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + labels = {} if label is None else {"io.vibepod.proxy.policy-schema": label} + mock_client.images.get.return_value.attrs = {"Config": {"Labels": labels}} + + manager = DockerManager() + with pytest.raises(DockerClientError, match="policy schema 2"): + manager.require_proxy_policy_schema("example/proxy:custom", "2") + + +@patch("vibepod.core.docker.docker") +def test_require_proxy_policy_schema_accepts_exact_label(mock_docker) -> None: + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_client.images.get.return_value.attrs = { + "Config": {"Labels": {"io.vibepod.proxy.policy-schema": "2"}}, + } + + manager = DockerManager() + manager.require_proxy_policy_schema("example/proxy:custom", "2") + + +@patch("vibepod.core.docker.docker") +def test_ensure_proxy_rejects_incompatible_running_container(mock_docker, tmp_path: Path) -> None: + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + existing = MagicMock(status="running") + existing.image.attrs = {"Config": {"Labels": {}}} + mock_client.containers.list.return_value = [existing] + + manager = DockerManager() + with pytest.raises(DockerClientError, match="policy schema 2"): + manager.ensure_proxy( + image="example/proxy:custom", + db_path=tmp_path / "proxy.db", + ca_dir=tmp_path / "ca", + network="vibepod-network", + policy_schema="2", + ) + + def test_discover_podman_socket_skipped_when_docker_host_set(monkeypatch) -> None: monkeypatch.setenv("DOCKER_HOST", "unix:///var/run/docker.sock") assert _discover_podman_socket() is None diff --git a/tests/test_list.py b/tests/test_list.py index 613938f..2992a70 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -79,7 +79,12 @@ def list_managed(self, all_containers: bool = True): # noqa: ARG002 _FakeContainer( "vibepod-claude-1", "running", - {"vibepod.agent": "claude", "vibepod.workspace": "/workspace/a"}, + { + "vibepod.agent": "claude", + "vibepod.workspace": "/workspace/a", + "vibepod.profile": "work", + "vibepod.proxy-policy": "1" * 32, + }, ), _FakeContainer( "vibepod-claude-2", @@ -94,6 +99,11 @@ def list_managed(self, all_containers: bool = True): # noqa: ARG002 ] monkeypatch.setattr(list_cmd, "DockerManager", _FakeDockerManager) + monkeypatch.setattr( + list_cmd, + "resolve_container_policy", + lambda config, policy_id: {"mode": "allow", "allow": [], "deny": []}, + ) result = runner.invoke(app, ["list", "--running", "--json"]) assert result.exit_code == 0 @@ -104,7 +114,44 @@ def list_managed(self, all_containers: bool = True): # noqa: ARG002 assert len(rows) == 2 assert [row["container"] for row in rows] == ["vibepod-claude-1", "vibepod-claude-2"] assert {row["context"] for row in rows} == {"/workspace/a", "/workspace/b"} - assert all(set(row) == {"agent", "container", "context"} for row in rows) + assert rows[0]["profile"] == "work" + assert rows[0]["proxy_mode"] == "allow" + assert rows[1]["profile"] == "-" + assert rows[1]["proxy_mode"] == "-" + assert all( + set(row) == {"agent", "container", "profile", "proxy_mode", "context"} for row in rows + ) + + +def test_list_running_table_includes_profile_and_proxy_mode(monkeypatch) -> None: + class _Container: + name = "vibepod-claude-1" + status = "running" + labels = { + "vibepod.agent": "claude", + "vibepod.workspace": "/workspace/a", + "vibepod.profile": "work", + "vibepod.proxy-policy": "1" * 32, + } + + class _Manager: + def list_managed(self, all_containers: bool = True): # noqa: ARG002 + return [_Container()] + + monkeypatch.setattr(list_cmd, "DockerManager", _Manager) + monkeypatch.setattr( + list_cmd, + "resolve_container_policy", + lambda config, policy_id: {"mode": "deny", "allow": [], "deny": []}, + ) + + result = runner.invoke(app, ["list", "--running"]) + + assert result.exit_code == 0 + assert "PROFILE" in result.stdout + assert "PROXY MODE" in result.stdout + assert "work" in result.stdout + assert "deny" in result.stdout def test_list_json_reports_project_overlay_image(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/test_profile_cmd.py b/tests/test_profile_cmd.py index ae46716..a15fd57 100644 --- a/tests/test_profile_cmd.py +++ b/tests/test_profile_cmd.py @@ -2,12 +2,14 @@ from __future__ import annotations +import json from pathlib import Path import pytest from typer.testing import CliRunner from vibepod.cli import app +from vibepod.commands import profile as profile_cmd from vibepod.core.agents import agent_config_dir runner = CliRunner() @@ -69,6 +71,71 @@ def test_profile_remove(config_root: Path) -> None: assert not (config_root / "profiles" / "work").exists() +def test_profile_remove_retains_materialized_policy_while_container_references_it( + config_root: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner.invoke(app, ["profile", "create", "work"]) + (config_root / "config.yaml").write_text( + f"proxy:\n db_path: {config_root / 'proxy' / 'proxy.db'}\n", + ) + policy_id = "6" * 32 + materialized = config_root / "proxy" / "policies" / "profiles" / "work.json" + record = config_root / "proxy" / "policies" / "containers" / f"{policy_id}.json" + materialized.parent.mkdir(parents=True) + record.parent.mkdir(parents=True) + materialized.write_text("{}") + record.write_text( + json.dumps( + { + "version": 2, + "policy_id": policy_id, + "profile": "work", + "project_filter": None, + "env_mode": None, + }, + ), + ) + + class _Container: + labels = {"vibepod.proxy-policy": policy_id} + + class _Manager: + def list_managed(self, all_containers: bool = True): # noqa: ARG002 + return [_Container()] + + monkeypatch.setattr(profile_cmd, "DockerManager", _Manager) + + result = runner.invoke(app, ["profile", "remove", "work", "--yes"]) + + assert result.exit_code == 0 + assert materialized.exists() + + +def test_profile_remove_cleans_unreferenced_materialized_policy( + config_root: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner.invoke(app, ["profile", "create", "work"]) + (config_root / "config.yaml").write_text( + f"proxy:\n db_path: {config_root / 'proxy' / 'proxy.db'}\n", + ) + materialized = config_root / "proxy" / "policies" / "profiles" / "work.json" + materialized.parent.mkdir(parents=True) + materialized.write_text("{}") + + class _Manager: + def list_managed(self, all_containers: bool = True): # noqa: ARG002 + return [] + + monkeypatch.setattr(profile_cmd, "DockerManager", _Manager) + + result = runner.invoke(app, ["profile", "remove", "work", "--yes"]) + + assert result.exit_code == 0 + assert not materialized.exists() + + def test_profile_remove_refuses_default(config_root: Path) -> None: result = runner.invoke(app, ["profile", "remove", "default", "--yes"]) assert result.exit_code != 0 diff --git a/tests/test_proxy_cmd.py b/tests/test_proxy_cmd.py index 0346fc2..ecd5f89 100644 --- a/tests/test_proxy_cmd.py +++ b/tests/test_proxy_cmd.py @@ -43,8 +43,8 @@ def _patch_common(monkeypatch, events: list[str], config: dict, updated: bool = monkeypatch.setattr(proxy_cmd, "get_config", lambda: config) monkeypatch.setattr( proxy_cmd, - "write_filter_file", - lambda config, profile=None: events.append("write_filter_file"), + "materialize_policy_bases", + lambda config, profile: events.append("materialize_policy_bases"), ) @@ -59,7 +59,7 @@ def test_proxy_start_recreates_container_before_cleanup(monkeypatch) -> None: "ensure_network", "pull_if_newer(auto_clean=False)", "container.remove", - "write_filter_file", + "materialize_policy_bases", "ensure_proxy", "clean_untagged_images", ] @@ -74,7 +74,7 @@ def test_proxy_start_keeps_container_without_update(monkeypatch) -> None: assert events == [ "ensure_network", "pull_if_newer(auto_clean=False)", - "write_filter_file", + "materialize_policy_bases", "ensure_proxy", "clean_untagged_images", ] @@ -90,6 +90,6 @@ def test_proxy_start_skips_cleanup_without_auto_clean(monkeypatch) -> None: "ensure_network", "pull_if_newer(auto_clean=False)", "container.remove", - "write_filter_file", + "materialize_policy_bases", "ensure_proxy", ] diff --git a/tests/test_proxy_filter.py b/tests/test_proxy_filter.py index bad9bd1..a6927b8 100644 --- a/tests/test_proxy_filter.py +++ b/tests/test_proxy_filter.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from pathlib import Path import pytest @@ -10,6 +11,7 @@ from vibepod.core import proxy_filter as pf from vibepod.core.config import get_config +from vibepod.core.proxy_identity import identified_proxy_url, new_policy_id def test_normalize_pattern_lowercases_and_strips() -> None: @@ -33,9 +35,16 @@ def test_get_filter_settings_defaults_open() -> None: assert pf.get_filter_settings({}) == {"mode": "open", "allow": [], "deny": []} -def test_get_filter_settings_coerces_invalid_mode() -> None: +def test_get_filter_settings_rejects_invalid_mode() -> None: config = {"proxy": {"filter": {"mode": "strict", "allow": ["a.com"], "deny": []}}} - assert pf.get_filter_settings(config)["mode"] == "open" + with pytest.raises(ValueError, match="strict"): + pf.get_filter_settings(config) + + +def test_get_filter_settings_rejects_non_string_pattern() -> None: + config = {"proxy": {"filter": {"mode": "deny", "allow": [], "deny": [123]}}} + with pytest.raises(ValueError, match="strings"): + pf.get_filter_settings(config) def test_get_filter_settings_passes_valid_config() -> None: @@ -128,19 +137,16 @@ def test_update_global_filter_refuses_non_mapping_config(monkeypatch, tmp_path: assert yaml.safe_load((tmp_path / "config.yaml").read_text()) == ["just", "a", "list"] -def test_write_filter_file_warns_on_invalid_mode(tmp_path: Path, capsys) -> None: - """Every startup path materializes; the fail-open coercion must be visible.""" +def test_write_filter_file_rejects_invalid_mode(tmp_path: Path) -> None: config = { "proxy": { "db_path": str(tmp_path / "proxy" / "proxy.db"), "filter": {"mode": "alow", "allow": [], "deny": []}, }, } - path = pf.write_filter_file(config) - assert json.loads(path.read_text())["mode"] == "open" - out = capsys.readouterr().out - assert "alow" in out - assert "open" in out + with pytest.raises(ValueError, match="alow"): + pf.write_filter_file(config) + assert not (tmp_path / "proxy" / "filter.json").exists() def test_atomic_write_preserves_symlinked_config(monkeypatch, tmp_path: Path) -> None: @@ -249,3 +255,133 @@ def test_write_filter_file_materializes_profile_settings(profile_env: Path) -> N "allow": ["api.anthropic.com"], "deny": [], } + + +def test_policy_identity_is_random_hex_and_builds_proxy_url() -> None: + first = new_policy_id() + second = new_policy_id() + assert first != second + assert re.fullmatch(r"[0-9a-f]{32}", first) + assert identified_proxy_url(first) == f"http://vp-{first}:vibepod@vibepod-proxy:8080" + + +def test_materialize_container_policy_captures_project_and_environment( + profile_env: Path, + monkeypatch, +) -> None: + workspace = profile_env / "project" + project_config = workspace / ".vibepod" / "config.yaml" + project_config.parent.mkdir(parents=True) + project_config.write_text("proxy:\n filter:\n mode: deny\n deny: [example.com]\n") + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "allow") + config = { + "proxy": { + "db_path": str(profile_env / "proxy" / "proxy.db"), + "filter": {"mode": "allow", "allow": ["api.anthropic.com"], "deny": []}, + }, + } + policy_id = "1" * 32 + + path = pf.materialize_container_policy( + config, + profile="work", + workspace=workspace, + policy_id=policy_id, + ) + + assert json.loads(path.read_text()) == { + "version": 2, + "policy_id": policy_id, + "profile": "work", + "project_filter": {"mode": "deny", "deny": ["example.com"]}, + "env_mode": "allow", + } + + +def test_materialized_profile_hot_reload_preserves_container_env_override( + profile_env: Path, + monkeypatch, +) -> None: + profile_path = profile_env / "profiles" / "work" / "filter.yaml" + profile_path.write_text("mode: allow\nallow: [api.anthropic.com]\ndeny: []\n") + monkeypatch.setenv("VP_PROXY_FILTER_MODE", "deny") + config = { + "proxy": { + "db_path": str(profile_env / "proxy" / "proxy.db"), + "filter": {"mode": "open", "allow": [], "deny": []}, + }, + } + policy_id = "2" * 32 + pf.materialize_policy_bases(config, "work") + pf.materialize_container_policy( + config, + profile="work", + workspace=profile_env, + policy_id=policy_id, + ) + assert pf.resolve_container_policy(config, policy_id)["mode"] == "deny" + + profile_path.write_text("mode: open\nallow: []\ndeny: []\n") + pf.materialize_policy_bases(config, "work") + + assert pf.resolve_container_policy(config, policy_id) == { + "mode": "deny", + "allow": [], + "deny": [], + } + + +def test_cleanup_orphan_policies_keeps_existing_container_and_referenced_profile( + profile_env: Path, +) -> None: + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + kept_id = "4" * 32 + orphan_id = "5" * 32 + containers_dir = profile_env / "proxy" / "policies" / "containers" + profiles_dir = profile_env / "proxy" / "policies" / "profiles" + containers_dir.mkdir(parents=True) + profiles_dir.mkdir(parents=True) + (containers_dir / f"{kept_id}.json").write_text( + json.dumps( + { + "version": 2, + "policy_id": kept_id, + "profile": "removed-work", + "project_filter": None, + "env_mode": None, + }, + ), + ) + (containers_dir / f"{orphan_id}.json").write_text("{}") + (profiles_dir / "removed-work.json").write_text("{}") + (profiles_dir / "unused.json").write_text("{}") + + removed = pf.cleanup_orphan_policies(config, {kept_id}) + + assert removed == {"containers": 1, "profiles": 1} + assert (containers_dir / f"{kept_id}.json").exists() + assert not (containers_dir / f"{orphan_id}.json").exists() + assert (profiles_dir / "removed-work.json").exists() + assert not (profiles_dir / "unused.json").exists() + + +def test_resolver_rejects_profile_path_traversal(profile_env: Path) -> None: + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + policy_id = "7" * 32 + pf.materialize_policy_bases(config, "default") + path = pf.container_policy_path(config, policy_id) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "version": 2, + "policy_id": policy_id, + "profile": "../../outside", + "project_filter": None, + "env_mode": None, + }, + ), + ) + + with pytest.raises(ValueError, match="Invalid profile name"): + pf.resolve_container_policy(config, policy_id) diff --git a/tests/test_proxy_filter_cmd.py b/tests/test_proxy_filter_cmd.py index b551824..6e3b869 100644 --- a/tests/test_proxy_filter_cmd.py +++ b/tests/test_proxy_filter_cmd.py @@ -126,7 +126,7 @@ def test_mode_warns_when_project_config_overrides(config_dir: Path) -> None: assert result.exit_code == 0 assert "overridden" in result.stdout - assert _filter_json(config_dir)["mode"] == "deny" + assert _filter_json(config_dir)["mode"] == "allow" def test_mode_warns_when_env_overrides(config_dir: Path, monkeypatch) -> None: @@ -136,7 +136,7 @@ def test_mode_warns_when_env_overrides(config_dir: Path, monkeypatch) -> None: assert result.exit_code == 0 assert "overridden" in result.stdout - assert _filter_json(config_dir)["mode"] == "deny" + assert _filter_json(config_dir)["mode"] == "allow" def test_add_warns_when_project_config_overrides(config_dir: Path) -> None: @@ -148,17 +148,17 @@ def test_add_warns_when_project_config_overrides(config_dir: Path) -> None: assert result.exit_code == 0 assert "overridden" in result.stdout - assert _filter_json(config_dir)["allow"] == [] + assert _filter_json(config_dir)["allow"] == ["a.com"] -def test_status_warns_on_invalid_configured_mode(config_dir: Path) -> None: +def test_status_rejects_invalid_configured_mode(config_dir: Path) -> None: (config_dir / "config.yaml").write_text( f"proxy:\n db_path: {config_dir / 'proxy' / 'proxy.db'}\n filter:\n mode: strict\n", ) result = runner.invoke(app, ["proxy", "filter", "status"]) - assert result.exit_code == 0 - assert "strict" in result.stdout - assert "open" in result.stdout + assert result.exit_code == 1 + assert isinstance(result.exception, ValueError) + assert "strict" in str(result.exception) # --- per-profile filter commands --- @@ -191,7 +191,11 @@ def test_filter_mode_targets_active_profile(work_profile: Path) -> None: result = runner.invoke(app, ["proxy", "filter", "mode", "allow"]) assert result.exit_code == 0 assert _work_filter(work_profile)["mode"] == "allow" - assert _filter_json(work_profile)["mode"] == "allow" + assert _filter_json(work_profile)["mode"] == "open" + materialized = json.loads( + (work_profile / "proxy" / "policies" / "profiles" / "work.json").read_text(), + ) + assert materialized["mode"] == "allow" def test_allow_add_with_profile(work_profile: Path) -> None: diff --git a/tests/test_proxy_permissions.py b/tests/test_proxy_permissions.py index b6cf26b..7bda914 100644 --- a/tests/test_proxy_permissions.py +++ b/tests/test_proxy_permissions.py @@ -20,6 +20,8 @@ def test_update_container_mapping_success(tmp_path: Path) -> None: "abc123", "vibepod-claude-test", "claude", + policy_id="1" * 32, + profile="work", ) assert updated is True @@ -27,6 +29,8 @@ def test_update_container_mapping_success(tmp_path: Path) -> None: assert data["172.18.0.3"]["container_id"] == "abc123" assert data["172.18.0.3"]["container_name"] == "vibepod-claude-test" assert data["172.18.0.3"]["agent"] == "claude" + assert data["172.18.0.3"]["policy_id"] == "1" * 32 + assert data["172.18.0.3"]["profile"] == "work" def test_update_container_mapping_permission_error_returns_false( diff --git a/tests/test_run.py b/tests/test_run.py index d964966..e5a7d59 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2598,9 +2598,9 @@ def test_run_dsh_prints_preview_warning_and_web_ui_url(monkeypatch, tmp_path: Pa assert any("http://127.0.0.1:3080" in msg for msg in successes) -def test_run_materializes_proxy_filter_file(monkeypatch, tmp_path: Path) -> None: - """Configured filter rules must reach the proxy even without vp proxy start.""" - filter_calls: list[dict] = [] +def test_run_materializes_source_policy_and_wires_identity(monkeypatch, tmp_path: Path) -> None: + """A proxied run receives a launch policy, identity, labels, and source binding.""" + captured: dict[str, dict] = {} class _ProxyDockerManager: def ensure_network(self, name: str) -> None: @@ -2613,9 +2613,10 @@ def pull_image(self, image: str, auto_clean: bool = False) -> None: pass def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] - pass + captured["proxy"] = kwargs def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] + captured["run"] = kwargs return type( "_Container", (), @@ -2623,7 +2624,11 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] "name": "vibepod-claude-test", "id": "abc123", "status": "running", - "attrs": {"NetworkSettings": {"Networks": {}}}, + "attrs": { + "NetworkSettings": { + "Networks": {"vibepod-network": {"IPAddress": "172.18.0.3"}}, + }, + }, "reload": lambda self: None, "labels": {}, "logs": lambda self, **kw: b"", @@ -2638,15 +2643,23 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] } monkeypatch.setattr(run_cmd, "get_config", lambda: config) monkeypatch.setattr(run_cmd, "DockerManager", _ProxyDockerManager) - monkeypatch.setattr( - run_cmd, - "write_filter_file", - lambda cfg, profile=None: filter_calls.append(cfg), - ) + monkeypatch.setattr(run_cmd, "new_policy_id", lambda: "1" * 32) + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "config")) run_cmd.run(agent="claude", workspace=tmp_path, detach=True) - assert filter_calls == [config] + assert captured["proxy"]["policy_schema"] == "2" + assert captured["run"]["env"]["HTTP_PROXY"].startswith(f"http://vp-{'1' * 32}:") + assert captured["run"]["env"]["HTTPS_PROXY"] == captured["run"]["env"]["HTTP_PROXY"] + assert captured["run"]["extra_labels"]["vibepod.profile"] == "default" + assert captured["run"]["extra_labels"]["vibepod.proxy-policy"] == "1" * 32 + record = json.loads( + (tmp_path / "proxy" / "policies" / "containers" / f"{'1' * 32}.json").read_text(), + ) + assert record["profile"] == "default" + mapping = json.loads((tmp_path / "proxy" / "containers.json").read_text()) + assert mapping["172.18.0.3"]["policy_id"] == "1" * 32 + assert mapping["172.18.0.3"]["profile"] == "default" def test_run_recreates_proxy_when_image_updated(monkeypatch, tmp_path: Path) -> None: @@ -2700,7 +2713,9 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] } monkeypatch.setattr(run_cmd, "get_config", lambda: config) monkeypatch.setattr(run_cmd, "DockerManager", _UpdatingDockerManager) - monkeypatch.setattr(run_cmd, "write_filter_file", lambda cfg, profile=None: None) + monkeypatch.setattr(run_cmd, "materialize_policy_bases", lambda cfg, profile: []) + monkeypatch.setattr(run_cmd, "materialize_container_policy", lambda *args, **kwargs: None) + monkeypatch.setattr(run_cmd, "new_policy_id", lambda: "3" * 32) run_cmd.run(agent="claude", workspace=tmp_path, detach=True) diff --git a/tests/test_task_cmd.py b/tests/test_task_cmd.py index 8881e92..6a0760c 100644 --- a/tests/test_task_cmd.py +++ b/tests/test_task_cmd.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -1205,13 +1206,15 @@ def test_task_create_dsh_keeps_user_extra_ports(monkeypatch, tmp_path, tmp_task_ assert any(key.split("/", 1)[0] == "9999" for key in ports) -def test_task_create_materializes_proxy_filter_file( +def test_task_create_materializes_source_policy_and_wires_identity( monkeypatch, tmp_path, tmp_task_store, ) -> None: - """Configured filter rules must reach the proxy even without vp proxy start.""" - filter_calls: list[dict] = [] + """A proxied task receives a launch policy, identity, labels, and schema check.""" + stub = _CapturingDockerManager() + proxy_calls: list[dict] = [] + stub.ensure_proxy = lambda **kwargs: proxy_calls.append(kwargs) # type: ignore[method-assign] config = _make_config() config["proxy"] = { "enabled": True, @@ -1219,13 +1222,18 @@ def test_task_create_materializes_proxy_filter_file( "db_path": str(tmp_path / "proxy" / "proxy.db"), } monkeypatch.setattr(task_cmd, "get_config", lambda: config) - monkeypatch.setattr(task_cmd, "DockerManager", _CapturingDockerManager) - monkeypatch.setattr( - task_cmd, - "write_filter_file", - lambda cfg, profile=None: filter_calls.append(cfg), - ) + monkeypatch.setattr(task_cmd, "DockerManager", lambda: stub) + monkeypatch.setattr(task_cmd, "new_policy_id", lambda: "2" * 32) + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "config")) task_cmd.task_create(agent="claude", prompt="hi", workspace=tmp_path) - assert filter_calls == [config] + assert proxy_calls[0]["policy_schema"] == "2" + assert stub.run_kwargs is not None + assert stub.run_kwargs["env"]["HTTP_PROXY"].startswith(f"http://vp-{'2' * 32}:") + assert stub.run_kwargs["extra_labels"]["vibepod.profile"] == "default" + assert stub.run_kwargs["extra_labels"]["vibepod.proxy-policy"] == "2" * 32 + record = json.loads( + (tmp_path / "proxy" / "policies" / "containers" / f"{'2' * 32}.json").read_text(), + ) + assert record["profile"] == "default" From 389e2f50d68ef9c3b2141bab23fd9d7c3190230a Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Wed, 26 Aug 2026 08:00:40 +0000 Subject: [PATCH 7/8] Fail closed on invalid profile filters and stale policy cleanup --- pyproject.toml | 1 + src/vibepod/core/proxy_filter.py | 86 +++++++++++------- tests/test_proxy_filter.py | 150 +++++++++++++++++++------------ 3 files changed, 150 insertions(+), 87 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9f8a7d..811a43c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "rich>=13.0.0", "docker>=7.0.0", "pyyaml>=6.0.0", + "ruamel.yaml>=0.18.0", "platformdirs>=4.0.0", "tomli>=2.0.1; python_version < '3.11'", ] diff --git a/src/vibepod/core/proxy_filter.py b/src/vibepod/core/proxy_filter.py index ea139b8..55d5b3f 100644 --- a/src/vibepod/core/proxy_filter.py +++ b/src/vibepod/core/proxy_filter.py @@ -2,15 +2,19 @@ from __future__ import annotations +import io import json import os import re import tempfile +import time from collections.abc import Callable from pathlib import Path from typing import Any import yaml +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap from vibepod.core.config import ( _default_config, @@ -25,6 +29,11 @@ POLICY_SCHEMA = 2 VALID_MODES = ("open", "allow", "deny") +# A launch writes its container policy record before its container exists, so a +# concurrent cleanup must not delete a record younger than this window or it +# would strand the still-starting agent with no policy (failing closed). +_ORPHAN_GRACE_SECONDS = 60.0 + _PATTERN_RE = re.compile( r"^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", ) @@ -73,15 +82,6 @@ def _patterns(raw: Any, key: str) -> list[str]: } -def raw_configured_mode(config: dict[str, Any]) -> str: - """Return the configured mode as written, before fail-open coercion.""" - proxy_cfg = config.get("proxy", {}) - filter_cfg = proxy_cfg.get("filter", {}) if isinstance(proxy_cfg, dict) else {} - if not isinstance(filter_cfg, dict): - return "open" - return str(filter_cfg.get("mode", "open")) - - def get_filter_file_path(config: dict[str, Any]) -> Path: proxy_cfg = config.get("proxy", {}) db_path = ( @@ -160,6 +160,10 @@ def update_profile_filter( data: dict[str, Any] if path.exists(): raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if raw is not None and not isinstance(raw, dict): + raise ValueError( + f"Profile filter at {path} is not a YAML mapping; refusing to rewrite it", + ) data = raw if isinstance(raw, dict) else {} else: data = get_filter_settings(_load_yaml(get_global_config_path())) @@ -169,15 +173,6 @@ def update_profile_filter( return data -def write_filter_file(config: dict[str, Any], profile: str = DEFAULT_PROFILE) -> Path: - """Materialize filter settings into the proxy data dir (hot-reloaded by the proxy).""" - path = get_filter_file_path(config) - path.parent.mkdir(parents=True, exist_ok=True) - settings = effective_filter_settings(config, profile) - _atomic_write_text(path, json.dumps(settings, indent=2) + "\n") - return path - - def _normalize_mode(raw: Any, source: str = "proxy.filter.mode") -> str: if not isinstance(raw, str): raise ValueError(f"{source} must be a string") @@ -237,8 +232,9 @@ def materialize_policy_bases(config: dict[str, Any], profile: str) -> list[Path] target = materialized_profile_path(config, profile) profile_filter = _load_profile_filter(profile) if profile_filter is None: - if target.exists(): - target.unlink() + # No source filter.yaml: leave any existing materialized base in place. + # A still-running container may reference it, and reference-aware + # deletion is cleanup_orphan_policies' job, not this hot path's. return written target.parent.mkdir(parents=True, exist_ok=True) @@ -355,11 +351,21 @@ def cleanup_orphan_policies( removed_containers = 0 referenced_profiles: set[str] = set() unknown_reference = False + now = time.time() if containers_dir.exists(): for path in containers_dir.glob("*.json"): policy_id = path.stem - if policy_id not in referenced_policy_ids: + keep = policy_id in referenced_policy_ids + if not keep: + try: + age = now - path.stat().st_mtime + except OSError: + continue + # A record younger than the grace window may belong to a + # concurrent launch whose container has not been created yet. + keep = age < _ORPHAN_GRACE_SECONDS + if not keep: path.unlink(missing_ok=True) removed_containers += 1 continue @@ -390,23 +396,41 @@ def cleanup_orphan_policies( return {"containers": removed_containers, "profiles": removed_profiles} +def _roundtrip_yaml() -> YAML: + """A round-trip YAML handler that keeps comments, anchors, and quoting.""" + handler = YAML() + handler.preserve_quotes = True + return handler + + def update_global_filter(mutate: Callable[[dict[str, Any]], None]) -> dict[str, Any]: - """Apply *mutate* to proxy.filter in the global config.yaml and save it.""" + """Apply *mutate* to proxy.filter in the global config.yaml and save it. + + The file is edited via a round-trip loader so a user's comments, anchors, + and formatting survive a filter change. + """ path = get_global_config_path() + handler = _roundtrip_yaml() + data: Any = None if path.exists(): - raw = yaml.safe_load(path.read_text(encoding="utf-8")) - if raw is not None and not isinstance(raw, dict): - raise ValueError( - f"Global config at {path} is not a YAML mapping; refusing to rewrite it", - ) - data = _load_yaml(path) - proxy_cfg = data.setdefault("proxy", {}) + text = path.read_text(encoding="utf-8") + if text.strip(): + data = handler.load(text) + if not isinstance(data, dict): + raise ValueError( + f"Global config at {path} is not a YAML mapping; refusing to rewrite it", + ) + if data is None: + data = CommentedMap() + proxy_cfg = data.setdefault("proxy", CommentedMap()) if not isinstance(proxy_cfg, dict): raise ValueError("Config key 'proxy' must be a mapping") - filter_cfg = proxy_cfg.setdefault("filter", {}) + filter_cfg = proxy_cfg.setdefault("filter", CommentedMap()) if not isinstance(filter_cfg, dict): raise ValueError("Config key 'proxy.filter' must be a mapping") mutate(filter_cfg) path.parent.mkdir(parents=True, exist_ok=True) - _atomic_write_text(path, yaml.safe_dump(data, sort_keys=False)) + buffer = io.StringIO() + handler.dump(data, buffer) + _atomic_write_text(path, buffer.getvalue()) return filter_cfg diff --git a/tests/test_proxy_filter.py b/tests/test_proxy_filter.py index a6927b8..615492d 100644 --- a/tests/test_proxy_filter.py +++ b/tests/test_proxy_filter.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import re from pathlib import Path @@ -56,22 +57,6 @@ def test_get_filter_settings_passes_valid_config() -> None: } -def test_write_filter_file_materializes_next_to_db(tmp_path: Path) -> None: - config = { - "proxy": { - "db_path": str(tmp_path / "proxy" / "proxy.db"), - "filter": {"mode": "deny", "allow": [], "deny": ["example.com"]}, - }, - } - path = pf.write_filter_file(config) - assert path == tmp_path / "proxy" / "filter.json" - assert json.loads(path.read_text()) == { - "mode": "deny", - "allow": [], - "deny": ["example.com"], - } - - def test_update_global_filter_writes_config_yaml(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) pf.update_global_filter(lambda f: f.update(mode="allow")) @@ -89,6 +74,22 @@ def test_update_global_filter_preserves_other_keys(monkeypatch, tmp_path: Path) assert data["proxy"]["filter"]["allow"] == ["a.com"] +def test_update_global_filter_preserves_comments(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + "# top-level note kept\n" + "default_agent: gemini # inline note kept\n" + "proxy:\n" + " filter:\n" + " mode: open\n", + ) + pf.update_global_filter(lambda f: f.update(mode="allow")) + text = (tmp_path / "config.yaml").read_text() + assert "# top-level note kept" in text + assert "# inline note kept" in text + assert yaml.safe_load(text)["proxy"]["filter"]["mode"] == "allow" + + def test_default_config_has_open_filter(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) monkeypatch.chdir(tmp_path) @@ -104,15 +105,14 @@ def test_env_overrides_filter_mode(monkeypatch, tmp_path: Path) -> None: assert config["proxy"]["filter"]["mode"] == "deny" -def test_write_filter_file_is_atomic(monkeypatch, tmp_path: Path) -> None: +def test_materialized_global_base_is_atomic(monkeypatch, tmp_path: Path) -> None: """No truncate-then-write: replace so hot-reload readers never see partials.""" - config = { - "proxy": { - "db_path": str(tmp_path / "proxy" / "proxy.db"), - "filter": {"mode": "deny", "allow": [], "deny": ["example.com"]}, - }, - } - pf.write_filter_file(config) + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + "proxy:\n filter:\n mode: deny\n deny: [example.com]\n", + ) + config = {"proxy": {"db_path": str(tmp_path / "proxy" / "proxy.db")}} + pf.materialize_policy_bases(config, "default") calls: list[tuple[Path, Path]] = [] real_replace = pf.os.replace @@ -122,7 +122,8 @@ def spy_replace(src, dst): real_replace(src, dst) monkeypatch.setattr(pf.os, "replace", spy_replace) - path = pf.write_filter_file(config) + pf.materialize_policy_bases(config, "default") + path = pf.get_filter_file_path(config).resolve() assert calls and calls[-1][1] == path assert json.loads(path.read_text())["mode"] == "deny" assert list(path.parent.glob(f".{path.name}.*")) == [] @@ -137,18 +138,6 @@ def test_update_global_filter_refuses_non_mapping_config(monkeypatch, tmp_path: assert yaml.safe_load((tmp_path / "config.yaml").read_text()) == ["just", "a", "list"] -def test_write_filter_file_rejects_invalid_mode(tmp_path: Path) -> None: - config = { - "proxy": { - "db_path": str(tmp_path / "proxy" / "proxy.db"), - "filter": {"mode": "alow", "allow": [], "deny": []}, - }, - } - with pytest.raises(ValueError, match="alow"): - pf.write_filter_file(config) - assert not (tmp_path / "proxy" / "filter.json").exists() - - def test_atomic_write_preserves_symlinked_config(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) target = tmp_path / "dotfiles" / "vibepod.yaml" @@ -239,24 +228,6 @@ def test_update_profile_filter_default_updates_global(profile_env: Path) -> None assert data["proxy"]["filter"]["mode"] == "deny" -def test_write_filter_file_materializes_profile_settings(profile_env: Path) -> None: - (profile_env / "profiles" / "work" / "filter.yaml").write_text( - yaml.safe_dump({"mode": "allow", "allow": ["api.anthropic.com"], "deny": []}), - ) - config = { - "proxy": { - "db_path": str(profile_env / "proxy" / "proxy.db"), - "filter": {"mode": "deny", "deny": ["example.com"]}, - }, - } - path = pf.write_filter_file(config, profile="work") - assert json.loads(path.read_text()) == { - "mode": "allow", - "allow": ["api.anthropic.com"], - "deny": [], - } - - def test_policy_identity_is_random_hex_and_builds_proxy_url() -> None: first = new_policy_id() second = new_policy_id() @@ -352,7 +323,10 @@ def test_cleanup_orphan_policies_keeps_existing_container_and_referenced_profile }, ), ) - (containers_dir / f"{orphan_id}.json").write_text("{}") + orphan_path = containers_dir / f"{orphan_id}.json" + orphan_path.write_text("{}") + # A real orphan predates the grace window that protects in-flight launches. + _age(orphan_path) (profiles_dir / "removed-work.json").write_text("{}") (profiles_dir / "unused.json").write_text("{}") @@ -365,6 +339,70 @@ def test_cleanup_orphan_policies_keeps_existing_container_and_referenced_profile assert not (profiles_dir / "unused.json").exists() +def _age(path: Path, seconds: float = 3600.0) -> None: + """Backdate a file's mtime so grace-window logic treats it as settled.""" + stat = path.stat() + os.utime(path, (stat.st_atime - seconds, stat.st_mtime - seconds)) + + +def test_update_profile_filter_refuses_non_mapping_file(profile_env: Path) -> None: + path = profile_env / "profiles" / "work" / "filter.yaml" + path.write_text("- a.com\n- b.com\n") + with pytest.raises(ValueError, match="mapping"): + pf.update_profile_filter("work", lambda f: f.update(mode="allow")) + assert path.read_text() == "- a.com\n- b.com\n" + + +def test_materialize_policy_bases_retains_base_without_source_file( + profile_env: Path, +) -> None: + """A removed profile's materialized base survives while a container references it.""" + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + base = pf.materialized_profile_path(config, "work") + base.parent.mkdir(parents=True) + base.write_text( + json.dumps( + { + "version": 2, + "profile": "work", + "mode": "allow", + "allow": ["api.anthropic.com"], + "deny": [], + }, + ), + ) + # 'work' has no filter.yaml (it was removed); re-materializing must not wipe it. + pf.materialize_policy_bases(config, "work") + assert base.exists() + + +def test_cleanup_orphan_policies_retains_recent_unreferenced_record( + profile_env: Path, +) -> None: + """A just-written record for a launch whose container isn't listed yet is kept.""" + config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} + fresh_id = "8" * 32 + containers_dir = profile_env / "proxy" / "policies" / "containers" + containers_dir.mkdir(parents=True) + fresh_path = containers_dir / f"{fresh_id}.json" + fresh_path.write_text( + json.dumps( + { + "version": 2, + "policy_id": fresh_id, + "profile": "default", + "project_filter": None, + "env_mode": None, + }, + ), + ) + + removed = pf.cleanup_orphan_policies(config, set()) + + assert removed["containers"] == 0 + assert fresh_path.exists() + + def test_resolver_rejects_profile_path_traversal(profile_env: Path) -> None: config = {"proxy": {"db_path": str(profile_env / "proxy" / "proxy.db")}} policy_id = "7" * 32 From 8fbfb0477fde2c978989661fc0808fb5dafd1285 Mon Sep 17 00:00:00 2001 From: Harald Nezbeda Date: Wed, 26 Aug 2026 08:01:24 +0000 Subject: [PATCH 8/8] Provision proxy safely and surface launch errors cleanly --- src/vibepod/commands/profile.py | 17 ++---- src/vibepod/commands/proxy.py | 86 +++++++++++++++------------ src/vibepod/commands/run.py | 84 +++++++++----------------- src/vibepod/commands/task.py | 82 ++++++++------------------ src/vibepod/core/docker.py | 3 + src/vibepod/core/launch.py | 101 +++++++++++++++++++++++++++++++- tests/test_provision_proxy.py | 76 ++++++++++++++++++++++++ tests/test_proxy_cmd.py | 13 ++-- tests/test_proxy_filter_cmd.py | 5 +- tests/test_run.py | 15 +++-- tests/test_task_cmd.py | 5 +- 11 files changed, 312 insertions(+), 175 deletions(-) create mode 100644 tests/test_provision_proxy.py diff --git a/src/vibepod/commands/profile.py b/src/vibepod/commands/profile.py index 3c09176..b187c8c 100644 --- a/src/vibepod/commands/profile.py +++ b/src/vibepod/commands/profile.py @@ -10,6 +10,7 @@ from vibepod.constants import SUPPORTED_AGENTS from vibepod.core.config import get_config from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.core.launch import managed_proxy_policy_ids from vibepod.core.profiles import ( DEFAULT_PROFILE, create_profile, @@ -49,20 +50,12 @@ def _active_profile() -> str | None: def _cleanup_removed_profile_policy(config: dict[str, Any]) -> None: """Sweep only when the complete managed-container set is available.""" try: - containers = DockerManager().list_managed(all_containers=True) + manager = DockerManager() except DockerClientError: return - policy_ids = { - policy_id - for container in containers - if isinstance( - policy_id := (getattr(container, "labels", {}) or {}).get( - "vibepod.proxy-policy", - ), - str, - ) - } - cleanup_orphan_policies(config, policy_ids) + referenced = managed_proxy_policy_ids(manager) + if referenced is not None: + cleanup_orphan_policies(config, referenced) @app.command("list") diff --git a/src/vibepod/commands/proxy.py b/src/vibepod/commands/proxy.py index 8d34ab0..22ff035 100644 --- a/src/vibepod/commands/proxy.py +++ b/src/vibepod/commands/proxy.py @@ -2,6 +2,8 @@ from __future__ import annotations +import contextlib +from collections.abc import Iterator from pathlib import Path from typing import Annotated, Any @@ -9,7 +11,8 @@ from vibepod.constants import EXIT_DOCKER_NOT_RUNNING from vibepod.core.config import get_config -from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag +from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.core.launch import provision_proxy from vibepod.core.profiles import DEFAULT_PROFILE, resolve_profile from vibepod.core.proxy_filter import ( VALID_MODES, @@ -45,6 +48,16 @@ def _target_profile(flag: str | None) -> str: raise typer.Exit(1) from exc +@contextlib.contextmanager +def _clean_config_errors() -> Iterator[None]: + """Surface invalid config/env (e.g. VP_PROXY_FILTER_MODE) as a clean exit.""" + try: + yield + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + def _sync_filter_file(profile: str | None = None) -> None: """Rematerialize the global and selected-profile schema-2 bases.""" config = get_config() @@ -76,7 +89,8 @@ def filter_status( """Show filter mode, lists, and proxy state.""" config = get_config() target = _target_profile(profile) - settings = effective_filter_settings(config, target) + with _clean_config_errors(): + settings = effective_filter_settings(config, target) if _uses_profile_file(target): info(f"Profile: {target} (profile-specific filter)") else: @@ -89,11 +103,12 @@ def filter_status( except DockerClientError: info("Proxy: unknown (Docker is not running)") return + # find_proxy already returns a container listed with a fresh status, so an + # extra reload() only risks a NotFound race if the proxy is removed midway. existing = manager.find_proxy() if existing is None: info("Proxy: not running") else: - existing.reload() info(f"Proxy: {existing.name} ({existing.status})") @@ -108,10 +123,11 @@ def filter_mode( error(f"Unknown mode '{value}'. Valid modes: {', '.join(VALID_MODES)}") raise typer.Exit(1) target = _target_profile(profile) - update_profile_filter(target, lambda f: f.update(mode=normalized)) - _sync_filter_file(target) + with _clean_config_errors(): + update_profile_filter(target, lambda f: f.update(mode=normalized)) + _sync_filter_file(target) + effective = effective_filter_settings(get_config(), target) success(f"Proxy filter mode set to '{normalized}' for profile '{target}'") - effective = effective_filter_settings(get_config(), target) if effective["mode"] != normalized: warning( f"Effective mode stays '{effective['mode']}' — {_OVERRIDE_HINT}", @@ -140,13 +156,16 @@ def mutate(filter_cfg: dict[str, Any]) -> None: return entries.append(pattern) - update_profile_filter(target, mutate) + with _clean_config_errors(): + update_profile_filter(target, mutate) if not added: warning(f"'{pattern}' is already in the {list_name} list") return - _sync_filter_file(target) + with _clean_config_errors(): + _sync_filter_file(target) + effective_list = effective_filter_settings(get_config(), target)[list_name] success(f"Added '{pattern}' to the {list_name} list of profile '{target}'") - if pattern not in effective_filter_settings(get_config(), target)[list_name]: + if pattern not in effective_list: warning( f"'{pattern}' saved globally but absent from the effective " f"{list_name} list — {_OVERRIDE_HINT}", @@ -173,13 +192,16 @@ def mutate(filter_cfg: dict[str, Any]) -> None: filter_cfg[list_name] = kept removed = True - update_profile_filter(target, mutate) + with _clean_config_errors(): + update_profile_filter(target, mutate) if not removed: warning(f"'{pattern}' is not in the {list_name} list") return - _sync_filter_file(target) + with _clean_config_errors(): + _sync_filter_file(target) + effective_list = effective_filter_settings(get_config(), target)[list_name] success(f"Removed '{pattern}' from the {list_name} list of profile '{target}'") - if pattern in effective_filter_settings(get_config(), target)[list_name]: + if pattern in effective_list: warning( f"'{pattern}' removed globally but still in the effective " f"{list_name} list — {_OVERRIDE_HINT}", @@ -249,32 +271,24 @@ def proxy_start() -> None: manager.ensure_network(network_name) - auto_clean = bool(config.get("auto_clean", True)) - updated = False - if _is_latest_tag(proxy_image): - info("Checking for proxy image updates…") - updated = manager.pull_if_newer(proxy_image) - if updated: - info("New image available — restarting proxy") - existing = manager.find_proxy() - if existing: - existing.remove(force=True) - - # Materialize the shared global and active-profile policy bases. - _sync_filter_file() + # Materialize the shared global and active-profile policy bases first; they + # live on disk independent of the proxy container's lifecycle. + with _clean_config_errors(): + _sync_filter_file() info("Starting proxy") - manager.ensure_proxy( - image=proxy_image, - db_path=db_path, - ca_dir=ca_dir, - network=network_name, - policy_schema="2", - ) - if auto_clean: - # Swept last: the replaced image is only removable once the proxy - # container that held it has been recreated on the new one. - manager.clean_untagged_images() + try: + provision_proxy( + manager, + image=proxy_image, + db_path=db_path, + ca_dir=ca_dir, + network=network_name, + auto_clean=bool(config.get("auto_clean", True)), + ) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc success("Proxy is running") diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 4b0fe91..4b60cef 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -55,6 +55,9 @@ from vibepod.core.launch import ( apply_overlay_if_enabled, ) +from vibepod.core.launch import ( + apply_proxy_env as _apply_proxy_env, +) from vibepod.core.launch import ( get_container_ip as _get_container_ip, ) @@ -68,7 +71,7 @@ init_entrypoint as _init_entrypoint, ) from vibepod.core.launch import ( - managed_proxy_policy_ids as _managed_proxy_policy_ids, + materialize_launch_policy as _materialize_launch_policy, ) from vibepod.core.launch import ( parse_env_pairs as _parse_env_pairs, @@ -76,6 +79,9 @@ from vibepod.core.launch import ( prepare_x11_auth as _prepare_x11_auth, ) +from vibepod.core.launch import ( + provision_proxy as _provision_proxy, +) from vibepod.core.launch import ( publish_port_bindings as _publish_port_bindings, ) @@ -95,13 +101,7 @@ x11_volumes_and_env as _x11_volumes_and_env, ) from vibepod.core.profiles import resolve_profile -from vibepod.core.proxy_filter import ( - cleanup_orphan_policies, - materialize_container_policy, - materialize_policy_bases, - remove_container_policy, -) -from vibepod.core.proxy_identity import identified_proxy_url, new_policy_id +from vibepod.core.proxy_filter import remove_container_policy from vibepod.core.resume import show_resume_hint from vibepod.core.session_logger import SessionLogger from vibepod.utils.console import error, info, success, warning @@ -650,37 +650,24 @@ def run( .resolve() ) - if _is_latest_tag(proxy_image): - updated = manager.pull_if_newer( - proxy_image, + try: + _provision_proxy( + manager, + image=proxy_image, + db_path=proxy_db_path, + ca_dir=proxy_ca_dir or proxy_db_path.parent / "mitmproxy", + network=network_name, auto_clean=bool(config.get("auto_clean", True)), ) - if updated: - # ensure_proxy reuses a running container; replace it so the - # freshly pulled image (and its features) actually serve. - existing_proxy = manager.find_proxy() - if existing_proxy: - existing_proxy.remove(force=True) - - manager.ensure_proxy( - image=proxy_image, - db_path=proxy_db_path, - ca_dir=proxy_ca_dir or proxy_db_path.parent / "mitmproxy", - network=network_name, - policy_schema="2", - ) - - materialize_policy_bases(config, active_profile) - referenced_policy_ids = _managed_proxy_policy_ids(manager) - if referenced_policy_ids is not None: - cleanup_orphan_policies(config, referenced_policy_ids) - proxy_policy_id = new_policy_id() - materialize_container_policy( - config, - profile=active_profile, - workspace=workspace_path, - policy_id=proxy_policy_id, - ) + proxy_policy_id = _materialize_launch_policy( + manager, + config, + profile=active_profile, + workspace=workspace_path, + ) + except (DockerClientError, ValueError) as exc: + error(str(exc)) + raise typer.Exit(1) from exc if proxy_ca_path: ca_ready = False @@ -693,20 +680,7 @@ def run( if not ca_ready: warning(f"Proxy CA not found yet at {proxy_ca_path}") - proxy_url = identified_proxy_url(proxy_policy_id) - merged_env.setdefault("HTTP_PROXY", proxy_url) - merged_env.setdefault("HTTPS_PROXY", proxy_url) - if any(merged_env[key] != proxy_url for key in ("HTTP_PROXY", "HTTPS_PROXY")): - warning( - "Explicit HTTP_PROXY or HTTPS_PROXY overrides the identified VibePod proxy; " - "this launch may bypass its per-container filter policy.", - ) - merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") - _ca = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" - merged_env.setdefault("NODE_EXTRA_CA_CERTS", _ca) - merged_env.setdefault("REQUESTS_CA_BUNDLE", _ca) - merged_env.setdefault("SSL_CERT_FILE", _ca) - merged_env.setdefault("CURL_CA_BUNDLE", _ca) + _apply_proxy_env(merged_env, proxy_policy_id) if proxy_ca_dir: extra_volumes.append((str(proxy_ca_dir), "/etc/vibepod-proxy-ca", "ro")) @@ -716,13 +690,9 @@ def run( if not rootless_podman and spec.run_as_host_user: container_user = _host_user() launch_labels = dict(herdr_labels) + launch_labels["vibepod.profile"] = active_profile if proxy_policy_id is not None: - launch_labels.update( - { - "vibepod.profile": active_profile, - "vibepod.proxy-policy": proxy_policy_id, - }, - ) + launch_labels["vibepod.proxy-policy"] = proxy_policy_id try: container = manager.run_agent( agent=selected_agent, diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index bc86987..677801b 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -35,24 +35,20 @@ agent_init_commands, agent_port_bindings, apply_overlay_if_enabled, + apply_proxy_env, get_container_ip, host_identity_env, host_user, init_entrypoint, - managed_proxy_policy_ids, + materialize_launch_policy, parse_env_pairs, + provision_proxy, read_claude_stored_token, terminal_env_defaults, update_container_mapping, ) from vibepod.core.profiles import resolve_profile -from vibepod.core.proxy_filter import ( - cleanup_orphan_policies, - materialize_container_policy, - materialize_policy_bases, - remove_container_policy, -) -from vibepod.core.proxy_identity import identified_proxy_url, new_policy_id +from vibepod.core.proxy_filter import remove_container_policy from vibepod.core.tasks import ( TASK_STATUS_CANCELLED, TASK_STATUS_COMPLETED, @@ -725,38 +721,25 @@ def task_create( .resolve() ) - if _is_latest_tag(proxy_image): - updated = manager.pull_if_newer( - proxy_image, + actual_ca_dir = proxy_ca_dir or proxy_db_path.parent / "mitmproxy" + try: + provision_proxy( + manager, + image=proxy_image, + db_path=proxy_db_path, + ca_dir=actual_ca_dir, + network=network_name, auto_clean=bool(config.get("auto_clean", True)), ) - if updated: - # ensure_proxy reuses a running container; replace it so the - # freshly pulled image (and its features) actually serve. - existing_proxy = manager.find_proxy() - if existing_proxy: - existing_proxy.remove(force=True) - - actual_ca_dir = proxy_ca_dir or proxy_db_path.parent / "mitmproxy" - manager.ensure_proxy( - image=proxy_image, - db_path=proxy_db_path, - ca_dir=actual_ca_dir, - network=network_name, - policy_schema="2", - ) - - materialize_policy_bases(config, active_profile) - referenced_policy_ids = managed_proxy_policy_ids(manager) - if referenced_policy_ids is not None: - cleanup_orphan_policies(config, referenced_policy_ids) - proxy_policy_id = new_policy_id() - materialize_container_policy( - config, - profile=active_profile, - workspace=workspace_path, - policy_id=proxy_policy_id, - ) + proxy_policy_id = materialize_launch_policy( + manager, + config, + profile=active_profile, + workspace=workspace_path, + ) + except (DockerClientError, ValueError) as exc: + error(str(exc)) + raise typer.Exit(1) from exc if proxy_ca_path: deadline = time.time() + 10 @@ -765,20 +748,7 @@ def task_create( break time.sleep(0.25) - proxy_url = identified_proxy_url(proxy_policy_id) - merged_env.setdefault("HTTP_PROXY", proxy_url) - merged_env.setdefault("HTTPS_PROXY", proxy_url) - if any(merged_env[key] != proxy_url for key in ("HTTP_PROXY", "HTTPS_PROXY")): - warning( - "Explicit HTTP_PROXY or HTTPS_PROXY overrides the identified VibePod proxy; " - "this launch may bypass its per-container filter policy.", - ) - merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") - _ca = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" - merged_env.setdefault("NODE_EXTRA_CA_CERTS", _ca) - merged_env.setdefault("REQUESTS_CA_BUNDLE", _ca) - merged_env.setdefault("SSL_CERT_FILE", _ca) - merged_env.setdefault("CURL_CA_BUNDLE", _ca) + apply_proxy_env(merged_env, proxy_policy_id) extra_volumes.append((str(actual_ca_dir), "/etc/vibepod-proxy-ca", "ro")) @@ -787,13 +757,9 @@ def task_create( if not rootless_podman and spec.run_as_host_user: container_user = host_user() launch_labels = dict(herdr_labels) + launch_labels["vibepod.profile"] = active_profile if proxy_policy_id is not None: - launch_labels.update( - { - "vibepod.profile": active_profile, - "vibepod.proxy-policy": proxy_policy_id, - }, - ) + launch_labels["vibepod.proxy-policy"] = proxy_policy_id try: container = manager.run_agent( agent=selected, diff --git a/src/vibepod/core/docker.py b/src/vibepod/core/docker.py index db3afa2..9d16949 100644 --- a/src/vibepod/core/docker.py +++ b/src/vibepod/core/docker.py @@ -73,6 +73,9 @@ class DockerClientError(RuntimeError): # inspection (resume hints appear in the last few lines of a session). ATTACH_TAIL_LIMIT = 64 * 1024 PROXY_POLICY_SCHEMA_LABEL = "io.vibepod.proxy.policy-schema" +# The per-source policy schema this CLI speaks; a proxy image must carry the +# matching PROXY_POLICY_SCHEMA_LABEL value. +PROXY_POLICY_SCHEMA = "2" def _run_podman(podman: str, args: list[str]) -> str | None: diff --git a/src/vibepod/core/launch.py b/src/vibepod/core/launch.py index deafc02..2df25af 100644 --- a/src/vibepod/core/launch.py +++ b/src/vibepod/core/launch.py @@ -15,7 +15,12 @@ from vibepod.core import overlay from vibepod.core.config import load_project_config -from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.core.docker import ( + PROXY_POLICY_SCHEMA, + DockerClientError, + DockerManager, + _is_latest_tag, +) from vibepod.utils.console import error, warning CLAUDE_TOKEN_FILENAME = "oauth-token" @@ -347,6 +352,100 @@ def get_container_ip(container: Any, network: str) -> str | None: return None +def provision_proxy( + manager: Any, + *, + image: str, + db_path: Path, + ca_dir: Path, + network: str, + auto_clean: bool, + policy_schema: str = PROXY_POLICY_SCHEMA, +) -> Any: + """Bring up a schema-compatible proxy, refreshing a ``:latest`` image safely. + + A freshly pulled image is validated *before* any running proxy is torn + down, so an incompatible image never leaves the host with no proxy. Untagged + images are swept only after the replacement is running (never before the old + container is removed, or the in-use image cannot be reclaimed). + """ + if _is_latest_tag(image): + if manager.pull_if_newer(image, auto_clean=False): + manager.require_proxy_policy_schema(image, policy_schema) + existing = manager.find_proxy() + if existing: + existing.remove(force=True) + container = manager.ensure_proxy( + image=image, + db_path=db_path, + ca_dir=ca_dir, + network=network, + policy_schema=policy_schema, + ) + if auto_clean: + manager.clean_untagged_images() + return container + + +_PROXY_URL_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy") +_PROXY_CA_PATH = "/etc/vibepod-proxy-ca/mitmproxy-ca-cert.pem" + + +def materialize_launch_policy( + manager: Any, + config: dict[str, Any], + *, + profile: str, + workspace: Path, +) -> str: + """Materialize the policy bases and this launch's record; return its id. + + Sweeps records orphaned by since-removed containers first (best effort), so + the policy dir does not grow without bound. + """ + from vibepod.core.proxy_filter import ( + cleanup_orphan_policies, + materialize_container_policy, + materialize_policy_bases, + ) + from vibepod.core.proxy_identity import new_policy_id + + materialize_policy_bases(config, profile) + referenced = managed_proxy_policy_ids(manager) + if referenced is not None: + cleanup_orphan_policies(config, referenced) + policy_id = new_policy_id() + materialize_container_policy( + config, + profile=profile, + workspace=workspace, + policy_id=policy_id, + ) + return policy_id + + +def apply_proxy_env(merged_env: dict[str, str], policy_id: str) -> None: + """Point the agent at the identified proxy and warn on manual overrides. + + Both cased forms of the proxy variables are inspected: ``http_proxy`` and + friends are honored by curl/git/requests, so an override there bypasses the + per-container filter just as ``HTTP_PROXY`` would. + """ + from vibepod.core.proxy_identity import identified_proxy_url + + proxy_url = identified_proxy_url(policy_id) + for key in ("HTTP_PROXY", "HTTPS_PROXY"): + merged_env.setdefault(key, proxy_url) + if any(merged_env.get(key, proxy_url) != proxy_url for key in _PROXY_URL_ENV_VARS): + warning( + "Explicit HTTP_PROXY/HTTPS_PROXY (either case) overrides the identified " + "VibePod proxy; this launch may bypass its per-container filter policy.", + ) + merged_env.setdefault("NO_PROXY", "localhost,127.0.0.1,::1") + for key in ("NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"): + merged_env.setdefault(key, _PROXY_CA_PATH) + + def managed_proxy_policy_ids(manager: Any) -> set[str] | None: """Return policy IDs on all existing managed containers, or None if unknowable.""" lister = getattr(manager, "list_managed", None) diff --git a/tests/test_provision_proxy.py b/tests/test_provision_proxy.py new file mode 100644 index 0000000..7311ec4 --- /dev/null +++ b/tests/test_provision_proxy.py @@ -0,0 +1,76 @@ +"""Tests for launch.provision_proxy: safe image refresh + proxy ensure.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from vibepod.core.docker import DockerClientError +from vibepod.core.launch import provision_proxy + + +class _FakeManager: + def __init__(self, *, pull_updates: bool = False, bad_new_image: bool = False) -> None: + self._pull_updates = pull_updates + self._bad_new_image = bad_new_image + self.pull_auto_clean: bool | None = None + self.ensured = False + self.swept = False + self.schema_checks: list[tuple[str, str]] = [] + self.running = MagicMock() + + def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: + self.pull_auto_clean = auto_clean + return self._pull_updates + + def require_proxy_policy_schema(self, image: str, required: str) -> None: + self.schema_checks.append((image, required)) + if self._bad_new_image: + raise DockerClientError("incompatible new image") + + def find_proxy(self) -> object: + return self.running + + def ensure_proxy(self, **kwargs: object) -> str: + self.ensured = True + return "proxy-container" + + def clean_untagged_images(self) -> None: + self.swept = True + + +def _provision(manager: _FakeManager, *, auto_clean: bool = True) -> object: + return provision_proxy( + manager, + image="vibepod/proxy:latest", + db_path=Path("/tmp/proxy.db"), + ca_dir=Path("/tmp/ca"), + network="vibepod-network", + auto_clean=auto_clean, + ) + + +def test_bad_new_image_does_not_tear_down_running_proxy() -> None: + manager = _FakeManager(pull_updates=True, bad_new_image=True) + with pytest.raises(DockerClientError): + _provision(manager) + manager.running.remove.assert_not_called() + assert manager.ensured is False + + +def test_pull_does_not_sweep_before_old_proxy_is_removed() -> None: + manager = _FakeManager(pull_updates=True) + _provision(manager) + assert manager.pull_auto_clean is False + manager.running.remove.assert_called_once() + assert manager.ensured is True + assert manager.swept is True + + +def test_no_auto_clean_skips_sweep() -> None: + manager = _FakeManager(pull_updates=True) + _provision(manager, auto_clean=False) + assert manager.swept is False + assert manager.ensured is True diff --git a/tests/test_proxy_cmd.py b/tests/test_proxy_cmd.py index ecd5f89..ccbe563 100644 --- a/tests/test_proxy_cmd.py +++ b/tests/test_proxy_cmd.py @@ -27,6 +27,9 @@ def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: self._events.append(f"pull_if_newer(auto_clean={auto_clean})") return self._updated + def require_proxy_policy_schema(self, image: object, required: str = "2") -> None: + self._events.append("require_proxy_policy_schema") + def find_proxy(self): return self._container @@ -57,9 +60,10 @@ def test_proxy_start_recreates_container_before_cleanup(monkeypatch) -> None: assert events == [ "ensure_network", + "materialize_policy_bases", "pull_if_newer(auto_clean=False)", + "require_proxy_policy_schema", "container.remove", - "materialize_policy_bases", "ensure_proxy", "clean_untagged_images", ] @@ -73,14 +77,14 @@ def test_proxy_start_keeps_container_without_update(monkeypatch) -> None: assert events == [ "ensure_network", - "pull_if_newer(auto_clean=False)", "materialize_policy_bases", + "pull_if_newer(auto_clean=False)", "ensure_proxy", "clean_untagged_images", ] -def test_proxy_start_skips_cleanup_without_auto_clean(monkeypatch) -> None: +def test_proxy_start_validates_new_image_before_removing_running_proxy(monkeypatch) -> None: events: list[str] = [] _patch_common(monkeypatch, events, {"auto_clean": False}) @@ -88,8 +92,9 @@ def test_proxy_start_skips_cleanup_without_auto_clean(monkeypatch) -> None: assert events == [ "ensure_network", + "materialize_policy_bases", "pull_if_newer(auto_clean=False)", + "require_proxy_policy_schema", "container.remove", - "materialize_policy_bases", "ensure_proxy", ] diff --git a/tests/test_proxy_filter_cmd.py b/tests/test_proxy_filter_cmd.py index 6e3b869..fccbd5a 100644 --- a/tests/test_proxy_filter_cmd.py +++ b/tests/test_proxy_filter_cmd.py @@ -157,8 +157,9 @@ def test_status_rejects_invalid_configured_mode(config_dir: Path) -> None: ) result = runner.invoke(app, ["proxy", "filter", "status"]) assert result.exit_code == 1 - assert isinstance(result.exception, ValueError) - assert "strict" in str(result.exception) + # Invalid config is surfaced as a clean error, not a raw traceback. + assert not isinstance(result.exception, ValueError) + assert "strict" in result.output # --- per-profile filter commands --- diff --git a/tests/test_run.py b/tests/test_run.py index e5a7d59..ebe5330 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2615,6 +2615,9 @@ def pull_image(self, image: str, auto_clean: bool = False) -> None: def ensure_proxy(self, **kwargs) -> None: # type: ignore[no-untyped-def] captured["proxy"] = kwargs + def clean_untagged_images(self) -> int: + return 0 + def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] captured["run"] = kwargs return type( @@ -2643,7 +2646,7 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] } monkeypatch.setattr(run_cmd, "get_config", lambda: config) monkeypatch.setattr(run_cmd, "DockerManager", _ProxyDockerManager) - monkeypatch.setattr(run_cmd, "new_policy_id", lambda: "1" * 32) + monkeypatch.setattr("vibepod.core.proxy_identity.new_policy_id", lambda: "1" * 32) monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "config")) run_cmd.run(agent="claude", workspace=tmp_path, detach=True) @@ -2684,6 +2687,12 @@ def pull_if_newer(self, image: str, auto_clean: bool = False) -> bool: events.append("pull_if_newer") return True + def require_proxy_policy_schema(self, image: object, required: str = "2") -> None: + pass + + def clean_untagged_images(self) -> int: + return 0 + def find_proxy(self) -> object: return _OldProxyContainer() @@ -2713,9 +2722,7 @@ def run_agent(self, **kwargs) -> object: # type: ignore[no-untyped-def] } monkeypatch.setattr(run_cmd, "get_config", lambda: config) monkeypatch.setattr(run_cmd, "DockerManager", _UpdatingDockerManager) - monkeypatch.setattr(run_cmd, "materialize_policy_bases", lambda cfg, profile: []) - monkeypatch.setattr(run_cmd, "materialize_container_policy", lambda *args, **kwargs: None) - monkeypatch.setattr(run_cmd, "new_policy_id", lambda: "3" * 32) + monkeypatch.setattr(run_cmd, "_materialize_launch_policy", lambda *args, **kwargs: "3" * 32) run_cmd.run(agent="claude", workspace=tmp_path, detach=True) diff --git a/tests/test_task_cmd.py b/tests/test_task_cmd.py index 6a0760c..3f7eb24 100644 --- a/tests/test_task_cmd.py +++ b/tests/test_task_cmd.py @@ -81,6 +81,9 @@ def pull_image(self, image: str, auto_clean: bool = False) -> None: def ensure_proxy(self, **kwargs) -> None: pass + def clean_untagged_images(self) -> int: + return 0 + def resolve_launch_command(self, image: str, command: list[str] | None) -> list[str]: # Tests that exercise init commands or a None command can mock this; # the happy-path tests never hit it. @@ -1223,7 +1226,7 @@ def test_task_create_materializes_source_policy_and_wires_identity( } monkeypatch.setattr(task_cmd, "get_config", lambda: config) monkeypatch.setattr(task_cmd, "DockerManager", lambda: stub) - monkeypatch.setattr(task_cmd, "new_policy_id", lambda: "2" * 32) + monkeypatch.setattr("vibepod.core.proxy_identity.new_policy_id", lambda: "2" * 32) monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path / "config")) task_cmd.task_create(agent="claude", prompt="hi", workspace=tmp_path)