From 08c7a485ce3ae13ab6bc9b03794645a357ad4c19 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 29 Jul 2026 17:05:45 +0000 Subject: [PATCH 1/5] Add gateway registration removal command --- projects/privacy-guard/README.md | 11 ++- projects/privacy-guard/docs/index.md | 2 +- projects/privacy-guard/docs/operations.md | 20 ++++- .../docs/reference/limits-and-failures.md | 2 +- .../examples/custom-engine/README.md | 26 +++++- .../examples/regex-engine/README.md | 26 +++++- .../privacy-guard/src/privacy_guard/cli.py | 59 +++++++++++- .../src/privacy_guard/gateway_config.py | 57 ++++++++++++ .../tests/examples/test_custom_engine.py | 3 +- .../tests/examples/test_regex_engine.py | 3 +- projects/privacy-guard/tests/test_cli.py | 90 ++++++++++++++++--- .../tests/test_gateway_config.py | 86 ++++++++++++++++++ 12 files changed, 354 insertions(+), 31 deletions(-) diff --git a/projects/privacy-guard/README.md b/projects/privacy-guard/README.md index d435c61..7da5515 100644 --- a/projects/privacy-guard/README.md +++ b/projects/privacy-guard/README.md @@ -98,20 +98,23 @@ UTF-8, and oversized catalogs are rejected. ```bash uv run privacy-guard engines uv run privacy-guard configuration-schema -uv run privacy-guard configure-gateway --host-ip YOUR_HOST_IPV4 +uv run privacy-guard add-gateway-registration --host-ip YOUR_HOST_IPV4 +uv run privacy-guard remove-gateway-registration --name privacy-guard uv run privacy-guard serve \ --listen 0.0.0.0:50051 \ --timeout-seconds 4 ``` -`configure-gateway` adds or updates a Privacy Guard registration in the +`add-gateway-registration` adds or updates a Privacy Guard registration in the OpenShell gateway TOML. Its registration name must match the policy's `middleware` field. Restart the gateway after changing registrations. +`remove-gateway-registration` removes one registration by name while preserving +unrelated gateway settings. The processing timeout is one bound shared by every stage. It defaults to 1 -second and cannot exceed 30 seconds. `configure-gateway` writes a five-second +second and cannot exceed 30 seconds. `add-gateway-registration` writes a five-second OpenShell middleware timeout, so use a shorter processing timeout or edit the -registration to provide more headroom. Rerunning `configure-gateway` restores +registration to provide more headroom. Rerunning `add-gateway-registration` restores the five-second value. Use a trusted registry factory for custom engines: diff --git a/projects/privacy-guard/docs/index.md b/projects/privacy-guard/docs/index.md index e4642cb..3a97f39 100644 --- a/projects/privacy-guard/docs/index.md +++ b/projects/privacy-guard/docs/index.md @@ -98,7 +98,7 @@ configuration: ```bash cd projects/privacy-guard/examples/regex-engine -uv run privacy-guard configure-gateway \ +uv run privacy-guard add-gateway-registration \ --host-ip YOUR_HOST_IPV4 \ --name privacy-guard-regex ``` diff --git a/projects/privacy-guard/docs/operations.md b/projects/privacy-guard/docs/operations.md index 753079e..77e24e1 100644 --- a/projects/privacy-guard/docs/operations.md +++ b/projects/privacy-guard/docs/operations.md @@ -91,7 +91,7 @@ Replace `YOUR_HOST_IPV4` with the address you selected. Then add or update the gateway registration: ```bash -uv run privacy-guard configure-gateway \ +uv run privacy-guard add-gateway-registration \ --host-ip YOUR_HOST_IPV4 \ --name privacy-guard \ --port 50051 @@ -106,11 +106,22 @@ The command writes to: Use `--config PATH` to write a different gateway TOML. Existing unrelated gateway settings are preserved. -`configure-gateway` writes a five-second OpenShell middleware timeout. The +Remove a registration by name when it is no longer needed: + +```bash +uv run privacy-guard remove-gateway-registration \ + --name privacy-guard +``` + +This command uses the same default config path and accepts `--config PATH`. +It leaves unrelated registrations and gateway settings unchanged. Restart the +gateway after a registration is removed. + +`add-gateway-registration` writes a five-second OpenShell middleware timeout. The four-second processing timeout above leaves one second for queueing, configuration validation, processor preparation, and transport overhead. If you select a longer processing timeout, edit the generated registration to add -headroom. Rerunning `configure-gateway` restores the timeout to five seconds. +headroom. Rerunning `add-gateway-registration` restores the timeout to five seconds. The registration name must match the policy's `middleware` field: @@ -135,7 +146,8 @@ brew services start openshell systemctl --user start openshell-gateway ``` -The gateway reads the default configuration that `configure-gateway` updated. +The gateway reads the default configuration that `add-gateway-registration` +updated. ## Verify connectivity diff --git a/projects/privacy-guard/docs/reference/limits-and-failures.md b/projects/privacy-guard/docs/reference/limits-and-failures.md index 004f524..928b284 100644 --- a/projects/privacy-guard/docs/reference/limits-and-failures.md +++ b/projects/privacy-guard/docs/reference/limits-and-failures.md @@ -57,7 +57,7 @@ privacy-guard serve --timeout-seconds 4 OpenShell's middleware timeout must be longer than the Privacy Guard processing timeout. Include headroom for worker queueing, configuration validation, and -processor preparation. `configure-gateway` writes a five-second OpenShell +processor preparation. `add-gateway-registration` writes a five-second OpenShell timeout, so edit that registration before using a processing timeout of five seconds or longer. diff --git a/projects/privacy-guard/examples/custom-engine/README.md b/projects/privacy-guard/examples/custom-engine/README.md index 8fd0807..59f5563 100644 --- a/projects/privacy-guard/examples/custom-engine/README.md +++ b/projects/privacy-guard/examples/custom-engine/README.md @@ -119,7 +119,7 @@ the address you selected, then update the default gateway configuration: ```bash cd projects/privacy-guard/examples/custom-engine -uv run privacy-guard configure-gateway \ +uv run privacy-guard add-gateway-registration \ --host-ip YOUR_HOST_IPV4 \ --name privacy-guard-custom-engine ``` @@ -193,10 +193,30 @@ Exit Claude and delete the sandbox: openshell sandbox delete privacy-guard-custom-engine ``` -Stop Privacy Guard with `Ctrl-C`. The gateway continues to run in the -background. Verify its connection: +Stop Privacy Guard with `Ctrl-C`, then stop the gateway before removing the +example registration: ```bash +# macOS with Homebrew +brew services stop openshell + +# Linux with a Debian or RPM package +systemctl --user stop openshell-gateway + +uv run privacy-guard remove-gateway-registration \ + --name privacy-guard-custom-engine +``` + +Restart the gateway with the command for your system, then verify its +connection: + +```bash +# macOS with Homebrew +brew services start openshell + +# Linux with a Debian or RPM package +systemctl --user start openshell-gateway + openshell status ``` diff --git a/projects/privacy-guard/examples/regex-engine/README.md b/projects/privacy-guard/examples/regex-engine/README.md index c7dfa70..27ca637 100644 --- a/projects/privacy-guard/examples/regex-engine/README.md +++ b/projects/privacy-guard/examples/regex-engine/README.md @@ -68,7 +68,7 @@ the address you selected, then update the default gateway configuration: ```bash cd projects/privacy-guard/examples/regex-engine -uv run privacy-guard configure-gateway \ +uv run privacy-guard add-gateway-registration \ --host-ip YOUR_HOST_IPV4 \ --name privacy-guard-regex ``` @@ -140,10 +140,30 @@ Exit Claude and delete the sandbox: openshell sandbox delete privacy-guard-regex ``` -Stop Privacy Guard with `Ctrl-C`. The gateway continues to run in the -background. Verify its connection: +Stop Privacy Guard with `Ctrl-C`, then stop the gateway before removing the +example registration: ```bash +# macOS with Homebrew +brew services stop openshell + +# Linux with a Debian or RPM package +systemctl --user stop openshell-gateway + +uv run privacy-guard remove-gateway-registration \ + --name privacy-guard-regex +``` + +Restart the gateway with the command for your system, then verify its +connection: + +```bash +# macOS with Homebrew +brew services start openshell + +# Linux with a Debian or RPM package +systemctl --user start openshell-gateway + openshell status ``` diff --git a/projects/privacy-guard/src/privacy_guard/cli.py b/projects/privacy-guard/src/privacy_guard/cli.py index c0d35f2..b11a06e 100644 --- a/projects/privacy-guard/src/privacy_guard/cli.py +++ b/projects/privacy-guard/src/privacy_guard/cli.py @@ -18,8 +18,10 @@ from privacy_guard.gateway_config import ( MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, GatewayConfigError, + GatewayConfigRemoval, GatewayConfigUpdate, default_gateway_config_path, + remove_gateway_config, update_gateway_config, validate_middleware_name, ) @@ -127,8 +129,8 @@ def serve( raise typer.Exit(code=1) from None -@app.command("configure-gateway") -def configure_gateway( +@app.command("add-gateway-registration") +def add_gateway_registration( host_ip: Annotated[ str, typer.Option( @@ -218,6 +220,59 @@ def configure_gateway( ) +@app.command("remove-gateway-registration") +def remove_gateway_registration( + name: Annotated[ + str, + typer.Option( + help=( + "Gateway registration name to remove. OpenShell allows " + f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes." + ), + ), + ], + config: Annotated[ + Path | None, + typer.Option( + help=( + "Gateway TOML to update. Defaults to " + "`$OPENSHELL_GATEWAY_CONFIG` when set, otherwise `gateway.toml` " + "under `$XDG_CONFIG_HOME/openshell`." + ), + ), + ] = None, +) -> None: + """Remove a named registration from an OpenShell gateway TOML file.""" + try: + validated_name = validate_middleware_name(name) + except GatewayConfigError as error: + raise typer.BadParameter( + str(error), + param_hint="--name", + ) from None + + config_path = config or default_gateway_config_path() + try: + result = remove_gateway_config( + config_path, + middleware_name=validated_name, + ) + except GatewayConfigError as error: + typer.echo( + f"Could not remove the OpenShell gateway registration: {error}", + err=True, + ) + raise typer.Exit(code=1) from None + + if result is GatewayConfigRemoval.REMOVED: + typer.echo(f"Removed {validated_name} from {config_path}") + typer.echo( + "Next: restart the OpenShell gateway so it unloads this registration." + ) + else: + typer.echo(f"No registration named {validated_name} found in {config_path}") + + @app.command("configuration-schema") def configuration_schema(context: typer.Context) -> None: """Print the policy configuration JSON Schema for the installed engines.""" diff --git a/projects/privacy-guard/src/privacy_guard/gateway_config.py b/projects/privacy-guard/src/privacy_guard/gateway_config.py index 8ad54d6..db870c6 100644 --- a/projects/privacy-guard/src/privacy_guard/gateway_config.py +++ b/projects/privacy-guard/src/privacy_guard/gateway_config.py @@ -20,6 +20,13 @@ class GatewayConfigUpdate(Enum): UNCHANGED = "unchanged" +class GatewayConfigRemoval(Enum): + """Result of removing one Privacy Guard middleware registration.""" + + REMOVED = "removed" + UNCHANGED = "unchanged" + + class GatewayConfigError(ValueError): """A safe, actionable gateway configuration update error.""" @@ -117,6 +124,54 @@ def update_gateway_config( return result +def remove_gateway_config( + path: Path, + *, + middleware_name: str, +) -> GatewayConfigRemoval: + """Remove one named Privacy Guard middleware registration.""" + validate_middleware_name(middleware_name) + try: + original = path.read_text(encoding="utf-8") + except FileNotFoundError: + return GatewayConfigRemoval.UNCHANGED + except (OSError, UnicodeError) as error: + raise GatewayConfigError( + f"Could not read {path}. Check that the file is readable UTF-8 TOML." + ) from error + + if not original.strip(): + return GatewayConfigRemoval.UNCHANGED + + values = _load_gateway_config(original, path) + middleware = _middleware_entries(values, path) + matching_indexes = [ + index + for index, entry in enumerate(middleware) + if entry.get("name") == middleware_name + ] + if len(matching_indexes) > 1: + raise GatewayConfigError( + f"{path} contains multiple middleware registrations named " + f"{middleware_name!r}. Remove the duplicate entries, then retry." + ) + if not matching_indexes: + return GatewayConfigRemoval.UNCHANGED + + blocks = list(_MIDDLEWARE_BLOCK_PATTERN.finditer(original)) + if len(blocks) != len(middleware): + raise GatewayConfigError( + f"Could not safely locate every middleware registration in {path}. " + "Format the file as standard TOML tables, then retry." + ) + + block = blocks[matching_indexes[0]] + updated = original[: block.start()] + original[block.end() :] + _load_gateway_config(updated, path) + _write_atomically(path, updated) + return GatewayConfigRemoval.REMOVED + + def validate_middleware_name(name: str) -> str: """Validate OpenShell's external middleware registration-name contract.""" if ( @@ -286,9 +341,11 @@ def _write_atomically(path: Path, contents: str) -> None: __all__ = [ "GatewayConfigError", + "GatewayConfigRemoval", "GatewayConfigUpdate", "MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES", "default_gateway_config_path", + "remove_gateway_config", "update_gateway_config", "validate_middleware_name", ] diff --git a/projects/privacy-guard/tests/examples/test_custom_engine.py b/projects/privacy-guard/tests/examples/test_custom_engine.py index 153f5b8..0391b37 100644 --- a/projects/privacy-guard/tests/examples/test_custom_engine.py +++ b/projects/privacy-guard/tests/examples/test_custom_engine.py @@ -130,7 +130,8 @@ def test_openshell_walkthrough_uses_the_custom_registry_and_current_policy() -> assert "uv sync --locked" not in readme assert "uv run --locked privacy-guard" in readme assert 'export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}"' in readme - assert "uv run privacy-guard configure-gateway" in readme + assert "uv run privacy-guard add-gateway-registration" in readme + assert "uv run privacy-guard remove-gateway-registration" in readme assert "--host-ip YOUR_HOST_IPV4" in readme assert "--name privacy-guard-custom-engine" in readme assert "--config" not in readme diff --git a/projects/privacy-guard/tests/examples/test_regex_engine.py b/projects/privacy-guard/tests/examples/test_regex_engine.py index 738cca7..a235ea4 100644 --- a/projects/privacy-guard/tests/examples/test_regex_engine.py +++ b/projects/privacy-guard/tests/examples/test_regex_engine.py @@ -101,7 +101,8 @@ def test_regex_walkthrough_uses_current_policy_and_gateway_schema() -> None: RegexPatternCatalog.model_validate(catalog) assert "uv sync --locked" not in readme assert "uv run --locked privacy-guard serve --listen 0.0.0.0:50051" in readme - assert "uv run privacy-guard configure-gateway" in readme + assert "uv run privacy-guard add-gateway-registration" in readme + assert "uv run privacy-guard remove-gateway-registration" in readme assert "--host-ip YOUR_HOST_IPV4" in readme assert "--name privacy-guard-regex" in readme assert "--config" not in readme diff --git a/projects/privacy-guard/tests/test_cli.py b/projects/privacy-guard/tests/test_cli.py index e22ef88..ae050e8 100644 --- a/projects/privacy-guard/tests/test_cli.py +++ b/projects/privacy-guard/tests/test_cli.py @@ -33,7 +33,8 @@ def test_cli_help_exposes_server_and_discovery_commands() -> None: output = _plain_output(result) assert "serve" in output assert "configuration-schema" in output - assert "configure-gateway" in output + assert "add-gateway-registration" in output + assert "remove-gateway-registration" in output assert "engines" in output assert "--debug" in output assert "--debug-log-content" in output @@ -43,10 +44,10 @@ def test_cli_help_exposes_server_and_discovery_commands() -> None: assert "--scanner-name" not in output -def test_cli_configure_gateway_help_requires_an_explicit_host_ip() -> None: +def test_cli_add_gateway_registration_help_requires_an_explicit_host_ip() -> None: result = CliRunner().invoke( app, - ["configure-gateway", "--help"], + ["add-gateway-registration", "--help"], terminal_width=240, ) @@ -61,7 +62,7 @@ def test_cli_configure_gateway_help_requires_an_explicit_host_ip() -> None: assert "restart the OpenShell gateway" not in output -def test_cli_configure_gateway_updates_the_default_xdg_config( +def test_cli_add_gateway_registration_updates_the_default_xdg_config( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -69,7 +70,7 @@ def test_cli_configure_gateway_updates_the_default_xdg_config( result = CliRunner().invoke( app, - ["configure-gateway", "--host-ip", "192.168.1.20"], + ["add-gateway-registration", "--host-ip", "192.168.1.20"], ) assert result.exit_code == 0 @@ -82,10 +83,10 @@ def test_cli_configure_gateway_updates_the_default_xdg_config( @pytest.mark.parametrize("host_ip", ["host.openshell.internal", "127.0.0.1", "0.0.0.0"]) -def test_cli_configure_gateway_rejects_unusable_host_ip(host_ip: str) -> None: +def test_cli_add_gateway_registration_rejects_unusable_host_ip(host_ip: str) -> None: result = CliRunner().invoke( app, - ["configure-gateway", "--host-ip", host_ip], + ["add-gateway-registration", "--host-ip", host_ip], terminal_width=240, ) @@ -103,13 +104,13 @@ def test_cli_configure_gateway_rejects_unusable_host_ip(host_ip: str) -> None: "openshell/privacy-guard", ], ) -def test_cli_configure_gateway_rejects_invalid_registration_name( +def test_cli_add_gateway_registration_rejects_invalid_registration_name( name: str, ) -> None: result = CliRunner().invoke( app, [ - "configure-gateway", + "add-gateway-registration", "--host-ip", "192.168.1.20", "--name", @@ -122,7 +123,7 @@ def test_cli_configure_gateway_rejects_invalid_registration_name( assert "--name" in _normalized_output(result) -def test_cli_configure_gateway_reports_invalid_existing_config( +def test_cli_add_gateway_registration_reports_invalid_existing_config( tmp_path: Path, ) -> None: path = tmp_path / "gateway.toml" @@ -131,7 +132,7 @@ def test_cli_configure_gateway_reports_invalid_existing_config( result = CliRunner().invoke( app, [ - "configure-gateway", + "add-gateway-registration", "--host-ip", "192.168.1.20", "--config", @@ -146,6 +147,73 @@ def test_cli_configure_gateway_reports_invalid_existing_config( assert path.read_text() == "not valid TOML" +def test_cli_remove_gateway_registration_removes_the_named_registration( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + path.write_text( + "[openshell]\n" + "version = 1\n\n" + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard-regex"\n' + 'grpc_endpoint = "http://192.168.1.20:50051"\n' + ) + + result = CliRunner().invoke( + app, + [ + "remove-gateway-registration", + "--name", + "privacy-guard-regex", + "--config", + str(path), + ], + ) + + assert result.exit_code == 0 + output = _plain_output(result) + assert f"Removed privacy-guard-regex from {path}" in output + assert "restart the OpenShell gateway" in output + assert "privacy-guard-regex" not in path.read_text() + + +def test_cli_remove_gateway_registration_requires_a_name() -> None: + result = CliRunner().invoke( + app, + ["remove-gateway-registration"], + terminal_width=240, + ) + + assert result.exit_code == 2 + output = _normalized_output(result) + assert "--name" in output + assert "missing option" in output.lower() + + +def test_cli_remove_gateway_registration_reports_absent_name( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + path.write_text("[openshell]\nversion = 1\n") + + result = CliRunner().invoke( + app, + [ + "remove-gateway-registration", + "--name", + "privacy-guard-regex", + "--config", + str(path), + ], + ) + + assert result.exit_code == 0 + assert ( + f"No registration named privacy-guard-regex found in {path}" + in _plain_output(result) + ) + + def test_console_script_targets_the_cli_module() -> None: console_script = next( entry_point diff --git a/projects/privacy-guard/tests/test_gateway_config.py b/projects/privacy-guard/tests/test_gateway_config.py index 1632dfc..fec3c50 100644 --- a/projects/privacy-guard/tests/test_gateway_config.py +++ b/projects/privacy-guard/tests/test_gateway_config.py @@ -10,8 +10,10 @@ from privacy_guard.gateway_config import ( MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, GatewayConfigError, + GatewayConfigRemoval, GatewayConfigUpdate, default_gateway_config_path, + remove_gateway_config, update_gateway_config, validate_middleware_name, ) @@ -192,3 +194,87 @@ def test_update_gateway_config_rejects_invalid_existing_config( ) assert path.read_text() == contents + + +def test_remove_gateway_config_removes_only_the_named_registration( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + path.write_text( + "# Keep this operator comment.\n" + "[openshell]\n" + "version = 1\n\n" + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard-regex"\n' + 'grpc_endpoint = "http://10.0.0.3:50051"\n' + "max_body_bytes = 4194304\n" + 'timeout = "5s"\n\n' + "[[openshell.supervisor.middleware]]\n" + 'name = "other-service"\n' + 'grpc_endpoint = "http://10.0.0.2:9000"\n' + "max_body_bytes = 1000\n" + 'timeout = "1s"\n' + ) + + result = remove_gateway_config( + path, + middleware_name="privacy-guard-regex", + ) + + assert result is GatewayConfigRemoval.REMOVED + contents = path.read_text() + assert "privacy-guard-regex" not in contents + assert "# Keep this operator comment." in contents + assert 'name = "other-service"' in contents + assert tomllib.loads(contents)["openshell"]["supervisor"]["middleware"] == [ + { + "name": "other-service", + "grpc_endpoint": "http://10.0.0.2:9000", + "max_body_bytes": 1000, + "timeout": "1s", + } + ] + + +@pytest.mark.parametrize("create_file", [False, True]) +def test_remove_gateway_config_is_unchanged_when_registration_is_absent( + tmp_path: Path, + create_file: bool, +) -> None: + path = tmp_path / "gateway.toml" + if create_file: + path.write_text("[openshell]\nversion = 1\n") + + result = remove_gateway_config( + path, + middleware_name="privacy-guard-regex", + ) + + assert result is GatewayConfigRemoval.UNCHANGED + if create_file: + assert path.read_text() == "[openshell]\nversion = 1\n" + else: + assert not path.exists() + + +def test_remove_gateway_config_rejects_duplicate_named_registrations( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + contents = ( + "[openshell]\n" + "version = 1\n\n" + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard-regex"\n\n' + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard-regex"\n' + ) + path.write_text(contents) + + with pytest.raises(GatewayConfigError, match="multiple middleware registrations"): + remove_gateway_config( + path, + middleware_name="privacy-guard-regex", + ) + + assert path.read_text() == contents From 67c21dec10dd9232ddf323f616e3dd05a48d4ab4 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 29 Jul 2026 17:10:22 +0000 Subject: [PATCH 2/5] Harden gateway registration removal --- .../src/privacy_guard/gateway_config.py | 50 ++++++++++++++++- .../tests/test_gateway_config.py | 56 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/projects/privacy-guard/src/privacy_guard/gateway_config.py b/projects/privacy-guard/src/privacy_guard/gateway_config.py index db870c6..7bbb19a 100644 --- a/projects/privacy-guard/src/privacy_guard/gateway_config.py +++ b/projects/privacy-guard/src/privacy_guard/gateway_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import os import re import stat @@ -165,9 +166,29 @@ def remove_gateway_config( "Format the file as standard TOML tables, then retry." ) - block = blocks[matching_indexes[0]] - updated = original[: block.start()] + original[block.end() :] - _load_gateway_config(updated, path) + matching_index = matching_indexes[0] + block = blocks[matching_index] + updated = ( + original[: block.start()] + + _trailing_middleware_block_layout(block.group(0)) + + original[block.end() :] + ) + updated_values = _load_gateway_config(updated, path) + unsafe_removal_message = ( + f"Could not safely remove the middleware registration from {path}. " + "Format it as a standard TOML array table without child tables, then retry." + ) + try: + updated_middleware = _middleware_entries(updated_values, path) + except GatewayConfigError as error: + raise GatewayConfigError(unsafe_removal_message) from error + expected_middleware = [ + entry for index, entry in enumerate(middleware) if index != matching_index + ] + if updated_middleware != expected_middleware or _without_middleware_entries( + updated_values + ) != _without_middleware_entries(values): + raise GatewayConfigError(unsafe_removal_message) _write_atomically(path, updated) return GatewayConfigRemoval.REMOVED @@ -308,6 +329,29 @@ def _replace_or_append_assignment(block: str, *, key: str, value: str) -> str: return block.rstrip() + f"\n{key} = {value}\n" +def _trailing_middleware_block_layout(block: str) -> str: + lines = block.splitlines(keepends=True) + for index in range(len(lines) - 1, -1, -1): + stripped = lines[index].lstrip() + if stripped.strip() and not stripped.startswith("#"): + return "".join(lines[index + 1 :]) + raise AssertionError("middleware block header is unavailable") + + +def _without_middleware_entries(values: dict[str, object]) -> dict[str, object]: + copied_values = copy.deepcopy(values) + openshell = copied_values["openshell"] + if not isinstance(openshell, dict): + raise AssertionError("validated OpenShell table is unavailable") + supervisor = openshell.get("supervisor") + if not isinstance(supervisor, dict): + return copied_values + supervisor.pop("middleware", None) + if not supervisor: + openshell.pop("supervisor", None) + return copied_values + + def _write_atomically(path: Path, contents: str) -> None: try: path.parent.mkdir(parents=True, exist_ok=True) diff --git a/projects/privacy-guard/tests/test_gateway_config.py b/projects/privacy-guard/tests/test_gateway_config.py index fec3c50..1934cf9 100644 --- a/projects/privacy-guard/tests/test_gateway_config.py +++ b/projects/privacy-guard/tests/test_gateway_config.py @@ -209,6 +209,7 @@ def test_remove_gateway_config_removes_only_the_named_registration( 'grpc_endpoint = "http://10.0.0.3:50051"\n' "max_body_bytes = 4194304\n" 'timeout = "5s"\n\n' + "# Keep this other-service comment.\n" "[[openshell.supervisor.middleware]]\n" 'name = "other-service"\n' 'grpc_endpoint = "http://10.0.0.2:9000"\n' @@ -225,6 +226,7 @@ def test_remove_gateway_config_removes_only_the_named_registration( contents = path.read_text() assert "privacy-guard-regex" not in contents assert "# Keep this operator comment." in contents + assert "# Keep this other-service comment." in contents assert 'name = "other-service"' in contents assert tomllib.loads(contents)["openshell"]["supervisor"]["middleware"] == [ { @@ -278,3 +280,57 @@ def test_remove_gateway_config_rejects_duplicate_named_registrations( ) assert path.read_text() == contents + + +def test_remove_gateway_config_rejects_registration_child_tables( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + contents = ( + "[openshell]\n" + "version = 1\n\n" + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard-regex"\n' + 'grpc_endpoint = "http://10.0.0.3:50051"\n\n' + "[openshell.supervisor.middleware.metadata]\n" + 'owner = "privacy-team"\n' + ) + path.write_text(contents) + + with pytest.raises(GatewayConfigError, match="Could not safely remove"): + remove_gateway_config( + path, + middleware_name="privacy-guard-regex", + ) + + assert path.read_text() == contents + + +def test_remove_gateway_config_rejects_table_headers_inside_multiline_strings( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + contents = ( + "[openshell]\n" + "version = 1\n" + 'note = """\n' + "[[openshell.supervisor.middleware]]\n" + "fake = true\n" + "[[not.a.real.table]]\n" + "still string\n" + '"""\n\n' + "[openshell.supervisor]\n" + "middleware = [\n" + ' { name = "privacy-guard-regex", ' + 'grpc_endpoint = "http://10.0.0.3:50051" },\n' + "]\n" + ) + path.write_text(contents) + + with pytest.raises(GatewayConfigError, match="Could not safely remove"): + remove_gateway_config( + path, + middleware_name="privacy-guard-regex", + ) + + assert path.read_text() == contents From 75cfaa4b47cbafa8a079697c787cd1f31b91678e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 29 Jul 2026 17:11:59 +0000 Subject: [PATCH 3/5] Clarify unsafe removal errors --- .../src/privacy_guard/gateway_config.py | 2 +- .../tests/test_gateway_config.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/projects/privacy-guard/src/privacy_guard/gateway_config.py b/projects/privacy-guard/src/privacy_guard/gateway_config.py index 7bbb19a..2f09719 100644 --- a/projects/privacy-guard/src/privacy_guard/gateway_config.py +++ b/projects/privacy-guard/src/privacy_guard/gateway_config.py @@ -173,12 +173,12 @@ def remove_gateway_config( + _trailing_middleware_block_layout(block.group(0)) + original[block.end() :] ) - updated_values = _load_gateway_config(updated, path) unsafe_removal_message = ( f"Could not safely remove the middleware registration from {path}. " "Format it as a standard TOML array table without child tables, then retry." ) try: + updated_values = _load_gateway_config(updated, path) updated_middleware = _middleware_entries(updated_values, path) except GatewayConfigError as error: raise GatewayConfigError(unsafe_removal_message) from error diff --git a/projects/privacy-guard/tests/test_gateway_config.py b/projects/privacy-guard/tests/test_gateway_config.py index 1934cf9..e08494a 100644 --- a/projects/privacy-guard/tests/test_gateway_config.py +++ b/projects/privacy-guard/tests/test_gateway_config.py @@ -334,3 +334,29 @@ def test_remove_gateway_config_rejects_table_headers_inside_multiline_strings( ) assert path.read_text() == contents + + +def test_remove_gateway_config_reports_unsafe_multiline_registration_layout( + tmp_path: Path, +) -> None: + path = tmp_path / "gateway.toml" + contents = ( + "[openshell]\n" + "version = 1\n\n" + "[[openshell.supervisor.middleware]]\n" + 'name = "privacy-guard-regex"\n' + 'description = """\n' + "[looks.like.a.table]\n" + "still string\n" + '"""\n' + 'grpc_endpoint = "http://10.0.0.3:50051"\n' + ) + path.write_text(contents) + + with pytest.raises(GatewayConfigError, match="Could not safely remove"): + remove_gateway_config( + path, + middleware_name="privacy-guard-regex", + ) + + assert path.read_text() == contents From 56539b70c972b216d609dd3366cd6768638d8b60 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 29 Jul 2026 17:17:01 +0000 Subject: [PATCH 4/5] Document registration cleanup --- projects/privacy-guard/docs/operations.md | 26 +++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/projects/privacy-guard/docs/operations.md b/projects/privacy-guard/docs/operations.md index 77e24e1..ebf8f15 100644 --- a/projects/privacy-guard/docs/operations.md +++ b/projects/privacy-guard/docs/operations.md @@ -257,15 +257,37 @@ port must be between 1 and 65535. ## Shutdown and cleanup -Stop Privacy Guard with `Ctrl-C`. Delete test sandboxes explicitly: +Delete test sandboxes explicitly, then stop Privacy Guard with `Ctrl-C`: ```bash openshell sandbox delete SANDBOX_NAME ``` -The gateway continues to run in the background. Verify its connection: +When the registration is no longer needed, stop the gateway before removing +it. Replace `REGISTRATION_NAME` with the name passed to +`add-gateway-registration`: ```bash +# macOS with Homebrew +brew services stop openshell + +# Linux with a Debian or RPM package +systemctl --user stop openshell-gateway + +uv run privacy-guard remove-gateway-registration \ + --name REGISTRATION_NAME +``` + +Restart the gateway with the command for your system, then verify its +connection: + +```bash +# macOS with Homebrew +brew services start openshell + +# Linux with a Debian or RPM package +systemctl --user start openshell-gateway + openshell status ``` From f26bf79c02b4e32f9238b005bd596a4111002575 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 29 Jul 2026 19:15:14 +0000 Subject: [PATCH 5/5] Align gateway registration terminology --- projects/privacy-guard/AGENTS.md | 6 +++--- projects/privacy-guard/src/privacy_guard/cli.py | 9 ++++++--- .../privacy-guard/src/privacy_guard/gateway_config.py | 4 ++-- projects/privacy-guard/tests/test_cli.py | 2 +- projects/privacy-guard/tests/test_gateway_config.py | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/projects/privacy-guard/AGENTS.md b/projects/privacy-guard/AGENTS.md index d84a913..838de35 100644 --- a/projects/privacy-guard/AGENTS.md +++ b/projects/privacy-guard/AGENTS.md @@ -33,10 +33,10 @@ Run focused tests while working and `make check` before handoff. - `src/privacy_guard/engines/`: engine contract, registry, and built-in implementations - `src/privacy_guard/config.py`: policy action and ordered stage configuration - `src/privacy_guard/request_processor.py`: stage execution and policy disposition -- `src/privacy_guard/cli.py`: command parsing, discovery, gateway setup, - configuration-schema output, and server adapter +- `src/privacy_guard/cli.py`: command parsing, discovery, gateway registration + management, configuration-schema output, and server adapter - `src/privacy_guard/gateway_config.py`: safe OpenShell gateway TOML - registration updates + registration management - `src/privacy_guard/logging.py`: package-scoped standard-library logging configuration - `src/privacy_guard/base.py`: package-wide strict immutable domain-model base - `src/privacy_guard/string_validators.py`: shared string validators and field types diff --git a/projects/privacy-guard/src/privacy_guard/cli.py b/projects/privacy-guard/src/privacy_guard/cli.py index b11a06e..773b898 100644 --- a/projects/privacy-guard/src/privacy_guard/cli.py +++ b/projects/privacy-guard/src/privacy_guard/cli.py @@ -32,8 +32,8 @@ app = typer.Typer( name="privacy-guard", help=( - "Run Privacy Guard, configure a local OpenShell gateway, and inspect " - "installed entity-processing engines." + "Run Privacy Guard, manage local OpenShell gateway registrations, and " + "inspect installed entity-processing engines." ), no_args_is_help=True, add_completion=False, @@ -203,7 +203,10 @@ def add_gateway_registration( port=port, ) except GatewayConfigError as error: - typer.echo(f"Could not configure the OpenShell gateway: {error}", err=True) + typer.echo( + f"Could not add or update the OpenShell gateway registration: {error}", + err=True, + ) raise typer.Exit(code=1) from None action = { diff --git a/projects/privacy-guard/src/privacy_guard/gateway_config.py b/projects/privacy-guard/src/privacy_guard/gateway_config.py index 2f09719..2170789 100644 --- a/projects/privacy-guard/src/privacy_guard/gateway_config.py +++ b/projects/privacy-guard/src/privacy_guard/gateway_config.py @@ -1,4 +1,4 @@ -"""OpenShell gateway configuration updates for the command-line application.""" +"""OpenShell gateway registration management for the command-line application.""" from __future__ import annotations @@ -29,7 +29,7 @@ class GatewayConfigRemoval(Enum): class GatewayConfigError(ValueError): - """A safe, actionable gateway configuration update error.""" + """A safe, actionable gateway registration management error.""" # Mirrors OpenShell's stable-identifier byte limit for external middleware diff --git a/projects/privacy-guard/tests/test_cli.py b/projects/privacy-guard/tests/test_cli.py index ae050e8..b48ba02 100644 --- a/projects/privacy-guard/tests/test_cli.py +++ b/projects/privacy-guard/tests/test_cli.py @@ -142,7 +142,7 @@ def test_cli_add_gateway_registration_reports_invalid_existing_config( assert result.exit_code == 1 output = _plain_output(result) - assert "Could not configure the OpenShell gateway" in output + assert "Could not add or update the OpenShell gateway registration" in output assert "not valid TOML" in output assert path.read_text() == "not valid TOML" diff --git a/projects/privacy-guard/tests/test_gateway_config.py b/projects/privacy-guard/tests/test_gateway_config.py index e08494a..3633301 100644 --- a/projects/privacy-guard/tests/test_gateway_config.py +++ b/projects/privacy-guard/tests/test_gateway_config.py @@ -1,4 +1,4 @@ -"""OpenShell gateway configuration update tests.""" +"""OpenShell gateway registration management tests.""" from __future__ import annotations