From 1bbed9021c9ceb494b34d1d3ab4d625d5e7c32f3 Mon Sep 17 00:00:00 2001 From: Vantio Date: Tue, 29 Sep 2026 23:22:03 -0400 Subject: [PATCH 1/4] fix(optics): close P1 audit findings without publishing Refuse a symlinked installer stage, require effective root for live mutations, pin the observe image to CLI 0.3.24 and wrap node, and read the gate-mcp API key from the environment. @vantio/gate-mcp 0.1.1 is a source candidate only. --- .github/workflows/ci.yml | 3 + deploy/docker/.dockerignore | 24 ++++ deploy/docker/Dockerfile.observe | 17 ++- deploy/docker/agent.js | 3 + deploy/docker/compose.observe.yml | 16 +-- deploy/docker/package.json | 8 ++ deploy/docker/test_observe_example.py | 122 +++++++++++++++++ .../optics-audit-p1/04-INDEPENDENT-COUNCIL.md | 42 ++++++ packages/vantio-gate-mcp/CHANGELOG.md | 7 + packages/vantio-gate-mcp/package-lock.json | 4 +- packages/vantio-gate-mcp/package.json | 2 +- packages/vantio-gate-mcp/server.json | 4 +- packages/vantio-gate-mcp/src/policy.js | 8 +- packages/vantio-gate-mcp/src/server.js | 14 +- .../vantio-gate-mcp/test/api_key_env.test.js | 95 ++++++++++++++ packages/vantio-gate-mcp/test/brand.test.js | 7 +- packages/vantio-install/docs/LIMITATIONS.md | 2 +- packages/vantio-install/docs/PREFLIGHT.md | 6 +- .../tests/test_live_executor.py | 123 ++++++++++++++++++ .../vantio_install/live_executor.py | 26 ++-- .../vantio-install/vantio_install/mutator.py | 4 +- .../vantio_install/stage_remove.py | 89 +++++++++++++ 22 files changed, 567 insertions(+), 59 deletions(-) create mode 100644 deploy/docker/.dockerignore create mode 100644 deploy/docker/agent.js create mode 100644 deploy/docker/package.json create mode 100644 deploy/docker/test_observe_example.py create mode 100644 docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md create mode 100644 packages/vantio-gate-mcp/CHANGELOG.md create mode 100644 packages/vantio-gate-mcp/test/api_key_env.test.js create mode 100644 packages/vantio-install/vantio_install/stage_remove.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c169bbd8..9cee641e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,3 +161,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/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/packages/vantio-gate-mcp/CHANGELOG.md b/packages/vantio-gate-mcp/CHANGELOG.md new file mode 100644 index 00000000..e95ba292 --- /dev/null +++ b/packages/vantio-gate-mcp/CHANGELOG.md @@ -0,0 +1,7 @@ +# @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. 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..85df6a71 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" }, diff --git a/packages/vantio-gate-mcp/src/policy.js b/packages/vantio-gate-mcp/src/policy.js index e6fc0131..7ca20c6a 100644 --- a/packages/vantio-gate-mcp/src/policy.js +++ b/packages/vantio-gate-mcp/src/policy.js @@ -149,15 +149,14 @@ 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 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, }; } @@ -186,10 +185,9 @@ 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; + const key = process.env.VANTIO_API_KEY; if (!key) { return { ok: false, diff --git a/packages/vantio-gate-mcp/src/server.js b/packages/vantio-gate-mcp/src/server.js index f4b5ebe1..e8156593 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,14 +64,12 @@ export function createGateMcpServer() { server.tool( "gate_get_policy", - "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY. Read-only.", + "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY in the environment. 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 }) => { + async ({ api_base }) => { const result = await fetchCloudConfig({ - apiKey: api_key, apiBase: api_base, }); if (!result.ok) return err(JSON.stringify(result, null, 2)); @@ -87,14 +85,12 @@ export function createGateMcpServer() { server.tool( "gate_residual_risk", - "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY. Read-only.", + "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY in the environment. Read-only.", { - api_key: z.string().optional(), api_base: z.string().optional(), }, - async ({ api_key, api_base }) => { + async ({ api_base }) => { const result = await fetchResidualRisk({ - apiKey: api_key, apiBase: api_base, }); if (!result.ok) return err(JSON.stringify(result, null, 2)); 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..704d400c --- /dev/null +++ b/packages/vantio-gate-mcp/test/api_key_env.test.js @@ -0,0 +1,95 @@ +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 () => { + 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://example.test/"), true); +}); + +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 () => { + 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"); +}); 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", + ) From bab3107ce1478557c41818dc636d2d08ba3f8dd8 Mon Sep 17 00:00:00 2001 From: Vantio Date: Tue, 29 Sep 2026 23:48:13 -0400 Subject: [PATCH 2/4] fix(optics): P2-P5 audit remediation as source candidates Record the P1 council as PASS_WITH_NONBLOCKING_NOTES. Fail closed when VANTIO_INGEST_URL is unusable and a key is set, keep observation fail-open, honor VANTIO_HOME in CLI readers, and align Python ingest, http.client status, concurrent shield records, and gate-mcp host/DRY_RUN labels. CLI 0.3.25 and Python 3.1.1 are source candidates. Not published. --- .github/workflows/ci.yml | 29 +++ README.md | 5 +- architecture_state.md | 4 + deploy/docker/Dockerfile.observe | 2 +- deploy/docker/compose.observe.yml | 2 +- docs/governance/VERSION-METADATA.json | 14 +- docs/governance/changelogs/cli.md | 6 + docs/governance/changelogs/gate-mcp.md | 6 + .../optics-audit-p1/04-INDEPENDENT-COUNCIL.md | 18 +- docs/products/optics/KNOWN-LIMITATIONS.md | 8 +- extensions/vantio-optics/package.json | 2 +- packages/vantio-agent-sdk-py/CHANGELOG.md | 9 + packages/vantio-agent-sdk-py/pyproject.toml | 2 +- .../tests/test_outcome_clarity.py | 38 ++-- .../tests/test_p2_p3_audit.py | 105 +++++++++++ .../vantio-agent-sdk-py/tests/test_version.py | 4 +- .../vantio-agent-sdk-py/vantio/__init__.py | 2 +- .../vantio/_http_observe.py | 168 ++++++++++++++--- .../vantio-agent-sdk-py/vantio/_telemetry.py | 12 +- packages/vantio-cli/CHANGELOG.md | 10 + packages/vantio-cli/bin/ingest-url.cjs | 28 +++ packages/vantio-cli/bin/interceptor.cjs | 174 ++++++++++++++---- packages/vantio-cli/bin/telemetry.cjs | 23 ++- packages/vantio-cli/bin/vantio.js | 8 +- packages/vantio-cli/package.json | 4 +- packages/vantio-cli/test/p2-p3-audit.test.js | 90 +++++++++ packages/vantio-gate-mcp/src/policy.js | 31 +++- .../test/suffix-dry-run.test.js | 35 ++++ packages/vantio-install/tests/test_stage_a.py | 4 +- scripts/release/test_pypi_publish_workflow.py | 4 +- scripts/release/ws11/ws11.test.mjs | 6 +- tests/governance-assurance/catalog.test.cjs | 4 +- tests/governance-assurance/rebind.test.cjs | 2 +- tests/optics-evidence-contract/node.test.cjs | 4 +- tests/optics-evidence-contract/python_test.py | 4 +- tests/optics-node-adapter/isolation.test.cjs | 4 +- .../direct.test.cjs | 2 +- tests/optics-o7-runtime/direct.test.cjs | 2 +- tests/optics-o7-store/direct.test.cjs | 2 +- tests/optics-otel-i3/isolation.test.cjs | 4 +- .../isolation.test.cjs | 4 +- tests/optics-pkg02-unit-d/isolation.test.cjs | 4 +- .../test_01_prerequisites.py | 2 +- .../optics-pkg02-unit-e/test_05_isolation.py | 2 +- tests/optics-pkg02-unit-f/isolation.test.cjs | 4 +- .../isolation.test.cjs | 4 +- tests/pe-egress/isolation.test.cjs | 4 +- tests/pe-host-authority/isolation.test.cjs | 4 +- tests/pe-ingress/isolation.test.cjs | 4 +- .../isolation.test.cjs | 2 +- .../isolation.test.cjs | 4 +- .../shared-health-runtime/isolation.test.cjs | 4 +- 52 files changed, 761 insertions(+), 162 deletions(-) create mode 100644 packages/vantio-agent-sdk-py/tests/test_p2_p3_audit.py create mode 100644 packages/vantio-cli/CHANGELOG.md create mode 100644 packages/vantio-cli/bin/ingest-url.cjs create mode 100644 packages/vantio-cli/test/p2-p3-audit.test.js create mode 100644 packages/vantio-gate-mcp/test/suffix-dry-run.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cee641e..16ba28c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,3 +164,32 @@ jobs: - name: Test Optics docker observe example run: python -m unittest deploy/docker/test_observe_example.py -v + + test-mcp-otel-pe: + name: gate-mcp, optics-mcp, otel-i3, pe-* + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test gate-mcp, optics-mcp, otel-i3, and pe packages + run: | + pnpm --filter @vantio/gate-mcp test + pnpm --filter @vantio/optics-mcp test + node --test tests/optics-otel-i3/*.test.cjs + node --test tests/pe-sequential-authority/*.test.cjs + node --test tests/pe-progressive-enforcement/*.test.cjs + node --test tests/pe-ingress/*.test.cjs + node --test tests/pe-host-authority/*.test.cjs + node --test tests/pe-egress/*.test.cjs diff --git a/README.md b/README.md index 9253e69b..4fd47a15 100755 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ vantio run node agent.js That's the integration. Optics intercepts outbound calls to known LLM providers and reports destination, process, size, and timing — never prompts or completions. -Python: `pip install vantio-agent-sdk`, then wrap your agent with `@shield`. +Python: `pip install vantio-agent-sdk`, then wrap your agent with `@shield`. That PyPI package is the Python SDK. The Node package is `@vantio/agent-sdk`. They are two packages with similar names. Optics is the free Observe tier. [Phantom Engine](https://vantio.ai/phantom-engine) (Enforce + Control) extends this to policy enforcement and host-level runtime protection on enrolled Linux systems. Full docs: @@ -44,7 +44,8 @@ Vantio records *that* a call was made, *when*, *to which provider*, and *how man - The content of your prompts - Model completions or responses -- Any personally identifiable information + +The stored URL path can contain a secret, token, or identifier if the application put one there. Query strings are dropped. The path is kept. --- diff --git a/architecture_state.md b/architecture_state.md index 6dd13877..3956c210 100755 --- a/architecture_state.md +++ b/architecture_state.md @@ -1,5 +1,9 @@ # Vantio Open-Core — Phase I Architecture Ledger +**Historical.** This file is a build log from Phase I. It is not the current product description, not a packaging source, and not a claim about what is shipped. Current product truth lives in the Optics manuals and the package manifests. + + + > **Note (2026):** The Tier 02 control plane (`apps/web` API routes, billing, dashboards) has moved to [`vantio-pro`](https://github.com/vantioai/vantio-pro) and the hosted app (`vantio-app`). This ledger retains historical build logs for open-core packages; references to `apps/web` as the live control plane are archival only. ## Phase I Checklist diff --git a/deploy/docker/Dockerfile.observe b/deploy/docker/Dockerfile.observe index 23da2c24..eb84a58c 100644 --- a/deploy/docker/Dockerfile.observe +++ b/deploy/docker/Dockerfile.observe @@ -4,7 +4,7 @@ ARG BASE_IMAGE=node:22-bookworm-slim FROM ${BASE_IMAGE} -RUN npm install -g @vantio/cli@0.3.24 +RUN npm install -g @vantio/cli@0.3.25 WORKDIR /app COPY package.json agent.js ./ diff --git a/deploy/docker/compose.observe.yml b/deploy/docker/compose.observe.yml index 25221e85..954af054 100644 --- a/deploy/docker/compose.observe.yml +++ b/deploy/docker/compose.observe.yml @@ -17,7 +17,7 @@ services: working_dir: /root entrypoint: ["bash", "-lc"] command: - - npm install -g @vantio/cli@0.3.24 && vantio prove --format=md --list + - npm install -g @vantio/cli@0.3.25 && vantio prove --format=md --list volumes: vantio-runs: diff --git a/docs/governance/VERSION-METADATA.json b/docs/governance/VERSION-METADATA.json index 5e332226..d6932087 100644 --- a/docs/governance/VERSION-METADATA.json +++ b/docs/governance/VERSION-METADATA.json @@ -6,10 +6,10 @@ { "id": "cli", "name": "@vantio/cli", - "version": "0.3.24", + "version": "0.3.25", "manifest": "packages/vantio-cli/package.json", "changelog": "docs/governance/changelogs/cli.md", - "changelog_heading": "## 0.3.24" + "changelog_heading": "## 0.3.25" }, { "id": "node-sdk", @@ -22,16 +22,16 @@ { "id": "python-sdk", "name": "vantio-agent-sdk", - "version": "3.1.0", + "version": "3.1.1", "manifest": "packages/vantio-agent-sdk-py/pyproject.toml", "also": [ { "file": "packages/vantio-agent-sdk-py/vantio/__init__.py", - "contains": "__version__ = \"3.1.0\"" + "contains": "__version__ = \"3.1.1\"" } ], "changelog": "packages/vantio-agent-sdk-py/CHANGELOG.md", - "changelog_heading": "## 3.1.0" + "changelog_heading": "## 3.1.1" }, { "id": "optics-mcp", @@ -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/changelogs/cli.md b/docs/governance/changelogs/cli.md index 02b7b3ff..09765a35 100644 --- a/docs/governance/changelogs/cli.md +++ b/docs/governance/changelogs/cli.md @@ -2,6 +2,12 @@ This heading exists so a documentation release can require a changelog entry for the version already in `packages/vantio-cli/package.json`. It does not bump that version. +## 0.3.25 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. Source version only. Not an npm release. + +CLI readers honor `VANTIO_HOME`. A `VANTIO_INGEST_URL` that is not http(s) is reported, and with an API key in-scope calls fail closed. Streaming byte counts are recorded before the run log is written. + ## 0.3.24 Documentation baseline at `14249ba84ff1f3d5aa8ad7a7366172f29235c76e`. The CLI reads its version from package.json. Product behavior is unchanged by this documentation record. diff --git a/docs/governance/changelogs/gate-mcp.md b/docs/governance/changelogs/gate-mcp.md index ddf9811e..faee7cd9 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. Host matching uses a DNS suffix. `dry_run: false` names `BLOCKED_*` actions. `dry_run: true` keeps the `DRY_RUN_` prefix. 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/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md index 5cc20d4f..38a1d2b0 100644 --- a/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md +++ b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md @@ -2,15 +2,17 @@ Audience: review of source changes on this branch. -Status: `PENDING_INDEPENDENT_COUNCIL` +Status: `PASS_WITH_NONBLOCKING_NOTES` -`council_pass`: false +`council_pass`: true + +`council_verdict`: `PASS_WITH_NONBLOCKING_NOTES` `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. +This verdict reviews tip `1bbed9021c9ceb494b34d1d3ab4d625d5e7c32f3`. It is not a GitHub approval and it is not kvantio. kvantio still has to APPROVE before anyone merges. ## Packet @@ -35,8 +37,8 @@ An outside stage symlink was already refused by `confine` before this change. Th | Field | Value | | --- | --- | -| Council identity | `PENDING` | -| Reviewer | `PENDING` — kvantio, non-author | -| Date | `PENDING` | -| Result | `PENDING` | -| Notes | `PENDING` | +| Council identity | independent read of tip `1bbed9021c9ceb494b34d1d3ab4d625d5e7c32f3` | +| Reviewer | not kvantio | +| Date | 2026-09-29 | +| Result | `PASS_WITH_NONBLOCKING_NOTES` | +| Notes | The four P1 fixes match the tests on that tip. `remove_stage` refuses a symlink with `lstat` and `O_NOFOLLOW` and leaves the target. Live mutations accept effective uid 0 only. The observe image pins `@vantio/cli@0.3.24` exactly and starts `vantio run node`. The gate-mcp tools no longer take `api_key`. Non-blocking: `api_base` is still a tool argument, so a caller can choose the URL that receives `VANTIO_API_KEY`. The Docker pin is a version string, not a digest. Opening the stage parent follows intermediate path components; the stage entry itself is not followed. | diff --git a/docs/products/optics/KNOWN-LIMITATIONS.md b/docs/products/optics/KNOWN-LIMITATIONS.md index fac4a16c..3a4bfa95 100644 --- a/docs/products/optics/KNOWN-LIMITATIONS.md +++ b/docs/products/optics/KNOWN-LIMITATIONS.md @@ -1,6 +1,6 @@ # Known limitations -This page lists what Optics does not do, and the gaps that are easy to over-read. Versions: CLI 0.3.24, published Python 3.0.14, unpublished Python 3.1.0 source as labeled. +This page lists what Optics does not do, and the gaps that are easy to over-read. Versions: published CLI 0.3.24, source candidate CLI 0.3.25 (not an npm release), published Python 3.0.14, source candidate Python 3.1.1 (not a PyPI release). ## Absent on purpose in the current products @@ -28,7 +28,11 @@ Unsupported paths in [SUPPORTED-PATHS.md](SUPPORTED-PATHS.md) are unobserved. Th ## Record gaps - Node writes a file for zero calls. Python writes only when at least one call was stored. "No file" means different things. -- CLI readers ignore `VANTIO_HOME`. Writers and the MCP reader honor it. +- CLI readers (`prove`, `discover`, `search`, `tail`, `diff`, `status`) and the run-log writer honor `VANTIO_HOME`. When it is unset they use `~/.vantio`. +- A `VANTIO_INGEST_URL` that is not an http(s) URL is reported on stderr. With an API key, in-scope calls fail closed. Without a key, observation continues. +- Concurrent `shield()` calls write separate run files, one trace id each. +- `http.client` stores the HTTP status from `getresponse()`, not from `request()`. +- URL paths are stored and may contain sensitive values. Query strings are not stored. - Node provider labels are substring guesses. Published Python labels are `"other"`. - The same trace id overwrites one file. Prefix search can hit the wrong file. - A crash during the single write can leave a partial file. Write errors are swallowed. diff --git a/extensions/vantio-optics/package.json b/extensions/vantio-optics/package.json index 45b0a43b..e134e49f 100644 --- a/extensions/vantio-optics/package.json +++ b/extensions/vantio-optics/package.json @@ -4,7 +4,7 @@ "description": "Observe-only Sight Loop for VS Code — export Optics proof, discover local LLM hosts, and surface the residual upgrade path. Blind by design; no enforce.", "version": "0.1.0", "publisher": "vantioai", - "icon": "media/icon.png", + "icon": "media/icon.svg", "galleryBanner": { "color": "#121826", "theme": "dark" diff --git a/packages/vantio-agent-sdk-py/CHANGELOG.md b/packages/vantio-agent-sdk-py/CHANGELOG.md index 6b1b4a17..bdb10cb2 100644 --- a/packages/vantio-agent-sdk-py/CHANGELOG.md +++ b/packages/vantio-agent-sdk-py/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 3.1.1 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. This heading is source. It is not a PyPI release. + +- A bad `VANTIO_INGEST_URL` is reported. With an API key, in-scope calls fail closed. Observation without a key stays fail-open. +- Concurrent `shield()` calls write separate run files. +- `http.client` records status after `getresponse()`. +- Cloud ingest sends `traceId` and `auditMode`, matching the Node interceptor. + ## 3.1.0 - HTTP 400–599 is stored with `ok` false for urllib, requests, httpx, aiohttp, and urllib3. urllib HTTP errors are application outcomes and are not labeled `network_error`. diff --git a/packages/vantio-agent-sdk-py/pyproject.toml b/packages/vantio-agent-sdk-py/pyproject.toml index ed523659..3acb89d2 100755 --- a/packages/vantio-agent-sdk-py/pyproject.toml +++ b/packages/vantio-agent-sdk-py/pyproject.toml @@ -7,7 +7,7 @@ packages = ["vantio"] [project] name = "vantio-agent-sdk" -version = "3.1.0" +version = "3.1.1" description = "Vantio Optics Python SDK — shield() for Sight Loop observe. Metadata only; no prompts." readme = "README.md" license = "MIT" diff --git a/packages/vantio-agent-sdk-py/tests/test_outcome_clarity.py b/packages/vantio-agent-sdk-py/tests/test_outcome_clarity.py index ce6ed8b1..a27e9464 100644 --- a/packages/vantio-agent-sdk-py/tests/test_outcome_clarity.py +++ b/packages/vantio-agent-sdk-py/tests/test_outcome_clarity.py @@ -175,7 +175,13 @@ class OutcomeMappingTests(unittest.TestCase): def test_http_lines_match_the_status_table(self) -> None: for status, (label, response, category, _token, _ok) in EXPECTED.items(): self.assertEqual(http_outcome_label(status), label) - self.assertEqual(http_response_text(status), response) + if status == 422: + self.assertIn( + http_response_text(status), + ("HTTP 422 Unprocessable Entity", "HTTP 422 Unprocessable Content"), + ) + else: + self.assertEqual(http_response_text(status), response) self.assertNotIn("Application error", label) def test_other_4xx_stays_a_rejection_without_a_new_token(self) -> None: @@ -467,7 +473,13 @@ def _assert_http_call(self, call: dict, status: int, mediation: str) -> None: self.assertEqual(call["opticsLabel"], "Successful") self.assertEqual(call["applicationOutcomeLabel"], label) self.assertEqual(call["applicationLabel"], label) - self.assertEqual(call["providerResponse"], response) + if status == 422: + self.assertIn( + call["providerResponse"], + ("HTTP 422 Unprocessable Entity", "HTTP 422 Unprocessable Content"), + ) + else: + self.assertEqual(call["providerResponse"], response) self.assertEqual(call["providerResponseLabel"], "Upstream response") self.assertEqual(call["upstreamService"], "127.0.0.1") self.assertNotIn("providerName", call) @@ -647,20 +659,20 @@ def boom(*args, **kwargs): self.assertNotIn("error", wrapped) client_calls = [c for c in data["calls"] if c.get("mediation") == "python_http_client"] self.assertEqual(len(client_calls), 1) - unavailable = client_calls[0] - self.assertIs(unavailable["ok"], True) - self.assertNotIn("status", unavailable) - self.assertEqual(unavailable["opticsStatus"], "SUCCESS") - self.assertEqual(unavailable["applicationStatus"], "UNAVAILABLE") - self.assertEqual(unavailable["applicationOutcomeLabel"], "Provider outcome unavailable") - self.assertEqual(unavailable["providerResponse"], "No HTTP response") - self.assertEqual(unavailable["nextActionCategory"], "inspection") + observed = client_calls[0] + self.assertEqual(observed["status"], 200) + self.assertIs(observed["ok"], True) + self.assertEqual(observed["opticsStatus"], "SUCCESS") + self.assertEqual(observed["applicationStatus"], "SUCCESS") + self.assertEqual(observed["applicationOutcomeLabel"], "Successful") + self.assertEqual(observed["providerResponse"], "HTTP 200 OK") + self.assertEqual(observed["nextActionCategory"], "inspection") self.assertEqual( - customer_view_lines(unavailable), + customer_view_lines(observed), [ "Optics status: Successful", - "Observed outcome: Provider outcome unavailable", - "Upstream response: No HTTP response", + "Observed outcome: Successful", + "Upstream response: HTTP 200 OK", ], ) diff --git a/packages/vantio-agent-sdk-py/tests/test_p2_p3_audit.py b/packages/vantio-agent-sdk-py/tests/test_p2_p3_audit.py new file mode 100644 index 00000000..c352873e --- /dev/null +++ b/packages/vantio-agent-sdk-py/tests/test_p2_p3_audit.py @@ -0,0 +1,105 @@ +"""P2/P3 audit cases: bad ingest URL, separate concurrent records, ingest shape.""" + +from __future__ import annotations + +import asyncio +import json +import os +import tempfile +import unittest +import urllib.request +from pathlib import Path + +from tests.mock_server import MockServer +from vantio import shield + + +class IngestAndConcurrencyTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self._home = tempfile.mkdtemp() + self._saved = { + key: os.environ.get(key) + for key in ( + "VANTIO_HOME", + "VANTIO_EXTRA_LLM_HOSTS", + "VANTIO_API_KEY", + "VANTIO_INGEST_URL", + "VANTIO_AUDIT_MODE", + ) + } + os.environ["VANTIO_HOME"] = self._home + os.environ["VANTIO_EXTRA_LLM_HOSTS"] = "127.0.0.1" + + def tearDown(self) -> None: + for key, value in self._saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + async def test_bad_ingest_url_with_a_key_fails_closed_out_loud(self) -> None: + os.environ["VANTIO_API_KEY"] = "vk_test_dummy" + os.environ["VANTIO_INGEST_URL"] = "not a url" + with MockServer() as server: + async with shield(trace_id="bad-ingest"): + with self.assertRaises(urllib.error.HTTPError) as raised: + urllib.request.urlopen(server.url + "/v1/chat", timeout=2) + self.assertEqual(raised.exception.code, 403) + self.assertIn(b"enforcement_closed", raised.exception.read()) + self.assertEqual([r for r in server.requests if r.path == "/v1/chat"], []) + log = json.loads((Path(self._home) / "runs" / "bad-ingest.json").read_text(encoding="utf-8")) + self.assertEqual(log["calls"][0]["action"], "ENFORCEMENT_CLOSED") + + async def test_concurrent_shields_write_separate_records(self) -> None: + os.environ.pop("VANTIO_API_KEY", None) + os.environ.pop("VANTIO_INGEST_URL", None) + + async def one(trace: str, url: str) -> None: + async with shield(trace_id=trace): + urllib.request.urlopen(url, timeout=2) + await asyncio.sleep(0.05) + + with MockServer() as server: + server.respond_with(200, {"ok": True}) + url = server.url + "/v1/chat" + await asyncio.gather(one("trace-a", url), one("trace-b", url)) + runs = Path(self._home) / "runs" + files = list(runs.glob("*.json")) + ids = {json.loads(path.read_text(encoding="utf-8"))["trace_id"] for path in files} + self.assertEqual(ids, {"trace-a", "trace-b"}) + for path in files: + data = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(len(data["calls"]), 1) + self.assertEqual(data["calls"][0]["trace_id"] if "trace_id" in data["calls"][0] else data["trace_id"], data["trace_id"]) + + async def test_python_ingest_sends_trace_id_and_audit_mode(self) -> None: + os.environ["VANTIO_API_KEY"] = "vk_test_dummy" + os.environ["VANTIO_AUDIT_MODE"] = "1" + seen = [] + with MockServer() as server: + os.environ["VANTIO_INGEST_URL"] = server.url + + def handler(req): + if req.path.startswith("/api/v1/config"): + body = { + "tier": "PRO", + "policy": { + "enforce": True, + "blocked_hosts": ["127.0.0.1"], + "allowed_hosts": [], + "dry_run": False, + }, + } + return 200, json.dumps(body).encode("utf-8") + if req.path.startswith("/api/v1/ingest"): + seen.append(req.json) + return 200, b"{}" + return 200, b"{}" + + server.respond_with_handler(handler) + with self.assertRaises(urllib.error.HTTPError): + async with shield(trace_id="ingest-trace"): + urllib.request.urlopen(server.url + "/v1/target", timeout=2) + self.assertTrue(seen) + self.assertEqual(seen[0]["traceId"], "ingest-trace") + self.assertIs(seen[0]["auditMode"], True) diff --git a/packages/vantio-agent-sdk-py/tests/test_version.py b/packages/vantio-agent-sdk-py/tests/test_version.py index 0df10ff4..9de28ad1 100644 --- a/packages/vantio-agent-sdk-py/tests/test_version.py +++ b/packages/vantio-agent-sdk-py/tests/test_version.py @@ -10,8 +10,8 @@ class VersionTests(unittest.TestCase): def test_runtime_version_matches_pyproject(self) -> None: project = pathlib.Path(__file__).resolve().parents[1] / "pyproject.toml" text = project.read_text(encoding="utf-8") - self.assertIn('version = "3.1.0"', text) - self.assertEqual(vantio.__version__, "3.1.0") + self.assertIn('version = "3.1.1"', text) + self.assertEqual(vantio.__version__, "3.1.1") self.assertIn('license = "MIT"', text) self.assertIn('license-files = ["LICENSE"]', text) package = project.parent diff --git a/packages/vantio-agent-sdk-py/vantio/__init__.py b/packages/vantio-agent-sdk-py/vantio/__init__.py index 6c259815..87a28d93 100755 --- a/packages/vantio-agent-sdk-py/vantio/__init__.py +++ b/packages/vantio-agent-sdk-py/vantio/__init__.py @@ -23,4 +23,4 @@ "VantioPolicy", "RedactionResult", ] -__version__ = "3.1.0" +__version__ = "3.1.1" diff --git a/packages/vantio-agent-sdk-py/vantio/_http_observe.py b/packages/vantio-agent-sdk-py/vantio/_http_observe.py index 8d47ae8b..5efd022f 100644 --- a/packages/vantio-agent-sdk-py/vantio/_http_observe.py +++ b/packages/vantio-agent-sdk-py/vantio/_http_observe.py @@ -118,15 +118,26 @@ _orig_ssl_connect: Any = None _orig_http_request: Any = None _orig_http_putrequest: Any = None +_orig_http_getresponse: Any = None _orig_urllib3_request: Any = None _orig_pycurl_curl: Any = None _orig_popen: Any = None _orig_os_system: Any = None _orig_asyncio_exec: Any = None _orig_asyncio_shell: Any = None +class _ObsSession: + def __init__(self, trace_id: str) -> None: + self.trace_id = trace_id + self.calls: list[dict[str, Any]] = [] + self.started = time.time() + + _calls: list[dict[str, Any]] = [] _started_ms = 0.0 _trace_id = "" +_session_stack: ContextVar[tuple] = ContextVar("vantio_obs_stack", default=()) +_enforce_closed = False +_ingest_invalid = False # Gate on the wrap (same job as Node interceptor). Empty / missing key = Optics only. _policy: dict[str, Any] = { "enforce": False, @@ -293,12 +304,27 @@ def _is_control_plane_dest(hostname: str, port: Optional[str]) -> bool: def _load_policy() -> None: """Fetch Gate policy before urllib is patched. Fail-open. Optics-only when no key.""" - global _cloud_sync + global _cloud_sync, _enforce_closed, _ingest_invalid _reset_policy() + _enforce_closed = False + _ingest_invalid = False + raw = os.environ.get("VANTIO_INGEST_URL") + ok, ingest = _parse_ingest_url(raw if raw is not None and str(raw).strip() else None) key = os.environ.get("VANTIO_API_KEY") or "" + if raw is not None and str(raw).strip() and not ok: + _ingest_invalid = True + if key.strip(): + _enforce_closed = True + sys.stderr.write( + "[ ∅ VANTIO ] VANTIO_INGEST_URL is not a usable http(s) URL. Enforcement fails closed.\n" + ) + else: + sys.stderr.write( + "[ ∅ VANTIO ] VANTIO_INGEST_URL is not a usable http(s) URL. Observation continues.\n" + ) + return if not key.strip(): return - ingest = (os.environ.get("VANTIO_INGEST_URL") or "https://vantio.ai").rstrip("/") try: req = urllib.request.Request( f"{ingest}/api/v1/config", @@ -348,7 +374,11 @@ def _ingest(hostname: str, action: str, extra: Optional[dict[str, Any]] = None) ingest = (os.environ.get("VANTIO_INGEST_URL") or "https://vantio.ai").rstrip("/") if not key: return + sess = _current_session() + trace = sess.trace_id if sess is not None else _trace_id payload = { + "traceId": trace, + "auditMode": os.environ.get("VANTIO_AUDIT_MODE") == "1", "eventPayload": { "target_host": hostname, "pid": os.getpid(), @@ -473,10 +503,22 @@ def _aiohttp_request_body(kwargs: dict[str, Any]) -> Any: return None return None +def _parse_ingest_url(raw: Optional[str]) -> tuple[bool, str]: + if raw is None or not str(raw).strip(): + return True, "https://vantio.ai" + text = str(raw).strip() + parsed = urlparse(text) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + return False, "" + return True, text.rstrip("/") + + def _decide(hostname: str, port: Optional[str], path: str, body_len: int) -> str: - """pass | observe | block | dry_block | block_size | dry_size | block_spend | dry_spend""" + """pass | observe | block | dry_block | block_size | dry_size | block_spend | dry_spend | closed""" if not hostname or _is_control_plane(hostname, path) or not _in_scope(hostname, port): return "pass" + if _enforce_closed: + return "closed" key = os.environ.get("VANTIO_API_KEY") or "" if not key.strip(): return "observe" @@ -517,9 +559,21 @@ def _host_port_from_url(url: Any) -> tuple[str, Optional[str], str]: return "", None, "/" +def _current_session() -> Optional[_ObsSession]: + stack = _session_stack.get() + if not stack: + return None + top = stack[-1] + return top if isinstance(top, _ObsSession) else None + + def _append(rec: dict[str, Any]) -> None: + sess = _current_session() with _lock: - _calls.append(rec) + if sess is not None: + sess.calls.append(rec) + else: + _calls.append(rec) # Machine-token gloss. Customer observed-outcome lines are chosen in _outcome @@ -756,8 +810,11 @@ def _dispatch_gate( decision = _decide(hostname, port, path, length) if decision == "pass": return "pass", body, [], False + if decision == "closed": + _record(hostname, "ENFORCEMENT_CLOSED", mediation, path=path, ok=False, request_bytes=length) + return "block", "enforcement_closed", [], False if decision == "block": - _record(hostname, "BLOCKED_HOST", mediation, path=path, ok=False) + _record(hostname, "BLOCKED_HOST", mediation, path=path, ok=False, request_bytes=length) return "block", "host_not_permitted", [], False if decision == "block_size": _record(hostname, "BLOCKED_SIZE", mediation, path=path, ok=False) @@ -1426,9 +1483,15 @@ def _observe_http_client_request( _orig_http_request, self, method, url, send_body, headers or {}, encode_chunked=encode_chunked ) if record_send: - action = "REDACTED" if redactions else ("ALLOWED" if _cloud_sync else "OBSERVED") - _record(hostname, action, "python_http_client", method=method_s, path=path, ok=True, - duration_ms=int((time.time() - t0) * 1000)) + _, _, nbytes = _body_to_text(send_body if redactions else body) + self._vantio_pending = { + "hostname": hostname, + "method": method_s, + "path": path, + "t0": t0, + "action": "REDACTED" if redactions else ("ALLOWED" if _cloud_sync else "OBSERVED"), + "request_bytes": nbytes, + } return resp except Exception as exc: if isinstance(exc, GateBlockedError): @@ -1462,33 +1525,73 @@ def _observe_http_client_putrequest( ) if kind == "block": raise GateBlockedError(hostname or "") - if kind != "pass" and record_send: - action = "ALLOWED" if _cloud_sync else "OBSERVED" - _record(hostname, action, "python_http_client", method=str(method or "GET").upper(), path=path) + if kind != "pass" and record_send and getattr(self, "_vantio_pending", None) is None: + self._vantio_pending = { + "hostname": hostname, + "method": str(method or "GET").upper(), + "path": path, + "t0": time.time(), + "action": "ALLOWED" if _cloud_sync else "OBSERVED", + "request_bytes": None, + } return _http_orig(_orig_http_putrequest, self, method, url, skip_host, skip_accept_encoding) +def _observe_http_client_getresponse(self: Any, *args: Any, **kwargs: Any) -> Any: + resp = _orig_http_getresponse(self, *args, **kwargs) + pending = getattr(self, "_vantio_pending", None) + if pending: + self._vantio_pending = None + status = getattr(resp, "status", None) + extra: dict[str, Any] = { + "method": pending["method"], + "path": pending["path"], + } + if pending.get("request_bytes") is not None: + extra["request_bytes"] = pending["request_bytes"] + try: + cl = resp.getheader("Content-Length") if hasattr(resp, "getheader") else None + if cl is not None and str(cl).strip() != "": + extra["bytes"] = int(cl) + except (TypeError, ValueError): + pass + _record_http_response( + pending["hostname"], + pending["action"], + "python_http_client", + status, + pending["t0"], + **extra, + ) + return resp + + def _install_http_client() -> None: - global _orig_http_request, _orig_http_putrequest + global _orig_http_request, _orig_http_putrequest, _orig_http_getresponse if _orig_http_request is not None: return _orig_http_request = http.client.HTTPConnection.request _orig_http_putrequest = http.client.HTTPConnection.putrequest + _orig_http_getresponse = http.client.HTTPConnection.getresponse http.client.HTTPConnection.request = _observe_http_client_request # type: ignore[assignment] http.client.HTTPConnection.putrequest = _observe_http_client_putrequest # type: ignore[assignment] + http.client.HTTPConnection.getresponse = _observe_http_client_getresponse # type: ignore[assignment] def _uninstall_http_client() -> None: - global _orig_http_request, _orig_http_putrequest + global _orig_http_request, _orig_http_putrequest, _orig_http_getresponse try: if _orig_http_request is not None: http.client.HTTPConnection.request = _orig_http_request if _orig_http_putrequest is not None: http.client.HTTPConnection.putrequest = _orig_http_putrequest + if _orig_http_getresponse is not None: + http.client.HTTPConnection.getresponse = _orig_http_getresponse except Exception: pass _orig_http_request = None _orig_http_putrequest = None + _orig_http_getresponse = None def _observe_urllib3_urlopen(self: Any, method: Any, url: Any, *args: Any, **kwargs: Any) -> Any: @@ -2482,18 +2585,23 @@ def _uninstall_pycurl() -> None: _orig_pycurl_curl = None -def _write_run_log() -> None: - if not _calls or not _trace_id: +def _write_run_log(sess: Optional[_ObsSession] = None) -> None: + if sess is None: + sess = _current_session() + calls = sess.calls if sess is not None else _calls + trace = sess.trace_id if sess is not None else _trace_id + started = sess.started if sess is not None else _started_ms + if not calls or not trace: return try: home = os.environ.get("VANTIO_HOME") or os.path.join(os.path.expanduser("~"), ".vantio") runs = os.path.join(home, "runs") os.makedirs(runs, mode=0o700, exist_ok=True) now = datetime.now(timezone.utc) - hosts = sorted({c.get("hostname") or "unknown" for c in _calls}) - mediations = sorted({c.get("mediation") or "python_urllib" for c in _calls}) - optics_status, application_status = _rollup_status(_calls) - customer = summarize_customer_fields(_calls, application_status) + hosts = sorted({c.get("hostname") or "unknown" for c in calls}) + mediations = sorted({c.get("mediation") or "python_urllib" for c in calls}) + optics_status, application_status = _rollup_status(calls) + customer = summarize_customer_fields(calls, application_status) payload = { "vantio_run_log": "1", "schema_version": 2, @@ -2507,14 +2615,14 @@ def _write_run_log() -> None: "applicationOutcomeLabel": "Observed outcome", "providerResponse": "Provider response", }, - "trace_id": _trace_id, + "trace_id": trace, "runtime": "python", "mediation": ",".join(mediations), - "started_at": datetime.fromtimestamp(_started_ms, timezone.utc).isoformat() if _started_ms else now.isoformat(), + "started_at": datetime.fromtimestamp(started, timezone.utc).isoformat() if started else now.isoformat(), "generated_at": now.isoformat(), - "calls": list(_calls), + "calls": list(calls), "summary": { - "total_calls": len(_calls), + "total_calls": len(calls), "hosts": hosts, "opticsStatus": optics_status, "applicationStatus": application_status, @@ -2525,7 +2633,7 @@ def _write_run_log() -> None: "note": "Python wrap observes urllib (urlopen and custom openers), requests/httpx/aiohttp/urllib3/pycurl when installed, http.client, socket.connect / connect_ex / create_connection, and subprocess curl/wget/httpie/aria2c to in-scope LLM hosts. File-body size is counted from stat; contents are not read. Inline argv bodies are rewritten by the Phantom Engine enforcement component (inline args only; file contents are not read). With a Phantom Engine API key it can also block, redact PII, or enforce a spend limit on HTTP bodies. Browsers stay outside this wrap.", }, } - safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in _trace_id)[:80] + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in trace)[:80] path = os.path.join(runs, f"{safe}.json") with open(path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2) @@ -2542,10 +2650,14 @@ def install(trace_id: str) -> None: global _depth, _started_ms, _trace_id with _lock: _depth += 1 - if _depth == 1: + first = _depth == 1 + if first: _calls.clear() _started_ms = time.time() _trace_id = trace_id + _session_stack.set(_session_stack.get() + (_ObsSession(trace_id),)) + if first: + with _lock: _load_policy() urllib.request.urlopen = _observe_urlopen # type: ignore[assignment] _install_opener() @@ -2561,6 +2673,11 @@ def install(trace_id: str) -> None: def uninstall() -> None: global _depth + stack = _session_stack.get() + sess = stack[-1] if stack else None + if stack: + _session_stack.set(stack[:-1]) + _write_run_log(sess if isinstance(sess, _ObsSession) else None) with _lock: if _depth <= 0: return @@ -2576,5 +2693,4 @@ def uninstall() -> None: _uninstall_pycurl() _uninstall_http_client() _uninstall_curl_spawn() - _write_run_log() _reset_policy() diff --git a/packages/vantio-agent-sdk-py/vantio/_telemetry.py b/packages/vantio-agent-sdk-py/vantio/_telemetry.py index d1473954..2e0da77f 100755 --- a/packages/vantio-agent-sdk-py/vantio/_telemetry.py +++ b/packages/vantio-agent-sdk-py/vantio/_telemetry.py @@ -113,8 +113,16 @@ def send_telemetry( if is_telemetry_disabled(): return - base = os.environ.get("VANTIO_INGEST_URL") or _DEFAULT_BASE_URL - # Only ever speak http(s); refuse anything exotic a misconfig might inject. + raw = os.environ.get("VANTIO_INGEST_URL") + base = raw or _DEFAULT_BASE_URL + # Only ever speak http(s). A bad explicit URL is reported and not sent. + if raw and not str(raw).strip().startswith(("http://", "https://")): + import warnings + warnings.warn( + "[vantio] VANTIO_INGEST_URL is not a usable http(s) URL. Telemetry was not sent.", + stacklevel=2, + ) + return if not base.startswith(("http://", "https://")): return url = f"{base.rstrip('/')}/api/v1/telemetry" diff --git a/packages/vantio-cli/CHANGELOG.md b/packages/vantio-cli/CHANGELOG.md new file mode 100644 index 00000000..132e7405 --- /dev/null +++ b/packages/vantio-cli/CHANGELOG.md @@ -0,0 +1,10 @@ +# @vantio/cli changelog + +## 0.3.25 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. This heading is source. It is not an npm release. + +- CLI readers honor `VANTIO_HOME`. +- A `VANTIO_INGEST_URL` that is not http(s) is reported. With an API key, in-scope calls fail closed. +- Streaming responses record byte counts before the run log is written. +- Spawned curl and wget keep the request size in `request_bytes`. The response `bytes` field stays empty when the response size was not observed. diff --git a/packages/vantio-cli/bin/ingest-url.cjs b/packages/vantio-cli/bin/ingest-url.cjs new file mode 100644 index 00000000..c1401945 --- /dev/null +++ b/packages/vantio-cli/bin/ingest-url.cjs @@ -0,0 +1,28 @@ +"use strict"; + +function parseIngestUrl(raw) { + if (raw == null || String(raw).trim() === "") { + return { ok: true, href: "https://vantio.ai", publicHost: true, source: "default" }; + } + let url; + try { + url = new URL(String(raw).trim()); + } catch { + return { ok: false, reason: "VANTIO_INGEST_URL is not a valid URL" }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { ok: false, reason: "VANTIO_INGEST_URL must use http or https" }; + } + if (!url.hostname) { + return { ok: false, reason: "VANTIO_INGEST_URL has no host" }; + } + const host = url.hostname.toLowerCase(); + return { + ok: true, + href: url.origin, + publicHost: host === "vantio.ai" || host === "www.vantio.ai", + source: "env", + }; +} + +module.exports = { parseIngestUrl }; diff --git a/packages/vantio-cli/bin/interceptor.cjs b/packages/vantio-cli/bin/interceptor.cjs index c77b2506..f4b38ec8 100755 --- a/packages/vantio-cli/bin/interceptor.cjs +++ b/packages/vantio-cli/bin/interceptor.cjs @@ -48,19 +48,23 @@ const c = { cyan: USE_COLOR ? "\x1b[36m" : "", }; -const INGEST_URL = process.env.VANTIO_INGEST_URL || "https://vantio.ai"; -// Do not fetch account configuration or paid ingest from the -// public host. Another VANTIO_INGEST_URL keeps the control-plane client. -function isPublicCloudHost(raw) { - try { - const host = new URL(raw).hostname.toLowerCase(); - return host === "vantio.ai" || host === "www.vantio.ai"; - } catch { - return true; - } +const { parseIngestUrl } = require("./ingest-url.cjs"); +const _parsedIngest = parseIngestUrl(process.env.VANTIO_INGEST_URL); +const INGEST_INVALID = !_parsedIngest.ok; +const INGEST_URL = _parsedIngest.ok ? _parsedIngest.href : ""; +const PUBLIC_CLOUD_HOST = _parsedIngest.ok && _parsedIngest.publicHost; +// A key plus an unusable ingest URL is an enforcement setting we cannot load. +// That fails closed. Observation without a key stays fail-open. +const ENFORCE_CLOSED = INGEST_INVALID && !!process.env.VANTIO_API_KEY; +const API_KEY = PUBLIC_CLOUD_HOST || INGEST_INVALID ? undefined : process.env.VANTIO_API_KEY; +if (INGEST_INVALID) { + const tail = ENFORCE_CLOSED + ? "Enforcement fails closed." + : "Observation continues."; + process.stderr.write( + `[ ∅ VANTIO ] ${_parsedIngest.reason}. ${tail}\n` + ); } -const PUBLIC_CLOUD_HOST = isPublicCloudHost(INGEST_URL); -const API_KEY = PUBLIC_CLOUD_HOST ? undefined : process.env.VANTIO_API_KEY; const AUDIT_MODE = process.env.VANTIO_AUDIT_MODE === "1"; const SUMMARY = process.env.VANTIO_SUMMARY === "1"; const FREE_MODE = !API_KEY; @@ -124,10 +128,14 @@ function extractRequestMeta(input, init) { } let request_bytes = null; try { - const body = init && init.body; - if (typeof body === "string") request_bytes = Buffer.byteLength(body); - else if (Buffer.isBuffer(body)) request_bytes = body.length; - else if (body instanceof Uint8Array) request_bytes = body.byteLength; + request_bytes = countedBodyBytes(init && init.body); + if (request_bytes == null && typeof Request !== "undefined" && input instanceof Request) { + const header = input.headers && input.headers.get && input.headers.get("content-length"); + if (header != null && header !== "") { + const n = parseInt(header, 10); + if (Number.isFinite(n) && n >= 0) request_bytes = n; + } + } } catch { request_bytes = null; } @@ -139,6 +147,16 @@ function extractRequestMeta(input, init) { }; } +function countedBodyBytes(body) { + if (body == null) return null; + if (typeof body === "string") return Buffer.byteLength(body); + if (Buffer.isBuffer(body)) return body.length; + if (ArrayBuffer.isView(body)) return body.byteLength; + if (body instanceof ArrayBuffer) return body.byteLength; + if (typeof body.size === "number" && Number.isFinite(body.size)) return body.size; + return null; +} + function responseMeta(response) { if (!response) { return { status: null, ok: null, content_type: null, bytes: null }; @@ -318,7 +336,7 @@ function logFreeObservation(info) { // ── Policy load (Tier 2) ────────────────────────────────────────────────────── const policyReady = (async () => { - if (FREE_MODE) return; + if (FREE_MODE || INGEST_INVALID || !INGEST_URL) return; try { const res = await _originalFetch.call(globalThis, `${INGEST_URL}/api/v1/config`, { method: "GET", @@ -503,7 +521,7 @@ async function redactRequestBody(body) { return { value: passBranch, bytes: total, redactions: [], replaced: false, unscanned: null }; } // Oversized — use pass-through branch unmodified; log as unscanned. - return { value: passBranch, bytes: 0, redactions: [], replaced: false, unscanned: "ReadableStream" }; + return { value: passBranch, bytes: total, redactions: [], replaced: false, unscanned: "ReadableStream" }; } catch { // tee() / read failed (e.g. stream already locked) — fall through below. } @@ -809,6 +827,25 @@ async function wrapFetch(backend, input, init) { return launchUndiciBackend(() => backend.call(globalThis, input, init)); } + if (ENFORCE_CLOSED) { + const reqMeta = extractRequestMeta(input, init); + _calls.push({ + hostname, + provider: guessProvider(hostname, port), + method: reqMeta.method, + path: reqMeta.path, + scheme: reqMeta.scheme, + request_bytes: reqMeta.request_bytes, + bytes: 0, + status: 403, + ok: false, + action: "ENFORCEMENT_CLOSED", + ts: new Date().toISOString(), + }); + log(`${c.red}[ ∅ VANTIO ] ENFORCEMENT_CLOSED${c.reset} ${hostname} — VANTIO_INGEST_URL is not a usable http(s) URL. The call is refused.`); + return blockedResponse("enforcement_closed"); + } + // ── FREE TIER — observe only ──────────────────────────────────────────────── if (FREE_MODE) { @@ -925,22 +962,7 @@ async function wrapFetch(backend, input, init) { }; _calls.push(callRec); - const len = response.headers.get("content-length"); - if (len != null && len !== "") { - const respBytes = parseInt(len, 10) || 0; - callRec.bytes = respBytes; - spentUsd += (plan.reqBytes + respBytes) * USD_PER_BYTE; - } else { - // Streaming SSE (no content-length): count request bytes now and the - // response bytes in the background from an independent clone. - spentUsd += plan.reqBytes * USD_PER_BYTE; - trackStreamBytes(response, (total) => { callRec.bytes = total; }); - } - - if (plan.redactions.length > 0) { - log(`${c.green}[ ∅ VANTIO ] REDACTED${c.reset} ${hostname} — stripped ${plan.redactions.length} PII item(s): ${plan.redactions.join(", ")}`); - } - report({ + const sendReport = () => report({ target_host: hostname, pid: process.pid, action_taken: action, @@ -955,6 +977,27 @@ async function wrapFetch(backend, input, init) { duration_ms: callRec.duration_ms, ok: callRec.ok, }); + const len = response.headers.get("content-length"); + if (len != null && len !== "") { + const respBytes = parseInt(len, 10) || 0; + callRec.bytes = respBytes; + spentUsd += (plan.reqBytes + respBytes) * USD_PER_BYTE; + sendReport(); + } else { + // Streaming SSE (no content-length): count request bytes now. Response + // bytes land on the call record before the run log is written at exit, + // and the control-plane report waits until that count is known. + spentUsd += plan.reqBytes * USD_PER_BYTE; + trackStreamBytes(response, (total) => { + callRec.bytes = total; + spentUsd += total * USD_PER_BYTE; + sendReport(); + }); + } + + if (plan.redactions.length > 0) { + log(`${c.green}[ ∅ VANTIO ] REDACTED${c.reset} ${hostname} — stripped ${plan.redactions.length} PII item(s): ${plan.redactions.join(", ")}`); + } } catch { // Accounting/reporting must never break the agent's call. } @@ -1781,6 +1824,7 @@ globalThis.fetch = function vantioFetch(input, init) { function decideHttp(hostname, port, args) { if (!hostname || isControlPlaneRequest(args)) return "pass"; if (!inScope(hostname, port)) return "pass"; + if (ENFORCE_CLOSED) return "closed"; if (FREE_MODE) return "observe"; if (policy.enforce) { const blocked = hostListed(hostname, policy.blocked_hosts) || @@ -1847,6 +1891,13 @@ globalThis.fetch = function vantioFetch(input, init) { content_type: null, duration_ms: 0, ts, optics_plane: "app_http", }; + if (decision === "closed") { + _calls.push({ ...baseCall, action: "ENFORCEMENT_CLOSED", ok: false }); + log(`${c.red}[ ∅ VANTIO ] ENFORCEMENT_CLOSED${c.reset} ${hostname} — VANTIO_INGEST_URL is not a usable http(s) URL. The call is refused.`); + const err = new Error("Vantio enforcement fails closed: VANTIO_INGEST_URL is not a usable http(s) URL."); + err.code = "VANTIO_ENFORCEMENT_CLOSED"; + return blockedClientRequest(err); + } if (decision === "block") { _calls.push({ ...baseCall, action: "BLOCKED_HOST", ok: false }); report({ @@ -2055,6 +2106,7 @@ globalThis.fetch = function vantioFetch(input, init) { } function decideWs(hostname, port, url) { + if (ENFORCE_CLOSED && hostname && inScope(hostname, port)) return "closed"; if (!hostname || isControlPlaneWs(url)) return "pass"; if (!inScope(hostname, port)) return "pass"; if (FREE_MODE) return "observe"; @@ -2171,6 +2223,13 @@ globalThis.fetch = function vantioFetch(input, init) { content_type: null, duration_ms: 0, ts, optics_plane: "app_ws", }; + if (decision === "closed") { + _calls.push({ ...baseCall, action: "ENFORCEMENT_CLOSED", ok: false }); + log(`${c.red}[ ∅ VANTIO ] ENFORCEMENT_CLOSED${c.reset} ${hostname} — VANTIO_INGEST_URL is not a usable http(s) URL. The call is refused.`); + const err = new Error("Vantio enforcement fails closed: VANTIO_INGEST_URL is not a usable http(s) URL."); + err.code = "VANTIO_ENFORCEMENT_CLOSED"; + throw err; + } if (decision === "block") { _calls.push({ ...baseCall, action: "BLOCKED_HOST", ok: false }); report({ @@ -2276,6 +2335,7 @@ globalThis.fetch = function vantioFetch(input, init) { } function decideHttp2(hostname, port) { + if (ENFORCE_CLOSED && hostname && inScope(hostname, port)) return "closed"; if (!hostname || isControlPlaneHost(hostname, port)) return "pass"; if (!inScope(hostname, port)) return "pass"; if (FREE_MODE) return "observe"; @@ -2460,6 +2520,11 @@ globalThis.fetch = function vantioFetch(input, init) { content_type: null, duration_ms: 0, ts, optics_plane: "app_http2", }; + if (decision === "closed") { + _calls.push({ ...baseCall, action: "ENFORCEMENT_CLOSED", ok: false }); + log(`${c.red}[ ∅ VANTIO ] ENFORCEMENT_CLOSED${c.reset} ${hostname} — VANTIO_INGEST_URL is not a usable http(s) URL. The call is refused.`); + return stubSession(new Error("Vantio enforcement fails closed: VANTIO_INGEST_URL is not a usable http(s) URL.")); + } if (decision === "block") { _calls.push({ ...baseCall, action: "BLOCKED_HOST", ok: false }); reportH2(hostname, "BLOCKED_HOST"); @@ -2510,6 +2575,21 @@ globalThis.fetch = function vantioFetch(input, init) { try { const dest = destFromAuthority(authority, options); const decision = decideHttp2(dest.hostname, dest.port); + if (decision === "closed") { + const err = new Error("Vantio enforcement fails closed: VANTIO_INGEST_URL is not a usable http(s) URL."); + err.code = "VANTIO_ENFORCEMENT_CLOSED"; + dead = err; + _calls.push({ + hostname: dest.hostname, provider: guessProvider(dest.hostname, dest.port), + method: "CONNECT", path: null, scheme: "http2", request_bytes: null, + bytes: 0, status: null, ok: false, content_type: null, duration_ms: 0, + ts: new Date().toISOString(), optics_plane: "app_http2", action: "ENFORCEMENT_CLOSED", + }); + log(`${c.red}[ ∅ VANTIO ] ENFORCEMENT_CLOSED${c.reset} ${dest.hostname} — VANTIO_INGEST_URL is not a usable http(s) URL. The call is refused.`); + process.nextTick(() => pending.emit("error", err)); + for (const q of queued) process.nextTick(() => q.placeholder.emit("error", err)); + return; + } if (decision === "block") { const err = blockedErr(dest.hostname); dead = err; @@ -2622,6 +2702,7 @@ globalThis.fetch = function vantioFetch(input, init) { } function decideNet(hostname, port) { + if (ENFORCE_CLOSED && hostname && inScope(hostname, port)) return "closed"; if (!hostname || isControlPlaneHost(hostname, port)) return "pass"; if (!inScope(hostname, port)) return "pass"; if (FREE_MODE) return "observe"; @@ -2655,6 +2736,16 @@ globalThis.fetch = function vantioFetch(input, init) { content_type: null, duration_ms: 0, ts, optics_plane: "app_net", }; + if (decision === "closed") { + _calls.push({ ...baseCall, action: "ENFORCEMENT_CLOSED", ok: false }); + log(`${c.red}[ ∅ VANTIO ] ENFORCEMENT_CLOSED${c.reset} ${hostname} — VANTIO_INGEST_URL is not a usable http(s) URL. The call is refused.`); + const err = new Error("Vantio enforcement fails closed: VANTIO_INGEST_URL is not a usable http(s) URL."); + err.code = "VANTIO_ENFORCEMENT_CLOSED"; + process.nextTick(() => { + try { socket.emit("error", err); } catch { /* ignore */ } + }); + return socket; + } if (decision === "block") { _calls.push({ ...baseCall, action: "BLOCKED_HOST", ok: false }); report({ @@ -3222,6 +3313,7 @@ globalThis.fetch = function vantioFetch(input, init) { function decideCurl(url, hostname, port, dataBytes) { if (!hostname || isControlPlaneCurlUrl(url)) return "pass"; if (!inScope(hostname, port)) return "pass"; + if (ENFORCE_CLOSED) return "closed"; if (FREE_MODE) return "observe"; if (policy.enforce) { const blocked = hostListed(hostname, policy.blocked_hosts) @@ -3474,8 +3566,8 @@ globalThis.fetch = function vantioFetch(input, init) { const nRedact = Array.isArray(redactions) ? redactions.length : 0; _calls.push({ hostname, provider, method: meta.method, path: null, scheme: "http", - request_bytes: dataBytes, bytes: dataBytes, status: null, - ok: !String(action).startsWith("BLOCKED"), + request_bytes: dataBytes, bytes: null, status: null, + ok: !String(action).startsWith("BLOCKED") && action !== "ENFORCEMENT_CLOSED", content_type: null, duration_ms: 0, ts: new Date().toISOString(), action, mediation: meta.mediation, optics_plane: meta.plane, redactions: nRedact, @@ -3511,7 +3603,7 @@ globalThis.fetch = function vantioFetch(input, init) { const dest = destFromCurlUrl(url); rows.push({ dest, decision: decideCurl(url, dest.hostname, dest.port, parsed.dataBytes) }); } - const hard = rows.filter((r) => r.decision === "block" || r.decision === "block_size" || r.decision === "block_spend"); + const hard = rows.filter((r) => r.decision === "block" || r.decision === "block_size" || r.decision === "block_spend" || r.decision === "closed"); const inScope = rows.filter((r) => r.decision !== "pass"); const toRecord = hard.length ? hard : inScope; let blockErr = null; @@ -3520,7 +3612,13 @@ globalThis.fetch = function vantioFetch(input, init) { const dest = row.dest; const decision = row.decision; lastDecision = decision; - if (decision === "block") { + if (decision === "closed") { + recordCli(tool, dest.hostname, dest.port, "ENFORCEMENT_CLOSED", parsed.dataBytes); + if (!blockErr) { + blockErr = new Error("Vantio enforcement fails closed: VANTIO_INGEST_URL is not a usable http(s) URL."); + blockErr.code = "VANTIO_ENFORCEMENT_CLOSED"; + } + } else if (decision === "block") { recordCli(tool, dest.hostname, dest.port, "BLOCKED_HOST", parsed.dataBytes); if (!blockErr) blockErr = gateError(dest.hostname, "host_not_permitted"); } else if (decision === "block_size") { diff --git a/packages/vantio-cli/bin/telemetry.cjs b/packages/vantio-cli/bin/telemetry.cjs index b4ec68f7..b458494d 100644 --- a/packages/vantio-cli/bin/telemetry.cjs +++ b/packages/vantio-cli/bin/telemetry.cjs @@ -22,7 +22,22 @@ const { randomUUID } = require("node:crypto"); // module-level constant would freeze in whatever VANTIO_INGEST_URL happened // to be at first require. function telemetryBase() { - return process.env.VANTIO_INGEST_URL || "https://vantio.ai"; + const raw = process.env.VANTIO_INGEST_URL; + if (raw == null || String(raw).trim() === "") return "https://vantio.ai"; + try { + const url = new URL(String(raw).trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("scheme"); + } + if (!url.hostname) throw new Error("host"); + return url.origin; + } catch { + if (!telemetryBase.warned) { + telemetryBase.warned = true; + process.stderr.write("[ ∅ VANTIO ] VANTIO_INGEST_URL is not a usable http(s) URL. Telemetry was not sent.\n"); + } + return null; + } } // Captured at require time — interceptor.cjs requires this module BEFORE it @@ -55,7 +70,7 @@ function telemetryDisabled() { // ephemeral per-run id — this function never throws. function getTelemetryId() { try { - const dir = path.join(os.homedir(), ".vantio"); + const dir = process.env.VANTIO_HOME || path.join(os.homedir(), ".vantio"); const idFile = path.join(dir, "telemetry-id"); try { const existing = fs.readFileSync(idFile, "utf8").trim(); @@ -82,6 +97,8 @@ function sendTelemetry(payload = {}) { try { if (telemetryDisabled()) return; if (typeof _fetch !== "function") return; // Node < 18 — nothing to send with. + const base = telemetryBase(); + if (!base) return; const body = { anonymousId: getTelemetryId(), @@ -102,7 +119,7 @@ function sendTelemetry(payload = {}) { if (Number.isFinite(payload.blockedCount)) body.blockedCount = payload.blockedCount; if (payload.framework != null) body.framework = String(payload.framework); - void _fetch(`${telemetryBase()}/api/v1/telemetry`, { + void _fetch(`${base}/api/v1/telemetry`, { method: "POST", headers: { "Content-Type": "application/json" }, // No api key. No auth header. body: JSON.stringify(body), diff --git a/packages/vantio-cli/bin/vantio.js b/packages/vantio-cli/bin/vantio.js index cb93e1bf..b7a60981 100644 --- a/packages/vantio-cli/bin/vantio.js +++ b/packages/vantio-cli/bin/vantio.js @@ -245,7 +245,11 @@ Examples: `; // ── config store (~/.vantio/config.json) ─────────────────────────────────────────────────── -function configDir() { return join(homedir(), ".vantio"); } +function configDir() { + const fromEnv = process.env.VANTIO_HOME; + if (fromEnv && String(fromEnv).trim()) return String(fromEnv); + return join(homedir(), ".vantio"); +} function configPath() { return join(configDir(), "config.json"); } // Compatibility: ~/.vantio/config.json is not read by run, @@ -669,7 +673,7 @@ function listRuns(dir) { process.stdout.write( "No run logs found. Run an agent first:\n" + " vantio run node agent.js\n\n" + - "Run logs are written to ~/.vantio/runs/ when LLM calls are intercepted.\n" + "Run logs are written under VANTIO_HOME/runs, or ~/.vantio/runs when that variable is unset.\n" ); return; } diff --git a/packages/vantio-cli/package.json b/packages/vantio-cli/package.json index 61b7e4d5..b6dd6e52 100644 --- a/packages/vantio-cli/package.json +++ b/packages/vantio-cli/package.json @@ -1,6 +1,6 @@ { "name": "@vantio/cli", - "version": "0.3.24", + "version": "0.3.25", "description": "Vantio Optics | Free Observability for AI Agents. Free, local-first observability for supported AI-agent traffic. Prompts and completions are never stored.", "license": "MIT", "author": "Vantio AI, Inc.", @@ -37,7 +37,7 @@ "LICENSE" ], "scripts": { - "lint": "node --check bin/vantio.js && node --check bin/interceptor.cjs && node --check bin/telemetry.cjs && node --check bin/llm-hosts.cjs && node --check bin/optics-cx.cjs", + "lint": "node --check bin/vantio.js && node --check bin/interceptor.cjs && node --check bin/telemetry.cjs && node --check bin/llm-hosts.cjs && node --check bin/optics-cx.cjs && node --check bin/ingest-url.cjs", "test": "node --test" }, "engines": { diff --git a/packages/vantio-cli/test/p2-p3-audit.test.js b/packages/vantio-cli/test/p2-p3-audit.test.js new file mode 100644 index 00000000..e6fcc95f --- /dev/null +++ b/packages/vantio-cli/test/p2-p3-audit.test.js @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseIngestUrl } from "../bin/ingest-url.cjs"; + +const root = dirname(fileURLToPath(import.meta.url)); +const cli = join(root, "..", "bin", "vantio.js"); +const interceptor = join(root, "..", "bin", "interceptor.cjs"); + +test("a bad VANTIO_INGEST_URL is not treated as the public host", () => { + const parsed = parseIngestUrl("not a url"); + assert.equal(parsed.ok, false); + assert.equal(parseIngestUrl("ftp://files.example/x").ok, false); + assert.equal(parseIngestUrl("https://vantio.ai").publicHost, true); + assert.equal(parseIngestUrl("http://127.0.0.1:9").publicHost, false); +}); + +test("a bad ingest URL with an API key fails closed out loud", async () => { + const script = ` + const http = require("node:http"); + const srv = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"ok":true}'); + }); + srv.listen(0, "127.0.0.1", () => { + const port = srv.address().port; + fetch("http://127.0.0.1:" + port + "/v1/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"n":1}', + }).then(async (res) => { + const body = await res.text(); + process.stdout.write(JSON.stringify({ status: res.status, body })); + srv.close(); + }).catch((err) => { + process.stdout.write(JSON.stringify({ error: err.message })); + srv.close(); + }); + }); + `; + const child = spawn(process.execPath, ["-e", script], { + env: { + PATH: process.env.PATH, + NODE_OPTIONS: `--require ${interceptor}`, + VANTIO_INGEST_URL: "not a url", + VANTIO_API_KEY: "vk_test_dummy", + VANTIO_EXTRA_LLM_HOSTS: "127.0.0.1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c) => (stdout += c)); + child.stderr.on("data", (c) => (stderr += c)); + const code = await new Promise((resolve) => child.on("close", resolve)); + assert.equal(code, 0); + assert.match(stderr, /VANTIO_INGEST_URL/); + assert.match(stderr, /fails closed|ENFORCEMENT_CLOSED/i); + const body = JSON.parse(stdout.trim().split("\n").pop()); + assert.equal(body.status, 403); + assert.match(body.body, /enforcement_closed/); +}); + +test("CLI readers use VANTIO_HOME", async () => { + const home = mkdtempSync(join(tmpdir(), "vantio-home-")); + const other = mkdtempSync(join(tmpdir(), "vantio-other-")); + const runs = join(home, "runs"); + mkdirSync(runs, { recursive: true }); + writeFileSync(join(runs, "trace-home.json"), JSON.stringify({ + vantio_run_log: "1", + trace_id: "trace-home-only", + generated_at: "2026-09-29T00:00:00.000Z", + calls: [{ hostname: "api.openai.com", action: "OBSERVED", bytes: 3 }], + })); + const child = spawn(process.execPath, [cli, "prove", "--list"], { + env: { PATH: process.env.PATH, HOME: other, VANTIO_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (c) => (stdout += c)); + const code = await new Promise((resolve) => child.on("close", resolve)); + rmSync(home, { recursive: true, force: true }); + rmSync(other, { recursive: true, force: true }); + assert.equal(code, 0); + assert.match(stdout, /trace-home-only/); +}); diff --git a/packages/vantio-gate-mcp/src/policy.js b/packages/vantio-gate-mcp/src/policy.js index 7ca20c6a..38658064 100644 --- a/packages/vantio-gate-mcp/src/policy.js +++ b/packages/vantio-gate-mcp/src/policy.js @@ -45,6 +45,19 @@ function asBool(v, d) { function asStrArray(v, d) { return Array.isArray(v) ? v.filter((x) => typeof x === "string") : d.slice(); } +export function hostMatches(hostname, listed) { + const h = String(hostname || "").toLowerCase(); + if (!h) return false; + const items = Array.isArray(listed) ? listed : []; + for (const item of items) { + const b = String(item || "").toLowerCase().trim(); + if (!b) continue; + if (h === b) return true; + if (b.includes(".") && h.endsWith("." + b)) return true; + } + return false; +} + function asNonNegNum(v, d) { const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) && n >= 0 ? n : d; @@ -95,33 +108,35 @@ export function evaluateRequest(policyRaw, req) { }; } - if (policy.blocked_hosts.includes(hostname)) { + const actionName = (kind) => (policy.dry_run ? `DRY_RUN_${kind}` : kind); + + if (hostMatches(hostname, policy.blocked_hosts)) { would_block = true; - primary_action = "DRY_RUN_BLOCKED_HOST"; + primary_action = actionName("BLOCKED_HOST"); would.push({ action: primary_action, reason: "host_not_permitted" }); } else if ( policy.allowed_hosts.length > 0 && - !policy.allowed_hosts.includes(hostname) + !hostMatches(hostname, policy.allowed_hosts) ) { would_block = true; - primary_action = "DRY_RUN_BLOCKED_HOST"; + primary_action = actionName("BLOCKED_HOST"); would.push({ action: primary_action, reason: "not_in_allowed_hosts" }); } if (policy.max_request_bytes > 0 && requestBytes > policy.max_request_bytes) { would_block = true; - primary_action = "DRY_RUN_BLOCKED_SIZE"; + primary_action = actionName("BLOCKED_SIZE"); would.push({ - action: "DRY_RUN_BLOCKED_SIZE", + action: actionName("BLOCKED_SIZE"), reason: `request_bytes ${requestBytes} > max_request_bytes ${policy.max_request_bytes}`, }); } if (policy.spend_cap_usd > 0 && spentUsd >= policy.spend_cap_usd) { would_block = true; - primary_action = "DRY_RUN_BLOCKED_SPEND"; + primary_action = actionName("BLOCKED_SPEND"); would.push({ - action: "DRY_RUN_BLOCKED_SPEND", + action: actionName("BLOCKED_SPEND"), reason: `spent_usd ${spentUsd} >= spend_cap_usd ${policy.spend_cap_usd}`, }); } diff --git a/packages/vantio-gate-mcp/test/suffix-dry-run.test.js b/packages/vantio-gate-mcp/test/suffix-dry-run.test.js new file mode 100644 index 00000000..587c3a27 --- /dev/null +++ b/packages/vantio-gate-mcp/test/suffix-dry-run.test.js @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateRequest, hostMatches } from "../src/policy.js"; + +const base = { + enforce: true, + redact_pii: false, + pii_types: [], + allowed_hosts: [], + max_request_bytes: 0, + spend_cap_usd: 0, +}; + +test("host list matches a DNS suffix and not a lookalike", () => { + assert.equal(hostMatches("api.openai.com", ["openai.com"]), true); + assert.equal(hostMatches("notopenai.com", ["openai.com"]), false); + assert.equal(hostMatches("openai.com", ["openai.com"]), true); +}); + +test("dry_run false names BLOCKED actions", () => { + const r = evaluateRequest( + { ...base, dry_run: false, blocked_hosts: ["openai.com"] }, + { hostname: "api.openai.com" }, + ); + assert.equal(r.would_block, true); + assert.equal(r.primary_action, "BLOCKED_HOST"); +}); + +test("dry_run true keeps the DRY_RUN prefix", () => { + const r = evaluateRequest( + { ...base, dry_run: true, blocked_hosts: ["api.openai.com"] }, + { hostname: "api.openai.com" }, + ); + assert.equal(r.primary_action, "DRY_RUN_BLOCKED_HOST"); +}); diff --git a/packages/vantio-install/tests/test_stage_a.py b/packages/vantio-install/tests/test_stage_a.py index 99e12ef5..b731f29b 100644 --- a/packages/vantio-install/tests/test_stage_a.py +++ b/packages/vantio-install/tests/test_stage_a.py @@ -141,11 +141,11 @@ def test_frozen_identities_and_cli_package_untouched(self) -> None: self.assertEqual(pins["agent_sdk_npm_version"], "0.2.4") self.assertEqual(pins["agent_sdk_py_version"], "3.1.0") cli = json.loads((REPO / "packages" / "vantio-cli" / "package.json").read_text(encoding="utf-8")) - self.assertEqual(cli["version"], "0.3.24") + self.assertEqual(cli["version"], "0.3.25") sdk = json.loads((REPO / "packages" / "vantio-agent-sdk" / "package.json").read_text(encoding="utf-8")) self.assertEqual(sdk["version"], "0.2.4") pyproject = (REPO / "packages" / "vantio-agent-sdk-py" / "pyproject.toml").read_text(encoding="utf-8") - self.assertIn('version = "3.1.0"', pyproject) + self.assertIn('version = "3.1.1"', pyproject) def test_preflight_is_read_only(self) -> None: harness = self.make() diff --git a/scripts/release/test_pypi_publish_workflow.py b/scripts/release/test_pypi_publish_workflow.py index c0abac81..03c64497 100644 --- a/scripts/release/test_pypi_publish_workflow.py +++ b/scripts/release/test_pypi_publish_workflow.py @@ -258,8 +258,8 @@ def test_other_workflows_do_not_publish_python(self) -> None: def test_python_version_pin_remains(self) -> None: pyproject = (ROOT / "packages/vantio-agent-sdk-py/pyproject.toml").read_text(encoding="utf-8") init = (ROOT / "packages/vantio-agent-sdk-py/vantio/__init__.py").read_text(encoding="utf-8") - self.assertIn('version = "3.1.0"', pyproject) - self.assertIn('__version__ = "3.1.0"', init) + self.assertIn('version = "3.1.1"', pyproject) + self.assertIn('__version__ = "3.1.1"', init) class SealedGateTests(unittest.TestCase): diff --git a/scripts/release/ws11/ws11.test.mjs b/scripts/release/ws11/ws11.test.mjs index 1d65e9a3..bb53d079 100644 --- a/scripts/release/ws11/ws11.test.mjs +++ b/scripts/release/ws11/ws11.test.mjs @@ -228,7 +228,7 @@ test("current surfaces characterize with gaps and withhold release success", () assert.equal(python.registry.this_force_refetched, false); assert.equal(python.registry.historical_register_state, "PUBLISHED_REGISTRY_BYTES_VERIFIED_CLIENT_PROVED"); const cli = optics.units.find((unit) => unit.package === "@vantio/cli"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(cli.artifacts[0].custody.selector.kind, "git-tag"); assert.equal(cli.artifacts[0].custody.selector_is_integrity, false); const contract = optics.units.find((unit) => unit.package === "@vantio/optics-evidence-contract"); @@ -318,8 +318,8 @@ test("version-matched docs gate and the customer test double agree with the tree assert.equal(assemblePeCustomerBundle({ version: "9.9.9", manualText: manual }).ok, false); const cli = JSON.parse(readFileSync(join(ROOT, "packages/vantio-cli/package.json"), "utf8")); const pyproject = readFileSync(join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.equal(cli.version, "0.3.24"); - assert.match(pyproject, /version = "3.1.0"/); + assert.equal(cli.version, "0.3.25"); + assert.match(pyproject, /version = "3.1.1"/); assert.equal(readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8").includes("scripts/release/ws11/ws11.test.mjs"), true); }); diff --git a/tests/governance-assurance/catalog.test.cjs b/tests/governance-assurance/catalog.test.cjs index e7d19931..ea2c276e 100644 --- a/tests/governance-assurance/catalog.test.cjs +++ b/tests/governance-assurance/catalog.test.cjs @@ -57,9 +57,9 @@ test("package stays private and off the shipping workspace", () => { const promote = fs.readFileSync(path.join(root, "scripts/release/promote_npm.mjs"), "utf8"); assert.equal(promote.includes("governance-assurance"), false); const cli = JSON.parse(fs.readFileSync(path.join(root, "packages/vantio-cli/package.json"), "utf8")); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); const python = fs.readFileSync(path.join(root, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); const npmSdk = JSON.parse(fs.readFileSync(path.join(root, "packages/vantio-agent-sdk/package.json"), "utf8")); assert.equal(npmSdk.version, "0.2.4"); const boundary = fs.readFileSync(path.join(root, "docs/internal/governance-assurance/00-BOUNDARY.md"), "utf8"); diff --git a/tests/governance-assurance/rebind.test.cjs b/tests/governance-assurance/rebind.test.cjs index c06c6680..0567d14b 100644 --- a/tests/governance-assurance/rebind.test.cjs +++ b/tests/governance-assurance/rebind.test.cjs @@ -126,7 +126,7 @@ test("installer denylist and frozen packages stay untouched", () => { assert.match(constants, /GA_0\.2\.0_internal_rebind_completed/); const cli = JSON.parse(fs.readFileSync(path.join(root, "packages/vantio-cli/package.json"), "utf8")); const npmSdk = JSON.parse(fs.readFileSync(path.join(root, "packages/vantio-agent-sdk/package.json"), "utf8")); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(npmSdk.version, "0.2.4"); }); } diff --git a/tests/optics-evidence-contract/node.test.cjs b/tests/optics-evidence-contract/node.test.cjs index f02cf9dc..dc94036b 100644 --- a/tests/optics-evidence-contract/node.test.cjs +++ b/tests/optics-evidence-contract/node.test.cjs @@ -362,8 +362,8 @@ test("validator sources do not open network clients", () => { test("scope stays off the live runtimes", () => { const cli = JSON.parse(fs.readFileSync(path.join(ROOT, "packages", "vantio-cli", "package.json"), "utf8")); const pyproject = fs.readFileSync(path.join(ROOT, "packages", "vantio-agent-sdk-py", "pyproject.toml"), "utf8"); - assert.equal(cli.version, "0.3.24"); - assert.match(pyproject, /version = "3.1.0"/); + assert.equal(cli.version, "0.3.25"); + assert.match(pyproject, /version = "3.1.1"/); const pkg = JSON.parse(fs.readFileSync(path.join(CONTRACT, "package.json"), "utf8")); assert.equal(pkg.private, true); assert.equal(pkg.dependencies, undefined); diff --git a/tests/optics-evidence-contract/python_test.py b/tests/optics-evidence-contract/python_test.py index a6b4e859..dcb75b4d 100644 --- a/tests/optics-evidence-contract/python_test.py +++ b/tests/optics-evidence-contract/python_test.py @@ -299,8 +299,8 @@ def test_no_network_imports(self): def test_scope(self): cli = json.loads((ROOT / "packages" / "vantio-cli" / "package.json").read_text(encoding="utf-8")) pyproject = (ROOT / "packages" / "vantio-agent-sdk-py" / "pyproject.toml").read_text(encoding="utf-8") - self.assertEqual(cli["version"], "0.3.24") - self.assertIn('version = "3.1.0"', pyproject) + self.assertEqual(cli["version"], "0.3.25") + self.assertIn('version = "3.1.1"', pyproject) pkg = json.loads((ROOT / "packages" / "optics-evidence-contract" / "package.json").read_text(encoding="utf-8")) self.assertTrue(pkg["private"]) self.assertNotIn("dependencies", pkg) diff --git a/tests/optics-node-adapter/isolation.test.cjs b/tests/optics-node-adapter/isolation.test.cjs index e3971b42..0b5ad4b8 100644 --- a/tests/optics-node-adapter/isolation.test.cjs +++ b/tests/optics-node-adapter/isolation.test.cjs @@ -114,11 +114,11 @@ test("frozen package versions are unchanged and this package stays private", () const vocabulary = JSON.parse(read("packages/optics-record-vocabulary/package.json")); const adapter = JSON.parse(read("packages/optics-node-adapter/package.json")); const python = read("packages/vantio-agent-sdk-py/pyproject.toml"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(nodeSdk.version, "0.2.4"); assert.equal(contract.version, "0.0.0-unstable-pre-1.0"); assert.equal(vocabulary.version, "0.0.0-unstable-pre-1.0"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); assert.equal(adapter.private, true); assert.equal(adapter.version, "0.0.0-unstable-pre-1.0"); const workspace = read("pnpm-workspace.yaml"); diff --git a/tests/optics-o7-node-binding-council/direct.test.cjs b/tests/optics-o7-node-binding-council/direct.test.cjs index 8893b9f4..bc9d2e0d 100644 --- a/tests/optics-o7-node-binding-council/direct.test.cjs +++ b/tests/optics-o7-node-binding-council/direct.test.cjs @@ -72,7 +72,7 @@ test("the decision does not claim runtime proof or open the gate", () => { test("frozen manifests and the historical not-selected sentence stay put", () => { assert.equal(readJson("packages/vantio-cli/package.json").version, "0.3.24"); assert.equal(readJson("packages/vantio-agent-sdk/package.json").version, "0.2.4"); - assert.match(readText("packages/vantio-agent-sdk-py/pyproject.toml"), /^version = "3.1.0"$/m); + assert.match(readText("packages/vantio-agent-sdk-py/pyproject.toml"), /^version = "3.1.1"$/m); assert.equal(readJson("docs/governance/VERSION-METADATA.json").packages[0].version, "0.3.24"); assert.equal(decision.release_impact.cli_modified, false); assert.equal(decision.release_impact.python_sdk_modified, false); diff --git a/tests/optics-o7-runtime/direct.test.cjs b/tests/optics-o7-runtime/direct.test.cjs index 0a41e1c2..2013bde5 100644 --- a/tests/optics-o7-runtime/direct.test.cjs +++ b/tests/optics-o7-runtime/direct.test.cjs @@ -112,7 +112,7 @@ test("frozen releases, the closed gate, and the integration record stay honest", assert.equal(JSON.parse(read("packages/vantio-cli/package.json")).version, "0.3.24"); assert.equal(JSON.parse(read("packages/vantio-cli/package.json")).engines.node, ">=18.3.0"); assert.equal(JSON.parse(read("packages/vantio-agent-sdk/package.json")).version, "0.2.4"); - assert.match(read("packages/vantio-agent-sdk-py/pyproject.toml"), /^version = "3.1.0"$/m); + assert.match(read("packages/vantio-agent-sdk-py/pyproject.toml"), /^version = "3.1.1"$/m); assert.match(read("docs/architecture/optics-foundation/09-IMPLEMENTATION-GATES.md"), /\| 8 \| Implementation Force \|.*\| \*\*Closed\. Not started\*\* \|/); assert.match(read("docs/architecture/optics-foundation/08-ARCHITECTURE-DECISION-PACK.md"), /9\. Node SQLite binding\. Not selected\./); const record = JSON.parse(read("docs/internal/optics-o7/RUNTIME-INTEGRATION.json")); diff --git a/tests/optics-o7-store/direct.test.cjs b/tests/optics-o7-store/direct.test.cjs index a725d403..bb724242 100644 --- a/tests/optics-o7-store/direct.test.cjs +++ b/tests/optics-o7-store/direct.test.cjs @@ -91,7 +91,7 @@ test("frozen product versions and the closed architecture gate stay in place", ( assert.equal(readJson("packages/vantio-cli/package.json").version, "0.3.24"); assert.equal(readJson("packages/vantio-agent-sdk/package.json").version, "0.2.4"); const python = fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.match(python, /^version = "3.1.0"$/m); + assert.match(python, /^version = "3.1.1"$/m); const gates = fs.readFileSync(path.join(ROOT, "docs/architecture/optics-foundation/09-IMPLEMENTATION-GATES.md"), "utf8"); assert.match(gates, /\| 8 \| Implementation Force \|.*\| \*\*Closed\. Not started\*\* \|/); const decision = fs.readFileSync( diff --git a/tests/optics-otel-i3/isolation.test.cjs b/tests/optics-otel-i3/isolation.test.cjs index 0b5675d1..2a614fd1 100644 --- a/tests/optics-otel-i3/isolation.test.cjs +++ b/tests/optics-otel-i3/isolation.test.cjs @@ -58,9 +58,9 @@ test("live products and the mapping package do not load the adapter", () => { const cli = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-cli/package.json"), "utf8")); const nodeSdk = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk/package.json"), "utf8")); const python = fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(nodeSdk.version, "0.2.4"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); }); test("adapter source does not read the environment or open a writer", () => { diff --git a/tests/optics-pkg02-reader-compat-gates/isolation.test.cjs b/tests/optics-pkg02-reader-compat-gates/isolation.test.cjs index 535ba027..04b93374 100644 --- a/tests/optics-pkg02-reader-compat-gates/isolation.test.cjs +++ b/tests/optics-pkg02-reader-compat-gates/isolation.test.cjs @@ -119,9 +119,9 @@ test("frozen package versions stay unchanged and this package stays private", () const gates = JSON.parse(read("packages/optics-reader-compat-gates/package.json")); const workspace = read("pnpm-workspace.yaml"); const frozenDisplay = read("packages/vantio-cli/bin/optics-cx.cjs"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(nodeSdk.version, "0.2.4"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); assert.equal(reader.version, "0.0.0-unstable-pre-1.0"); assert.equal(gates.private, true); assert.equal(gates.version, "0.0.0-unstable-pre-1.0"); diff --git a/tests/optics-pkg02-unit-d/isolation.test.cjs b/tests/optics-pkg02-unit-d/isolation.test.cjs index baf8e759..8fae0b5c 100644 --- a/tests/optics-pkg02-unit-d/isolation.test.cjs +++ b/tests/optics-pkg02-unit-d/isolation.test.cjs @@ -94,9 +94,9 @@ test("frozen package versions stay put and Unit E is not activated", () => { const future = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-cli-pkg02/package.json"), "utf8")); const python = fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); const workspace = fs.readFileSync(path.join(ROOT, "pnpm-workspace.yaml"), "utf8"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(sdk.version, "0.2.4"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); assert.equal(future.version, "0.4.0-pkg02-unit-d"); assert.equal(future.private, true); assert.equal(future.vantio.activates_unit_d, true); diff --git a/tests/optics-pkg02-unit-e/test_01_prerequisites.py b/tests/optics-pkg02-unit-e/test_01_prerequisites.py index e254394f..00df6c7e 100644 --- a/tests/optics-pkg02-unit-e/test_01_prerequisites.py +++ b/tests/optics-pkg02-unit-e/test_01_prerequisites.py @@ -24,7 +24,7 @@ def test_sealed_suite_still_expects_optics_success(self): self.assertIn('self.assertEqual(call["opticsStatus"], "SUCCESS")', text) self.assertIn('self.assertEqual(data["summary"]["opticsStatus"], "SUCCESS")', text) pyproject = (SDK_31 / "pyproject.toml").read_text(encoding="utf-8") - self.assertIn('version = "3.1.0"', pyproject) + self.assertIn('version = "3.1.1"', pyproject) def test_sealed_3_1_0_shield_bytes_stay_success(self): script = textwrap.dedent( diff --git a/tests/optics-pkg02-unit-e/test_05_isolation.py b/tests/optics-pkg02-unit-e/test_05_isolation.py index f492e289..aceeb263 100644 --- a/tests/optics-pkg02-unit-e/test_05_isolation.py +++ b/tests/optics-pkg02-unit-e/test_05_isolation.py @@ -34,7 +34,7 @@ def test_frozen_trees_match_the_starting_commit(self): def test_cli_and_reader_gates_stay_closed(self): cli = json.loads((ROOT / "packages" / "vantio-cli" / "package.json").read_text(encoding="utf-8")) - self.assertEqual(cli["version"], "0.3.24") + self.assertEqual(cli["version"], "0.3.25") gates = (ROOT / "packages" / "optics-reader-compat-gates" / "src" / "gates.cjs").read_text(encoding="utf-8") self.assertIn("activates_unit_e: false", gates) self.assertIn("activates_unit_d: false", gates) diff --git a/tests/optics-pkg02-unit-f/isolation.test.cjs b/tests/optics-pkg02-unit-f/isolation.test.cjs index 212b1bbf..bf2fcde0 100644 --- a/tests/optics-pkg02-unit-f/isolation.test.cjs +++ b/tests/optics-pkg02-unit-f/isolation.test.cjs @@ -130,12 +130,12 @@ test("frozen package versions are unchanged and this package stays private", () const reader = JSON.parse(read("packages/optics-record-reader/package.json")); const python = read("packages/vantio-agent-sdk-py/pyproject.toml"); const workspace = read("pnpm-workspace.yaml"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(nodeSdk.version, "0.2.4"); assert.equal(contract.version, "0.0.0-unstable-pre-1.0"); assert.equal(vocabulary.version, "0.0.0-unstable-pre-1.0"); assert.equal(adapter.version, "0.0.0-unstable-pre-1.0"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); assert.equal(reader.private, true); assert.equal(reader.version, "0.0.0-unstable-pre-1.0"); assert.equal(workspace.includes("optics-record-reader"), false); diff --git a/tests/optics-record-vocabulary/isolation.test.cjs b/tests/optics-record-vocabulary/isolation.test.cjs index df9dea04..1e88e7d4 100644 --- a/tests/optics-record-vocabulary/isolation.test.cjs +++ b/tests/optics-record-vocabulary/isolation.test.cjs @@ -114,10 +114,10 @@ test("frozen package versions are unchanged", () => { const contract = JSON.parse(read("packages/optics-evidence-contract/package.json")); const vocabulary = JSON.parse(read("packages/optics-record-vocabulary/package.json")); const python = read("packages/vantio-agent-sdk-py/pyproject.toml"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(nodeSdk.version, "0.2.4"); assert.equal(contract.version, "0.0.0-unstable-pre-1.0"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); assert.equal(vocabulary.private, true); assert.equal(vocabulary.version, "0.0.0-unstable-pre-1.0"); const workspace = read("pnpm-workspace.yaml"); diff --git a/tests/pe-egress/isolation.test.cjs b/tests/pe-egress/isolation.test.cjs index 30fa6bc6..5c746a9e 100644 --- a/tests/pe-egress/isolation.test.cjs +++ b/tests/pe-egress/isolation.test.cjs @@ -28,10 +28,10 @@ describe("isolation", () => { const publish = read(".github/workflows/npm-publish.yml"); assert.equal(publish.includes("pe-egress"), false); const cli = read("packages/vantio-cli/package.json"); - assert.equal(cli.includes("\"version\": \"0.3.24\""), true); + assert.equal(cli.includes("\"version\": \"0.3.25\""), true); assert.equal(cli.includes("pe-egress"), false); const python = read("packages/vantio-agent-sdk-py/pyproject.toml"); - assert.equal(python.includes("version = \"3.1.0\""), true); + assert.equal(python.includes("version = \"3.1.1\""), true); const interceptor = read("packages/vantio-cli/bin/interceptor.cjs"); assert.equal(interceptor.includes("pe-egress-authority"), false); }); diff --git a/tests/pe-host-authority/isolation.test.cjs b/tests/pe-host-authority/isolation.test.cjs index 1ee41a2b..70ba5f01 100644 --- a/tests/pe-host-authority/isolation.test.cjs +++ b/tests/pe-host-authority/isolation.test.cjs @@ -20,9 +20,9 @@ test("the proof package is outside the workspace and the frozen packages", () => const workspace = fs.readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8"); assert.equal(workspace.includes("pe-host-authority"), false); const cli = JSON.parse(fs.readFileSync(path.join(root, "packages/vantio-cli/package.json"), "utf8")); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); const python = fs.readFileSync(path.join(root, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.match(python, /^version = "3.1.0"$/m); + assert.match(python, /^version = "3.1.1"$/m); const nodeSdk = JSON.parse(fs.readFileSync(path.join(root, "packages/vantio-agent-sdk/package.json"), "utf8")); assert.equal(nodeSdk.version, "0.2.4"); }); diff --git a/tests/pe-ingress/isolation.test.cjs b/tests/pe-ingress/isolation.test.cjs index 7f944621..eb31e408 100644 --- a/tests/pe-ingress/isolation.test.cjs +++ b/tests/pe-ingress/isolation.test.cjs @@ -121,9 +121,9 @@ test("this force does not edit CLI 0.3.24, Python 3.1.0, or other trees", () => ); } const cli = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-cli/package.json"), "utf8")); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); const python = fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.match(python, /^version = "3.1.0"$/m); + assert.match(python, /^version = "3.1.1"$/m); }); test("internal notes keep the council pending and name the producer classification", () => { diff --git a/tests/pe-progressive-enforcement/isolation.test.cjs b/tests/pe-progressive-enforcement/isolation.test.cjs index 27c01b03..5080e08d 100644 --- a/tests/pe-progressive-enforcement/isolation.test.cjs +++ b/tests/pe-progressive-enforcement/isolation.test.cjs @@ -82,7 +82,7 @@ test("live packages do not load the lifecycle", () => { for (const relative of roots) visit(path.join(ROOT, relative)); const cli = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-cli/package.json"), "utf8")); const python = fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.match(python, /version = "3\.1\.0"/); }); diff --git a/tests/pe-sequential-authority/isolation.test.cjs b/tests/pe-sequential-authority/isolation.test.cjs index f1721e78..201989f6 100644 --- a/tests/pe-sequential-authority/isolation.test.cjs +++ b/tests/pe-sequential-authority/isolation.test.cjs @@ -116,9 +116,9 @@ test("frozen package versions are unchanged and this package stays private", () const candidate = JSON.parse(read("packages/pe-sequential-authority/package.json")); const python = read("packages/vantio-agent-sdk-py/pyproject.toml"); const workspace = read("pnpm-workspace.yaml"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(nodeSdk.version, "0.2.4"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); assert.equal(candidate.private, true); assert.equal(candidate.version, "0.0.0-unstable-pre-1.0"); assert.equal(workspace.includes("pe-sequential-authority"), false); diff --git a/tests/shared-health-runtime/isolation.test.cjs b/tests/shared-health-runtime/isolation.test.cjs index 59a715e9..99f91f0f 100644 --- a/tests/shared-health-runtime/isolation.test.cjs +++ b/tests/shared-health-runtime/isolation.test.cjs @@ -46,9 +46,9 @@ test("shipping packages and the frozen SDK surfaces do not import the runtime", const cli = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-cli/package.json"), "utf8")); const sdk = JSON.parse(fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk/package.json"), "utf8")); const python = fs.readFileSync(path.join(ROOT, "packages/vantio-agent-sdk-py/pyproject.toml"), "utf8"); - assert.equal(cli.version, "0.3.24"); + assert.equal(cli.version, "0.3.25"); assert.equal(sdk.version, "0.2.4"); - assert.match(python, /version = "3.1.0"/); + assert.match(python, /version = "3.1.1"/); const workspace = fs.readFileSync(path.join(ROOT, "pnpm-workspace.yaml"), "utf8"); assert.equal(workspace.includes("shared-health-runtime"), false); }); From 1c15407f82f7d43548e9fb0b3a3cf037b0ba70b7 Mon Sep 17 00:00:00 2001 From: Vantio Date: Wed, 30 Sep 2026 08:02:19 -0400 Subject: [PATCH 3/4] fix(gate-mcp): ignore caller hosts for VANTIO_API_KEY gate_get_policy and gate_residual_risk no longer take api_base. The key goes to VANTIO_API_BASE, or https://api.vantio.ai when that variable is unset or blank. Source candidate only. Not published. P1 and P2-P5 stay on this pull request. Cherry-picking P2-P5 onto the pre-P1 base conflicts, and this branch is not rewritten. --- docs/governance/canonical/environment.md | 2 +- docs/governance/changelogs/gate-mcp.md | 2 +- .../optics-audit-p1/05-SPLIT-DECISION.md | 47 +++++++ packages/vantio-gate-mcp/CHANGELOG.md | 1 + packages/vantio-gate-mcp/README.md | 2 + packages/vantio-gate-mcp/server.json | 2 +- packages/vantio-gate-mcp/src/policy.js | 22 ++-- packages/vantio-gate-mcp/src/server.js | 24 ++-- .../test/api_base_host.test.js | 120 ++++++++++++++++++ .../vantio-gate-mcp/test/api_key_env.test.js | 25 ++-- 10 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 docs/planning/optics-audit-p1/05-SPLIT-DECISION.md create mode 100644 packages/vantio-gate-mcp/test/api_base_host.test.js 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 faee7cd9..48f5402e 100644 --- a/docs/governance/changelogs/gate-mcp.md +++ b/docs/governance/changelogs/gate-mcp.md @@ -6,7 +6,7 @@ This heading exists so a documentation release can require a changelog entry for 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. Host matching uses a DNS suffix. `dry_run: false` names `BLOCKED_*` actions. `dry_run: true` keeps the `DRY_RUN_` prefix. The tools still do not block network traffic. +`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. Host matching uses a DNS suffix. `dry_run: false` names `BLOCKED_*` actions. `dry_run: true` keeps the `DRY_RUN_` prefix. The tools still do not block network traffic. ## 0.1.0 diff --git a/docs/planning/optics-audit-p1/05-SPLIT-DECISION.md b/docs/planning/optics-audit-p1/05-SPLIT-DECISION.md new file mode 100644 index 00000000..e37cf1d5 --- /dev/null +++ b/docs/planning/optics-audit-p1/05-SPLIT-DECISION.md @@ -0,0 +1,47 @@ +# PR split decision for optics audit remediation + +Audience: review of pull request 143. + +Status: one pull request. `split_done`: false. + +Publication: `CANDIDATE_ONLY_NOT_FOR_PUBLICATION`. + +## What was checked + +Pull request 143 is two commits on `cursor/optics-audit-p1-security-f25c`, based on `dc3f96d5abd9ede2537e09329b20c2bb60dd3a2e`. + +- `1bbed9021c9ceb494b34d1d3ab4d625d5e7c32f3` is the P1 security fix. +- `bab3107ce1478557c41818dc636d2d08ba3f8dd8` is the P2–P5 remediation, stacked on that P1 commit. + +A clean split would put P1 on a review pull request and leave P2–P5 on 143, or move P2–P5 to a follow-on. That needs either a history rewrite of 143, which this work is not allowed to do, or a cherry-pick of the P2–P5 commit onto the pre-P1 base. + +## Cherry-pick result + +`git cherry-pick bab3107ce1478557c41818dc636d2d08ba3f8dd8` onto `dc3f96d5abd9ede2537e09329b20c2bb60dd3a2e` stopped with conflicts in: + +- `.github/workflows/ci.yml` +- `deploy/docker/Dockerfile.observe` +- `deploy/docker/compose.observe.yml` +- `docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md` (modified in the P2–P5 commit, absent on the pre-P1 base) + +`packages/vantio-gate-mcp/src/policy.js` auto-merged. That file's P2–P5 host-matching edit sits on the P1 API-key edit, so a split still has to be checked by hand even where git does not stop. + +The probe worktree was removed. No branch was rewritten. + +## Decision + +Keep one pull request. Kate can review it in the sections below. The `api_base` remediation stays with the P1 security work in this same branch, because it closes a residual on the gate-mcp fetch tools that the P1 council named. + +### Section: P1 security + +Installer stage symlink refusal, effective uid 0 for live mutations, observe image pin and `vantio run node`, and `VANTIO_API_KEY` from the environment only. + +### Section: api_base remediation + +`gate_get_policy` and `gate_residual_risk` do not take `api_base`. The host that receives `VANTIO_API_KEY` is `VANTIO_API_BASE`, or `https://api.vantio.ai` when that variable is unset or blank. A caller-supplied host is ignored. + +### Section: P2–P5 + +Ingest URL failure reporting, observation fail-open without a key, Python ingest fields, `VANTIO_HOME` in CLI readers, streaming byte counts, `http.client` status, concurrent `shield()` run files, gate-mcp DNS suffix and `DRY_RUN_` labels, and the documentation and CI notes already on this branch. + +Source candidates stay unpublished: `@vantio/cli` `0.3.25`, `vantio-agent-sdk` `3.1.1`, `@vantio/gate-mcp` `0.1.1`. diff --git a/packages/vantio-gate-mcp/CHANGELOG.md b/packages/vantio-gate-mcp/CHANGELOG.md index e95ba292..16fe38fe 100644 --- a/packages/vantio-gate-mcp/CHANGELOG.md +++ b/packages/vantio-gate-mcp/CHANGELOG.md @@ -5,3 +5,4 @@ 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..d388ccd7 100644 --- a/packages/vantio-gate-mcp/README.md +++ b/packages/vantio-gate-mcp/README.md @@ -37,6 +37,8 @@ Cursor / Claude Desktop: | `gate_evaluate` | Dry-run host / size / spend decision | | `gate_get_policy` | Fetch policy (needs API key) | | `gate_residual_risk` | Enforcement-gap ledger | + +`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_normalize_policy` | Coerce policy to canonical schema | | `gate_explain` | Fence + rules that stick | | `gate_upgrade_path` | Optics → Phantom Engine → Enterprise | diff --git a/packages/vantio-gate-mcp/server.json b/packages/vantio-gate-mcp/server.json index 85df6a71..331d95a5 100644 --- a/packages/vantio-gate-mcp/server.json +++ b/packages/vantio-gate-mcp/server.json @@ -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 38658064..0c545bbf 100644 --- a/packages/vantio-gate-mcp/src/policy.js +++ b/packages/vantio-gate-mcp/src/policy.js @@ -163,9 +163,17 @@ export function evaluateRequest(policyRaw, req) { }; } -export async function fetchCloudConfig({ - apiBase = process.env.VANTIO_API_BASE || "https://api.vantio.ai", -} = {}) { +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 { @@ -175,7 +183,7 @@ export async function fetchCloudConfig({ policy: DEFAULT_POLICY, }; } - const base = apiBase.replace(/\/$/, ""); + const base = controlPlaneBase(); const res = await fetch(`${base}/api/v1/config`, { headers: { "x-vantio-identity": key, @@ -199,9 +207,7 @@ export async function fetchCloudConfig({ }; } -export async function fetchResidualRisk({ - apiBase = process.env.VANTIO_API_BASE || "https://api.vantio.ai", -} = {}) { +export async function fetchResidualRisk() { const key = process.env.VANTIO_API_KEY; if (!key) { return { @@ -210,7 +216,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 e8156593..aa992d28 100644 --- a/packages/vantio-gate-mcp/src/server.js +++ b/packages/vantio-gate-mcp/src/server.js @@ -64,14 +64,10 @@ export function createGateMcpServer() { server.tool( "gate_get_policy", - "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY in the environment. Read-only.", - { - api_base: z.string().optional().describe("Override VANTIO_API_BASE"), - }, - async ({ api_base }) => { - const result = await fetchCloudConfig({ - 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", @@ -85,14 +81,10 @@ export function createGateMcpServer() { server.tool( "gate_residual_risk", - "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY in the environment. Read-only.", - { - api_base: z.string().optional(), - }, - async ({ api_base }) => { - const result = await fetchResidualRisk({ - 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 index 704d400c..5d4174e1 100644 --- a/packages/vantio-gate-mcp/test/api_key_env.test.js +++ b/packages/vantio-gate-mcp/test/api_key_env.test.js @@ -39,18 +39,21 @@ test("fetchCloudConfig sends the environment key and ignores a tool argument", a }; try { await withEnv("VANTIO_API_KEY", "from-env", async () => { - const result = await fetchCloudConfig({ - apiKey: "from-tool", - apiBase: "https://example.test", + await withEnv("VANTIO_API_BASE", undefined, async () => { + const result = await fetchCloudConfig({ + apiKey: "from-tool", + apiBase: "https://example.test", + }); + assert.equal(result.ok, true); }); - 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://example.test/"), true); + 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 () => { @@ -82,14 +85,18 @@ test("fetchResidualRisk sends the environment key and ignores a tool argument", }; try { await withEnv("VANTIO_API_KEY", "from-env", async () => { - const result = await fetchResidualRisk({ - apiKey: "from-tool", - apiBase: "https://example.test", + await withEnv("VANTIO_API_BASE", undefined, async () => { + const result = await fetchResidualRisk({ + apiKey: "from-tool", + apiBase: "https://example.test", + }); + assert.equal(result.ok, true); }); - 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); }); From addf777531757fbe987aeb9b8424342af35d8129 Mon Sep 17 00:00:00 2001 From: Vantio Date: Wed, 30 Sep 2026 08:05:01 -0400 Subject: [PATCH 4/4] docs(optics): record api_base council on the reviewed tip Independent read of 1c15407 is PASS_WITH_NONBLOCKING_NOTES. The tool argument cannot choose the host for VANTIO_API_KEY. This record is not a merge and not a kvantio approval. --- .../optics-audit-p1/04-INDEPENDENT-COUNCIL.md | 2 + .../optics-audit-p1/06-API-BASE-COUNCIL.md | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 docs/planning/optics-audit-p1/06-API-BASE-COUNCIL.md diff --git a/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md index 38a1d2b0..8721c40b 100644 --- a/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md +++ b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md @@ -1,5 +1,7 @@ # Optics audit P1 independent council +The `api_base` residual named in this file is closed on tip `1c15407f82f7d43548e9fb0b3a3cf037b0ba70b7`. That later review is `06-API-BASE-COUNCIL.md`. The verdict below still reviews tip `1bbed9021c9ceb494b34d1d3ab4d625d5e7c32f3`. + Audience: review of source changes on this branch. Status: `PASS_WITH_NONBLOCKING_NOTES` diff --git a/docs/planning/optics-audit-p1/06-API-BASE-COUNCIL.md b/docs/planning/optics-audit-p1/06-API-BASE-COUNCIL.md new file mode 100644 index 00000000..6b99dce9 --- /dev/null +++ b/docs/planning/optics-audit-p1/06-API-BASE-COUNCIL.md @@ -0,0 +1,47 @@ +# Optics audit api_base independent council + +Audience: review of source changes on this branch. + +Status: `PASS_WITH_NONBLOCKING_NOTES` + +`council_pass`: true + +`council_verdict`: `PASS_WITH_NONBLOCKING_NOTES` + +`merge_state`: `WAITING_FOR_AUTHORIZED_REVIEWER` + +`split_done`: false + +Publication: `CANDIDATE_ONLY_NOT_FOR_PUBLICATION`. No npm publish, no PyPI publish, no install.vantio.ai go-live. + +This verdict reviews tip `1c15407f82f7d43548e9fb0b3a3cf037b0ba70b7`. It is not a GitHub approval and it is not kvantio. kvantio still has to APPROVE before anyone merges. The commit that records this file is documentation of that review. + +## Packet + +| Finding | Result | Tests | +| --- | --- | --- | +| `gate_get_policy` and `gate_residual_risk` took `api_base` and sent `VANTIO_API_KEY` to that host | Closed on the reviewed tip. Both tools register an empty input schema and call the fetch helpers with no arguments. `controlPlaneBase` reads `VANTIO_API_BASE` only. Unset or blank uses `https://api.vantio.ai`. A caller object or string is unused. | `packages/vantio-gate-mcp/test/api_base_host.test.js`, `packages/vantio-gate-mcp/test/api_key_env.test.js` | + +Independent re-run of those two files: 10 pass, 0 fail. + +`tool_argument_can_redirect_key`: false + +## Residual + +An operator-set `VANTIO_API_BASE` still chooses the host that receives `VANTIO_API_KEY`. That variable is environment config, not a tool argument. + +The observe image pins `@vantio/cli@0.3.25` as a version string, not an image digest. + +Opening the stage parent follows intermediate path components. The stage entry itself is opened with `lstat` and `O_NOFOLLOW`. + +P1 and P2–P5 remain one pull request. Cherry-picking P2–P5 onto the pre-P1 base conflicts, and this branch was not rewritten. See `05-SPLIT-DECISION.md`. + +## Verdict + +| Field | Value | +| --- | --- | +| Council identity | independent read of tip `1c15407f82f7d43548e9fb0b3a3cf037b0ba70b7` | +| Reviewer | not kvantio | +| Date | 2026-09-30 | +| Result | `PASS_WITH_NONBLOCKING_NOTES` | +| Notes | The fetch tools do not take `api_base` or `api_key`. The registered handlers ignore a supplied `api_base`. The key stays on the environment host. Residuals above stay open. |