diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c169bbd8..16ba28c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,3 +161,35 @@ 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 + + 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/.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..eb84a58c 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.25 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..954af054 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.25 && vantio prove --format=md --list volumes: vantio-runs: diff --git a/deploy/docker/package.json b/deploy/docker/package.json new file mode 100644 index 00000000..ed21a40c --- /dev/null +++ b/deploy/docker/package.json @@ -0,0 +1,8 @@ +{ + "name": "vantio-optics-observe-example", + "private": true, + "description": "Tiny Node process for the Optics observe image. Run only under vantio run node.", + "scripts": { + "start": "node agent.js" + } +} diff --git a/deploy/docker/test_observe_example.py b/deploy/docker/test_observe_example.py new file mode 100644 index 00000000..904967af --- /dev/null +++ b/deploy/docker/test_observe_example.py @@ -0,0 +1,122 @@ +"""Contract for the Optics observe Docker example. + +The example must pin an exact CLI version, keep secrets out of the build +context, and start Node under ``vantio run`` so observation actually attaches. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +DOCKER = Path(__file__).resolve().parent +DOCKERFILE = DOCKER / "Dockerfile.observe" +COMPOSE = DOCKER / "compose.observe.yml" +IGNORE = DOCKER / ".dockerignore" +AGENT = DOCKER / "agent.js" + +_PIN = re.compile(r"@vantio/cli@([^\s\"']+)") +_EXACT = re.compile(r"^\d+\.\d+\.\d+$") +_ENTRYPOINT = re.compile(r"^ENTRYPOINT\s+(\[.*\])\s*$", re.M) +_CMD = re.compile(r"^CMD\s+(\[.*\])\s*$", re.M) +_SECRET_LINES = ( + "**/.env", + "**/.env.*", + "**/.git", + "**/.git/**", + "**/.ssh", + "**/.ssh/**", + "**/*.pem", + "**/*.key", + "**/id_rsa", + "**/*credentials*", + "**/secrets", + "**/secrets/**", + "**/.npmrc", +) + + +class ObserveExampleTests(unittest.TestCase): + def test_cli_pin_is_exact_and_matches_the_tree(self) -> None: + cli = (DOCKER.parents[1] / "packages" / "vantio-cli" / "package.json").read_text(encoding="utf-8") + version = re.search(r'"version":\s*"([^"]+)"', cli) + self.assertIsNotNone(version) + expected = version.group(1) + pins = [] + for path in (DOCKERFILE, COMPOSE): + for match in _PIN.finditer(path.read_text(encoding="utf-8")): + pins.append((path.name, match.group(1))) + self.assertTrue(pins, "the observe example does not pin @vantio/cli") + for name, pin in pins: + self.assertNotIn("^", pin, name) + self.assertNotIn("~", pin, name) + self.assertIsNotNone(_EXACT.fullmatch(pin), f"{name} pin {pin} is not exact") + self.assertEqual(pin, expected, name) + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + self.assertNotIn("@vantio/cli@^", dockerfile) + self.assertNotIn("@vantio/cli@~", dockerfile) + self.assertNotRegex(dockerfile, r"npm install -g @vantio/cli(\s|$)") + + def test_default_command_runs_node_under_vantio_run(self) -> None: + text = DOCKERFILE.read_text(encoding="utf-8") + entry = _ENTRYPOINT.search(text) + cmd = _CMD.search(text) + self.assertIsNotNone(entry) + self.assertIsNotNone(cmd) + self.assertEqual(entry.group(1), '["vantio", "run", "node"]') + self.assertNotIn('"npm"', cmd.group(1)) + self.assertIn("agent.js", cmd.group(1)) + self.assertNotIn("VANTIO_OBSERVE", text) + agent = AGENT.read_text(encoding="utf-8") + self.assertNotIn("fetch(", agent) + self.assertNotIn("http.request", agent) + compose = COMPOSE.read_text(encoding="utf-8") + self.assertNotIn("VANTIO_HOOKS=0", compose) + self.assertNotIn("|| true", compose) + + def test_build_context_is_strict_and_excludes_secrets(self) -> None: + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + self.assertNotIn("COPY .", dockerfile) + self.assertIn("COPY package.json agent.js", dockerfile) + compose = COMPOSE.read_text(encoding="utf-8") + self.assertNotIn("../..", compose) + self.assertIn("context: .", compose) + self.assertTrue(IGNORE.is_file(), ".dockerignore is missing") + ignored = IGNORE.read_text(encoding="utf-8") + for line in _SECRET_LINES: + self.assertIn(line, ignored.splitlines(), line) + self.assertIn("\n*\n", f"\n{ignored}") + for name in (".env", ".env.local", "id_rsa", "secrets/token", ".git/config", ".ssh/id_rsa", "keys/app.pem"): + self.assertTrue(_docker_ignored(ignored, name), name) + self.assertFalse(_docker_ignored(ignored, "agent.js")) + self.assertFalse(_docker_ignored(ignored, "package.json")) + self.assertFalse(_docker_ignored(ignored, "Dockerfile.observe")) + + +def _docker_ignored(text: str, relpath: str) -> bool: + """Last-match dockerignore check for the patterns this example uses.""" + ignored = False + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + negate = line.startswith("!") + pattern = line[1:] if negate else line + if _docker_match(pattern, relpath): + ignored = not negate + return ignored + + +def _docker_match(pattern: str, relpath: str) -> bool: + if pattern == "*": + return "/" not in relpath + regex = re.escape(pattern).replace(r"\*\*/", "(?:.*/)?") + regex = regex.replace(r"\*\*", ".*").replace(r"\*", "[^/]*") + if pattern.startswith("**/"): + return re.fullmatch(regex, relpath) is not None + return re.fullmatch(regex, relpath) is not None or re.fullmatch(regex, relpath.split("/")[-1]) is not None + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/governance/VERSION-METADATA.json b/docs/governance/VERSION-METADATA.json index 5e332226..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/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/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..48f5402e 100644 --- a/docs/governance/changelogs/gate-mcp.md +++ b/docs/governance/changelogs/gate-mcp.md @@ -2,6 +2,12 @@ This heading exists so a documentation release can require a changelog entry for the version already in `packages/vantio-gate-mcp/package.json`. It does not bump that version. +## 0.1.1 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. Source version only. Not an npm release. + +`gate_get_policy` and `gate_residual_risk` read `VANTIO_API_KEY` from the environment. They do not take an `api_key` tool argument. They also do not take an `api_base` tool argument. The control-plane host is `VANTIO_API_BASE`, or `https://api.vantio.ai` when that variable is unset or blank. A caller-supplied host is ignored. 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 new file mode 100644 index 00000000..8721c40b --- /dev/null +++ b/docs/planning/optics-audit-p1/04-INDEPENDENT-COUNCIL.md @@ -0,0 +1,46 @@ +# 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` + +`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. + +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 + +| 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 | 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/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/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. | 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/CHANGELOG.md b/packages/vantio-gate-mcp/CHANGELOG.md new file mode 100644 index 00000000..16fe38fe --- /dev/null +++ b/packages/vantio-gate-mcp/CHANGELOG.md @@ -0,0 +1,8 @@ +# @vantio/gate-mcp changelog + +## 0.1.1 + +CANDIDATE_ONLY_NOT_FOR_PUBLICATION. This heading is source. It is not an npm release. + +- `gate_get_policy` and `gate_residual_risk` no longer take an `api_key` tool argument. The key is `VANTIO_API_KEY` from the environment. +- Those tools no longer take an `api_base` tool argument. The control-plane host is `VANTIO_API_BASE` from the environment, or `https://api.vantio.ai` when that variable is unset or blank. A caller-supplied host is ignored, so the tool cannot send `VANTIO_API_KEY` to a URL the caller chooses. diff --git a/packages/vantio-gate-mcp/README.md b/packages/vantio-gate-mcp/README.md index 01334590..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/package-lock.json b/packages/vantio-gate-mcp/package-lock.json index f5789fac..e6f006db 100644 --- a/packages/vantio-gate-mcp/package-lock.json +++ b/packages/vantio-gate-mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/packages/vantio-gate-mcp/package.json b/packages/vantio-gate-mcp/package.json index ffcc3f1e..049adc7e 100644 --- a/packages/vantio-gate-mcp/package.json +++ b/packages/vantio-gate-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "mcpName": "io.github.vantioai/vantio-gate", "description": "@vantio/gate-mcp is a legacy compatibility package for Phantom Engine application-path enforcement dry-run. Gate is not a separate Vantio product or subscription.", "license": "MIT", diff --git a/packages/vantio-gate-mcp/server.json b/packages/vantio-gate-mcp/server.json index 21be724b..331d95a5 100644 --- a/packages/vantio-gate-mcp/server.json +++ b/packages/vantio-gate-mcp/server.json @@ -9,12 +9,12 @@ "source": "github", "subdirectory": "packages/vantio-gate-mcp" }, - "version": "0.1.0", + "version": "0.1.1", "packages": [ { "registryType": "npm", "identifier": "@vantio/gate-mcp", - "version": "0.1.0", + "version": "0.1.1", "transport": { "type": "stdio" }, @@ -28,7 +28,7 @@ }, { "name": "VANTIO_API_BASE", - "description": "Optional Phantom Engine API base URL (default https://api.vantio.ai)", + "description": "Optional Phantom Engine API base URL (default https://api.vantio.ai). Environment only. The fetch tools do not take a host argument.", "isRequired": false, "format": "string", "isSecret": false diff --git a/packages/vantio-gate-mcp/src/policy.js b/packages/vantio-gate-mcp/src/policy.js index e6fc0131..0c545bbf 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}`, }); } @@ -148,20 +163,27 @@ export function evaluateRequest(policyRaw, req) { }; } -export async function fetchCloudConfig({ - apiKey, - apiBase = process.env.VANTIO_API_BASE || "https://api.vantio.ai", -} = {}) { - const key = apiKey || process.env.VANTIO_API_KEY; +const DEFAULT_CONTROL_PLANE_BASE = "https://api.vantio.ai"; + +// Host that receives VANTIO_API_KEY. Environment only. Arguments are ignored. +export function controlPlaneBase() { + const raw = process.env.VANTIO_API_BASE; + if (typeof raw !== "string") return DEFAULT_CONTROL_PLANE_BASE; + const trimmed = raw.trim().replace(/\/+$/, ""); + return trimmed || DEFAULT_CONTROL_PLANE_BASE; +} + +export async function fetchCloudConfig() { + const key = process.env.VANTIO_API_KEY; if (!key) { return { ok: false, error: "missing_api_key", - hint: "Set VANTIO_API_KEY or pass api_key. Free Optics needs no key; Phantom Engine control-plane config requires a key.", + hint: "Set VANTIO_API_KEY. Free Optics needs no key; Phantom Engine control-plane config requires a key.", policy: DEFAULT_POLICY, }; } - const base = apiBase.replace(/\/$/, ""); + const base = controlPlaneBase(); const res = await fetch(`${base}/api/v1/config`, { headers: { "x-vantio-identity": key, @@ -185,11 +207,8 @@ export async function fetchCloudConfig({ }; } -export async function fetchResidualRisk({ - apiKey, - apiBase = process.env.VANTIO_API_BASE || "https://api.vantio.ai", -} = {}) { - const key = apiKey || process.env.VANTIO_API_KEY; +export async function fetchResidualRisk() { + const key = process.env.VANTIO_API_KEY; if (!key) { return { ok: false, @@ -197,7 +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 f4b5ebe1..aa992d28 100644 --- a/packages/vantio-gate-mcp/src/server.js +++ b/packages/vantio-gate-mcp/src/server.js @@ -39,7 +39,7 @@ const policyShape = z export function createGateMcpServer() { const server = new McpServer({ name: "vantio-gate", - version: "0.1.0", + version: "0.1.1", }); server.tool( @@ -64,16 +64,10 @@ export function createGateMcpServer() { server.tool( "gate_get_policy", - "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY. Read-only.", - { - api_key: z.string().optional().describe("Override VANTIO_API_KEY"), - api_base: z.string().optional().describe("Override VANTIO_API_BASE"), - }, - async ({ api_key, api_base }) => { - const result = await fetchCloudConfig({ - apiKey: api_key, - apiBase: api_base, - }); + "Fetch current tenant policy from the Phantom Engine control plane. Requires VANTIO_API_KEY in the environment. The host is VANTIO_API_BASE, or https://api.vantio.ai when that is unset. Read-only.", + {}, + async () => { + const result = await fetchCloudConfig(); if (!result.ok) return err(JSON.stringify(result, null, 2)); return text({ plane: "Enforce", @@ -87,16 +81,10 @@ export function createGateMcpServer() { server.tool( "gate_residual_risk", - "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY. Read-only.", - { - api_key: z.string().optional(), - api_base: z.string().optional(), - }, - async ({ api_key, api_base }) => { - const result = await fetchResidualRisk({ - apiKey: api_key, - apiBase: api_base, - }); + "Fetch residual-risk / dry-run / enforcement-gap ledger. Requires VANTIO_API_KEY in the environment. The host is VANTIO_API_BASE, or https://api.vantio.ai when that is unset. Read-only.", + {}, + async () => { + const result = await fetchResidualRisk(); if (!result.ok) return err(JSON.stringify(result, null, 2)); return text({ plane: "Enforce", diff --git a/packages/vantio-gate-mcp/test/api_base_host.test.js b/packages/vantio-gate-mcp/test/api_base_host.test.js new file mode 100644 index 00000000..901b3441 --- /dev/null +++ b/packages/vantio-gate-mcp/test/api_base_host.test.js @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { fetchCloudConfig, fetchResidualRisk } from "../src/policy.js"; +import { createGateMcpServer } from "../src/server.js"; + +const root = dirname(fileURLToPath(import.meta.url)); +const serverSrc = readFileSync(join(root, "../src/server.js"), "utf8"); + +function withEnv(pairs, fn) { + const previous = new Map(); + for (const [name, value] of Object.entries(pairs)) { + previous.set(name, process.env[name]); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + return Promise.resolve() + .then(fn) + .finally(() => { + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); +} + +async function captureFetch(fn) { + const seen = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + seen.push({ url: String(url), opts }); + return { + ok: true, + status: 200, + json: async () => ({ tier: "phantom", policy: { enforce: false }, gaps: [] }), + }; + }; + try { + await fn(); + } finally { + globalThis.fetch = original; + } + return seen; +} + +test("gate_get_policy and gate_residual_risk do not take an api_base tool argument", () => { + assert.doesNotMatch(serverSrc, /api_base\s*:/); + assert.doesNotMatch(serverSrc, /apiBase\s*:/); +}); + +test("fetchCloudConfig does not send VANTIO_API_KEY to a caller-supplied host", async () => { + const seen = await captureFetch(() => + withEnv( + { VANTIO_API_KEY: "from-env", VANTIO_API_BASE: "https://api.vantio.ai" }, + () => fetchCloudConfig({ apiBase: "https://evil.example/steal" }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); + +test("fetchResidualRisk does not send VANTIO_API_KEY to a caller-supplied host", async () => { + const seen = await captureFetch(() => + withEnv( + { VANTIO_API_KEY: "from-env", VANTIO_API_BASE: "https://control.example.test" }, + () => fetchResidualRisk({ apiBase: "https://evil.example/steal" }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://control.example.test/"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); + +test("an unset VANTIO_API_BASE stays on the default host when a caller passes apiBase", async () => { + const seen = await captureFetch(() => + withEnv({ VANTIO_API_KEY: "from-env", VANTIO_API_BASE: undefined }, () => + fetchCloudConfig({ apiBase: "http://127.0.0.1:9" }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/"), true); + assert.equal(seen[0].url.includes("127.0.0.1"), false); +}); + +test("registered fetch tools drop api_base before the key is sent", async () => { + const server = createGateMcpServer(); + for (const name of ["gate_get_policy", "gate_residual_risk"]) { + const shape = server._registeredTools[name].inputSchema.shape; + assert.equal(Object.hasOwn(shape, "api_base"), false); + assert.equal(Object.hasOwn(shape, "api_key"), false); + } + const seen = await captureFetch(() => + withEnv({ VANTIO_API_KEY: "from-env", VANTIO_API_BASE: "https://api.vantio.ai" }, () => + server._registeredTools.gate_get_policy.handler({ + api_base: "https://evil.example/steal", + }), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); + +test("a blank VANTIO_API_BASE stays on the default host when a caller passes apiBase", async () => { + const seen = await captureFetch(() => + withEnv({ VANTIO_API_KEY: "from-env", VANTIO_API_BASE: " " }, () => + fetchResidualRisk("https://evil.example"), + ), + ); + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(seen[0].url.startsWith("https://api.vantio.ai/api/v1/residual-risk"), true); + assert.equal(seen[0].url.includes("evil.example"), false); +}); diff --git a/packages/vantio-gate-mcp/test/api_key_env.test.js b/packages/vantio-gate-mcp/test/api_key_env.test.js new file mode 100644 index 00000000..5d4174e1 --- /dev/null +++ b/packages/vantio-gate-mcp/test/api_key_env.test.js @@ -0,0 +1,102 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { fetchCloudConfig, fetchResidualRisk } from "../src/policy.js"; + +const root = dirname(fileURLToPath(import.meta.url)); +const serverSrc = readFileSync(join(root, "../src/server.js"), "utf8"); + +function withEnv(name, value, fn) { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + return Promise.resolve() + .then(fn) + .finally(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); +} + +test("gate tool schemas do not accept an api_key argument", () => { + assert.doesNotMatch(serverSrc, /api_key\s*:/); + assert.doesNotMatch(serverSrc, /apiKey\s*:/); + assert.doesNotMatch(serverSrc, /pass api_key/); +}); + +test("fetchCloudConfig sends the environment key and ignores a tool argument", async () => { + const seen = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + seen.push({ url, opts }); + return { + ok: true, + status: 200, + json: async () => ({ tier: "phantom", policy: { enforce: false } }), + }; + }; + try { + await withEnv("VANTIO_API_KEY", "from-env", async () => { + await withEnv("VANTIO_API_BASE", undefined, async () => { + const result = await fetchCloudConfig({ + apiKey: "from-tool", + apiBase: "https://example.test", + }); + assert.equal(result.ok, true); + }); + }); + } finally { + globalThis.fetch = original; + } + assert.equal(seen.length, 1); + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(String(seen[0].url).startsWith("https://api.vantio.ai/"), true); + assert.equal(String(seen[0].url).includes("example.test"), false); +}); + +test("fetchCloudConfig does not use a tool api key when the environment is empty", async () => { + let called = false; + const original = globalThis.fetch; + globalThis.fetch = async () => { + called = true; + throw new Error("fetch should not run"); + }; + try { + await withEnv("VANTIO_API_KEY", undefined, async () => { + const result = await fetchCloudConfig({ apiKey: "from-tool" }); + assert.equal(result.ok, false); + assert.equal(result.error, "missing_api_key"); + assert.doesNotMatch(result.hint || "", /pass api_key/); + }); + } finally { + globalThis.fetch = original; + } + assert.equal(called, false); +}); + +test("fetchResidualRisk sends the environment key and ignores a tool argument", async () => { + const seen = []; + const original = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + seen.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ gaps: [] }) }; + }; + try { + await withEnv("VANTIO_API_KEY", "from-env", async () => { + await withEnv("VANTIO_API_BASE", undefined, async () => { + const result = await fetchResidualRisk({ + apiKey: "from-tool", + apiBase: "https://example.test", + }); + assert.equal(result.ok, true); + }); + }); + } finally { + globalThis.fetch = original; + } + assert.equal(seen[0].opts.headers["x-vantio-identity"], "from-env"); + assert.equal(String(seen[0].url).startsWith("https://api.vantio.ai/"), true); + assert.equal(String(seen[0].url).includes("example.test"), false); +}); diff --git a/packages/vantio-gate-mcp/test/brand.test.js b/packages/vantio-gate-mcp/test/brand.test.js index 4451dfdd..9cb459a0 100644 --- a/packages/vantio-gate-mcp/test/brand.test.js +++ b/packages/vantio-gate-mcp/test/brand.test.js @@ -7,7 +7,7 @@ * - gate_upgrade_path description is Optics → Phantom Engine → Enterprise (not Optics → Gate → …) * - no Gate $499 / Gate Pro / hosted Gate / four-product ladder anywhere * - package name preserved as @vantio/gate-mcp - * - version preserved as 0.1.0 + * - source candidate version is 0.1.1 (not a registry publish) * - all required tool names present * - schema / evaluate behavior preserved */ @@ -40,8 +40,9 @@ test("package name preserved as @vantio/gate-mcp", () => { assert.equal(pkg.name, "@vantio/gate-mcp"); }); -test("version preserved as 0.1.0", () => { - assert.equal(pkg.version, "0.1.0"); +test("source candidate version is 0.1.1 and is not a registry publish", () => { + assert.equal(pkg.version, "0.1.1"); + assert.equal(serverMeta.version, "0.1.1"); }); test("package homepage points to /phantom not /gate", () => { diff --git a/packages/vantio-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/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/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/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", + ) 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); });