diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c169bbd8..98308f98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Test @vantio/cli run: pnpm --filter @vantio/cli run test + - name: Test @vantio/gate-mcp + run: pnpm --filter @vantio/gate-mcp test + - name: Test @vantio/agent-sdk run: pnpm --filter @vantio/agent-sdk run test @@ -161,3 +164,6 @@ jobs: run: | python -m compileall -q vantio_install python -m unittest discover -s tests -t . -v + + - name: Test Optics docker observe example + run: python -m unittest deploy/docker/test_observe_example.py -v diff --git a/deploy/docker/.dockerignore b/deploy/docker/.dockerignore new file mode 100644 index 00000000..96bb61f4 --- /dev/null +++ b/deploy/docker/.dockerignore @@ -0,0 +1,24 @@ +# Strict context. Only the named observe example files are sent to the daemon. +* +!Dockerfile.observe +!agent.js +!package.json +!compose.observe.yml + +# Secret and VCS names stay excluded even if a later exception is added. +**/.env +**/.env.* +**/.git +**/.git/** +**/.ssh +**/.ssh/** +**/*.pem +**/*.key +**/id_rsa +**/id_rsa.pub +**/*credentials* +**/secrets +**/secrets/** +**/.npmrc +**/.aws +**/.aws/** diff --git a/deploy/docker/Dockerfile.observe b/deploy/docker/Dockerfile.observe index aa5a43c2..23da2c24 100644 --- a/deploy/docker/Dockerfile.observe +++ b/deploy/docker/Dockerfile.observe @@ -1,15 +1,14 @@ -# Wrap any Node agent image with Vantio Optics (Sight Loop observe). -# Build: docker build -f deploy/docker/Dockerfile.observe -t my-agent:optics . -# Run: docker run --rm -v vantio-runs:/root/.vantio/runs my-agent:optics +# Optics observe example. Build context is this directory. +# The CLI pin is exact and matches packages/vantio-cli. It is not a range. +# CANDIDATE_ONLY_NOT_FOR_PUBLICATION: this file does not publish the package. ARG BASE_IMAGE=node:22-bookworm-slim FROM ${BASE_IMAGE} -RUN npm install -g @vantio/cli@^0.3.1 +RUN npm install -g @vantio/cli@0.3.24 WORKDIR /app -COPY . /app +COPY package.json agent.js ./ -# Default: observe the package start script. Override CMD as needed. -ENV VANTIO_OBSERVE=1 -ENTRYPOINT ["vantio", "run"] -CMD ["npm", "start"] +# `vantio run node` attaches the Node interceptor. `npm` is not a wrapped runtime. +ENTRYPOINT ["vantio", "run", "node"] +CMD ["agent.js"] diff --git a/deploy/docker/agent.js b/deploy/docker/agent.js new file mode 100644 index 00000000..2632b3a6 --- /dev/null +++ b/deploy/docker/agent.js @@ -0,0 +1,3 @@ +// Started as `vantio run node agent.js`. Optics records supported Node traffic +// from this process. This example does not call the network. +console.log("vantio optics observe example: node process started"); diff --git a/deploy/docker/compose.observe.yml b/deploy/docker/compose.observe.yml index 776045df..25221e85 100644 --- a/deploy/docker/compose.observe.yml +++ b/deploy/docker/compose.observe.yml @@ -1,18 +1,14 @@ -# Example: run an agent under Optics and persist local run logs. +# Example: run a Node process under Optics and persist local run logs. +# Build context is this directory. It does not send the repository root. services: agent: build: - context: ../.. - dockerfile: deploy/docker/Dockerfile.observe - args: - BASE_IMAGE: node:22-bookworm-slim - environment: - - VANTIO_HOOKS=0 + context: . + dockerfile: Dockerfile.observe volumes: - vantio-runs:/root/.vantio/runs - # command: ["node", "agent.js"] - # Optional: sidecar that tails proofs (placeholder — mount runs volume) + # Optional: read the local run logs the agent service wrote. prove: image: node:22-bookworm-slim profiles: ["tools"] @@ -21,7 +17,7 @@ services: working_dir: /root entrypoint: ["bash", "-lc"] command: - - npm install -g @vantio/cli && vantio prove --format=md --list || true + - npm install -g @vantio/cli@0.3.24 && vantio prove --format=md --list volumes: vantio-runs: diff --git a/deploy/docker/package.json b/deploy/docker/package.json new file mode 100644 index 00000000..ed21a40c --- /dev/null +++ b/deploy/docker/package.json @@ -0,0 +1,8 @@ +{ + "name": "vantio-optics-observe-example", + "private": true, + "description": "Tiny Node process for the Optics observe image. Run only under vantio run node.", + "scripts": { + "start": "node agent.js" + } +} diff --git a/deploy/docker/test_observe_example.py b/deploy/docker/test_observe_example.py new file mode 100644 index 00000000..904967af --- /dev/null +++ b/deploy/docker/test_observe_example.py @@ -0,0 +1,122 @@ +"""Contract for the Optics observe Docker example. + +The example must pin an exact CLI version, keep secrets out of the build +context, and start Node under ``vantio run`` so observation actually attaches. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +DOCKER = Path(__file__).resolve().parent +DOCKERFILE = DOCKER / "Dockerfile.observe" +COMPOSE = DOCKER / "compose.observe.yml" +IGNORE = DOCKER / ".dockerignore" +AGENT = DOCKER / "agent.js" + +_PIN = re.compile(r"@vantio/cli@([^\s\"']+)") +_EXACT = re.compile(r"^\d+\.\d+\.\d+$") +_ENTRYPOINT = re.compile(r"^ENTRYPOINT\s+(\[.*\])\s*$", re.M) +_CMD = re.compile(r"^CMD\s+(\[.*\])\s*$", re.M) +_SECRET_LINES = ( + "**/.env", + "**/.env.*", + "**/.git", + "**/.git/**", + "**/.ssh", + "**/.ssh/**", + "**/*.pem", + "**/*.key", + "**/id_rsa", + "**/*credentials*", + "**/secrets", + "**/secrets/**", + "**/.npmrc", +) + + +class ObserveExampleTests(unittest.TestCase): + def test_cli_pin_is_exact_and_matches_the_tree(self) -> None: + cli = (DOCKER.parents[1] / "packages" / "vantio-cli" / "package.json").read_text(encoding="utf-8") + version = re.search(r'"version":\s*"([^"]+)"', cli) + self.assertIsNotNone(version) + expected = version.group(1) + pins = [] + for path in (DOCKERFILE, COMPOSE): + for match in _PIN.finditer(path.read_text(encoding="utf-8")): + pins.append((path.name, match.group(1))) + self.assertTrue(pins, "the observe example does not pin @vantio/cli") + for name, pin in pins: + self.assertNotIn("^", pin, name) + self.assertNotIn("~", pin, name) + self.assertIsNotNone(_EXACT.fullmatch(pin), f"{name} pin {pin} is not exact") + self.assertEqual(pin, expected, name) + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + self.assertNotIn("@vantio/cli@^", dockerfile) + self.assertNotIn("@vantio/cli@~", dockerfile) + self.assertNotRegex(dockerfile, r"npm install -g @vantio/cli(\s|$)") + + def test_default_command_runs_node_under_vantio_run(self) -> None: + text = DOCKERFILE.read_text(encoding="utf-8") + entry = _ENTRYPOINT.search(text) + cmd = _CMD.search(text) + self.assertIsNotNone(entry) + self.assertIsNotNone(cmd) + self.assertEqual(entry.group(1), '["vantio", "run", "node"]') + self.assertNotIn('"npm"', cmd.group(1)) + self.assertIn("agent.js", cmd.group(1)) + self.assertNotIn("VANTIO_OBSERVE", text) + agent = AGENT.read_text(encoding="utf-8") + self.assertNotIn("fetch(", agent) + self.assertNotIn("http.request", agent) + compose = COMPOSE.read_text(encoding="utf-8") + self.assertNotIn("VANTIO_HOOKS=0", compose) + self.assertNotIn("|| true", compose) + + def test_build_context_is_strict_and_excludes_secrets(self) -> None: + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + self.assertNotIn("COPY .", dockerfile) + self.assertIn("COPY package.json agent.js", dockerfile) + compose = COMPOSE.read_text(encoding="utf-8") + self.assertNotIn("../..", compose) + self.assertIn("context: .", compose) + self.assertTrue(IGNORE.is_file(), ".dockerignore is missing") + ignored = IGNORE.read_text(encoding="utf-8") + for line in _SECRET_LINES: + self.assertIn(line, ignored.splitlines(), line) + self.assertIn("\n*\n", f"\n{ignored}") + for name in (".env", ".env.local", "id_rsa", "secrets/token", ".git/config", ".ssh/id_rsa", "keys/app.pem"): + self.assertTrue(_docker_ignored(ignored, name), name) + self.assertFalse(_docker_ignored(ignored, "agent.js")) + self.assertFalse(_docker_ignored(ignored, "package.json")) + self.assertFalse(_docker_ignored(ignored, "Dockerfile.observe")) + + +def _docker_ignored(text: str, relpath: str) -> bool: + """Last-match dockerignore check for the patterns this example uses.""" + ignored = False + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + negate = line.startswith("!") + pattern = line[1:] if negate else line + if _docker_match(pattern, relpath): + ignored = not negate + return ignored + + +def _docker_match(pattern: str, relpath: str) -> bool: + if pattern == "*": + return "/" not in relpath + regex = re.escape(pattern).replace(r"\*\*/", "(?:.*/)?") + regex = regex.replace(r"\*\*", ".*").replace(r"\*", "[^/]*") + if pattern.startswith("**/"): + return re.fullmatch(regex, relpath) is not None + return re.fullmatch(regex, relpath) is not None or re.fullmatch(regex, relpath.split("/")[-1]) is not None + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/governance/VERSION-METADATA.json b/docs/governance/VERSION-METADATA.json index 5e332226..ae4cc7a4 100644 --- a/docs/governance/VERSION-METADATA.json +++ b/docs/governance/VERSION-METADATA.json @@ -44,10 +44,10 @@ { "id": "gate-mcp", "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "manifest": "packages/vantio-gate-mcp/package.json", "changelog": "docs/governance/changelogs/gate-mcp.md", - "changelog_heading": "## 0.1.0" + "changelog_heading": "## 0.1.1" }, { "id": "vscode", diff --git a/docs/governance/canonical/ai-guide.md b/docs/governance/canonical/ai-guide.md index 590f787e..cbe7c602 100644 --- a/docs/governance/canonical/ai-guide.md +++ b/docs/governance/canonical/ai-guide.md @@ -9,7 +9,7 @@ Use this guide when editing Vantio Optics documentation in this repository. Pack "@vantio/agent-sdk": "0.2.4", "vantio-agent-sdk": "3.1.0", "@vantio/optics-mcp": "0.1.2", - "@vantio/gate-mcp": "0.1.0", + "@vantio/gate-mcp": "0.1.1", "vantio-optics": "0.1.0", "@vantio/optics-evidence-contract": "0.0.0-unstable-pre-1.0" } diff --git a/docs/governance/canonical/environment.md b/docs/governance/canonical/environment.md index 49a84953..29270718 100644 --- a/docs/governance/canonical/environment.md +++ b/docs/governance/canonical/environment.md @@ -5,7 +5,7 @@ Free Optics runs with no account and no API key. Telemetry stays off unless `VAN | Variable | Role | |---|---| | `DO_NOT_TRACK` | Set to `1` to keep telemetry off. | -| `VANTIO_API_BASE` | Base URL for the Gate MCP control-plane client. Default `https://api.vantio.ai`. Not required for free Optics. | +| `VANTIO_API_BASE` | Base URL for the Gate MCP control-plane client. Default `https://api.vantio.ai`. Not required for free Optics. `gate_get_policy` and `gate_residual_risk` read this from the environment. They do not take a host argument. | | `VANTIO_API_KEY` | Control-plane key for Phantom Engine / Enterprise policy and ingest. Not required for free Optics. | | `VANTIO_AUDIT_MODE` | Set to `1` to flag events as audit mode. | | `VANTIO_CLOUD_INGEST` | Set to `true` or `1` before `reportAnomaly` / `report_anomaly` will send. | diff --git a/docs/governance/changelogs/gate-mcp.md b/docs/governance/changelogs/gate-mcp.md index ddf9811e..074b13e3 100644 --- a/docs/governance/changelogs/gate-mcp.md +++ b/docs/governance/changelogs/gate-mcp.md @@ -2,6 +2,12 @@ This heading exists so a documentation release can require a changelog entry for the version already in `packages/vantio-gate-mcp/package.json`. It does not bump that version. +## 0.1.1 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. Source version only. Not an npm release. + +`gate_get_policy` and `gate_residual_risk` read `VANTIO_API_KEY` from the environment. They do not take an `api_key` tool argument. They also do not take an `api_base` tool argument. The control-plane host is `VANTIO_API_BASE`, or `https://api.vantio.ai` when that variable is unset or blank. A caller-supplied host is ignored. The tools still do not block network traffic. + ## 0.1.0 Documentation baseline at `14249ba84ff1f3d5aa8ad7a7366172f29235c76e`. `@vantio/gate-mcp` remains a legacy compatibility package. Gate is not a separate product. Product behavior is unchanged by this documentation record. diff --git a/docs/governance/llms-full.txt b/docs/governance/llms-full.txt index b8de919f..7724dee1 100644 --- a/docs/governance/llms-full.txt +++ b/docs/governance/llms-full.txt @@ -820,7 +820,7 @@ Free Optics runs with no account and no API key. Telemetry stays off unless `VAN | Variable | Role | |---|---| | `DO_NOT_TRACK` | Set to `1` to keep telemetry off. | -| `VANTIO_API_BASE` | Base URL for the Gate MCP control-plane client. Default `https://api.vantio.ai`. Not required for free Optics. | +| `VANTIO_API_BASE` | Base URL for the Gate MCP control-plane client. Default `https://api.vantio.ai`. Not required for free Optics. `gate_get_policy` and `gate_residual_risk` read this from the environment. They do not take a host argument. | | `VANTIO_API_KEY` | Control-plane key for Phantom Engine / Enterprise policy and ingest. Not required for free Optics. | | `VANTIO_AUDIT_MODE` | Set to `1` to flag events as audit mode. | | `VANTIO_CLOUD_INGEST` | Set to `true` or `1` before `reportAnomaly` / `report_anomaly` will send. | @@ -954,7 +954,7 @@ Use this guide when editing Vantio Optics documentation in this repository. Pack "@vantio/agent-sdk": "0.2.4", "vantio-agent-sdk": "3.1.0", "@vantio/optics-mcp": "0.1.2", - "@vantio/gate-mcp": "0.1.0", + "@vantio/gate-mcp": "0.1.1", "vantio-optics": "0.1.0", "@vantio/optics-evidence-contract": "0.0.0-unstable-pre-1.0" } diff --git a/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md new file mode 100644 index 00000000..5cc20d4f --- /dev/null +++ b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md @@ -0,0 +1,42 @@ +# Optics audit P1 independent council + +Audience: review of source changes on this branch. + +Status: `PENDING_INDEPENDENT_COUNCIL` + +`council_pass`: false + +`merge_state`: `WAITING_FOR_AUTHORIZED_REVIEWER` + +Publication: `CANDIDATE_ONLY_NOT_FOR_PUBLICATION`. No npm publish, no PyPI publish, no install.vantio.ai go-live. + +The producer wrote this packet and the tests. The producer does not sit this council and does not fill the verdict. Approval has to come from kvantio, and the author of the change is not that reviewer. + +## Packet + +| Finding | Result | Tests | +| --- | --- | --- | +| Installer `remove_stage` followed a stage symlink that stayed inside the parent, then `shutil.rmtree` raised instead of refusing | Reproduced. Removal now `lstat`s the stage, opens it with `O_NOFOLLOW`, and refuses a symlink. Child symlinks are unlinked. Their targets stay. | `tests.test_live_executor.LiveExecutorTests.test_remove_stage_refuses_symlink_and_does_not_follow_it`, `test_remove_stage_does_not_follow_a_symlink_inside_the_directory`, `test_fixture_remove_stage_refuses_symlink` | +| `_privilege_ok` treated `sudo` on `PATH`, and a docker-group socket writer, as a live grant | Reproduced. Live apply, rollback, and uninstall require effective uid 0. | `test_sudo_on_path_is_not_live_privilege`, `test_docker_group_without_effective_root_is_not_live_privilege`, `test_effective_root_is_live_privilege_without_sudo_on_path` | +| Observe image used `@vantio/cli@^0.3.1`, copied the repo context, and ran `vantio run npm start`, which does not attach the Node interceptor | Reproduced. Exact pin `0.3.24`, strict `.dockerignore`, `vantio run node agent.js`. | `deploy/docker/test_observe_example.py` | +| `gate_get_policy` and `gate_residual_risk` accepted `api_key` and sent that value | Reproduced. The key is `VANTIO_API_KEY` only. Source version `@vantio/gate-mcp` `0.1.1` is a candidate, not a registry release. | `packages/vantio-gate-mcp/test/api_key_env.test.js` | + +CLI `0.3.25` and Python `3.1.1` are not staged. Those packages were not changed. The observe example pins the CLI version already in this tree, `0.3.24`. + +## Residual + +`api_base` is still a tool argument on the two gate-mcp fetch tools. A caller can choose the URL that receives `VANTIO_API_KEY`. The key itself is no longer a tool argument. + +An outside stage symlink was already refused by `confine` before this change. The reproduced hole was a symlink whose target stayed inside the stage parent. + +`vantio-install` stays `0.1.0-stage-a`. That string is sealed. + +## Verdict + +| Field | Value | +| --- | --- | +| Council identity | `PENDING` | +| Reviewer | `PENDING` — kvantio, non-author | +| Date | `PENDING` | +| Result | `PENDING` | +| Notes | `PENDING` | diff --git a/docs/programs/release-engineering/dossiers/optics-public.json b/docs/programs/release-engineering/dossiers/optics-public.json index 34ed60c0..cd5092d2 100644 --- a/docs/programs/release-engineering/dossiers/optics-public.json +++ b/docs/programs/release-engineering/dossiers/optics-public.json @@ -349,12 +349,12 @@ "selector_is_integrity": false }, "distribution": "public", - "filename": "@vantio-gate-mcp-0.1.0.source", + "filename": "@vantio-gate-mcp-0.1.1.source", "hash_status": "UNRECORDED", "role": "source-tree", "sha256": null, "source_commit": "UNRECORDED", - "version": "0.1.0" + "version": "0.1.1" } ], "clean_env": { @@ -362,8 +362,8 @@ }, "distribution": "public", "docs_gate": { - "docs_version": "0.1.0", - "manifest_version": "0.1.0", + "docs_version": "0.1.1", + "manifest_version": "0.1.1", "status": "MATCH" }, "ordinary_client": { @@ -379,10 +379,10 @@ "from_version": null, "rollback_sha256": null, "to_sha256": null, - "to_version": "0.1.0", + "to_version": "0.1.1", "verified": false }, - "version": "0.1.0" + "version": "0.1.1" }, { "artifacts": [ diff --git a/docs/programs/release-engineering/generated/evaluations.json b/docs/programs/release-engineering/generated/evaluations.json index 795c1622..ce955a09 100644 --- a/docs/programs/release-engineering/generated/evaluations.json +++ b/docs/programs/release-engineering/generated/evaluations.json @@ -20,12 +20,12 @@ "status": "SATISFIED" }, { - "detail": "@vantio-cli-0.3.24.source hash unrecorded; @vantio-agent-sdk-0.2.4.source hash unrecorded; @vantio-optics-mcp-0.1.2.source hash unrecorded; @vantio-gate-mcp-0.1.0.source hash unrecorded; vantio-optics-0.1.0.source hash unrecorded; @vantio-optics-evidence-contract-0.0.0-unstable-pre-1.0.source hash unrecorded", + "detail": "@vantio-cli-0.3.24.source hash unrecorded; @vantio-agent-sdk-0.2.4.source hash unrecorded; @vantio-optics-mcp-0.1.2.source hash unrecorded; @vantio-gate-mcp-0.1.1.source hash unrecorded; vantio-optics-0.1.0.source hash unrecorded; @vantio-optics-evidence-contract-0.0.0-unstable-pre-1.0.source hash unrecorded", "id": "R3", "status": "GAP" }, { - "detail": "@vantio-cli-0.3.24.source custody hash unrecorded; @vantio-agent-sdk-0.2.4.source custody hash unrecorded; @vantio-optics-mcp-0.1.2.source custody hash unrecorded; @vantio-gate-mcp-0.1.0.source custody hash unrecorded; vantio-optics-0.1.0.source custody hash unrecorded; @vantio-optics-evidence-contract-0.0.0-unstable-pre-1.0.source custody hash unrecorded", + "detail": "@vantio-cli-0.3.24.source custody hash unrecorded; @vantio-agent-sdk-0.2.4.source custody hash unrecorded; @vantio-optics-mcp-0.1.2.source custody hash unrecorded; @vantio-gate-mcp-0.1.1.source custody hash unrecorded; vantio-optics-0.1.0.source custody hash unrecorded; @vantio-optics-evidence-contract-0.0.0-unstable-pre-1.0.source custody hash unrecorded", "id": "R4", "status": "GAP" }, diff --git a/docs/programs/release-engineering/generated/manifest-license-scan.json b/docs/programs/release-engineering/generated/manifest-license-scan.json index 22298962..e75d0261 100644 --- a/docs/programs/release-engineering/generated/manifest-license-scan.json +++ b/docs/programs/release-engineering/generated/manifest-license-scan.json @@ -1,6 +1,12 @@ { "bound_to_sealed_bytes": false, "findings": [ + { + "accepted": false, + "id": "license-missing:deploy/docker/package.json", + "name": "vantio-optics-observe-example", + "path": "deploy/docker/package.json" + }, { "accepted": false, "id": "license-missing:package.json", @@ -105,6 +111,7 @@ } ], "scanned": [ + "deploy/docker/package.json", "extensions/vantio-optics/package.json", "package.json", "packages/governance-assurance/package.json", diff --git a/docs/programs/release-engineering/generated/open-core-sbom.cdx.json b/docs/programs/release-engineering/generated/open-core-sbom.cdx.json index c8d01e56..1de9760b 100644 --- a/docs/programs/release-engineering/generated/open-core-sbom.cdx.json +++ b/docs/programs/release-engineering/generated/open-core-sbom.cdx.json @@ -178,10 +178,10 @@ } ], "name": "@vantio/gate-mcp", - "purl": "pkg:npm/%40vantio/gate-mcp@0.1.0", + "purl": "pkg:npm/%40vantio/gate-mcp@0.1.1", "scope": "workspace-manifest", "type": "library", - "version": "0.1.0" + "version": "0.1.1" }, { "licenses": [ diff --git a/docs/programs/release-engineering/generated/pin-report.json b/docs/programs/release-engineering/generated/pin-report.json index ae0dd176..e32d0d6e 100644 --- a/docs/programs/release-engineering/generated/pin-report.json +++ b/docs/programs/release-engineering/generated/pin-report.json @@ -268,7 +268,7 @@ "id": "gate-mcp", "manifest": "packages/vantio-gate-mcp/package.json", "name": "@vantio/gate-mcp", - "version": "0.1.0" + "version": "0.1.1" }, { "id": "vscode", diff --git a/packages/vantio-gate-mcp/CHANGELOG.md b/packages/vantio-gate-mcp/CHANGELOG.md new file mode 100644 index 00000000..16fe38fe --- /dev/null +++ b/packages/vantio-gate-mcp/CHANGELOG.md @@ -0,0 +1,8 @@ +# @vantio/gate-mcp changelog + +## 0.1.1 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. This heading is source. It is not an npm release. + +- `gate_get_policy` and `gate_residual_risk` no longer take an `api_key` tool argument. The key is `VANTIO_API_KEY` from the environment. +- Those tools no longer take an `api_base` tool argument. The control-plane host is `VANTIO_API_BASE` from the environment, or `https://api.vantio.ai` when that variable is unset or blank. A caller-supplied host is ignored, so the tool cannot send `VANTIO_API_KEY` to a URL the caller chooses. diff --git a/packages/vantio-gate-mcp/README.md b/packages/vantio-gate-mcp/README.md index 01334590..f62d0224 100644 --- a/packages/vantio-gate-mcp/README.md +++ b/packages/vantio-gate-mcp/README.md @@ -41,6 +41,8 @@ Cursor / Claude Desktop: | `gate_explain` | Fence + rules that stick | | `gate_upgrade_path` | Optics → Phantom Engine → Enterprise | +`gate_get_policy` and `gate_residual_risk` read `VANTIO_API_KEY` and `VANTIO_API_BASE` from the environment. They do not take a key argument or a host argument. When `VANTIO_API_BASE` is unset, the host is `https://api.vantio.ai`. + > `gate_explain` JSON: `phantom` is the current product URL field (`https://vantio.ai/phantom`). The `gate` key is a **legacy compatibility alias** for that same URL — not a product SKU. ## Upgrade path diff --git a/packages/vantio-gate-mcp/package-lock.json b/packages/vantio-gate-mcp/package-lock.json index f5789fac..e6f006db 100644 --- a/packages/vantio-gate-mcp/package-lock.json +++ b/packages/vantio-gate-mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/packages/vantio-gate-mcp/package.json b/packages/vantio-gate-mcp/package.json index ffcc3f1e..049adc7e 100644 --- a/packages/vantio-gate-mcp/package.json +++ b/packages/vantio-gate-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "mcpName": "io.github.vantioai/vantio-gate", "description": "@vantio/gate-mcp is a legacy compatibility package for Phantom Engine application-path enforcement dry-run. Gate is not a separate Vantio product or subscription.", "license": "MIT", diff --git a/packages/vantio-gate-mcp/server.json b/packages/vantio-gate-mcp/server.json index 21be724b..331d95a5 100644 --- a/packages/vantio-gate-mcp/server.json +++ b/packages/vantio-gate-mcp/server.json @@ -9,12 +9,12 @@ "source": "github", "subdirectory": "packages/vantio-gate-mcp" }, - "version": "0.1.0", + "version": "0.1.1", "packages": [ { "registryType": "npm", "identifier": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "transport": { "type": "stdio" }, @@ -28,7 +28,7 @@ }, { "name": "VANTIO_API_BASE", - "description": "Optional Phantom Engine API base URL (default https://api.vantio.ai)", + "description": "Optional Phantom Engine API base URL (default https://api.vantio.ai). Environment only. The fetch tools do not take a host argument.", "isRequired": false, "format": "string", "isSecret": false diff --git a/packages/vantio-gate-mcp/src/policy.js b/packages/vantio-gate-mcp/src/policy.js index e6fc0131..b592d27f 100644 --- a/packages/vantio-gate-mcp/src/policy.js +++ b/packages/vantio-gate-mcp/src/policy.js @@ -148,20 +148,27 @@ export function evaluateRequest(policyRaw, req) { }; } -export async function fetchCloudConfig({ - apiKey, - apiBase = process.env.VANTIO_API_BASE || "https://api.vantio.ai", -} = {}) { - const key = apiKey || process.env.VANTIO_API_KEY; +const DEFAULT_CONTROL_PLANE_BASE = "https://api.vantio.ai"; + +// Host that receives VANTIO_API_KEY. Environment only. Arguments are ignored. +export function controlPlaneBase() { + const raw = process.env.VANTIO_API_BASE; + if (typeof raw !== "string") return DEFAULT_CONTROL_PLANE_BASE; + const trimmed = raw.trim().replace(/\/+$/, ""); + return trimmed || DEFAULT_CONTROL_PLANE_BASE; +} + +export async function fetchCloudConfig() { + const key = process.env.VANTIO_API_KEY; if (!key) { return { ok: false, error: "missing_api_key", - hint: "Set VANTIO_API_KEY or pass api_key. Free Optics needs no key; Phantom Engine control-plane config requires a key.", + hint: "Set VANTIO_API_KEY. Free Optics needs no key; Phantom Engine control-plane config requires a key.", policy: DEFAULT_POLICY, }; } - const base = apiBase.replace(/\/$/, ""); + const base = controlPlaneBase(); const res = await fetch(`${base}/api/v1/config`, { headers: { "x-vantio-identity": key, @@ -185,11 +192,8 @@ export async function fetchCloudConfig({ }; } -export async function fetchResidualRisk({ - apiKey, - apiBase = process.env.VANTIO_API_BASE || "https://api.vantio.ai", -} = {}) { - const key = apiKey || process.env.VANTIO_API_KEY; +export async function fetchResidualRisk() { + const key = process.env.VANTIO_API_KEY; if (!key) { return { ok: false, @@ -197,7 +201,7 @@ export async function fetchResidualRisk({ hint: "Residual-risk ledger requires VANTIO_API_KEY.", }; } - const base = apiBase.replace(/\/$/, ""); + const base = controlPlaneBase(); const res = await fetch(`${base}/api/v1/residual-risk`, { headers: { "x-vantio-identity": key, diff --git a/packages/vantio-gate-mcp/src/server.js b/packages/vantio-gate-mcp/src/server.js index f4b5ebe1..aa992d28 100644 --- a/packages/vantio-gate-mcp/src/server.js +++ b/packages/vantio-gate-mcp/src/server.js @@ -39,7 +39,7 @@ const policyShape = z export function createGateMcpServer() { const server = new McpServer({ name: "vantio-gate", - version: "0.1.0", + version: "0.1.1", }); server.tool( @@ -64,16 +64,10 @@ export function createGateMcpServer() { server.tool( "gate_get_policy", - "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY. Read-only.", - { - api_key: z.string().optional().describe("Override VANTIO_API_KEY"), - api_base: z.string().optional().describe("Override VANTIO_API_BASE"), - }, - async ({ api_key, api_base }) => { - const result = await fetchCloudConfig({ - apiKey: api_key, - apiBase: api_base, - }); + "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY in the environment. The host is VANTIO_API_BASE, or https://api.vantio.ai when that is unset. Read-only.", + {}, + async () => { + const result = await fetchCloudConfig(); if (!result.ok) return err(JSON.stringify(result, null, 2)); return text({ plane: "Enforce", @@ -87,16 +81,10 @@ export function createGateMcpServer() { server.tool( "gate_residual_risk", - "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY. Read-only.", - { - api_key: z.string().optional(), - api_base: z.string().optional(), - }, - async ({ api_key, api_base }) => { - const result = await fetchResidualRisk({ - apiKey: api_key, - apiBase: api_base, - }); + "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY in the environment. The host is VANTIO_API_BASE, or https://api.vantio.ai when that is unset. Read-only.", + {}, + async () => { + const result = await fetchResidualRisk(); if (!result.ok) return err(JSON.stringify(result, null, 2)); return text({ plane: "Enforce", diff --git a/packages/vantio-gate-mcp/test/api_base_host.test.js b/packages/vantio-gate-mcp/test/api_base_host.test.js new file mode 100644 index 00000000..901b3441 --- /dev/null +++ b/packages/vantio-gate-mcp/test/api_base_host.test.js @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { fetchCloudConfig, fetchResidualRisk } from "../src/policy.js"; +import { createGateMcpServer } from "../src/server.js"; + +const root = dirname(fileURLToPath(import.meta.url)); +const serverSrc = readFileSync(join(root, "../src/server.js"), "utf8"); + +function withEnv(pairs, fn) { + const previous = new Map(); + for (const [name, value] of Object.entries(pairs)) { + previous.set(name, process.env[name]); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + return Promise.resolve() + .then(fn) + .finally(() => { + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); +} + +async function captureFetch(fn) { + const seen = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + seen.push({ url: String(url), opts }); + return { + ok: true, + status: 200, + json: async () => ({ tier: "phantom", policy: { enforce: false }, gaps: [] }), + }; + }; + try { + await fn(); + } finally { + globalThis.fetch = original; + } + return seen; +} + +test("gate_get_policy and gate_residual_risk do not take an api_base tool argument", () => { + assert.doesNotMatch(serverSrc, /api_base\s*:/); + assert.doesNotMatch(serverSrc, /apiBase\s*:/); +}); + +test("fetchCloudConfig does not send VANTIO_API_KEY to a caller-supplied host", async () => { + const seen = await captureFetch(() => + withEnv( + { VANTIO_API_KEY: "from-env", VANTIO_API_BASE: "https://api.vantio.ai" }, + () => fetchCloudConfig({ apiBase: "https://evil.example/steal" }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); + +test("fetchResidualRisk does not send VANTIO_API_KEY to a caller-supplied host", async () => { + const seen = await captureFetch(() => + withEnv( + { VANTIO_API_KEY: "from-env", VANTIO_API_BASE: "https://control.example.test" }, + () => fetchResidualRisk({ apiBase: "https://evil.example/steal" }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://control.example.test/"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); + +test("an unset VANTIO_API_BASE stays on the default host when a caller passes apiBase", async () => { + const seen = await captureFetch(() => + withEnv({ VANTIO_API_KEY: "from-env", VANTIO_API_BASE: undefined }, () => + fetchCloudConfig({ apiBase: "http://127.0.0.1:9" }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/"), true); + assert.equal(seen[0].url.includes("127.0.0.1"), false); +}); + +test("registered fetch tools drop api_base before the key is sent", async () => { + const server = createGateMcpServer(); + for (const name of ["gate_get_policy", "gate_residual_risk"]) { + const shape = server._registeredTools[name].inputSchema.shape; + assert.equal(Object.hasOwn(shape, "api_base"), false); + assert.equal(Object.hasOwn(shape, "api_key"), false); + } + const seen = await captureFetch(() => + withEnv({ VANTIO_API_KEY: "from-env", VANTIO_API_BASE: "https://api.vantio.ai" }, () => + server._registeredTools.gate_get_policy.handler({ + api_base: "https://evil.example/steal", + }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); + +test("a blank VANTIO_API_BASE stays on the default host when a caller passes apiBase", async () => { + const seen = await captureFetch(() => + withEnv({ VANTIO_API_KEY: "from-env", VANTIO_API_BASE: " " }, () => + fetchResidualRisk("https://evil.example"), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/api/v1/residual-risk"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); diff --git a/packages/vantio-gate-mcp/test/api_key_env.test.js b/packages/vantio-gate-mcp/test/api_key_env.test.js new file mode 100644 index 00000000..5d4174e1 --- /dev/null +++ b/packages/vantio-gate-mcp/test/api_key_env.test.js @@ -0,0 +1,102 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { fetchCloudConfig, fetchResidualRisk } from "../src/policy.js"; + +const root = dirname(fileURLToPath(import.meta.url)); +const serverSrc = readFileSync(join(root, "../src/server.js"), "utf8"); + +function withEnv(name, value, fn) { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + return Promise.resolve() + .then(fn) + .finally(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); +} + +test("gate tool schemas do not accept an api_key argument", () => { + assert.doesNotMatch(serverSrc, /api_key\s*:/); + assert.doesNotMatch(serverSrc, /apiKey\s*:/); + assert.doesNotMatch(serverSrc, /pass api_key/); +}); + +test("fetchCloudConfig sends the environment key and ignores a tool argument", async () => { + const seen = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + seen.push({ url, opts }); + return { + ok: true, + status: 200, + json: async () => ({ tier: "phantom", policy: { enforce: false } }), + }; + }; + try { + await withEnv("VANTIO_API_KEY", "from-env", async () => { + await withEnv("VANTIO_API_BASE", undefined, async () => { + const result = await fetchCloudConfig({ + apiKey: "from-tool", + apiBase: "https://example.test", + }); + assert.equal(result.ok, true); + }); + }); + } finally { + globalThis.fetch = original; + } + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(String(seen[0].url).startsWith("https://api.vantio.ai/"), true); + assert.equal(String(seen[0].url).includes("example.test"), false); +}); + +test("fetchCloudConfig does not use a tool api key when the environment is empty", async () => { + let called = false; + const original = globalThis.fetch; + globalThis.fetch = async () => { + called = true; + throw new Error("fetch should not run"); + }; + try { + await withEnv("VANTIO_API_KEY", undefined, async () => { + const result = await fetchCloudConfig({ apiKey: "from-tool" }); + assert.equal(result.ok, false); + assert.equal(result.error, "missing_api_key"); + assert.doesNotMatch(result.hint || "", /pass api_key/); + }); + } finally { + globalThis.fetch = original; + } + assert.equal(called, false); +}); + +test("fetchResidualRisk sends the environment key and ignores a tool argument", async () => { + const seen = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + seen.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ gaps: [] }) }; + }; + try { + await withEnv("VANTIO_API_KEY", "from-env", async () => { + await withEnv("VANTIO_API_BASE", undefined, async () => { + const result = await fetchResidualRisk({ + apiKey: "from-tool", + apiBase: "https://example.test", + }); + assert.equal(result.ok, true); + }); + }); + } finally { + globalThis.fetch = original; + } + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(String(seen[0].url).startsWith("https://api.vantio.ai/"), true); + assert.equal(String(seen[0].url).includes("example.test"), false); +}); diff --git a/packages/vantio-gate-mcp/test/brand.test.js b/packages/vantio-gate-mcp/test/brand.test.js index 4451dfdd..9cb459a0 100644 --- a/packages/vantio-gate-mcp/test/brand.test.js +++ b/packages/vantio-gate-mcp/test/brand.test.js @@ -7,7 +7,7 @@ * - gate_upgrade_path description is Optics → Phantom Engine → Enterprise (not Optics → Gate → …) * - no Gate $499 / Gate Pro / hosted Gate / four-product ladder anywhere * - package name preserved as @vantio/gate-mcp - * - version preserved as 0.1.0 + * - source candidate version is 0.1.1 (not a registry publish) * - all required tool names present * - schema / evaluate behavior preserved */ @@ -40,8 +40,9 @@ test("package name preserved as @vantio/gate-mcp", () => { assert.equal(pkg.name, "@vantio/gate-mcp"); }); -test("version preserved as 0.1.0", () => { - assert.equal(pkg.version, "0.1.0"); +test("source candidate version is 0.1.1 and is not a registry publish", () => { + assert.equal(pkg.version, "0.1.1"); + assert.equal(serverMeta.version, "0.1.1"); }); test("package homepage points to /phantom not /gate", () => { diff --git a/packages/vantio-install/docs/LIMITATIONS.md b/packages/vantio-install/docs/LIMITATIONS.md index 6b315719..b9659a99 100644 --- a/packages/vantio-install/docs/LIMITATIONS.md +++ b/packages/vantio-install/docs/LIMITATIONS.md @@ -20,6 +20,6 @@ The sealed Phantom Engine archive, manifest digest, and Optics package versions Live changes on a host run only when you set `VANTIO_INSTALL_ALLOW_LIVE=1` and pass `--i-accept-live-mutations` on `apply`, `rollback`, or `uninstall`. Either one alone stops before any host change and the command returns `FAILED_SAFE`. With both set, the command still stops unless the plan hash matches, the sealed artifacts match, the host is x86_64 with kernel BTF, the mode is observe-only, and rollback and residual checks are in the plan. The command runs an allowlisted argv list. It does not use a shell string. Exit 0 from one of those commands is not enough; the installer reads the host again before it records the step as verified. -The live privilege check accepts effective uid 0, `privilege_mode` `sudo` with `sudo` on `PATH`, or `privilege_mode` `docker_group` with write access to `/var/run/docker.sock`. `privilege_mode` `UNKNOWN` blocks `PF-DOCKER-PERM`. Unless the process is root, the live command then returns `FAILED_SAFE` and the message `Live mutations need root or the documented sudo or docker privilege.` `PREFLIGHT.md` records the symptoms. `sudo` is not an allowlisted executable. Raw `docker` and raw `sudo docker` outside the installer argv list are forbidden. The installer does not insert `sudo` in front of `docker`. A live residual check reports `RESIDUAL_FOUND` when something from that scope is still on the host. From that state, the same dual-gated rollback, or uninstall with `--scope optics` or `--scope all`, removes a leftover Optics CLI or Agent SDK tree under the prefix. Rollback, or uninstall with `--scope pe` or `--scope all`, also unlinks the known bpffs pin names when they are still present. Apply stays refused until `verify-removal` reports an empty residual list. +The live privilege check accepts effective uid 0 only. `privilege_mode` `sudo` with `sudo` on `PATH`, and `privilege_mode` `docker_group` with write access to `/var/run/docker.sock`, are recorded facts and are not a live grant. `privilege_mode` `UNKNOWN` blocks `PF-DOCKER-PERM`. When the effective uid is not 0, the live command returns `FAILED_SAFE` and the message `Live mutations need effective root. sudo on PATH is not privilege.` `PREFLIGHT.md` records the symptoms. `sudo` is not an allowlisted executable. Raw `docker` and raw `sudo docker` outside the installer argv list are forbidden. The installer does not insert `sudo` in front of `docker`. A live residual check reports `RESIDUAL_FOUND` when something from that scope is still on the host. From that state, the same dual-gated rollback, or uninstall with `--scope optics` or `--scope all`, removes a leftover Optics CLI or Agent SDK tree under the prefix. Rollback, or uninstall with `--scope pe` or `--scope all`, also unlinks the known bpffs pin names when they are still present. Apply stays refused until `verify-removal` reports an empty residual list. Fixture tests exercise the transaction without those live commands. Those tests are internal. They are not a customer rehearsal, and they do not make `--fixture-host` a customer flag. `proof_state` stays `NOT_PROVED`. The second-lab gate stays closed until a later authorization. The proof ceiling stays `INTERNAL_CLEAN_HOST_PROOF`. diff --git a/packages/vantio-install/docs/PREFLIGHT.md b/packages/vantio-install/docs/PREFLIGHT.md index 3974c58a..6c156bd5 100644 --- a/packages/vantio-install/docs/PREFLIGHT.md +++ b/packages/vantio-install/docs/PREFLIGHT.md @@ -12,16 +12,16 @@ Docker availability and Docker privilege are different checks. - `sudo` when this principal cannot write that socket and `sudo` is on `PATH`. `sudo_available` is true. - `UNKNOWN` when neither fact is true. -Root is effective uid 0. The probe does not store the string `root` in `privilege_mode`. A live `apply`, `rollback`, or `uninstall` accepts the command when the effective uid is 0, or when `privilege_mode` is `sudo` and `sudo` is on `PATH`, or when `privilege_mode` is `docker_group` and the principal can write the socket. +Root is effective uid 0. The probe does not store the string `root` in `privilege_mode`. A live `apply`, `rollback`, or `uninstall` accepts the command only when the effective uid is 0. `privilege_mode` `sudo` means `sudo` is on `PATH`. `privilege_mode` `docker_group` means this principal can write `/var/run/docker.sock`. Neither fact is a live grant, and the installer does not exec sudo. `PF-DOCKER-PERM` is `PASS` when the principal can write the socket and `privilege_mode` is `docker_group` or `sudo`, or when `privilege_mode` is `sudo` and `sudo` is on `PATH`. The remediation stored on that check is: add the operator to the docker group, or rerun the installer with sudo. Group membership is not assumed. Rerun means `sudo` in front of `vantio-install`, so the installer process is root. It does not mean a Docker command typed by hand. `PF-DOCKER-PERM` is `BLOCKED` when `privilege_mode` is `UNKNOWN` and the principal cannot write the socket. The plan overall is then `BLOCKED` when no unsupported check fired, the process exit is 2, and `state` stays off `PLANNED`. `PREFLIGHT.json` shows check id `PF-DOCKER-PERM`, the observed `privilege_mode`, and that remediation. -A live command returns `FAILED_SAFE` with the message `Live mutations need root or the documented sudo or docker privilege.` when the effective uid is not 0 and the recorded mode is not a passing `sudo` or `docker_group` fact. +A live command returns `FAILED_SAFE` with the message `Live mutations need effective root. sudo on PATH is not privilege.` when the effective uid is not 0. A passing `sudo` or `docker_group` fact does not change that. The installer is the only program on this path that runs Docker. It uses an argv list and `shell` is false. A shell string is refused. The executables it may run are `mkdir`, `npm`, `python3`, `docker`, `tc`, and `apparmor_parser`. `sudo`, `su`, and a shell are refused as the executable. An argument that contains a shell metacharacter is refused. An argv list that differs from the catalog entry for that step is refused. -Raw `docker`, raw `sudo docker`, and a direct call on `docker.sock` are forbidden for customer operators. So is changing the socket mode by hand. When `privilege_mode` is `sudo`, rerun `vantio-install` under `sudo`, or use a principal that can already write the socket. The allowlist does not insert `sudo` in front of `docker`. A host check can still fail when `docker` runs as a user who cannot open the socket. +Raw `docker`, raw `sudo docker`, and a direct call on `docker.sock` are forbidden for customer operators. So is changing the socket mode by hand. When the effective uid is not 0, rerun `vantio-install` under `sudo` so the process is root. A principal that can already write the socket is still not a live grant until that process is root. The allowlist does not insert `sudo` in front of `docker`. A host check can still fail when `docker` runs as a user who cannot open the socket. `proof_state` stays `NOT_PROVED`. The proof ceiling stays `INTERNAL_CLEAN_HOST_PROOF`. diff --git a/packages/vantio-install/tests/test_live_executor.py b/packages/vantio-install/tests/test_live_executor.py index 9d63bbb6..defd4930 100644 --- a/packages/vantio-install/tests/test_live_executor.py +++ b/packages/vantio-install/tests/test_live_executor.py @@ -23,7 +23,10 @@ from vantio_install.docker_object import DockerCommandResult, interpret_probe # noqa: E402 from vantio_install.live_executor import ( # noqa: E402 ExecResult, + LiveGrant, ProductionObserver, + _filesystem, + _privilege_ok, _recover_absent_target, authorize_live, catalog_argv, @@ -33,6 +36,7 @@ reject_argv, residual_result, ) +from vantio_install.mutator import FixtureMutator # noqa: E402 from vantio_install.agent_sdk import ( # noqa: E402 observed_agent_sdk_npm_version, observed_agent_sdk_py_version, @@ -499,6 +503,42 @@ def test_live_missing_privilege_refuses(self) -> None: self.grant_for(harness, host=host, euid=1000) self.assertIn("root", str(caught.exception)) + def test_sudo_on_path_is_not_live_privilege(self) -> None: + harness = self.planned() + self.set_env("1") + host = harness.snapshot() + host["privilege_mode"] = "sudo" + host["sudo_available"] = True + host["principal_can_talk_to_docker"] = False + self.assertFalse(_privilege_ok(host, 1000)) + with self.assertRaises(InstallError) as caught: + self.grant_for(harness, host=host, euid=1000) + self.assertIn("effective root", str(caught.exception)) + self.assertEqual(caught.exception.failure_class, "FAILED_SAFE") + + def test_docker_group_without_effective_root_is_not_live_privilege(self) -> None: + harness = self.planned() + self.set_env("1") + host = harness.snapshot() + host["privilege_mode"] = "docker_group" + host["sudo_available"] = False + host["principal_can_talk_to_docker"] = True + self.assertFalse(_privilege_ok(host, 1000)) + with self.assertRaises(InstallError) as caught: + self.grant_for(harness, host=host, euid=1000) + self.assertIn("effective root", str(caught.exception)) + + def test_effective_root_is_live_privilege_without_sudo_on_path(self) -> None: + harness = self.planned() + self.set_env("1") + host = harness.snapshot() + host["privilege_mode"] = "sudo" + host["sudo_available"] = False + host["principal_can_talk_to_docker"] = True + self.assertTrue(_privilege_ok(host, 0)) + grant = self.grant_for(harness, host=host, euid=0) + self.assertEqual(grant.command, "apply") + def test_live_preflight_blocked_refuses(self) -> None: harness = self.planned() self.set_env("1") @@ -1610,6 +1650,89 @@ def fake_run(argv, **_kwargs): self.assertTrue(any(row["phase"] == "RESIDUAL_FOUND" and row["op"] == "docker_rm" for row in ops)) self.assertFalse(any(row["phase"] == "VERIFIED" for row in ops)) + def _stage_grant(self, stage: Path) -> LiveGrant: + root = stage.parent + return LiveGrant( + command="rollback", + transaction_id=TX, + plan_sha256="0" * 64, + bundle_digest="0" * 64, + iface="ens5", + prefix=root / "prefix", + stage=stage, + evidence=root / "evidence", + bundle=root / "bundle", + tx_dir=root, + tag="local", + archive=root / "archive.tar", + optics_tarball=root / "optics.tgz", + sdk_npm=root / "sdk.tgz", + sdk_wheel=root / "sdk.whl", + container_name="vantio-pe-test", + observe_config=root / "observe-config.json", + ) + + def test_remove_stage_refuses_symlink_and_does_not_follow_it(self) -> None: + root = Path(tempfile.mkdtemp(prefix="vantio-stage-link-")) + self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True)) + tx = root / "tx" + tx.mkdir() + # A sibling inside the stage parent still passes a resolve-and-confine check. + victim = tx / "sibling" + victim.mkdir() + secret = victim / "keep.txt" + secret.write_text("keep\n", encoding="utf-8") + stage = tx / "stage" + stage.symlink_to(victim, target_is_directory=True) + outside = root / "outside" + outside.mkdir() + (outside / "keep.txt").write_text("keep\n", encoding="utf-8") + outside_stage = tx / "outside-stage" + outside_stage.symlink_to(outside, target_is_directory=True) + for link, kept in ((stage, secret), (outside_stage, outside / "keep.txt")): + with self.assertRaises(InstallError) as caught: + _filesystem("remove_stage", self._stage_grant(link)) + self.assertIn("symlink", str(caught.exception).lower()) + self.assertEqual(caught.exception.failure_class, "FAILED_SAFE") + self.assertEqual(kept.read_text(encoding="utf-8"), "keep\n") + self.assertTrue(link.is_symlink()) + + def test_remove_stage_does_not_follow_a_symlink_inside_the_directory(self) -> None: + root = Path(tempfile.mkdtemp(prefix="vantio-stage-child-")) + self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True)) + outside = root / "outside" + outside.mkdir() + secret = outside / "keep.txt" + secret.write_text("keep\n", encoding="utf-8") + stage = root / "tx" / "stage" + stage.mkdir(parents=True) + (stage / "note.txt").write_text("stage\n", encoding="utf-8") + (stage / "link").symlink_to(outside, target_is_directory=True) + nested = stage / "nested" + nested.mkdir() + (nested / "inner").symlink_to(secret) + _filesystem("remove_stage", self._stage_grant(stage)) + self.assertFalse(stage.exists()) + self.assertFalse(stage.is_symlink()) + self.assertEqual(secret.read_text(encoding="utf-8"), "keep\n") + self.assertTrue(outside.is_dir()) + + def test_fixture_remove_stage_refuses_symlink(self) -> None: + root = Path(tempfile.mkdtemp(prefix="vantio-fixture-stage-")) + self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True)) + victim = root / "victim" + victim.mkdir() + secret = victim / "keep.txt" + secret.write_text("keep\n", encoding="utf-8") + stage = root / "stage" + stage.symlink_to(victim, target_is_directory=True) + mutator = FixtureMutator({"product_files": [str(secret)]}, root / "prefix", stage) + with self.assertRaises(InstallError) as caught: + mutator._remove_stage({}) + self.assertIn("symlink", str(caught.exception).lower()) + self.assertEqual(secret.read_text(encoding="utf-8"), "keep\n") + self.assertTrue(stage.is_symlink()) + if __name__ == "__main__": unittest.main() diff --git a/packages/vantio-install/vantio_install/live_executor.py b/packages/vantio-install/vantio_install/live_executor.py index be67b363..ea67258b 100644 --- a/packages/vantio-install/vantio_install/live_executor.py +++ b/packages/vantio-install/vantio_install/live_executor.py @@ -70,6 +70,7 @@ from vantio_install.paths import assert_safe_root from vantio_install.state_machine import RESIDUAL_STATES from vantio_install.preflight import run_preflight +from vantio_install.stage_remove import remove_stage_nofollow from vantio_install.util import read_json, sha256_file, write_json _ENV_GATE = "VANTIO_INSTALL_ALLOW_LIVE" @@ -244,14 +245,14 @@ def _env_open(env: dict[str, str]) -> bool: def _privilege_ok(host: dict, euid: int) -> bool: - if euid == 0: - return True - mode = str(host.get("privilege_mode", "UNKNOWN")) - if mode == "sudo" and host.get("sudo_available") is True: - return True - if mode == "docker_group" and host.get("principal_can_talk_to_docker") is True: - return True - return False + """Live mutations require effective root. + + ``privilege_mode`` ``sudo`` means ``sudo`` is on ``PATH``. ``docker_group`` + means this principal can write the Docker socket. Neither fact is a grant, + and this function does not exec sudo. + """ + del host + return euid == 0 def _iface_ok(host: dict, iface: str) -> bool: @@ -485,9 +486,7 @@ def _filesystem(op_type: str, grant: LiveGrant) -> None: ) return if op_type == "remove_stage": - if grant.stage.exists(): - confine(grant.stage, [grant.stage.parent]) - shutil.rmtree(grant.stage) + remove_stage_nofollow(grant.stage) return if op_type == "remove_observe_config": path = confine(grant.observe_config, [grant.tx_dir]) @@ -708,7 +707,10 @@ def authorize_live( failure_class="FAILED_SAFE", ) if not _privilege_ok(host, euid): - _fail("Live mutations need root or the documented sudo or docker privilege.", failure_class="FAILED_SAFE") + _fail( + "Live mutations need effective root. sudo on PATH is not privilege.", + failure_class="FAILED_SAFE", + ) if not _observe_only(config): _fail("Live mutations run observe-only. Enforcement stays off.", failure_class="FAILED_SAFE") arch = str(host.get("uname_m", "UNKNOWN")) diff --git a/packages/vantio-install/vantio_install/mutator.py b/packages/vantio-install/vantio_install/mutator.py index b8c6fd20..5f28cc28 100644 --- a/packages/vantio-install/vantio_install/mutator.py +++ b/packages/vantio-install/vantio_install/mutator.py @@ -22,6 +22,7 @@ ) from vantio_install.pe_apparmor import pe_apparmor_profile_path from vantio_install.errors import InstallError +from vantio_install.stage_remove import remove_stage_nofollow from vantio_install.util import sha256_file, write_json @@ -161,8 +162,7 @@ def _stage_pe(self, ctx: dict) -> None: self._mark_file(self.stage / "STAGE.json", {"archive": target.name, "sha256": observed}) def _remove_stage(self, ctx: dict) -> None: - if self.stage.is_dir(): - shutil.rmtree(self.stage) + remove_stage_nofollow(self.stage) prefix = self.stage.as_posix() files = self.snapshot.get("product_files") or [] self.snapshot["product_files"] = [item for item in files if not item.startswith(prefix)] diff --git a/packages/vantio-install/vantio_install/stage_remove.py b/packages/vantio-install/vantio_install/stage_remove.py new file mode 100644 index 00000000..5facbd01 --- /dev/null +++ b/packages/vantio-install/vantio_install/stage_remove.py @@ -0,0 +1,89 @@ +"""Remove an installer stage directory without following symlinks.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path +from typing import NoReturn + +from vantio_install.errors import InstallError + +_OPEN_DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + + +def remove_stage_nofollow(stage: Path) -> None: + """Delete a real stage directory. A symlinked stage is refused. + + The stage path is inspected with ``lstat`` and opened with ``O_NOFOLLOW``. + Child symlinks are unlinked. Their targets are left in place. + """ + if stage.name in {"", ".", ".."}: + _refuse("Refusing to remove a stage path that is not a named directory.") + parent = stage.parent + try: + parent_fd = os.open(parent, _OPEN_DIR) + except FileNotFoundError: + return + except OSError: + _refuse("Refusing to remove a stage whose parent cannot be opened without following a symlink.") + try: + try: + info = os.lstat(stage.name, dir_fd=parent_fd) + except FileNotFoundError: + return + except OSError: + _refuse("The stage path could not be inspected without following a symlink.") + if stat.S_ISLNK(info.st_mode): + _refuse("Refusing to remove a symlinked stage.") + if not stat.S_ISDIR(info.st_mode): + _refuse("Refusing to remove a stage that is not a directory.") + try: + stage_fd = os.open(stage.name, _OPEN_DIR, dir_fd=parent_fd) + except OSError: + _refuse("Refusing to open the stage directory because the no-follow check failed.") + try: + _clear_directory(stage_fd) + finally: + os.close(stage_fd) + try: + os.rmdir(stage.name, dir_fd=parent_fd) + except OSError: + _refuse("The stage directory could not be removed without following a symlink.") + finally: + os.close(parent_fd) + + +def _clear_directory(dir_fd: int) -> None: + for name in os.listdir(dir_fd): + try: + info = os.lstat(name, dir_fd=dir_fd) + except OSError: + _refuse("A stage entry could not be inspected without following a symlink.") + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + try: + os.unlink(name, dir_fd=dir_fd) + except OSError: + _refuse("A stage entry could not be unlinked without following a symlink.") + continue + try: + child = os.open(name, _OPEN_DIR, dir_fd=dir_fd) + except OSError: + _refuse("Refusing to follow a stage entry that is not a plain directory.") + try: + _clear_directory(child) + finally: + os.close(child) + try: + os.rmdir(name, dir_fd=dir_fd) + except OSError: + _refuse("A stage subdirectory could not be removed without following a symlink.") + + +def _refuse(message: str) -> NoReturn: + raise InstallError( + message, + exit_code=4, + state="FAILED_SAFE", + failure_class="FAILED_SAFE", + )