From ed8e39f0035a69164297c8ba4289fe6fdd6f5484 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 29 Jul 2026 16:19:41 -0700 Subject: [PATCH 1/3] refactor: adopt bounded query naming Rename configuration, commands, images, services, paths, types, tests, and documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ca8d9d74-46ab-48db-b05f-640cbc6d47be --- .github/workflows/release.yml | 78 ++++---- CLAUDE.md | 16 +- README.md | 1 + containers/agent/Dockerfile | 6 +- ...be-wrapper.sh => bounded-query-wrapper.sh} | 20 +- containers/agent/entrypoint.sh | 50 ++--- .../Dockerfile | 42 ++--- .../broker/audit.js | 10 +- .../broker/broker.js | 28 +-- .../broker/config.js | 62 +++---- .../broker/framing.js | 10 +- .../broker/healthcheck.js | 2 +- .../broker/ledger.js | 4 +- .../broker/protocol.js | 28 +-- .../broker/query-runner.js} | 78 ++++---- .../broker/scheduler.js | 4 +- .../broker/sensitivity.js | 10 +- .../broker/server.js | 14 +- .../broker/workspace.js | 48 ++--- .../query-entrypoint.py} | 8 +- .../query-seccomp.json} | 2 +- docs/awf-config-spec.md | 130 ++++++------- docs/awf-config.schema.json | 18 +- .../bounded-queries.md | 70 +++---- src/awf-config-schema.json | 18 +- .../broker.test.ts | 160 ++++++++-------- .../end-to-end.test.ts | 38 ++-- .../ledger.test.ts | 2 +- .../manager.test.ts | 132 +++++++------- .../manager.ts | 112 ++++++------ src/bounded-query/naming.test.ts | 41 +++++ .../paths.test.ts | 22 +-- src/{sealed-probe => bounded-query}/paths.ts | 62 +++---- .../preflight.test.ts | 88 ++++----- src/bounded-query/preflight.ts | 168 +++++++++++++++++ .../protocol-parity.test.ts | 52 +++--- .../protocol.test.ts | 122 ++++++------- .../protocol.ts | 84 ++++----- .../query-seccomp.test.ts} | 22 +-- .../scheduler.test.ts | 4 +- .../skill.test.ts | 24 +-- src/{sealed-probe => bounded-query}/skill.ts | 48 ++--- .../staging.test.ts | 40 ++-- .../staging.ts | 80 ++++---- src/{sealed-probe => bounded-query}/types.ts | 26 +-- .../workflow-integration.test.ts | 42 ++--- .../wrapper.test.ts | 18 +- src/cli-workflow.ts | 18 +- src/commands/build-config.test.ts | 16 +- src/commands/build-config.ts | 6 +- src/commands/main-action.ts | 6 +- ...g-file-bounded-queries-validation.test.ts} | 72 ++++---- src/config-file-loading.test.ts | 6 +- src/config-file-mapping.test.ts | 12 +- src/config-file.ts | 4 +- src/config-mapper.ts | 4 +- src/constants.ts | 2 +- src/container-lifecycle.ts | 4 +- src/container-start.test.ts | 2 +- src/image-tag.test.ts | 11 +- src/image-tag.ts | 2 +- ...r.test.ts => bounded-query-parser.test.ts} | 52 +++--- ...robe-parser.ts => bounded-query-parser.ts} | 50 ++--- src/schema.test.ts | 2 +- src/sealed-probe/preflight.ts | 168 ----------------- .../agent-environment/excluded-vars.test.ts | 10 +- .../agent-environment/excluded-vars.ts | 6 +- src/services/agent-volumes/docker-socket.ts | 2 +- ....test.ts => bounded-query-compose.test.ts} | 68 +++---- ....test.ts => bounded-query-service.test.ts} | 122 ++++++------- ...be-service.ts => bounded-query-service.ts} | 172 +++++++++--------- src/services/optional-services.ts | 28 +-- ...be-options.ts => bounded-query-options.ts} | 70 +++---- src/types/index.ts | 20 +- src/types/wrapper-config.ts | 4 +- ...est.ts => bounded-query-isolation.test.ts} | 32 ++-- 76 files changed, 1583 insertions(+), 1532 deletions(-) rename containers/agent/{sealed-probe-wrapper.sh => bounded-query-wrapper.sh} (87%) rename containers/{sealed-probe => bounded-query}/Dockerfile (66%) rename containers/{sealed-probe => bounded-query}/broker/audit.js (83%) rename containers/{sealed-probe => bounded-query}/broker/broker.js (91%) rename containers/{sealed-probe => bounded-query}/broker/config.js (67%) rename containers/{sealed-probe => bounded-query}/broker/framing.js (95%) rename containers/{sealed-probe => bounded-query}/broker/healthcheck.js (87%) rename containers/{sealed-probe => bounded-query}/broker/ledger.js (92%) rename containers/{sealed-probe => bounded-query}/broker/protocol.js (96%) rename containers/{sealed-probe/broker/probe-runner.js => bounded-query/broker/query-runner.js} (64%) rename containers/{sealed-probe => bounded-query}/broker/scheduler.js (96%) rename containers/{sealed-probe => bounded-query}/broker/sensitivity.js (67%) rename containers/{sealed-probe => bounded-query}/broker/server.js (92%) rename containers/{sealed-probe => bounded-query}/broker/workspace.js (75%) rename containers/{sealed-probe/probe-entrypoint.py => bounded-query/query-entrypoint.py} (74%) rename containers/{sealed-probe/probe-seccomp.json => bounded-query/query-seccomp.json} (98%) rename docs-site/src/content/docs/guides/sealed-probes.md => docs/bounded-queries.md (79%) rename src/{sealed-probe => bounded-query}/broker.test.ts (87%) rename src/{sealed-probe => bounded-query}/end-to-end.test.ts (89%) rename src/{sealed-probe => bounded-query}/ledger.test.ts (99%) rename src/{sealed-probe => bounded-query}/manager.test.ts (57%) rename src/{sealed-probe => bounded-query}/manager.ts (63%) create mode 100644 src/bounded-query/naming.test.ts rename src/{sealed-probe => bounded-query}/paths.test.ts (79%) rename src/{sealed-probe => bounded-query}/paths.ts (68%) rename src/{sealed-probe => bounded-query}/preflight.test.ts (56%) create mode 100644 src/bounded-query/preflight.ts rename src/{sealed-probe => bounded-query}/protocol-parity.test.ts (91%) rename src/{sealed-probe => bounded-query}/protocol.test.ts (85%) rename src/{sealed-probe => bounded-query}/protocol.ts (92%) rename src/{sealed-probe/probe-seccomp.test.ts => bounded-query/query-seccomp.test.ts} (77%) rename src/{sealed-probe => bounded-query}/scheduler.test.ts (98%) rename src/{sealed-probe => bounded-query}/skill.test.ts (82%) rename src/{sealed-probe => bounded-query}/skill.ts (85%) rename src/{sealed-probe => bounded-query}/staging.test.ts (92%) rename src/{sealed-probe => bounded-query}/staging.ts (83%) rename src/{sealed-probe => bounded-query}/types.ts (66%) rename src/{sealed-probe => bounded-query}/workflow-integration.test.ts (67%) rename src/{sealed-probe => bounded-query}/wrapper.test.ts (94%) rename src/{config-file-sealed-probes-validation.test.ts => config-file-bounded-queries-validation.test.ts} (51%) rename src/parsers/{sealed-probe-parser.test.ts => bounded-query-parser.test.ts} (62%) rename src/parsers/{sealed-probe-parser.ts => bounded-query-parser.ts} (59%) delete mode 100644 src/sealed-probe/preflight.ts rename src/services/{sealed-probe-compose.test.ts => bounded-query-compose.test.ts} (61%) rename src/services/{sealed-probe-service.test.ts => bounded-query-service.test.ts} (62%) rename src/services/{sealed-probe-service.ts => bounded-query-service.ts} (60%) rename src/types/{sealed-probe-options.ts => bounded-query-options.ts} (60%) rename tests/integration/{sealed-probe-isolation.test.ts => bounded-query-isolation.test.ts} (76%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e51af8e7..095b26e02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -349,15 +349,15 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/cli-proxy@${{ steps.build_cli_proxy.outputs.digest }} - # Build the minimal probe sandbox and trusted broker from separate Dockerfile + # Build the minimal query sandbox and trusted broker from separate Dockerfile # targets. The runtime pulls both before the offline broker starts. - build-sealed-probe: - name: Build Sealed Probe Image + build-bounded-query: + name: Build Bounded Query Image runs-on: ubuntu-latest needs: bump-version outputs: - probe_digest: ${{ steps.build_sealed_probe.outputs.digest }} - broker_digest: ${{ steps.build_sealed_probe_broker.outputs.digest }} + query_digest: ${{ steps.build_bounded_query.outputs.digest }} + broker_digest: ${{ steps.build_bounded_query_broker.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 @@ -382,71 +382,71 @@ jobs: - name: Install cosign uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 - - name: Build and push Sealed Probe image - id: build_sealed_probe + - name: Build and push Bounded Query image + id: build_bounded_query uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: - context: ./containers/sealed-probe - target: probe + context: ./containers/bounded-query + target: query push: true platforms: linux/amd64,linux/arm64 tags: | - ghcr.io/${{ github.repository }}/sealed-probe:${{ needs.bump-version.outputs.version_number }} - ghcr.io/${{ github.repository }}/sealed-probe:latest - cache-from: type=gha,scope=sealed-probe - cache-to: type=gha,mode=max,scope=sealed-probe + ghcr.io/${{ github.repository }}/bounded-query:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/bounded-query:latest + cache-from: type=gha,scope=bounded-query + cache-to: type=gha,mode=max,scope=bounded-query - - name: Sign Sealed Probe image with cosign + - name: Sign Bounded Query image with cosign run: | cosign sign --yes \ - ghcr.io/${{ github.repository }}/sealed-probe@${{ steps.build_sealed_probe.outputs.digest }} + ghcr.io/${{ github.repository }}/bounded-query@${{ steps.build_bounded_query.outputs.digest }} - - name: Generate SBOM for Sealed Probe image + - name: Generate SBOM for Bounded Query image uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 with: - image: ghcr.io/${{ github.repository }}/sealed-probe@${{ steps.build_sealed_probe.outputs.digest }} + image: ghcr.io/${{ github.repository }}/bounded-query@${{ steps.build_bounded_query.outputs.digest }} format: spdx-json - output-file: sealed-probe-sbom.spdx.json + output-file: bounded-query-sbom.spdx.json - - name: Attest SBOM for Sealed Probe image + - name: Attest SBOM for Bounded Query image run: | cosign attest --yes \ - --predicate sealed-probe-sbom.spdx.json \ + --predicate bounded-query-sbom.spdx.json \ --type spdxjson \ - ghcr.io/${{ github.repository }}/sealed-probe@${{ steps.build_sealed_probe.outputs.digest }} + ghcr.io/${{ github.repository }}/bounded-query@${{ steps.build_bounded_query.outputs.digest }} - - name: Build and push Sealed Probe Broker image - id: build_sealed_probe_broker + - name: Build and push Bounded Query Broker image + id: build_bounded_query_broker uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: - context: ./containers/sealed-probe + context: ./containers/bounded-query target: broker push: true platforms: linux/amd64,linux/arm64 tags: | - ghcr.io/${{ github.repository }}/sealed-probe-broker:${{ needs.bump-version.outputs.version_number }} - ghcr.io/${{ github.repository }}/sealed-probe-broker:latest - cache-from: type=gha,scope=sealed-probe-broker - cache-to: type=gha,mode=max,scope=sealed-probe-broker + ghcr.io/${{ github.repository }}/bounded-query-broker:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/bounded-query-broker:latest + cache-from: type=gha,scope=bounded-query-broker + cache-to: type=gha,mode=max,scope=bounded-query-broker - - name: Sign Sealed Probe Broker image with cosign + - name: Sign Bounded Query Broker image with cosign run: | cosign sign --yes \ - ghcr.io/${{ github.repository }}/sealed-probe-broker@${{ steps.build_sealed_probe_broker.outputs.digest }} + ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }} - - name: Generate SBOM for Sealed Probe Broker image + - name: Generate SBOM for Bounded Query Broker image uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 with: - image: ghcr.io/${{ github.repository }}/sealed-probe-broker@${{ steps.build_sealed_probe_broker.outputs.digest }} + image: ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }} format: spdx-json - output-file: sealed-probe-broker-sbom.spdx.json + output-file: bounded-query-broker-sbom.spdx.json - - name: Attest SBOM for Sealed Probe Broker image + - name: Attest SBOM for Bounded Query Broker image run: | cosign attest --yes \ - --predicate sealed-probe-broker-sbom.spdx.json \ + --predicate bounded-query-broker-sbom.spdx.json \ --type spdxjson \ - ghcr.io/${{ github.repository }}/sealed-probe-broker@${{ steps.build_sealed_probe_broker.outputs.digest }} + ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }} # Build agent-act image with catthehacker/ubuntu:act-24.04 base for GitHub Actions parity # amd64-only: catthehacker/ubuntu:act-24.04 does not publish arm64 manifests @@ -684,7 +684,7 @@ jobs: release: name: Create Release runs-on: ubuntu-latest - needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-sealed-probe, build-gh-aw-node] + needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-bounded-query, build-gh-aw-node] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 @@ -784,8 +784,8 @@ jobs: "ghcr.io/${{ github.repository }}/agent-act@${{ needs['build-agent-act'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/api-proxy@${{ needs['build-api-proxy'].outputs.digest }}" \ "ghcr.io/${{ github.repository }}/cli-proxy@${{ needs['build-cli-proxy'].outputs.digest }}" \ - "ghcr.io/${{ github.repository }}/sealed-probe@${{ needs['build-sealed-probe'].outputs.probe_digest }}" \ - "ghcr.io/${{ github.repository }}/sealed-probe-broker@${{ needs['build-sealed-probe'].outputs.broker_digest }}" \ + "ghcr.io/${{ github.repository }}/bounded-query@${{ needs['build-bounded-query'].outputs.query_digest }}" \ + "ghcr.io/${{ github.repository }}/bounded-query-broker@${{ needs['build-bounded-query'].outputs.broker_digest }}" \ "ghcr.io/${{ github.repository }}/gh-aw-node@${{ needs['build-gh-aw-node'].outputs.digest }}" \ > release/containers.txt echo "Generated containers.txt:" diff --git a/CLAUDE.md b/CLAUDE.md index 90c934df7..f95570caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,15 +28,15 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - Agent calls the sidecar with no auth (e.g., `http://172.30.0.30:10001` for Anthropic); sidecar injects the real key and forwards via Squid - Ports: 10000 (OpenAI), 10001 (Anthropic), 10002 (Copilot), 10003 (Gemini) — these are discrete ports, not a contiguous range -**4. Sealed-Probe Broker (optional)** — `containers/sealed-probe/`, no network -- Enabled via `sealedProbes.enabled` in the AWF config file (config-only; there is no CLI flag family) +**4. Bounded-Query Broker (optional)** — `containers/bounded-query/`, no network +- Enabled via `boundedQueries.enabled` in the AWF config file (config-only; there is no CLI flag family) - The only AWF service with `network_mode: none`: no `awf-net`, no external bridge, no DNS, no Squid, no host gateway -- Reachable only through one Unix socket in `/sealed-probes/run/`, bind-mounted into the agent at `/run/awf-sealed-probe/broker.sock` -- Receives the resolved Docker socket so it can launch per-invocation probe containers; that path never enters the agent's env or volumes -- The same image is used for the probe sandbox, which guarantees the probe image is already local (the broker cannot pull — it has no network) -- Probes run `python3` with `--network none`, `--read-only`, non-root, `--cap-drop ALL`, `no-new-privileges`, a seccomp profile, and time/memory/CPU/PID/file-size bounds -- Agent surface: `/usr/local/bin/sealed-probe` (from `containers/agent/sealed-probe-wrapper.sh`) plus a generated read-only `SKILL.md`; the wrapper always prints one canonical JSON line, writes nothing to stderr, and exits `0` -- Trusted host staging (`src/sealed-probe/staging.ts`) materializes an immutable seed per configured repo *before* the agent starts, using `GH_TOKEN`/`GITHUB_TOKEN` only in a child-process env — never in argv, a URL, a log, or the compose file +- Reachable only through one Unix socket in `/bounded-queries/run/`, bind-mounted into the agent at `/run/awf-bounded-query/broker.sock` +- Receives the resolved Docker socket so it can launch per-invocation query containers; that path never enters the agent's env or volumes +- The same image is used for the query sandbox, which guarantees the query image is already local (the broker cannot pull — it has no network) +- Queries run `python3` with `--network none`, `--read-only`, non-root, `--cap-drop ALL`, `no-new-privileges`, a seccomp profile, and time/memory/CPU/PID/file-size bounds +- Agent surface: `/usr/local/bin/bounded-query` (from `containers/agent/bounded-query-wrapper.sh`) plus a generated read-only `SKILL.md`; the wrapper always prints one canonical JSON line, writes nothing to stderr, and exits `0` +- Trusted host staging (`src/bounded-query/staging.ts`) materializes an immutable seed per configured repo *before* the agent starts, using `GH_TOKEN`/`GITHUB_TOKEN` only in a child-process env — never in argv, a URL, a log, or the compose file - See [docs/awf-config-spec.md](docs/awf-config-spec.md) §14 for the full model, including the two-bit disclosure bound and residual channels ### Documentation Files diff --git a/README.md b/README.md index e009de65f..86029e798 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su - [Usage guide](docs/usage.md) — CLI flags, domain allowlists, examples - [AWF config schema](docs/awf-config.schema.json) — machine-readable JSON Schema for JSON/YAML configs (also published as a [versioned release asset](https://github.com/github/gh-aw-firewall/releases/latest/download/awf-config.schema.json) for IDE autocomplete) - [AWF config spec](docs/awf-config-spec.md) — normative processing and precedence rules for tooling/compiler integration +- [Bounded queries](docs/bounded-queries.md) — run information-budgeted queries against private repositories without exposing their contents - [Audit log schema](schemas/audit.schema.json) — JSON Schema for L7 traffic audit records (`audit.jsonl`) - [Token usage schema](schemas/token-usage.schema.json) — JSON Schema for per-call token usage records (`token-usage.jsonl`) - [Schemas README](schemas/README.md) — versioning policy, record identification, and validation examples diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index f7785d83a..3d878fe69 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -273,15 +273,15 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \ # Copy iptables setup script, PID logger, API proxy health check, Claude key helper, # gh CLI proxy wrapper (used when --enable-cli-proxy is active), and the -# sealed-probe wrapper (installed as `sealed-probe` when sealed probes are enabled) +# bounded-query wrapper (installed as `bounded-query` when bounded queries are enabled) COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY pid-logger.sh /usr/local/bin/pid-logger.sh COPY api-proxy-health-check.sh /usr/local/bin/api-proxy-health-check.sh COPY get-claude-key.sh /usr/local/bin/get-claude-key.sh COPY gh-cli-proxy-wrapper.sh /usr/local/bin/gh-cli-proxy-wrapper.sh -COPY sealed-probe-wrapper.sh /usr/local/bin/sealed-probe-wrapper.sh -RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh /usr/local/bin/sealed-probe-wrapper.sh +COPY bounded-query-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh +RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh /usr/local/bin/bounded-query-wrapper.sh # Copy pre-built one-shot-token library from rust-builder stage # This prevents tokens from being read multiple times (e.g., by malicious code) diff --git a/containers/agent/sealed-probe-wrapper.sh b/containers/agent/bounded-query-wrapper.sh similarity index 87% rename from containers/agent/sealed-probe-wrapper.sh rename to containers/agent/bounded-query-wrapper.sh index dd5f05057..2ead94713 100755 --- a/containers/agent/sealed-probe-wrapper.sh +++ b/containers/agent/bounded-query-wrapper.sh @@ -1,9 +1,9 @@ #!/bin/sh -# /usr/local/bin/sealed-probe +# /usr/local/bin/bounded-query # -# Agent-facing sealed-probe CLI (protocol v2). +# Agent-facing bounded-query CLI (protocol v2). # -# Forwards a *narrow* request to the trusted sealed-probe broker over a +# Forwards a *narrow* request to the trusted bounded-query broker over a # dedicated Unix socket. It is analogous to gh-cli-proxy-wrapper.sh, but the # API is deliberately far narrower: this wrapper cannot express a command, an # image, a path, a URL, a ref, a mount, a runtime, a timeout, an environment, @@ -11,8 +11,8 @@ # # --repo owner/repo (exactly once) # --schema '' (exactly once; a finite response schema, see -# src/sealed-probe/protocol.ts) -# the probe script on stdin +# src/bounded-query/protocol.ts) +# the query script on stdin # # Output contract: exactly one line of canonical JSON on stdout, nothing on # stderr, and exit status 0 — for every outcome and for every failure. @@ -29,10 +29,10 @@ # image). CANONICAL_ERROR='{"status":"error"}' -SOCKET="${AWF_SEALED_PROBE_SOCKET:-/run/awf-sealed-probe/broker.sock}" +SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-/run/awf-bounded-query/broker.sock}" PROTOCOL_VERSION=2 -# Keep in sync with MAX_SCHEMA_BYTES in src/sealed-probe/protocol.ts and -# containers/sealed-probe/broker/protocol.js. +# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-query/protocol.ts and +# containers/bounded-query/broker/protocol.js. MAX_SCHEMA_BYTES=4096 emit_error() { @@ -101,11 +101,11 @@ RESPONSE=$( -X POST \ -H "Expect:" \ -H "Content-Type: application/octet-stream" \ - -H "X-AWF-Probe-Version: ${PROTOCOL_VERSION}" \ + -H "X-AWF-Query-Version: ${PROTOCOL_VERSION}" \ -H "X-AWF-Repo: ${REPO}" \ -H "X-AWF-Schema-B64: ${SCHEMA_B64}" \ --data-binary @- \ - "http://localhost/probe" 2>/dev/null + "http://localhost/query" 2>/dev/null ) || emit_error # Pass the broker's canonical response through unmodified, but only if it has diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 51cb154eb..52540a302 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -629,37 +629,37 @@ copy_agent_helper_scripts() { fi fi - # Activate the sealed-probe CLI when the sealed-probe broker socket is present. - # The wrapper is copied to /tmp/awf-lib/sealed-probe so it resolves inside the + # Activate the bounded-query CLI when the bounded-query broker socket is present. + # The wrapper is copied to /tmp/awf-lib/bounded-query so it resolves inside the # chroot on the same PATH entry used for the gh wrapper. - if [ -n "$AWF_SEALED_PROBE_SOCKET" ] && [ -f /usr/local/bin/sealed-probe-wrapper.sh ]; then + if [ -n "$AWF_BOUNDED_QUERY_SOCKET" ] && [ -f /usr/local/bin/bounded-query-wrapper.sh ]; then if mkdir -p /host/tmp/awf-lib 2>/dev/null; then - if cp /usr/local/bin/sealed-probe-wrapper.sh /host/tmp/awf-lib/sealed-probe 2>/dev/null && \ - chmod +x /host/tmp/awf-lib/sealed-probe 2>/dev/null; then - echo "[entrypoint] sealed-probe CLI installed at /tmp/awf-lib/sealed-probe (inside chroot)" + if cp /usr/local/bin/bounded-query-wrapper.sh /host/tmp/awf-lib/bounded-query 2>/dev/null && \ + chmod +x /host/tmp/awf-lib/bounded-query 2>/dev/null; then + echo "[entrypoint] bounded-query CLI installed at /tmp/awf-lib/bounded-query (inside chroot)" case ":${AWF_HOST_PATH:-$PATH}:" in *":/tmp/awf-lib:"*) ;; *) export AWF_HOST_PATH="/tmp/awf-lib:${AWF_HOST_PATH:-$PATH}" ;; esac else - echo "[entrypoint][WARN] Could not install sealed-probe CLI" + echo "[entrypoint][WARN] Could not install bounded-query CLI" fi fi fi - # Install the sealed-probe SKILL.md at the standard GitHub Copilot skill + # Install the bounded-query SKILL.md at the standard GitHub Copilot skill # discovery path so agents find it via the same scan that discovers other # skills in ~/.github/skills/. The source file is bind-mounted read-only # from the host; we copy it into the chroot home so it is discovered inside # the chroot without leaving host state modified. - if [ -n "$AWF_SEALED_PROBE_SKILL" ] && [ -f "$AWF_SEALED_PROBE_SKILL" ] && \ + if [ -n "$AWF_BOUNDED_QUERY_SKILL" ] && [ -f "$AWF_BOUNDED_QUERY_SKILL" ] && \ [ -n "$SYNTH_HOME" ]; then - SKILL_DEST_DIR="/host${SYNTH_HOME}/.github/skills/sealed-probe" + SKILL_DEST_DIR="/host${SYNTH_HOME}/.github/skills/bounded-query" if mkdir -p "$SKILL_DEST_DIR" 2>/dev/null && \ - cp "$AWF_SEALED_PROBE_SKILL" "$SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then - echo "[entrypoint] sealed-probe SKILL.md installed at ${SYNTH_HOME}/.github/skills/sealed-probe/SKILL.md (inside chroot)" + cp "$AWF_BOUNDED_QUERY_SKILL" "$SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then + echo "[entrypoint] bounded-query SKILL.md installed at ${SYNTH_HOME}/.github/skills/bounded-query/SKILL.md (inside chroot)" else - echo "[entrypoint][WARN] Could not install sealed-probe SKILL.md" + echo "[entrypoint][WARN] Could not install bounded-query SKILL.md" fi fi } @@ -1380,31 +1380,31 @@ run_non_chroot_command() { fi fi - # Activate the sealed-probe CLI in non-chroot mode. - if [ -n "$AWF_SEALED_PROBE_SOCKET" ] && [ -f /usr/local/bin/sealed-probe-wrapper.sh ]; then + # Activate the bounded-query CLI in non-chroot mode. + if [ -n "$AWF_BOUNDED_QUERY_SOCKET" ] && [ -f /usr/local/bin/bounded-query-wrapper.sh ]; then mkdir -p /tmp/awf-lib - if cp /usr/local/bin/sealed-probe-wrapper.sh /tmp/awf-lib/sealed-probe 2>/dev/null && \ - chmod +x /tmp/awf-lib/sealed-probe 2>/dev/null; then + if cp /usr/local/bin/bounded-query-wrapper.sh /tmp/awf-lib/bounded-query 2>/dev/null && \ + chmod +x /tmp/awf-lib/bounded-query 2>/dev/null; then case ":${PATH}:" in *":/tmp/awf-lib:"*) ;; *) export PATH="/tmp/awf-lib:${PATH}" ;; esac - echo "[entrypoint] sealed-probe CLI installed at /tmp/awf-lib/sealed-probe" + echo "[entrypoint] bounded-query CLI installed at /tmp/awf-lib/bounded-query" else - echo "[entrypoint][WARN] Could not install sealed-probe CLI" + echo "[entrypoint][WARN] Could not install bounded-query CLI" fi fi - # Install the sealed-probe SKILL.md at the standard GitHub Copilot skill + # Install the bounded-query SKILL.md at the standard GitHub Copilot skill # discovery path so agents find it via the same scan that discovers other # skills in ~/.github/skills/ (non-chroot mode). - if [ -n "$AWF_SEALED_PROBE_SKILL" ] && [ -f "$AWF_SEALED_PROBE_SKILL" ]; then - SKILL_DEST_DIR="${HOME}/.github/skills/sealed-probe" + if [ -n "$AWF_BOUNDED_QUERY_SKILL" ] && [ -f "$AWF_BOUNDED_QUERY_SKILL" ]; then + SKILL_DEST_DIR="${HOME}/.github/skills/bounded-query" if mkdir -p "$SKILL_DEST_DIR" 2>/dev/null && \ - cp "$AWF_SEALED_PROBE_SKILL" "$SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then - echo "[entrypoint] sealed-probe SKILL.md installed at ${SKILL_DEST_DIR}/SKILL.md" + cp "$AWF_BOUNDED_QUERY_SKILL" "$SKILL_DEST_DIR/SKILL.md" 2>/dev/null; then + echo "[entrypoint] bounded-query SKILL.md installed at ${SKILL_DEST_DIR}/SKILL.md" else - echo "[entrypoint][WARN] Could not install sealed-probe SKILL.md" + echo "[entrypoint][WARN] Could not install bounded-query SKILL.md" fi fi diff --git a/containers/sealed-probe/Dockerfile b/containers/bounded-query/Dockerfile similarity index 66% rename from containers/sealed-probe/Dockerfile rename to containers/bounded-query/Dockerfile index e01ca21e5..28ab60c9b 100644 --- a/containers/sealed-probe/Dockerfile +++ b/containers/bounded-query/Dockerfile @@ -1,50 +1,50 @@ -# Sealed-probe image — multi-stage build producing two images: +# Bounded-query image — multi-stage build producing two images: # -# 1. `probe` stage — minimal Python 3-only sandbox rootfs published as -# `sealed-probe:*`. The probe process gets none of the broker's tools: -# no Node runtime, no docker-cli, no Alpine package manager. The probe +# 1. `query` stage — minimal Python 3-only sandbox rootfs published as +# `bounded-query:*`. The query process gets none of the broker's tools: +# no Node runtime, no docker-cli, no Alpine package manager. The query # runs --read-only, --network none, --cap-drop ALL, unprivileged (UID # 65534), and with a restrictive seccomp profile, so the absence of # those binaries is defence-in-depth rather than the primary control. # -# 2. `broker` (default) stage — published as `sealed-probe-broker:*`. -# Has Node + docker-cli to run the server and launch probe containers. +# 2. `broker` (default) stage — published as `bounded-query-broker:*`. +# Has Node + docker-cli to run the server and launch query containers. # -# Using two images keeps the probe environment minimal while still -# guaranteeing the probe image is local when the broker starts: the release +# Using two images keeps the query environment minimal while still +# guaranteeing the query image is local when the broker starts: the release # workflow builds and pushes both tags, and the compose service pulls the -# broker image, which is declared as depending on the probe image being -# present (verified by assertProbeImageAvailable before the first request). +# broker image, which is declared as depending on the query image being +# present (verified by assertQueryImageAvailable before the first request). # ────────────────────────────────────────────────────────────────────────── -# probe stage: Python 3 standard-library-only sandbox rootfs. +# query stage: Python 3 standard-library-only sandbox rootfs. # No Node, no docker-cli, no apk package manager. # ────────────────────────────────────────────────────────────────────────── -FROM python:3.12-alpine3.21 AS probe +FROM python:3.12-alpine3.21 AS query RUN python3 -c 'import json, pathlib, sys; sys.exit(0)' \ && test -x /usr/local/bin/python3 \ && rm -f /sbin/apk -COPY probe-entrypoint.py /usr/local/bin/run-probe -RUN chmod 0555 /usr/local/bin/run-probe +COPY query-entrypoint.py /usr/local/bin/run-query +RUN chmod 0555 /usr/local/bin/run-query -# Pre-create mount points used by the probe container so a missing bind +# Pre-create mount points used by the query container so a missing bind # mount fails loudly rather than silently materialising an empty directory. -RUN mkdir -p /probe /awf/seed +RUN mkdir -p /query /awf/seed # ────────────────────────────────────────────────────────────────────────── # broker stage: trusted broker with Node + docker-cli (default build target) # ────────────────────────────────────────────────────────────────────────── FROM node:22.23.1-alpine3.24 AS broker -# docker-cli — used by the broker to launch probe containers +# docker-cli — used by the broker to launch query containers RUN apk add --no-cache docker-cli \ && test -x /usr/bin/docker WORKDIR /opt/awf/broker COPY broker/ /opt/awf/broker/ -COPY probe-seccomp.json /opt/awf/probe-seccomp.json +COPY query-seccomp.json /opt/awf/query-seccomp.json RUN chmod -R a-w /opt/awf \ && node --check /opt/awf/broker/server.js \ @@ -52,14 +52,14 @@ RUN chmod -R a-w /opt/awf \ && node --check /opt/awf/broker/protocol.js \ && node --check /opt/awf/broker/framing.js \ && node --check /opt/awf/broker/workspace.js \ - && node --check /opt/awf/broker/probe-runner.js \ + && node --check /opt/awf/broker/query-runner.js \ && node --check /opt/awf/broker/healthcheck.js # Fixed broker-only mount points. -RUN mkdir -p /srv/awf/seeds /srv/awf/work /run/awf-sealed-probe /var/log/awf-sealed-probe +RUN mkdir -p /srv/awf/seeds /srv/awf/work /run/awf-bounded-query /var/log/awf-bounded-query # The broker is root only to copy host-owned read-only seeds into private -# workspaces and hand those workspaces to the unprivileged probe uid. +# workspaces and hand those workspaces to the unprivileged query uid. # Keep the default set dropped and restore only those filesystem duties. USER root diff --git a/containers/sealed-probe/broker/audit.js b/containers/bounded-query/broker/audit.js similarity index 83% rename from containers/sealed-probe/broker/audit.js rename to containers/bounded-query/broker/audit.js index 2c6d8a670..d19ff1f9d 100644 --- a/containers/sealed-probe/broker/audit.js +++ b/containers/bounded-query/broker/audit.js @@ -7,11 +7,11 @@ const path = require('path'); * Protected broker diagnostics. * * Written to a directory that is mounted into the broker only — never into - * the agent and never into a probe. This is where the *reason* for an + * the agent and never into a query. This is where the *reason* for an * `{"result":"ERROR"}` lives; the agent-visible answer never distinguishes * failure classes. * - * Records deliberately exclude repository contents, probe stdout/stderr, and + * Records deliberately exclude repository contents, query stdout/stderr, and * script bytes. */ @@ -21,7 +21,7 @@ function createAuditLog(auditDir) { let stream; try { fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 }); - const auditPath = path.join(auditDir, 'sealed-probe.jsonl'); + const auditPath = path.join(auditDir, 'bounded-query.jsonl'); const fd = fs.openSync(auditPath, 'a', 0o600); stream = fs.createWriteStream(null, { fd, @@ -30,14 +30,14 @@ function createAuditLog(auditDir) { autoClose: true, }); stream.on('error', (error) => { - process.stderr.write(`[sealed-probe] audit log unavailable: ${error.message}\n`); + process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`); stream = undefined; }); } catch (error) { // Losing the audit stream must not take the broker down; fall back to // stderr, which is captured by `docker logs` on the broker container // (also outside the agent's reach). - process.stderr.write(`[sealed-probe] audit log unavailable: ${error.message}\n`); + process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`); stream = undefined; } diff --git a/containers/sealed-probe/broker/broker.js b/containers/bounded-query/broker/broker.js similarity index 91% rename from containers/sealed-probe/broker/broker.js rename to containers/bounded-query/broker/broker.js index a6da55644..326caff47 100644 --- a/containers/sealed-probe/broker/broker.js +++ b/containers/bounded-query/broker/broker.js @@ -4,17 +4,17 @@ const crypto = require('crypto'); const { CANONICAL_ERROR_JSON, canonicalOkJson, - parseAndValidateProbeOutput, + parseAndValidateQueryOutput, queryBitsForSchema, - validateSealedProbeRequest, + validateBoundedQueryRequest, } = require('./protocol'); const { createLedger } = require('./ledger'); const { createRealClock, waitForBucket } = require('./scheduler'); const defaultWorkspace = require('./workspace'); -const defaultRunner = require('./probe-runner'); +const defaultRunner = require('./query-runner'); /** - * The trusted sealed-probe broker (protocol v2). + * The trusted bounded-query broker (protocol v2). * * Responsibilities, in order, for every request: * @@ -31,10 +31,10 @@ const defaultRunner = require('./probe-runner'); * remaining balance — there is no separate per-query cap; * 4. map the normalized repo id through AWF's static seed map to an opaque * seed directory the caller never sees or names; - * 5. build a fresh private writable copy and launch the probe with a fixed + * 5. build a fresh private writable copy and launch the query with a fixed * argument vector, using a monotonic clock for every timing decision; * 6. strictly validate the result against the approved schema and - * canonically re-serialize it — raw probe bytes/stdout/stderr/exit + * canonically re-serialize it — raw query bytes/stdout/stderr/exit * status never reach the caller; * 7. destroy the private copy, then respond at the first timing bucket * boundary at or after all secret-dependent processing completed (see @@ -42,7 +42,7 @@ const defaultRunner = require('./probe-runner'); * * Every failure at every step produces the identical canonical * `{"status":"error"}`. The reason is recorded in the protected audit log, - * which is never mounted into the agent or a probe. + * which is never mounted into the agent or a query. * * Invocations are serialized. That bounds concurrent resource use and removes * any cross-invocation race in workspace creation/teardown/ledger access. @@ -61,7 +61,7 @@ function createBroker(params) { /** * Executes one request and reports its canonical result through * `respond` (called exactly once). The invocations run only through - * validation, ledger debit, workspace creation, probe launch, and result + * validation, ledger debit, workspace creation, query launch, and result * validation actually reach the point where the response must be * time-bucketed; everything rejected before that responds immediately. */ @@ -74,7 +74,7 @@ function createBroker(params) { respond(json); }; - const validation = validateSealedProbeRequest(request); + const validation = validateBoundedQueryRequest(request); if (!validation.valid) { audit.failure(invocationId, 'invalid-request', validation.errors.join('; ')); safeRespond(CANONICAL_ERROR_JSON); @@ -102,7 +102,7 @@ function createBroker(params) { } // From here on the charge is committed (never refunded) and every - // response must be time-bucketed: workspace creation and probe + // response must be time-bucketed: workspace creation and query // execution both run against secret repository content, so their // latency alone is a signal. const startMs = clock.nowMs(); @@ -128,19 +128,19 @@ function createBroker(params) { failureReason = ['timeout', 'workspace-creation-overran-deadline']; } else { try { - const run = await runner.runProbeContainer({ config, runId, invocationId, timeoutMs: remainingMs }); + const run = await runner.runQueryContainer({ config, runId, invocationId, timeoutMs: remainingMs }); if (run.timedOut) { failureReason = ['timeout']; } else if (run.exitCode !== 0) { failureReason = ['non-zero-exit', `exit=${run.exitCode}`]; } else { - const raw = workspace.readProbeOutput(layout.outPath); + const raw = workspace.readQueryOutput(layout.outPath); if (raw === undefined) { // Covers a missing file, an oversized file, invalid UTF-8, and // any non-regular replacement (symlink/FIFO/device/socket). failureReason = ['unreadable-output']; } else { - const parsed = parseAndValidateProbeOutput(raw, schema); + const parsed = parseAndValidateQueryOutput(raw, schema); if (!parsed.ok) { failureReason = ['nonconformant-output']; } else { @@ -209,7 +209,7 @@ function createBroker(params) { * cleanup) is complete; it carries no value and exists only to let the * caller serialize/await broker shutdown. * - * Requests are queued so at most one probe runs at a time. + * Requests are queued so at most one query runs at a time. */ handle(request, respond) { let responded = false; diff --git a/containers/sealed-probe/broker/config.js b/containers/bounded-query/broker/config.js similarity index 67% rename from containers/sealed-probe/broker/config.js rename to containers/bounded-query/broker/config.js index 7a0f9d11c..2047ea05b 100644 --- a/containers/sealed-probe/broker/config.js +++ b/containers/bounded-query/broker/config.js @@ -2,14 +2,14 @@ const fs = require('fs'); const path = require('path'); -const { MAX_PROBE_TIMEOUT_SECONDS } = require('./protocol'); -const { SEALED_PROBE_SENSITIVITY_RUN_BITS } = require('./sensitivity'); +const { MAX_QUERY_TIMEOUT_SECONDS } = require('./protocol'); +const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity'); /** * Broker configuration. * * Everything here is supplied by AWF through the container environment and - * fixed mount points. Nothing in this file is influenced by a probe request: + * fixed mount points. Nothing in this file is influenced by a query request: * the caller cannot choose an image, a runtime, a path, a mount, a limit, or * a timeout. */ @@ -17,20 +17,20 @@ const { SEALED_PROBE_SENSITIVITY_RUN_BITS } = require('./sensitivity'); const SEEDS_DIR = '/srv/awf/seeds'; const WORK_DIR = '/srv/awf/work'; const SEED_MAP_PATH = '/srv/awf/seed-map.json'; -const SOCKET_DIR = '/run/awf-sealed-probe'; +const SOCKET_DIR = '/run/awf-bounded-query'; const SOCKET_PATH = path.join(SOCKET_DIR, 'broker.sock'); -const AUDIT_DIR = '/var/log/awf-sealed-probe'; +const AUDIT_DIR = '/var/log/awf-bounded-query'; /** Broker-private readiness marker; the audit directory is never agent-mounted. */ const READY_PATH = path.join(AUDIT_DIR, 'broker.ready'); -const PROBE_SECCOMP_PATH = '/opt/awf/probe-seccomp.json'; +const QUERY_SECCOMP_PATH = '/opt/awf/query-seccomp.json'; -/** Mount points inside the probe container. Fixed, never caller-supplied. */ -const PROBE_MOUNT_DIR = '/probe'; -const PROBE_SCRIPT_PATH = '/awf/probe-script.py'; +/** Mount points inside the query container. Fixed, never caller-supplied. */ +const QUERY_MOUNT_DIR = '/query'; +const QUERY_SCRIPT_PATH = '/awf/query-script.py'; -/** Unprivileged uid/gid the probe process runs as. */ -const PROBE_UID = 65534; -const PROBE_GID = 65534; +/** Unprivileged uid/gid the query process runs as. */ +const QUERY_UID = 65534; +const QUERY_GID = 65534; function requireEnv(name) { const value = process.env[name]; @@ -57,10 +57,10 @@ function parsePositiveInt(name, fallback) { * bucket's post-processing margin. */ function parseTimeoutSeconds() { - const parsed = parsePositiveInt('AWF_SEALED_PROBE_TIMEOUT', 30); - if (parsed > MAX_PROBE_TIMEOUT_SECONDS) { + const parsed = parsePositiveInt('AWF_BOUNDED_QUERY_TIMEOUT', 30); + if (parsed > MAX_QUERY_TIMEOUT_SECONDS) { throw new Error( - `Environment variable AWF_SEALED_PROBE_TIMEOUT must be at most ${MAX_PROBE_TIMEOUT_SECONDS} seconds ` + + `Environment variable AWF_BOUNDED_QUERY_TIMEOUT must be at most ${MAX_QUERY_TIMEOUT_SECONDS} seconds ` + '(the final response bucket reserves one minute for termination, validation, and cleanup)', ); } @@ -68,14 +68,14 @@ function parseTimeoutSeconds() { } function loadConfig() { - const memoryLimit = process.env.AWF_SEALED_PROBE_MEMORY || '512m'; + const memoryLimit = process.env.AWF_BOUNDED_QUERY_MEMORY || '512m'; if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(memoryLimit)) { - throw new Error('AWF_SEALED_PROBE_MEMORY must be a Docker memory limit (e.g. "512m")'); + throw new Error('AWF_BOUNDED_QUERY_MEMORY must be a Docker memory limit (e.g. "512m")'); } - const dockerRuntime = process.env.AWF_SEALED_PROBE_RUNTIME || ''; + const dockerRuntime = process.env.AWF_BOUNDED_QUERY_RUNTIME || ''; if (dockerRuntime && !/^[A-Za-z0-9_.-]+$/.test(dockerRuntime)) { - throw new Error('AWF_SEALED_PROBE_RUNTIME contains unexpected characters'); + throw new Error('AWF_BOUNDED_QUERY_RUNTIME contains unexpected characters'); } return { @@ -86,21 +86,21 @@ function loadConfig() { socketPath: SOCKET_PATH, readyPath: READY_PATH, auditDir: AUDIT_DIR, - probeSeccompPath: PROBE_SECCOMP_PATH, - probeMountDir: PROBE_MOUNT_DIR, - probeScriptPath: PROBE_SCRIPT_PATH, - probeUid: PROBE_UID, - probeGid: PROBE_GID, - probeImage: requireEnv('AWF_SEALED_PROBE_IMAGE'), - // The daemon resolves probe bind-mount sources in *its* filesystem view, + querySeccompPath: QUERY_SECCOMP_PATH, + queryMountDir: QUERY_MOUNT_DIR, + queryScriptPath: QUERY_SCRIPT_PATH, + queryUid: QUERY_UID, + queryGid: QUERY_GID, + queryImage: requireEnv('AWF_BOUNDED_QUERY_IMAGE'), + // The daemon resolves query bind-mount sources in *its* filesystem view, // which is not necessarily the broker's (ARC/DinD split filesystems). - hostWorkDir: requireEnv('AWF_SEALED_PROBE_HOST_WORK_DIR'), + hostWorkDir: requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR'), dockerRuntime, timeoutSeconds: parseTimeoutSeconds(), - maxInvocations: parsePositiveInt('AWF_SEALED_PROBE_MAX_INVOCATIONS', 32), + maxInvocations: parsePositiveInt('AWF_BOUNDED_QUERY_MAX_INVOCATIONS', 32), memoryLimit, - socketUid: parsePositiveInt('AWF_SEALED_PROBE_SOCKET_UID', 0), - socketGid: parsePositiveInt('AWF_SEALED_PROBE_SOCKET_GID', 0), + socketUid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_UID', 0), + socketGid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_GID', 0), }; } @@ -128,7 +128,7 @@ function loadSeedMap(seedMapPath) { !entry || typeof entry.repo !== 'string' || typeof entry.seedId !== 'string' - || !Object.prototype.hasOwnProperty.call(SEALED_PROBE_SENSITIVITY_RUN_BITS, entry.sensitivity) + || !Object.prototype.hasOwnProperty.call(BOUNDED_QUERY_SENSITIVITY_RUN_BITS, entry.sensitivity) ) { throw new Error('Seed map entry is malformed'); } diff --git a/containers/sealed-probe/broker/framing.js b/containers/bounded-query/broker/framing.js similarity index 95% rename from containers/sealed-probe/broker/framing.js rename to containers/bounded-query/broker/framing.js index c2835ea6a..ca0a56e6e 100644 --- a/containers/sealed-probe/broker/framing.js +++ b/containers/bounded-query/broker/framing.js @@ -17,15 +17,15 @@ const { MAX_SCHEMA_BYTES, MAX_SCRIPT_BYTES, strictParseJson } = require('./proto * HTTP header values are restricted to a printable-ASCII-ish subset, while a * `const`/`enum` schema literal may contain arbitrary non-control UTF-8. The * assembled object is then validated by the shared protocol rules - * (`validateSealedProbeRequest`), so this framing layer adds no new degrees + * (`validateBoundedQueryRequest`), so this framing layer adds no new degrees * of freedom — it only assembles the object and enforces cheap size/shape * bounds before that shared validation runs. */ /** Supported request framing version. */ -const PROBE_PROTOCOL_VERSION = '2'; +const QUERY_PROTOCOL_VERSION = '2'; -const VERSION_HEADER = 'x-awf-probe-version'; +const VERSION_HEADER = 'x-awf-query-version'; const REPO_HEADER = 'x-awf-repo'; const SCHEMA_HEADER = 'x-awf-schema-b64'; @@ -88,7 +88,7 @@ function buildRequestFromFrame(headers, rawHeaders, script) { const headerError = validateRawHeaders(rawHeaders); if (headerError) return { error: headerError }; - if (headers[VERSION_HEADER] !== PROBE_PROTOCOL_VERSION) { + if (headers[VERSION_HEADER] !== QUERY_PROTOCOL_VERSION) { return { error: 'unsupported or missing protocol version' }; } @@ -154,7 +154,7 @@ function readBoundedBody(req) { } module.exports = { - PROBE_PROTOCOL_VERSION, + QUERY_PROTOCOL_VERSION, VERSION_HEADER, REPO_HEADER, SCHEMA_HEADER, diff --git a/containers/sealed-probe/broker/healthcheck.js b/containers/bounded-query/broker/healthcheck.js similarity index 87% rename from containers/sealed-probe/broker/healthcheck.js rename to containers/bounded-query/broker/healthcheck.js index 6cf79463e..0323996bd 100644 --- a/containers/sealed-probe/broker/healthcheck.js +++ b/containers/bounded-query/broker/healthcheck.js @@ -8,7 +8,7 @@ const { READY_PATH } = require('./config'); * * Checks for the broker-internal ready file written by `main()` in server.js * once the socket is accepting connections. This avoids hitting the - * agent-visible `/probe` socket, which has only one route and no health + * agent-visible `/query` socket, which has only one route and no health * endpoint. Exits non-zero if the ready file is absent or unreadable. */ diff --git a/containers/sealed-probe/broker/ledger.js b/containers/bounded-query/broker/ledger.js similarity index 92% rename from containers/sealed-probe/broker/ledger.js rename to containers/bounded-query/broker/ledger.js index 3609de346..3191d7ef5 100644 --- a/containers/sealed-probe/broker/ledger.js +++ b/containers/bounded-query/broker/ledger.js @@ -1,6 +1,6 @@ 'use strict'; -const { SEALED_PROBE_SENSITIVITY_RUN_BITS } = require('./sensitivity'); +const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity'); /** * Per-repository information-budget ledger. @@ -27,7 +27,7 @@ const { SEALED_PROBE_SENSITIVITY_RUN_BITS } = require('./sensitivity'); function createLedger(seeds) { const remaining = new Map(); for (const [repoKey, seed] of seeds) { - remaining.set(repoKey, SEALED_PROBE_SENSITIVITY_RUN_BITS[seed.sensitivity]); + remaining.set(repoKey, BOUNDED_QUERY_SENSITIVITY_RUN_BITS[seed.sensitivity]); } return { diff --git a/containers/sealed-probe/broker/protocol.js b/containers/bounded-query/broker/protocol.js similarity index 96% rename from containers/sealed-probe/broker/protocol.js rename to containers/bounded-query/broker/protocol.js index 48d184152..8d5113e9a 100644 --- a/containers/sealed-probe/broker/protocol.js +++ b/containers/bounded-query/broker/protocol.js @@ -1,18 +1,18 @@ 'use strict'; /** - * Sealed-probe request/result protocol v2 — broker-side implementation. + * Bounded-query request/result protocol v2 — broker-side implementation. * - * This is a deliberate, behaviour-identical mirror of `src/sealed-probe/ + * This is a deliberate, behaviour-identical mirror of `src/bounded-query/ * protocol.ts`. The broker runs inside its own container image and cannot * import AWF's TypeScript sources, so the rules are restated here and pinned - * by `src/sealed-probe/protocol-parity.test.ts`, which runs the *same* vector + * by `src/bounded-query/protocol-parity.test.ts`, which runs the *same* vector * table through both implementations and fails if they ever diverge. * * Do not "improve" one side without the other. */ -const PROBE_PROTOCOL_VERSION = 2; +const QUERY_PROTOCOL_VERSION = 2; const MAX_SCHEMA_BYTES = 4096; const MAX_SCHEMA_DEPTH = 6; @@ -29,7 +29,7 @@ const MAX_PRIVATE_REPO_LENGTH = 140; const TIMING_BUCKETS_MS = [10, 100, 1_000, 10_000, 60_000, 600_000]; const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000; -const MAX_PROBE_TIMEOUT_SECONDS = +const MAX_QUERY_TIMEOUT_SECONDS = (TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000; function ceilLog2(n) { @@ -39,7 +39,7 @@ function ceilLog2(n) { const TIMING_BUCKET_BITS = ceilLog2(TIMING_BUCKETS_MS.length); const RESULT_STATUS_BIT_COST = 1; -const SEALED_PROBE_REPO_PATTERN = +const BOUNDED_QUERY_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/; const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; @@ -528,7 +528,7 @@ function strictParseJson(text) { // ── Request/result validation and canonical envelopes ─────────────────────── -function validateSealedProbeRequest(raw) { +function validateBoundedQueryRequest(raw) { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { return { valid: false, errors: ['request must be a JSON object'] }; } @@ -542,7 +542,7 @@ function validateSealedProbeRequest(raw) { if (typeof privateRepo !== 'string' || privateRepo.length === 0) { errors.push('privateRepo must be a non-empty string'); - } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !SEALED_PROBE_REPO_PATTERN.test(privateRepo)) { + } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { errors.push( 'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)', ); @@ -577,7 +577,7 @@ function canonicalOkJson(canonicalResultJson) { return `{"status":"ok","result":${canonicalResultJson}}`; } -function parseAndValidateProbeOutput(raw, schema) { +function parseAndValidateQueryOutput(raw, schema) { if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false }; const parsed = strictParseJson(raw); if (!parsed) return { ok: false }; @@ -586,7 +586,7 @@ function parseAndValidateProbeOutput(raw, schema) { } module.exports = { - PROBE_PROTOCOL_VERSION, + QUERY_PROTOCOL_VERSION, MAX_SCHEMA_BYTES, MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES, @@ -601,10 +601,10 @@ module.exports = { MAX_PRIVATE_REPO_LENGTH, TIMING_BUCKETS_MS, FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS, - MAX_PROBE_TIMEOUT_SECONDS, + MAX_QUERY_TIMEOUT_SECONDS, TIMING_BUCKET_BITS, RESULT_STATUS_BIT_COST, - SEALED_PROBE_REPO_PATTERN, + BOUNDED_QUERY_REPO_PATTERN, CANONICAL_ERROR_JSON, validateSchema, ceilLog2BigInt, @@ -613,7 +613,7 @@ module.exports = { validateValueAgainstSchema, canonicalizeSchemaValue, strictParseJson, - validateSealedProbeRequest, + validateBoundedQueryRequest, canonicalOkJson, - parseAndValidateProbeOutput, + parseAndValidateQueryOutput, }; diff --git a/containers/sealed-probe/broker/probe-runner.js b/containers/bounded-query/broker/query-runner.js similarity index 64% rename from containers/sealed-probe/broker/probe-runner.js rename to containers/bounded-query/broker/query-runner.js index e747e24e7..5c2c38731 100644 --- a/containers/sealed-probe/broker/probe-runner.js +++ b/containers/bounded-query/broker/query-runner.js @@ -3,7 +3,7 @@ const { execFile } = require('child_process'); /** - * Launches a single probe container. + * Launches a single query container. * * The argument vector is built entirely from broker configuration and * AWF-generated invocation identifiers. No part of it is derived from the @@ -11,19 +11,19 @@ const { execFile } = require('child_process'); * mounts, limits, labels, or environment. */ -/** Extra grace beyond the probe's wall-clock budget for docker CLI overhead. */ +/** Extra grace beyond the query's wall-clock budget for docker CLI overhead. */ const CLI_GRACE_MS = 5_000; -/** Maximum file size a probe may create, in bytes (per-file RLIMIT_FSIZE). */ -const PROBE_MAX_FILE_BYTES = 64 * 1024 * 1024; +/** Maximum file size a query may create, in bytes (per-file RLIMIT_FSIZE). */ +const QUERY_MAX_FILE_BYTES = 64 * 1024 * 1024; /** - * Aggregate size limit for the probe's writable tmpfs workspace in bytes. + * Aggregate size limit for the query's writable tmpfs workspace in bytes. * - * `/probe` is backed by a tmpfs of this size, bounding the total amount of - * new data the probe can write outside the pre-seeded repo copy. + * `/query` is backed by a tmpfs of this size, bounding the total amount of + * new data the query can write outside the pre-seeded repo copy. */ -const PROBE_WORKSPACE_TMPFS_BYTES = 256 * 1024 * 1024; +const QUERY_WORKSPACE_TMPFS_BYTES = 256 * 1024 * 1024; function runDocker(args, timeoutMs) { return new Promise((resolve) => { @@ -33,7 +33,7 @@ function runDocker(args, timeoutMs) { { timeout: timeoutMs, killSignal: 'SIGKILL', - // Probe stdout/stderr is never returned to the caller and never + // Query stdout/stderr is never returned to the caller and never // inspected for content; a tiny buffer is enough and caps memory. maxBuffer: 64 * 1024, env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin' }, @@ -51,11 +51,11 @@ function runDocker(args, timeoutMs) { } /** - * Builds the fixed `docker run` argument vector for a probe. + * Builds the fixed `docker run` argument vector for a query. * * Exported so the sandbox flags are unit-testable without a Docker daemon. */ -function buildProbeArgs(params) { +function buildQueryArgs(params) { const { config, runId, invocationId, containerName } = params; const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`; @@ -65,43 +65,43 @@ function buildProbeArgs(params) { // invocation ask the daemon to contact a registry if that image disappears. '--pull', 'never', '--name', containerName, - '--label', `awf.sealed-probe.run=${runId}`, - '--label', `awf.sealed-probe.invocation=${invocationId}`, + '--label', `awf.bounded-query.run=${runId}`, + '--label', `awf.bounded-query.invocation=${invocationId}`, // No network namespace connectivity at all: no internet, no DNS, no host // gateway, no bridge peers, no proxies, no other AWF container. '--network', 'none', '--read-only', - '--user', `${config.probeUid}:${config.probeGid}`, + '--user', `${config.queryUid}:${config.queryGid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges:true', - '--security-opt', `seccomp=${config.probeSeccompPath}`, + '--security-opt', `seccomp=${config.querySeccompPath}`, '--memory', config.memoryLimit, '--memory-swap', config.memoryLimit, '--cpus', '1', '--pids-limit', '128', - '--ulimit', `fsize=${PROBE_MAX_FILE_BYTES}`, + '--ulimit', `fsize=${QUERY_MAX_FILE_BYTES}`, '--ulimit', 'nofile=1024:1024', // /tmp: small tmpfs for Python's own scratch (cached imports, etc.) '--tmpfs', '/tmp:rw,noexec,nosuid,nodev,size=16m', - // /probe: size-limited tmpfs as the aggregate workspace. New files the - // probe creates here cannot exceed PROBE_WORKSPACE_TMPFS_BYTES in total, + // /query: size-limited tmpfs as the aggregate workspace. New files the + // query creates here cannot exceed QUERY_WORKSPACE_TMPFS_BYTES in total, // which bounds host filesystem consumption beyond the pre-seeded repo. - '--tmpfs', `/probe:rw,nosuid,nodev,size=${PROBE_WORKSPACE_TMPFS_BYTES},uid=${config.probeUid},gid=${config.probeGid},mode=0700`, - '--hostname', 'probe', - '--workdir', config.probeMountDir, + '--tmpfs', `/query:rw,nosuid,nodev,size=${QUERY_WORKSPACE_TMPFS_BYTES},uid=${config.queryUid},gid=${config.queryGid},mode=0700`, + '--hostname', 'query', + '--workdir', config.queryMountDir, '--env', 'HOME=/tmp', '--env', 'PYTHONDONTWRITEBYTECODE=1', '--env', 'PYTHONUNBUFFERED=1', // Mount the assigned seed copy read-only at a broker-chosen internal path. // The fixed image entrypoint copies it into the bounded tmpfs at - // /probe/repo before executing the submitted script, giving the script a + // /query/repo before executing the submitted script, giving the script a // writable ephemeral repository without unbounded host filesystem writes. '-v', `${hostInvocationDir}/repo:/awf/seed:ro`, - // Mount the pre-created output file: the probe writes its answer here + // Mount the pre-created output file: the query writes its answer here // and it persists on the host for the broker to read back. - '-v', `${hostInvocationDir}/out:${config.probeMountDir}/out:rw`, + '-v', `${hostInvocationDir}/out:${config.queryMountDir}/out:rw`, // The submitted script at its fixed read-only path. - '-v', `${hostInvocationDir}/script.py:${config.probeScriptPath}:ro`, + '-v', `${hostInvocationDir}/script.py:${config.queryScriptPath}:ro`, ]; if (config.dockerRuntime) { @@ -109,22 +109,22 @@ function buildProbeArgs(params) { } args.push( - '--entrypoint', '/usr/local/bin/run-probe', - config.probeImage, + '--entrypoint', '/usr/local/bin/run-query', + config.queryImage, ); return args; } /** - * Runs the probe and force-removes its container afterwards. + * Runs the query and force-removes its container afterwards. * * Never throws: the caller maps everything to the canonical error result. */ -async function runProbeContainer(params) { +async function runQueryContainer(params) { const { config } = params; - const containerName = `awf-probe-${params.invocationId}`; - const args = buildProbeArgs({ ...params, containerName }); + const containerName = `awf-query-${params.invocationId}`; + const args = buildQueryArgs({ ...params, containerName }); // Use the caller-supplied remaining budget (which already excludes workspace // creation time) rather than the raw config timeout. @@ -139,18 +139,18 @@ async function runProbeContainer(params) { } } -/** Verifies the probe image is present locally — probes must never pull. */ -async function assertProbeImageAvailable(image) { +/** Verifies the query image is present locally — queries must never pull. */ +async function assertQueryImageAvailable(image) { const result = await runDocker(['image', 'inspect', image], 60_000); if (result.exitCode !== 0) { - throw new Error(`Probe image is not available locally: ${image}`); + throw new Error(`Query image is not available locally: ${image}`); } } module.exports = { - PROBE_MAX_FILE_BYTES, - PROBE_WORKSPACE_TMPFS_BYTES, - buildProbeArgs, - runProbeContainer, - assertProbeImageAvailable, + QUERY_MAX_FILE_BYTES, + QUERY_WORKSPACE_TMPFS_BYTES, + buildQueryArgs, + runQueryContainer, + assertQueryImageAvailable, }; diff --git a/containers/sealed-probe/broker/scheduler.js b/containers/bounded-query/broker/scheduler.js similarity index 96% rename from containers/sealed-probe/broker/scheduler.js rename to containers/bounded-query/broker/scheduler.js index 73245b18f..95e9e13d4 100644 --- a/containers/sealed-probe/broker/scheduler.js +++ b/containers/bounded-query/broker/scheduler.js @@ -5,7 +5,7 @@ const { TIMING_BUCKETS_MS } = require('./protocol'); /** * Response-timing bucketing. * - * A probe's actual completion latency is itself a secret-dependent signal + * A query's actual completion latency is itself a secret-dependent signal * (a script that raises early on one branch and runs to completion on * another leaks information purely through wall-clock time, with no * dependence on the declared response schema at all). This module makes @@ -18,7 +18,7 @@ const { TIMING_BUCKETS_MS } = require('./protocol'); * - Time is measured with a monotonic clock (`process.hrtime.bigint()` by * default, injectable for tests), never `Date.now()`, so system clock * adjustments cannot shift a response across a bucket boundary. - * - `waitForBucket` resolves the bucket only after probe execution, result + * - `waitForBucket` resolves the bucket only after query execution, result * validation, Docker removal, and host workspace teardown complete. * Repository size and tree shape can affect cleanup latency, so cleanup * must be included before choosing the charged timing bucket. Invocations diff --git a/containers/sealed-probe/broker/sensitivity.js b/containers/bounded-query/broker/sensitivity.js similarity index 67% rename from containers/sealed-probe/broker/sensitivity.js rename to containers/bounded-query/broker/sensitivity.js index 4fbb203be..d13933240 100644 --- a/containers/sealed-probe/broker/sensitivity.js +++ b/containers/bounded-query/broker/sensitivity.js @@ -2,8 +2,8 @@ /** * Repository sensitivity categories and their fixed per-run information - * budgets — broker-side mirror of `SEALED_PROBE_SENSITIVITY_RUN_BITS` in - * `src/types/sealed-probe-options.ts`. Kept in a tiny standalone module (not + * budgets — broker-side mirror of `BOUNDED_QUERY_SENSITIVITY_RUN_BITS` in + * `src/types/bounded-query-options.ts`. Kept in a tiny standalone module (not * `protocol.js`) because it is config/ledger data, not wire protocol. * * `null` means "unmetered": `public` still runs through the same finite @@ -15,13 +15,13 @@ * repository can never fund a single query and therefore never copies a * seed or launches Python. */ -const SEALED_PROBE_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed']; +const BOUNDED_QUERY_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed']; -const SEALED_PROBE_SENSITIVITY_RUN_BITS = { +const BOUNDED_QUERY_SENSITIVITY_RUN_BITS = { public: null, internal: 64, confidential: 8, sealed: 0, }; -module.exports = { SEALED_PROBE_SENSITIVITIES, SEALED_PROBE_SENSITIVITY_RUN_BITS }; +module.exports = { BOUNDED_QUERY_SENSITIVITIES, BOUNDED_QUERY_SENSITIVITY_RUN_BITS }; diff --git a/containers/sealed-probe/broker/server.js b/containers/bounded-query/broker/server.js similarity index 92% rename from containers/sealed-probe/broker/server.js rename to containers/bounded-query/broker/server.js index 490117a47..53db9a13e 100644 --- a/containers/sealed-probe/broker/server.js +++ b/containers/bounded-query/broker/server.js @@ -7,24 +7,24 @@ const { createBroker } = require('./broker'); const { loadConfig, loadSeedMap } = require('./config'); const { buildRequestFromFrame, readBoundedBody } = require('./framing'); const { CANONICAL_ERROR_JSON } = require('./protocol'); -const { assertProbeImageAvailable } = require('./probe-runner'); +const { assertQueryImageAvailable } = require('./query-runner'); /** - * Sealed-probe broker server. + * Bounded-query broker server. * * Listens on a single Unix domain socket. The container has * `network_mode: none`, so this socket — shared with the agent through one * bind mount — is the broker's entire attack surface. * * One route exists: - * POST /probe the sealed-probe API + * POST /query the bounded-query API * * The agent-visible socket has no `/health` route. The compose healthcheck * instead polls for a broker-internal ready file written by `main()` after * the socket starts accepting connections. This removes a distinguishable * extra response (the health status body) from the agent-observable surface. * - * `/probe` always answers `200` with a canonical result body: `{"status": + * `/query` always answers `200` with a canonical result body: `{"status": * "ok","result":}` or `{"status":"error"}` — status code and headers * are identical either way, and every failure class collapses to the same * error body. For any invocation that reached workspace creation, the @@ -47,7 +47,7 @@ function createServer(deps) { const { broker, audit } = deps; return http.createServer((req, res) => { - if (req.method !== 'POST' || req.url !== '/probe') { + if (req.method !== 'POST' || req.url !== '/query') { // Not part of the API. Answer with the canonical error rather than a // distinguishable 404/405 so probing the surface yields no extra signal. sendResult(res, CANONICAL_ERROR_JSON); @@ -105,7 +105,7 @@ async function main() { // Fail closed before accepting a single request: an invocation must never // trigger a registry pull, and the broker has no network to perform one. - await assertProbeImageAvailable(config.probeImage); + await assertQueryImageAvailable(config.queryImage); const broker = createBroker({ config, seedMap: seeds, runId, audit }); const server = createServer({ broker, audit }); @@ -133,7 +133,7 @@ async function main() { if (require.main === module) { main().catch((error) => { - process.stderr.write(`[sealed-probe] broker failed to start: ${error.message}\n`); + process.stderr.write(`[bounded-query] broker failed to start: ${error.message}\n`); process.exit(1); }); } diff --git a/containers/sealed-probe/broker/workspace.js b/containers/bounded-query/broker/workspace.js similarity index 75% rename from containers/sealed-probe/broker/workspace.js rename to containers/bounded-query/broker/workspace.js index 5fed149f9..16c80d99f 100644 --- a/containers/sealed-probe/broker/workspace.js +++ b/containers/bounded-query/broker/workspace.js @@ -9,12 +9,12 @@ const { MAX_RESULT_BYTES } = require('./protocol'); * * Every invocation receives a fresh copy of exactly one immutable seed. The * copy is mounted read-only at an internal path and materialized by the fixed - * probe entrypoint into the size-limited tmpfs at `/probe/repo`, where the + * query entrypoint into the size-limited tmpfs at `/query/repo`, where the * submitted script may modify it freely. * - * `/probe` is backed by a size-limited tmpfs so the probe cannot create - * unbounded numbers of files on the Docker host. The probe writes its - * answer to `/probe/out`, which is a pre-created bind-mounted file whose + * `/query` is backed by a size-limited tmpfs so the query cannot create + * unbounded numbers of files on the Docker host. The query writes its + * answer to `/query/out`, which is a pre-created bind-mounted file whose * contents the broker reads back from the host filesystem after the * container exits. */ @@ -26,16 +26,16 @@ function invocationLayout(workDir, invocationId) { root, // The seed copy is mounted read-only at /awf/seed for the fixed entrypoint. repoDir: path.join(root, 'repo'), - // Pre-created empty file bound at probeMountDir/out so the probe can write + // Pre-created empty file bound at queryMountDir/out so the query can write // its answer to the host filesystem; the broker reads it back after exit. outPath: path.join(root, 'out'), - // The submitted script is bound read-only at probeScriptPath. + // The submitted script is bound read-only at queryScriptPath. scriptPath: path.join(root, 'script.py'), }; } -/** Recursively grants the probe user ownership and write access to a tree. */ -function grantProbeOwnership(target, uid, gid) { +/** Recursively grants the query user ownership and write access to a tree. */ +function grantQueryOwnership(target, uid, gid) { const stat = fs.lstatSync(target); fs.lchownSync(target, uid, gid); @@ -44,7 +44,7 @@ function grantProbeOwnership(target, uid, gid) { if (stat.isDirectory()) { fs.chmodSync(target, 0o700); for (const entry of fs.readdirSync(target)) { - grantProbeOwnership(path.join(target, entry), uid, gid); + grantQueryOwnership(path.join(target, entry), uid, gid); } return; } @@ -57,8 +57,8 @@ function grantProbeOwnership(target, uid, gid) { * the submitted script, and a pre-created empty output file. * * The seed copy is mounted read-only for the fixed entrypoint, which copies it - * into the bounded `/probe` tmpfs. The output file is a bind-mounted regular - * file owned by the probe uid so the probe can write its answer there. + * into the bounded `/query` tmpfs. The output file is a bind-mounted regular + * file owned by the query uid so the query can write its answer there. */ function createInvocationWorkspace(params) { const { config, invocationId, seedId, script } = params; @@ -67,9 +67,9 @@ function createInvocationWorkspace(params) { fs.mkdirSync(layout.root, { recursive: true, mode: 0o700 }); // Copy the seed to layout.repoDir. The copy is mounted read-only for - // the fixed probe entrypoint, but the broker-side copy - // must be writable so the broker can delete it after the probe exits. - // FS permissions on the host do NOT enforce the probe's read-only + // the fixed query entrypoint, but the broker-side copy + // must be writable so the broker can delete it after the query exits. + // FS permissions on the host do NOT enforce the query's read-only // constraint — the Docker mount flag does. fs.cpSync(path.join(config.seedsDir, seedId), layout.repoDir, { recursive: true, @@ -80,7 +80,7 @@ function createInvocationWorkspace(params) { }); // Ensure every entry in the repo copy is owner-writable so the broker - // can remove it cleanly after the probe exits. The seed is read-locked; + // can remove it cleanly after the query exits. The seed is read-locked; // without this, rmSync on the invocation root would fail with EACCES. const makeOwnerWritable = (p) => { try { @@ -98,28 +98,28 @@ function createInvocationWorkspace(params) { }; makeOwnerWritable(layout.repoDir); - // Script: broker-owned read-only on the host, mounted ro into the probe. + // Script: broker-owned read-only on the host, mounted ro into the query. fs.writeFileSync(layout.scriptPath, script, { mode: 0o444 }); fs.chmodSync(layout.scriptPath, 0o444); // Output file: pre-created as an empty file so Docker can bind-mount it - // into the tmpfs-backed /probe directory. Owned by the probe uid so the - // probe process (--user probeUid:probeGid) can write to it. + // into the tmpfs-backed /query directory. Owned by the query uid so the + // query process (--user queryUid:queryGid) can write to it. fs.writeFileSync(layout.outPath, '', { mode: 0o600 }); - fs.chownSync(layout.outPath, config.probeUid, config.probeGid); + fs.chownSync(layout.outPath, config.queryUid, config.queryGid); return layout; } /** - * Reads the probe's result file defensively. + * Reads the query's result file defensively. * - * `O_NOFOLLOW` plus an explicit regular-file check means a probe cannot make - * the broker read something else by replacing `/probe/out` with a symlink, + * `O_NOFOLLOW` plus an explicit regular-file check means a query cannot make + * the broker read something else by replacing `/query/out` with a symlink, * FIFO, device, or socket. Anything unexpected returns `undefined`, which the * caller maps to the canonical error result. */ -function readProbeOutput(outPath) { +function readQueryOutput(outPath) { let fd; try { fd = fs.openSync(outPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); @@ -156,6 +156,6 @@ function destroyInvocationWorkspace(workDir, invocationId) { module.exports = { invocationLayout, createInvocationWorkspace, - readProbeOutput, + readQueryOutput, destroyInvocationWorkspace, }; diff --git a/containers/sealed-probe/probe-entrypoint.py b/containers/bounded-query/query-entrypoint.py similarity index 74% rename from containers/sealed-probe/probe-entrypoint.py rename to containers/bounded-query/query-entrypoint.py index 8c6b566c8..5dac974cb 100644 --- a/containers/sealed-probe/probe-entrypoint.py +++ b/containers/bounded-query/query-entrypoint.py @@ -1,5 +1,5 @@ #!/usr/local/bin/python3 -"""Materialize the assigned seed into bounded tmpfs, then run the probe.""" +"""Materialize the assigned seed into bounded tmpfs, then run the query.""" import os import runpy @@ -7,9 +7,9 @@ from pathlib import Path SEED = Path("/awf/seed") -REPO = Path("/probe/repo") -SCRIPT = "/awf/probe-script.py" +REPO = Path("/query/repo") +SCRIPT = "/awf/query-script.py" shutil.copytree(SEED, REPO, symlinks=True) -os.chdir("/probe") +os.chdir("/query") runpy.run_path(SCRIPT, run_name="__main__") diff --git a/containers/sealed-probe/probe-seccomp.json b/containers/bounded-query/query-seccomp.json similarity index 98% rename from containers/sealed-probe/probe-seccomp.json rename to containers/bounded-query/query-seccomp.json index e7d9f2b2d..b07c2b7d7 100644 --- a/containers/sealed-probe/probe-seccomp.json +++ b/containers/bounded-query/query-seccomp.json @@ -339,7 +339,7 @@ "writev" ], "action": "SCMP_ACT_ALLOW", - "comment": "Syscalls a stdlib-only python3 probe needs. Derived from containers/agent/seccomp-profile.json minus the deny list below; kept in sync by src/sealed-probe/probe-seccomp.test.ts." + "comment": "Syscalls a stdlib-only python3 query needs. Derived from containers/agent/seccomp-profile.json minus the deny list below; kept in sync by src/bounded-query/query-seccomp.test.ts." }, { "names": [ diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 9e69bf57a..52b4fbe03 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -82,7 +82,7 @@ following top-level properties. All are OPTIONAL: | `logging` | object | Logging and diagnostics | | `rateLimiting` | object | Egress rate limiting | | `platform` | object | GitHub platform deployment type declaration | -| `sealedProbes` | object | Sealed-probe sandbox subsystem (see §14) | +| `boundedQueries` | object | Bounded-query sandbox subsystem (see §14) | Property-level constraints, types, and descriptions are defined normatively by `docs/awf-config.schema.json`. @@ -213,13 +213,13 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `platform.type` → *(config-only; maps to `AWF_PLATFORM_TYPE`)* - `runner.topology` → *(config-only; sets runner deployment model — `standard` or `arc-dind`; when `arc-dind`, enables sysroot staging and emits RUNNER_TOOL_CACHE warnings)* - `runner.sysrootImage` → *(config-only; sysroot init-container image for `arc-dind` topology; defaults to `/build-tools:`, where `container.imageRegistry` defaults to `ghcr.io/github/gh-aw-firewall`)* -- `sealedProbes.enabled` → *(config-only; no CLI equivalent, see §14)* -- `sealedProbes.privateRepos[]` → *(config-only; no CLI equivalent, see §14)* -- `sealedProbes.runtime` → *(config-only; no CLI equivalent, see §14)* -- `sealedProbes.timeout` → *(config-only; no CLI equivalent, see §14)* -- `sealedProbes.memoryLimit` → *(config-only; no CLI equivalent, see §14)* -- `sealedProbes.interpreter` → *(config-only; no CLI equivalent, see §14)* -- `sealedProbes.maxInvocations` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.enabled` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.privateRepos[]` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.runtime` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.timeout` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.memoryLimit` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.interpreter` → *(config-only; no CLI equivalent, see §14)* +- `boundedQueries.maxInvocations` → *(config-only; no CLI equivalent, see §14)* When `container.dockerHostPathPrefix` points at a daemon-visible shared `/tmp` path, the implementation stages the invoking CLI binary together with `/etc/passwd`, `/etc/group`, and the generated chroot `/etc/hosts` under that shared path so chroot mode can bootstrap on split-filesystem ARC/DinD hosts. @@ -1536,17 +1536,17 @@ Each record follows the `blocked-request-diag/v` schema: - The file is written to `AWF_TOKEN_LOG_DIR` alongside `token-usage.jsonl` and is governed by the same artifact-retention policy. -## 14. Sealed Probes +## 14. Bounded Queries ### 14.1 Purpose -A *sealed probe* lets an agent ask a trusted broker to run a short, +A *bounded query* lets an agent ask a trusted broker to run a short, agent-authored Python 3 script against a private repository and get back a value conforming to a finite response schema the agent declares up front — without the agent ever gaining network or filesystem access to that repository. -Every private repository configured for sealed probes carries one of four +Every private repository configured for bounded queries carries one of four fixed **sensitivity categories**, each with an immutable maximum number of bits the broker may reveal about that repository across an entire AWF run (not per query): @@ -1566,16 +1566,16 @@ An invocation is allowed iff its charge fits the remaining balance — a cheap boolean question and an expensive high-cardinality question both draw from the same budget, just at different rates. Charges are never refunded, regardless of outcome (success, failure, or timeout). -`sealedProbes.maxInvocations` is a separate, independent operational limit +`boundedQueries.maxInvocations` is a separate, independent operational limit (§14.2) unrelated to the bit ledger. ### 14.2 Configuration -The root object MAY contain a `sealedProbes` section: +The root object MAY contain a `boundedQueries` section: ```json { - "sealedProbes": { + "boundedQueries": { "enabled": true, "privateRepos": [ { "repo": "my-org/my-private-repo", "sensitivity": "internal" }, @@ -1600,23 +1600,23 @@ The root object MAY contain a `sealedProbes` section: | `interpreter` | string | Only `"python3"` is currently supported | `"python3"` | | `maxInvocations` | integer | `1`–`10000`; an independent operational cap, unrelated to the per-repository bit ledger | `32` | -Property-level constraints are defined normatively by the `sealedProbes` +Property-level constraints are defined normatively by the `boundedQueries` subschema in `docs/awf-config.schema.json`. **Legacy `privateRepos` string entries.** A bare `owner/repo` string is accepted for one release for backward compatibility and is normalized to `{ repo, sensitivity: "internal" }`, emitting a warning -(`sealedProbes.privateRepos entry "..." is a legacy bare string...`) through +(`boundedQueries.privateRepos entry "..." is a legacy bare string...`) through the same warning channel other config normalization uses. New configuration SHOULD use the explicit object form so the intended sensitivity is never left implicit. -**Mapping:** every `sealedProbes.*` field is *(config-only; no CLI -equivalent)*. There is no `--sealed-probes-*` CLI flag family. The config-file +**Mapping:** every `boundedQueries.*` field is *(config-only; no CLI +equivalent)*. There is no `--bounded-queries-*` CLI flag family. The config-file value is passed through `config-mapper.ts` and normalized (defaults applied -via `src/types/sealed-probe-options.ts`'s `SEALED_PROBE_DEFAULTS`, legacy -string entries normalized in `src/parsers/sealed-probe-parser.ts`) into -`WrapperConfig.sealedProbes`. Only an explicit `enabled: true` normalizes to +via `src/types/bounded-query-options.ts`'s `BOUNDED_QUERY_DEFAULTS`, legacy +string entries normalized in `src/parsers/bounded-query-parser.ts`) into +`WrapperConfig.boundedQueries`. Only an explicit `enabled: true` normalizes to an enabled config; omission or any other value normalizes to `enabled: false`. @@ -1644,36 +1644,36 @@ repository's sensitivity or run budget. ### 14.3 Request/Result Protocol (v2) -`src/sealed-probe/protocol.ts` defines the wire protocol. The broker restates -it in `containers/sealed-probe/broker/protocol.js` because it runs from its +`src/bounded-query/protocol.ts` defines the wire protocol. The broker restates +it in `containers/bounded-query/broker/protocol.js` because it runs from its own container image and cannot import AWF's TypeScript sources; the two implementations are pinned together by -`src/sealed-probe/protocol-parity.test.ts`, which runs one large shared -vector table (schemas, values, requests, and probe results) through both. +`src/bounded-query/protocol-parity.test.ts`, which runs one large shared +vector table (schemas, values, requests, and query results) through both. -**Request.** A sealed-probe request is a JSON object with exactly three +**Request.** A bounded-query request is a JSON object with exactly three fields: ```json { "privateRepo": "my-org/my-private-repo", "schema": { "type": "boolean" }, - "script": "" + "script": "" } ``` - `privateRepo` MUST match the same `owner/repo` slug rule as - `sealedProbes.privateRepos` entries (§14.2). + `boundedQueries.privateRepos` entries (§14.2). - `schema` MUST be a valid document in the finite schema DSL below. - `script` MUST be non-empty and at most 64 KiB (`MAX_SCRIPT_BYTES`). Script and schema sizes are enforced independently on their raw UTF-8 bytes; JSON escaping does not reduce either allowance. -**Result.** A successful probe result is the canonical envelope +**Result.** A successful query result is the canonical envelope `{"status":"ok","result":}`, where `` conforms exactly to the request's declared `schema`. Every failure mode — invalid request, disallowed repository, exhausted bit budget, launch failure, timeout, crash, -non-conformant probe output, or internal error — collapses to the single +non-conformant query output, or internal error — collapses to the single canonical `{"status":"error"}`, indistinguishable from one another by design. @@ -1754,7 +1754,7 @@ that many bits of signal the moment it decided to run. #### 14.3.1 Response-timing buckets -A probe's raw completion latency is itself a secret-dependent signal — a +A query's raw completion latency is itself a secret-dependent signal — a script that raises early on one code path and runs to completion on another leaks information purely through wall-clock time, independent of the declared schema. The broker makes every *launched* invocation's observable @@ -1785,7 +1785,7 @@ observe a preceding invocation's unaccounted cleanup duration. Cleanup failure maps to canonical error and is recorded only in the protected audit log. -**Fail-closed timing overflow.** `sealedProbes.timeout` is capped at 540 +**Fail-closed timing overflow.** `boundedQueries.timeout` is capped at 540 seconds, reserving the final minute before the 600-second boundary for Docker termination, result validation, container removal, and workspace cleanup. If pathological infrastructure overhead nevertheless pushes total processing or @@ -1797,7 +1797,7 @@ deliberate, tested (`broker.test.ts`) fallback, not a normal code path. Every failure mode — an invalid request, a disallowed repository, an exhausted bit budget, an exhausted `maxInvocations` count, a launch failure, -a timeout, a script crash, non-conformant probe output, a timing-bucket +a timeout, a script crash, non-conformant query output, a timing-bucket overflow, or an internal broker error — collapses to the single canonical `{"status":"error"}`. Failures are indistinguishable from each other by design: the agent cannot infer which failure mode occurred from the @@ -1806,7 +1806,7 @@ response alone. ### 14.5 Strict, Non-Schema Result Parsing and Post-Execution Validation Result parsing intentionally does **not** execute a general-purpose JSON -Schema validator against the (potentially attacker-influenced) raw probe +Schema validator against the (potentially attacker-influenced) raw query output text. `strictParseJson` enforces well-formedness with a small, linear-time, non-backtracking hand-written grammar — rejecting, rather than throwing, on: @@ -1822,22 +1822,22 @@ an undeclared enum member, extra or missing object fields, the wrong tuple/array length, or an unrecognized union tag are all rejected. A value that passes validation is canonically re-serialized (`canonicalizeSchemaValue`) before being wrapped in the `{"status":"ok",...}` -envelope, so the exact byte layout the probe wrote (whitespace, key order, +envelope, so the exact byte layout the query wrote (whitespace, key order, duplicate-safe encoding) never reaches the agent — only a canonical re-encoding of the validated value does. -Raw probe bytes, stdout, stderr, and exit status never reach the agent under +Raw query bytes, stdout, stderr, and exit status never reach the agent under any circumstance, success or failure. ### 14.6 Offline Staging Before any configuration is generated and before any container exists, AWF -runs a trusted host-side staging phase (`src/sealed-probe/staging.ts`): +runs a trusted host-side staging phase (`src/bounded-query/staging.ts`): 1. resolves the staging credential from `GH_TOKEN` or `GITHUB_TOKEN`; 2. clones each configured repository from an AWF-constructed `https://github.com//.git` URL into a run-unique, opaque seed - directory under `/sealed-probes/seeds/`. The credential is passed + directory under `/bounded-queries/seeds/`. The credential is passed only through a `GIT_ASKPASS` helper reading it from the child process environment — never in argv, never in the URL, never in a log line, and never in the generated compose file; @@ -1847,7 +1847,7 @@ runs a trusted host-side staging phase (`src/sealed-probe/staging.ts`): `.git/config` is replaced with a minimal, credential-free file; 5. rejects repositories that declare submodules (`.gitmodules` or `.git/modules`) and any checkout whose `.git` is a symlink or a gitdir - pointer — both are external references a probe must never resolve; + pointer — both are external references a query must never resolve; 6. strips every write bit from the seed and verifies the result; 7. deletes the askpass helper and the isolated staging `HOME`, so no staging artifact survives into the broker/agent phase. @@ -1858,19 +1858,19 @@ map is the broker's *only* source of sensitivity information — a request field can never supply or override it. Staging failure aborts the run. There is no fallback clone or fetch anywhere -else in the system: neither the broker nor a probe has a network path. A +else in the system: neither the broker nor a query has a network path. A `sealed` (0-bit) repository is still staged like any other (so its configuration is validated the same way), but its run budget structurally guarantees the broker never copies that seed or launches Python for it. -### 14.7 Trusted Broker and Probe Sandbox +### 14.7 Trusted Broker and Query Sandbox The broker runs as an optional Docker Compose service -(`sealed-probe-broker`, container `awf-sealed-probe-broker`) with +(`bounded-query-broker`, container `awf-bounded-query-broker`) with `network_mode: none`: no `awf-net`, no external bridge, no DNS, no Squid, no api-proxy/cli-proxy, and no host-network path. Its entire surface is one Unix socket in a directory bind-mounted into the agent. It also receives the -resolved Docker socket so it can launch probes; that path is never placed in +resolved Docker socket so it can launch queries; that path is never placed in the agent's environment or volumes. The broker maps a normalized `owner/repo` id through the AWF-generated seed @@ -1880,16 +1880,16 @@ or sensitivity. For each request that passes schema validation and clears its repository's remaining bit ledger (in that order — an invalid schema or an unaffordable charge is rejected before any seed is touched), the broker creates a fresh, full, private writable copy of exactly one seed and -launches one probe container with a fixed argument vector: +launches one query container with a fixed argument vector: - `--network none`, `--read-only`, `--user 65534:65534`, `--cap-drop ALL`, `--security-opt no-new-privileges:true`, and a restrictive seccomp profile - (`containers/sealed-probe/probe-seccomp.json`); + (`containers/bounded-query/query-seccomp.json`); - memory, swap, CPU, PID, open-file, and file-size bounds plus the configured wall-clock timeout; -- exactly two mounts: the invocation's private tree at `/probe` (containing - `repo/`, and where the probe writes `out`) and the submitted script at the - fixed read-only path `/awf/probe-script.py`; +- exactly two mounts: the invocation's private tree at `/query` (containing + `repo/`, and where the query writes `out`) and the submitted script at the + fixed read-only path `/awf/query-script.py`; - no Docker socket, no seed parent, no other repository, no workspace, no credentials, and no prior invocation's data. @@ -1899,29 +1899,29 @@ channel. A cleanup failure produces canonical error and is recorded in the protected audit log (`reason: 'cleanup-failed'`). Repository mutations are ephemeral and are never returned or persisted. The result file is opened with `O_NOFOLLOW` and must -be a regular file within the size cap, so replacing `/probe/out` with a +be a regular file within the size cap, so replacing `/query/out` with a symlink, FIFO, device, or socket cannot make the broker read anything else. -Probe stdout/stderr is capped and discarded — never parsed, never returned, +Query stdout/stderr is capped and discarded — never parsed, never returned, never logged in a form reachable by the agent. Failure reasons (with protected detail, e.g. `repo-not-allowed`, `bit-budget-exhausted`, -`invalid-request`, `probe-launch-failed`, `timing-bucket-overflow`, -`cleanup-failed`) are written only to `/sealed-probes/audit/`, +`invalid-request`, `query-launch-failed`, `timing-bucket-overflow`, +`cleanup-failed`) are written only to `/bounded-queries/audit/`, which is mounted into the broker alone. ### 14.8 Agent Interface -When sealed probes are enabled, the agent receives exactly two bind mounts — +When bounded queries are enabled, the agent receives exactly two bind mounts — the broker socket directory (read-write) and a generated skill directory (read-only) — plus three environment variables -(`AWF_SEALED_PROBE_SOCKET`, `AWF_SEALED_PROBE_SKILL`, -`AWF_SEALED_PROBE_REPOS`, the last a comma-separated list of configured repo +(`AWF_BOUNDED_QUERY_SOCKET`, `AWF_BOUNDED_QUERY_SKILL`, +`AWF_BOUNDED_QUERY_REPOS`, the last a comma-separated list of configured repo slugs only — never sensitivities or budgets). GitHub tokens are removed from -the agent environment whenever sealed probes are enabled, independently of +the agent environment whenever bounded queries are enabled, independently of the API and DIFC proxies. -`containers/agent/sealed-probe-wrapper.sh` is installed on the agent's `PATH` -as `sealed-probe` (protocol v2). It accepts only `--repo` once, `--schema` +`containers/agent/bounded-query-wrapper.sh` is installed on the agent's `PATH` +as `bounded-query` (protocol v2). It accepts only `--repo` once, `--schema` once (a JSON document, at most `MAX_SCHEMA_BYTES` bytes), and the script on stdin; every other option, the `--flag=value` form, and positional arguments are rejected without contacting the broker. It always prints exactly one @@ -1934,8 +1934,8 @@ responsibilities are enforcing the fixed CLI shape, base64url-encoding the schema into a request header, transporting the script body unmodified, and passing the broker's response through unmodified. -The generated `SKILL.md` is written under `/sealed-probes/agent/` and -mounted read-only at `/run/awf-sealed-probe-skill/SKILL.md`. It documents, +The generated `SKILL.md` is written under `/bounded-queries/agent/` and +mounted read-only at `/run/awf-bounded-query-skill/SKILL.md`. It documents, per configured repository, its sensitivity and run budget (e.g. `` `octo/alpha` — 64 bits/run (`internal`) ``), the finite schema DSL, the bit-charge formula, the timing buckets, and the operational `maxInvocations` limit — @@ -1943,12 +1943,12 @@ so an agent can design informed, low-cardinality questions. AWF deliberately does **not** mount it into `$HOME/.copilot/skills` or the workspace's `.github/skills`: Docker would create the mount point inside host user state or inside the checked-out workspace. Agents therefore discover it through -`AWF_SEALED_PROBE_SKILL` rather than through automatic skill discovery. This +`AWF_BOUNDED_QUERY_SKILL` rather than through automatic skill discovery. This is a documented limitation, not an oversight. For the same reason, a microVM primary agent runtime (`sbx`) is rejected at preflight: it does not receive Compose bind mounts, so the socket and skill -could not be exposed. Sealed probes are never partially enabled. +could not be exposed. Bounded queries are never partially enabled. ### 14.9 Protocol v1 Compatibility @@ -1956,7 +1956,7 @@ Protocol v1 (three fixed outcomes plus the reserved `"ERROR"` sentinel, no schema, no sensitivity, no bit ledger) is superseded by v2. There is no runtime v1/v2 auto-negotiation in the current wrapper or broker — both are deployed together as part of the same AWF release, and the wrapper always -sends `X-AWF-Probe-Version: 2`. A safe compatibility translation for legacy +sends `X-AWF-Query-Version: 2`. A safe compatibility translation for legacy v1 three-outcome calls (mapping a fixed three-value `enum` schema to the old `outcomes` shape) is a natural extension point if a future release needs to accept both wire versions from mismatched wrapper/broker builds, but is not @@ -1976,8 +1976,8 @@ matched pair. before the bucket is selected (§14.3.1, §14.7). - Per-invocation aggregate disk usage is bounded by the wall-clock timeout and a per-file size limit rather than a hard filesystem quota. -- The probe rootfs is the broker image, so it also contains a Node runtime and - the Docker CLI. Both are inert inside a probe: there is no network, no +- The query rootfs is the broker image, so it also contains a Node runtime and + the Docker CLI. Both are inert inside a query: there is no network, no Docker socket, no capability, and the entrypoint is fixed to `python3`. ## Normative References diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 9f2c6181e..95c59c073 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -836,19 +836,19 @@ } } }, - "sealedProbes": { + "boundedQueries": { "type": "object", - "description": "Sealed-probe sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `sealed-probe` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.", + "description": "Bounded-query sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `bounded-query` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", - "description": "Enable sealed probes for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host and a Compose-based container runtime. Default: false.", + "description": "Enable bounded queries for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host and a Compose-based container runtime. Default: false.", "default": false }, "privateRepos": { "type": "array", - "description": "Private repositories the sealed-probe broker may run probes against, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches a probe). A legacy bare `owner/repo` string is accepted for one release only and normalized to `{ repo, sensitivity: \"internal\" }` with a warning; update it to the object form. Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. AWF stages one immutable seed per entry before the primary agent starts.", + "description": "Private repositories the bounded-query broker may run queries against, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches a query). A legacy bare `owner/repo` string is accepted for one release only and normalized to `{ repo, sensitivity: \"internal\" }` with a warning; update it to the object form. Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. AWF stages one immutable seed per entry before the primary agent starts.", "items": { "oneOf": [ { @@ -891,20 +891,20 @@ "docker", "gvisor" ], - "description": "Sandbox runtime backend used to execute the probe script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime and fails closed when it is unavailable. Default: \"docker\".", + "description": "Sandbox runtime backend used to execute the query script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime and fails closed when it is unavailable. Default: \"docker\".", "default": "docker" }, "timeout": { "type": "integer", "minimum": 1, "maximum": 540, - "description": "Maximum wall-clock time in seconds allowed for a single probe invocation. Exceeding it returns the canonical error. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 30.", + "description": "Maximum wall-clock time in seconds allowed for a single query invocation. Exceeding it returns the canonical error. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 30.", "default": 30 }, "memoryLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "description": "Docker-style memory limit applied to the probe sandbox (e.g. \"512m\", \"1g\"). Swap is disabled at the same value. Default: \"512m\".", + "description": "Docker-style memory limit applied to the query sandbox (e.g. \"512m\", \"1g\"). Swap is disabled at the same value. Default: \"512m\".", "default": "512m" }, "interpreter": { @@ -912,14 +912,14 @@ "enum": [ "python3" ], - "description": "Script interpreter used to run the probe. Only \"python3\" (standard library only, no package installation) is supported.", + "description": "Script interpreter used to run the query. Only \"python3\" (standard library only, no package installation) is supported.", "default": "python3" }, "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 10000, - "description": "Maximum number of probe responses permitted for the current AWF run. Every response — including a rejection — counts, because each one reveals one of the four permitted symbols. Exhaustion returns the canonical ERROR without launching a probe. Default: 32.", + "description": "Maximum number of query responses permitted for the current AWF run. Every response — including a rejection — counts, because each one reveals one of the four permitted symbols. Exhaustion returns the canonical ERROR without launching a query. Default: 32.", "default": 32 } }, diff --git a/docs-site/src/content/docs/guides/sealed-probes.md b/docs/bounded-queries.md similarity index 79% rename from docs-site/src/content/docs/guides/sealed-probes.md rename to docs/bounded-queries.md index 4f8dd19f3..80d92dd0b 100644 --- a/docs-site/src/content/docs/guides/sealed-probes.md +++ b/docs/bounded-queries.md @@ -1,15 +1,15 @@ ---- -title: Sealed Probes -description: Run narrow, brokered Python scripts against private repositories without exposing repository contents to the primary agent. ---- +# Bounded Queries -A **sealed probe** lets an agent ask a trusted broker to run a short, agent-authored Python 3 script against a private repository and get back a single value conforming to a finite schema the agent declares up front -- without the agent ever seeing repository contents, receiving diagnostic output, or gaining network access to the repository. +Run narrow, brokered Python scripts against private repositories without +exposing repository contents to the primary agent. -The feature is config-only: there are no `--sealed-probes-*` CLI flags. Everything is expressed in the AWF JSON configuration file. +A **bounded query** lets an agent ask a trusted broker to run a short, agent-authored Python 3 script against a private repository and get back a single value conforming to a finite schema the agent declares up front -- without the agent ever seeing repository contents, receiving diagnostic output, or gaining network access to the repository. + +The feature is config-only: there are no `--bounded-queries-*` CLI flags. Everything is expressed in the AWF JSON configuration file. ## Use cases -Sealed probes are designed for **bounded, answerable questions** about a private repository where the question and its full range of answers can be expressed as a finite schema. +Bounded queries are designed for **bounded, answerable questions** about a private repository where the question and its full range of answers can be expressed as a finite schema. **Good uses** @@ -17,17 +17,17 @@ Sealed probes are designed for **bounded, answerable questions** about a private - "How many Python files are in `src/`?" -- bounded integer with a known upper limit - "Which license identifier is declared: MIT, Apache-2.0, GPL-3.0, or something else?" -- small enum - "Is the `requires-python` minimum in `pyproject.toml` at least 3.10?" -- boolean -- "Do both repositories declare the same major API version in their manifest?" -- each queried separately; answers compared by the agent after two probes +- "Do both repositories declare the same major API version in their manifest?" -- each queried separately; answers compared by the agent after two queries **Not suited for** - Extracting source code, documentation, or any variable-length text -- unbounded strings are structurally impossible in the schema DSL - Arbitrary repository exploration or browsing -- Tasks where the answer space cannot be described by a finite schema before the probe runs +- Tasks where the answer space cannot be described by a finite schema before the query runs - Repositories marked `sealed` (0-bit budget) -- these can never fund even the cheapest query :::note -Sealed probes bound *quantity* of information revealed, not *semantics*. Classifying a repository's sensitivity level is an operator responsibility; the feature enforces the declared limit but cannot validate that the classification is correct. +Bounded queries bound *quantity* of information revealed, not *semantics*. Classifying a repository's sensitivity level is an operator responsibility; the feature enforces the declared limit but cannot validate that the classification is correct. ::: ## Architecture @@ -36,19 +36,19 @@ The trust boundary operates in four stages: 1. **Trusted host staging.** Before any container starts, AWF clones each configured repository using `GH_TOKEN`/`GITHUB_TOKEN`, strips all credentials, remotes, hooks, and write bits from the resulting seed, and records the resolved commit in trusted staging metadata. Submodules and gitdir pointers are rejected. The staging credential is scrubbed after this phase and never reaches the broker or agent. -2. **Trusted broker over Unix socket.** A dedicated `awf-sealed-probe-broker` container with `network_mode: none` serves requests over a Unix socket mounted into the agent. It receives no network, no Squid proxy, and no external bridge. Its only connections are the Unix socket and the Docker socket (agent-invisible), used to launch probes. The broker holds the seed map -- including each repository's trusted sensitivity -- which the agent can never read or modify. +2. **Trusted broker over Unix socket.** A dedicated `awf-bounded-query-broker` container with `network_mode: none` serves requests over a Unix socket mounted into the agent. It receives no network, no Squid proxy, and no external bridge. Its only connections are the Unix socket and the Docker socket (agent-invisible), used to launch queries. The broker holds the seed map -- including each repository's trusted sensitivity -- which the agent can never read or modify. -3. **Fresh, no-network probe sandbox.** For each accepted request the broker creates a private writable copy of exactly one seed, then launches a single-use container with no network, a read-only root filesystem with bounded writable tmpfs mounts at `/tmp` and `/probe`, no capabilities, a restrictive seccomp profile, and fixed memory, CPU, PID, and timeout limits. The agent-authored script runs at `/awf/probe-script.py` and must write its result to `/probe/out`. Stdout, stderr, and exit status are discarded. +3. **Fresh, no-network query sandbox.** For each accepted request the broker creates a private writable copy of exactly one seed, then launches a single-use container with no network, a read-only root filesystem with bounded writable tmpfs mounts at `/tmp` and `/query`, no capabilities, a restrictive seccomp profile, and fixed memory, CPU, PID, and timeout limits. The agent-authored script runs at `/awf/query-script.py` and must write its result to `/query/out`. Stdout, stderr, and exit status are discarded. 4. **Canonical finite result and cleanup.** After the script exits, the broker validates the result file against the declared schema using a non-backtracking hand-written parser, re-serializes the canonical form, tears down the workspace, then -- only after cleanup completes -- selects the timing bucket and responds. The agent receives exactly `{"status":"ok","result":}` or `{"status":"error"}` with nothing else. ## Configuration -Add a `sealedProbes` section to your AWF JSON config file: +Add a `boundedQueries` section to your AWF JSON config file: ```json { - "sealedProbes": { + "boundedQueries": { "enabled": true, "privateRepos": [ { "repo": "my-org/private-service", "sensitivity": "internal" }, @@ -131,7 +131,7 @@ An `internal` repository with a 64-bit budget can fund 12 consecutive boolean qu ## Timing buckets -Probe response latency is itself a side channel: a script that exits early on one code path and runs longer on another leaks information through wall-clock time. The broker makes every launched invocation's observable response time land on one of six fixed boundaries: +Query response latency is itself a side channel: a script that exits early on one code path and runs longer on another leaks information through wall-clock time. The broker makes every launched invocation's observable response time land on one of six fixed boundaries: | Bucket | Boundary | |---|---| @@ -152,27 +152,27 @@ The three timing bits are charged as part of every accepted invocation's budget ## Agent interface -When sealed probes are enabled the agent container receives: +When bounded queries are enabled the agent container receives: -- A Unix socket directory (read-write) mounted at `$AWF_SEALED_PROBE_SOCKET` -- A generated skill file (read-only) at `$AWF_SEALED_PROBE_SKILL` -- `AWF_SEALED_PROBE_REPOS` -- a comma-separated list of configured repo slugs +- A Unix socket directory (read-write) mounted at `$AWF_BOUNDED_QUERY_SOCKET` +- A generated skill file (read-only) at `$AWF_BOUNDED_QUERY_SKILL` +- `AWF_BOUNDED_QUERY_REPOS` -- a comma-separated list of configured repo slugs The generated skill lists each repository's configured sensitivity and initial run budget. It does not expose the broker's remaining ledger balance. -GitHub tokens are removed from the agent environment whenever sealed probes are enabled, independently of the API and CLI proxies. +GitHub tokens are removed from the agent environment whenever bounded queries are enabled, independently of the API and CLI proxies. -The `sealed-probe` command is installed on the agent's `PATH` and is the only supported way to invoke a probe. +The `bounded-query` command is installed on the agent's `PATH` and is the only supported way to invoke a query. -### Invoking the `sealed-probe` command +### Invoking the `bounded-query` command ``` -sealed-probe --repo --schema '' < script.py +bounded-query --repo --schema '' < script.py ``` - `--repo` must appear exactly once. The value must be a valid `owner/repo` slug matching a configured repository. - `--schema` must appear exactly once. The value is a JSON document (at most 4096 bytes) conforming to the finite schema DSL. -- The probe script arrives on **stdin**. Interactive terminals are rejected. +- The query script arrives on **stdin**. Interactive terminals are rejected. - Any other flag, the `--flag=value` form, and positional arguments are rejected without contacting the broker. The command always prints exactly one canonical JSON line to stdout, writes nothing to stderr, and exits with status 0 -- for both outcomes and for every failure, including transport failures. @@ -182,14 +182,14 @@ The command always prints exactly one canonical JSON line to stdout, writes noth Ask whether a repository contains a `SECURITY.md` at its root. Schema cardinality is 2, charge is 5 bits from the repository's run budget. ```bash -sealed-probe \ +bounded-query \ --repo my-org/private-service \ --schema '{"type":"boolean"}' \ <<'EOF' import json, os -result = os.path.isfile('/probe/repo/SECURITY.md') -with open('/probe/out', 'w') as f: +result = os.path.isfile('/query/repo/SECURITY.md') +with open('/query/out', 'w') as f: json.dump(result, f) EOF ``` @@ -206,7 +206,7 @@ On any failure (invalid repo, exhausted budget, script crash, timeout, non-confo {"status":"error"} ``` -**Probe environment.** The script runs as an unprivileged user (uid 65534) with no network and a read-only filesystem, except for `/probe`. The repository tree is at `/probe/repo/`. The script must write exactly one JSON value conforming to the declared schema to `/probe/out`. Stdout and stderr are discarded and never reach the agent. +**Query environment.** The script runs as an unprivileged user (uid 65534) with no network and a read-only filesystem, except for `/query`. The repository tree is at `/query/repo/`. The script must write exactly one JSON value conforming to the declared schema to `/query/out`. Stdout and stderr are discarded and never reach the agent. ## Finite response schema DSL @@ -247,7 +247,7 @@ In practice the 4096-byte size limit is the binding constraint for wide `enum` o The schema is validated **before** the broker copies a seed or launches Python. If the schema is structurally invalid the request is rejected immediately (canonical error) without touching the repository. -After the script exits, the result file is parsed with a non-backtracking hand-written parser that rejects malformed JSON, duplicate object keys, leading or trailing content, and invalid UTF-8. The parsed value is then validated against the exact declared schema. A value that passes is canonically re-serialized before being wrapped in the response envelope -- the exact byte layout written by the probe (whitespace, key order) never reaches the agent. +After the script exits, the result file is parsed with a non-backtracking hand-written parser that rejects malformed JSON, duplicate object keys, leading or trailing content, and invalid UTF-8. The parsed value is then validated against the exact declared schema. A value that passes is canonically re-serialized before being wrapped in the response envelope -- the exact byte layout written by the query (whitespace, key order) never reaches the agent. ## Failure semantics @@ -257,21 +257,21 @@ All failure modes collapse to a single canonical response: {"status":"error"} ``` -Failures that map to this response include: invalid request format, schema validation failure, repo not in `privateRepos`, exhausted bit budget, exhausted `maxInvocations`, probe launch failure, timeout, script crash, non-conformant output, timing-bucket overflow, and internal broker errors. +Failures that map to this response include: invalid request format, schema validation failure, repo not in `privateRepos`, exhausted bit budget, exhausted `maxInvocations`, query launch failure, timeout, script crash, non-conformant output, timing-bucket overflow, and internal broker errors. Failures are indistinguishable from each other by design: the agent cannot infer which failure mode occurred from the response alone. `maxInvocations` counts **every** response, including rejected requests. It is a separate operational limit unrelated to per-repository bit budgets. Once exhausted, all further requests return `{"status":"error"}` without consulting the bit ledger. -Failure details (with protected labels such as `repo-not-allowed`, `bit-budget-exhausted`, `invalid-request`, `launch-failed`, `timing-bucket-overflow`, and `cleanup-failed`) are written only to the protected audit log at `/sealed-probes/audit/`. They are never returned to the agent. +Failure details (with protected labels such as `repo-not-allowed`, `bit-budget-exhausted`, `invalid-request`, `launch-failed`, `timing-bucket-overflow`, and `cleanup-failed`) are written only to the protected audit log at `/bounded-queries/audit/`. They are never returned to the agent. ## Security limitations -Sealed probes provide a **quantitative information bound**, not a semantic approval of disclosed content: +Bounded queries provide a **quantitative information bound**, not a semantic approval of disclosed content: - The bit budget limits how many bits of signal the broker may reveal, not whether any particular fact is sensitive. -- Timing is included in the charge and bucketed, but six bucket outcomes are still observable (3 bits). Repeated probes can reveal additional bucket outcomes, and each accepted invocation pays that timing charge. -- Agent-authored code is arbitrary Python within the sandbox. The sandbox enforces isolation, but a probe can compute and express any value that fits the declared schema. +- Timing is included in the charge and bucketed, but six bucket outcomes are still observable (3 bits). Repeated queries can reveal additional bucket outcomes, and each accepted invocation pays that timing charge. +- Agent-authored code is arbitrary Python within the sandbox. The sandbox enforces isolation, but a query can compute and express any value that fits the declared schema. - `public` repositories are unmetered. The schema and operational limits (`maxInvocations`, timeouts, sandboxing) still apply, but there is no bit ledger to exhaust. - Budgets reset each AWF run. The broker has no durable identity or storage across runs. - Classifying a repository's sensitivity level is an operator responsibility. Selecting a less restrictive category with a larger budget than warranted undermines the bound the feature provides. @@ -279,4 +279,4 @@ Sealed probes provide a **quantitative information bound**, not a semantic appro ## See also - [Security Architecture](/gh-aw-firewall/reference/security-architecture) - Firewall trust model and isolation layers -- [AWF config spec section 14](https://github.com/github/gh-aw-firewall/blob/main/docs/awf-config-spec.md#14-sealed-probes) - Normative specification with full field constraints, protocol details, and staging implementation notes +- [AWF config spec section 14](https://github.com/github/gh-aw-firewall/blob/main/docs/awf-config-spec.md#14-bounded-queries) - Normative specification with full field constraints, protocol details, and staging implementation notes diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 9f2c6181e..95c59c073 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -836,19 +836,19 @@ } } }, - "sealedProbes": { + "boundedQueries": { "type": "object", - "description": "Sealed-probe sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `sealed-probe` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.", + "description": "Bounded-query sandbox configuration. When enabled, AWF stages an immutable seed per configured private repository, starts an offline broker (network_mode: none), and exposes a fixed `bounded-query` CLI plus a generated skill to the agent. See docs/awf-config-spec.md §14.", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", - "description": "Enable sealed probes for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host and a Compose-based container runtime. Default: false.", + "description": "Enable bounded queries for this run. Requires a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host and a Compose-based container runtime. Default: false.", "default": false }, "privateRepos": { "type": "array", - "description": "Private repositories the sealed-probe broker may run probes against, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches a probe). A legacy bare `owner/repo` string is accepted for one release only and normalized to `{ repo, sensitivity: \"internal\" }` with a warning; update it to the object form. Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. AWF stages one immutable seed per entry before the primary agent starts.", + "description": "Private repositories the bounded-query broker may run queries against, each with a trusted confidentiality category that fixes its per-run information budget (`public` unmetered, `internal` 64 bits/run, `confidential` 8 bits/run, `sealed` 0 bits/run — never launches a query). A legacy bare `owner/repo` string is accepted for one release only and normalized to `{ repo, sensitivity: \"internal\" }` with a warning; update it to the object form. Each `repo` must be a bare `owner/repo` slug — no scheme, host, credentials, path traversal, query string, fragment, or wildcard. Repository names must be unique case-insensitively. AWF stages one immutable seed per entry before the primary agent starts.", "items": { "oneOf": [ { @@ -891,20 +891,20 @@ "docker", "gvisor" ], - "description": "Sandbox runtime backend used to execute the probe script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime and fails closed when it is unavailable. Default: \"docker\".", + "description": "Sandbox runtime backend used to execute the query script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime and fails closed when it is unavailable. Default: \"docker\".", "default": "docker" }, "timeout": { "type": "integer", "minimum": 1, "maximum": 540, - "description": "Maximum wall-clock time in seconds allowed for a single probe invocation. Exceeding it returns the canonical error. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 30.", + "description": "Maximum wall-clock time in seconds allowed for a single query invocation. Exceeding it returns the canonical error. Capped at 540 so the 10-minute response bucket reserves its final minute for termination, validation, container removal, and workspace cleanup. Default: 30.", "default": 30 }, "memoryLimit": { "type": "string", "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "description": "Docker-style memory limit applied to the probe sandbox (e.g. \"512m\", \"1g\"). Swap is disabled at the same value. Default: \"512m\".", + "description": "Docker-style memory limit applied to the query sandbox (e.g. \"512m\", \"1g\"). Swap is disabled at the same value. Default: \"512m\".", "default": "512m" }, "interpreter": { @@ -912,14 +912,14 @@ "enum": [ "python3" ], - "description": "Script interpreter used to run the probe. Only \"python3\" (standard library only, no package installation) is supported.", + "description": "Script interpreter used to run the query. Only \"python3\" (standard library only, no package installation) is supported.", "default": "python3" }, "maxInvocations": { "type": "integer", "minimum": 1, "maximum": 10000, - "description": "Maximum number of probe responses permitted for the current AWF run. Every response — including a rejection — counts, because each one reveals one of the four permitted symbols. Exhaustion returns the canonical ERROR without launching a probe. Default: 32.", + "description": "Maximum number of query responses permitted for the current AWF run. Every response — including a rejection — counts, because each one reveals one of the four permitted symbols. Exhaustion returns the canonical ERROR without launching a query. Default: 32.", "default": 32 } }, diff --git a/src/sealed-probe/broker.test.ts b/src/bounded-query/broker.test.ts similarity index 87% rename from src/sealed-probe/broker.test.ts rename to src/bounded-query/broker.test.ts index 2549f4dfa..ae9f398cb 100644 --- a/src/sealed-probe/broker.test.ts +++ b/src/bounded-query/broker.test.ts @@ -8,7 +8,7 @@ import { EventEmitter } from 'events'; * its real filesystem workspace code with a mocked Docker runner and an * injectable clock. * - * These stand in for a full end-to-end probe run: they prove the + * These stand in for a full end-to-end query run: they prove the * writable-copy semantics, the seed's immutability, repository isolation, * the operational invocation budget, the per-repository *bit* ledger (no * per-query cap — every invocation's schema-derived charge is computed and @@ -18,10 +18,10 @@ import { EventEmitter } from 'events'; * canonical `{"status":"error"}` with no extra signal. */ /* eslint-disable @typescript-eslint/no-require-imports */ -const brokerDir = path.join(__dirname, '..', '..', 'containers', 'sealed-probe', 'broker'); +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker'); const { createBroker } = require(path.join(brokerDir, 'broker.js')); const workspace = require(path.join(brokerDir, 'workspace.js')); -const { buildProbeArgs } = require(path.join(brokerDir, 'probe-runner.js')); +const { buildQueryArgs } = require(path.join(brokerDir, 'query-runner.js')); const { buildRequestFromFrame, readBoundedBody } = require(path.join(brokerDir, 'framing.js')); const { TIMING_BUCKETS_MS } = require(path.join(brokerDir, 'scheduler.js')); /* eslint-enable @typescript-eslint/no-require-imports */ @@ -83,7 +83,7 @@ async function invoke( return response; } -describe('sealed-probe broker', () => { +describe('bounded-query broker', () => { let root: string; let config: Record; let seedMap: Map; @@ -129,18 +129,18 @@ describe('sealed-probe broker', () => { seedsDir: path.join(root, 'seeds'), workDir: path.join(root, 'work'), hostWorkDir: '/daemon/work', - probeMountDir: '/probe', - probeScriptPath: '/awf/probe-script.py', - probeSeccompPath: '/opt/awf/probe-seccomp.json', - probeImage: 'ghcr.io/example/sealed-probe:1', + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + querySeccompPath: '/opt/awf/query-seccomp.json', + queryImage: 'ghcr.io/example/bounded-query:1', dockerRuntime: '', memoryLimit: '512m', timeoutSeconds: 30, maxInvocations: 3, // The real broker runs as root; tests keep the invoking uid so the // ownership transfer is exercised without requiring privileges. - probeUid: process.getuid?.() ?? 0, - probeGid: process.getgid?.() ?? 0, + queryUid: process.getuid?.() ?? 0, + queryGid: process.getgid?.() ?? 0, }; fs.mkdirSync(String(config.workDir), { recursive: true }); fs.mkdirSync(String(config.seedsDir), { recursive: true }); @@ -158,7 +158,7 @@ describe('sealed-probe broker', () => { }); function build( - runner: { runProbeContainer: (params: never) => Promise }, + runner: { runQueryContainer: (params: never) => Promise }, opts: { workspace?: typeof workspace; clock?: { nowMs: () => number; sleep: (ms: number) => Promise }; @@ -178,12 +178,12 @@ describe('sealed-probe broker', () => { return { broker, audit }; } - /** Mock runner that behaves like a probe script executing inside the sandbox. */ - function probeRunner(behaviour: (invocationDir: string) => void, overrides: Record = {}) { + /** Mock runner that behaves like a query script executing inside the sandbox. */ + function queryRunner(behaviour: (invocationDir: string) => void, overrides: Record = {}) { const seen: string[] = []; return { seen, - runProbeContainer: async ({ invocationId }: { invocationId: string }) => { + runQueryContainer: async ({ invocationId }: { invocationId: string }) => { // The invocation root contains the assigned seed copy, output file, and // submitted script. The fixed entrypoint copies the read-only seed // mount into bounded tmpfs before running the script. @@ -192,17 +192,17 @@ describe('sealed-probe broker', () => { behaviour(invocationDir); return { exitCode: 0, timedOut: false, stdout: '', stderr: '', ...overrides }; }, - } as unknown as { runProbeContainer: (params: never) => Promise } & { seen: string[] }; + } as unknown as { runQueryContainer: (params: never) => Promise } & { seen: string[] }; } const validRequest = (repo = 'octo/alpha') => ({ privateRepo: repo, schema: OUTCOME_SCHEMA, - script: 'probe', + script: 'query', }); it('returns the canonically re-serialized declared outcome inside the ok envelope', async () => { - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), ' {"result": "YES"} '); }); const { broker } = build(runner); @@ -210,12 +210,12 @@ describe('sealed-probe broker', () => { expect(await invoke(broker, validRequest())).toBe('{"status":"ok","result":{"result":"YES"}}'); }); - it('gives the probe a read-only copy of the repo and leaves the seed unchanged', async () => { + it('gives the query a read-only copy of the repo and leaves the seed unchanged', async () => { let observed = ''; - const runner = probeRunner((invocationDir) => { - // Probe reads from the repo copy (mounted :ro in Docker). + const runner = queryRunner((invocationDir) => { + // Query reads from the repo copy (mounted :ro in Docker). observed = fs.readFileSync(path.join(invocationDir, 'repo', 'README.md'), 'utf8'); - // Probe writes its answer to the pre-created out file. + // Query writes its answer to the pre-created out file. fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"NO"}'); }); const { broker } = build(runner); @@ -228,7 +228,7 @@ describe('sealed-probe broker', () => { }); it('destroys the per-invocation copy afterwards', async () => { - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); const { broker } = build(runner); @@ -238,10 +238,10 @@ describe('sealed-probe broker', () => { expect(fs.readdirSync(String(config.workDir))).toEqual([]); }); - it('never exposes another repository or the seed parent to a probe', async () => { + it('never exposes another repository or the seed parent to a query', async () => { let repoContents = ''; let siblings: string[] = []; - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { repoContents = fs.readFileSync(path.join(invocationDir, 'repo', 'README.md'), 'utf8'); fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); siblings = fs.readdirSync(invocationDir).sort(); @@ -256,8 +256,8 @@ describe('sealed-probe broker', () => { }); it('rejects a repository outside the AWF-generated map without launching', async () => { - const runner = probeRunner(() => { - throw new Error('probe must not launch'); + const runner = queryRunner(() => { + throw new Error('query must not launch'); }); const { broker, audit } = build(runner); @@ -272,8 +272,8 @@ describe('sealed-probe broker', () => { ['invalid schema construct', { ...validRequest(), schema: { type: 'nope' } }], ['path traversal repo selector', { privateRepo: '../../seeds', schema: OUTCOME_SCHEMA, script: 'x' }], ])('rejects %s before copying or launching', async (_name, request) => { - const runner = probeRunner(() => { - throw new Error('probe must not launch'); + const runner = queryRunner(() => { + throw new Error('query must not launch'); }); const { broker, audit } = build(runner); @@ -341,7 +341,7 @@ describe('sealed-probe broker', () => { 'unreadable-output', ], ])('maps %s to the canonical error', async (_name, behaviour, reason) => { - const runner = probeRunner(behaviour as (invocationDir: string) => void); + const runner = queryRunner(behaviour as (invocationDir: string) => void); const { broker, audit } = build(runner); expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR); @@ -350,7 +350,7 @@ describe('sealed-probe broker', () => { }); it('maps a timeout to the canonical error', async () => { - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }, { timedOut: true, exitCode: 137 }); const { broker, audit } = build(runner); @@ -359,8 +359,8 @@ describe('sealed-probe broker', () => { expect(audit.records[audit.records.length - 1]).toMatchObject({ reason: 'timeout' }); }); - it('maps a non-zero probe exit to the canonical error even when output is valid', async () => { - const runner = probeRunner((invocationDir) => { + it('maps a non-zero query exit to the canonical error even when output is valid', async () => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }, { exitCode: 2 }); const { broker, audit } = build(runner); @@ -370,7 +370,7 @@ describe('sealed-probe broker', () => { }); it('includes cleanup before the response and maps cleanup failure to canonical error', async () => { - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); const cleanupFailingWorkspace = { @@ -394,8 +394,8 @@ describe('sealed-probe broker', () => { throw new Error('copy failed'); }, }; - const runner = probeRunner(() => { - throw new Error('probe must not launch'); + const runner = queryRunner(() => { + throw new Error('query must not launch'); }); const { broker, audit } = build(runner, { workspace: partialWorkspace }); @@ -406,10 +406,10 @@ describe('sealed-probe broker', () => { it('maps a launch failure to the canonical error', async () => { const runner = { - runProbeContainer: async () => { + runQueryContainer: async () => { throw new Error('daemon unreachable'); }, - } as unknown as { runProbeContainer: (params: never) => Promise }; + } as unknown as { runQueryContainer: (params: never) => Promise }; const { broker, audit } = build(runner); expect(await invoke(broker, validRequest())).toBe(CANONICAL_ERROR); @@ -419,8 +419,8 @@ describe('sealed-probe broker', () => { it('produces byte-identical responses for every failure-shaped answer', async () => { const failures = await Promise.all([ - invoke(build(probeRunner(() => {})).broker, validRequest('octo/nope')), - invoke(build(probeRunner(() => {})).broker, { privateRepo: 'octo/alpha', schema: { type: 'nope' }, script: 'x' }), + invoke(build(queryRunner(() => {})).broker, validRequest('octo/nope')), + invoke(build(queryRunner(() => {})).broker, { privateRepo: 'octo/alpha', schema: { type: 'nope' }, script: 'x' }), ]); expect(new Set(failures)).toEqual(new Set([CANONICAL_ERROR])); @@ -429,13 +429,13 @@ describe('sealed-probe broker', () => { it('enforces the per-run invocation budget atomically and without launching', async () => { const launches: string[] = []; const runner = { - runProbeContainer: async ({ invocationId }: { invocationId: string }) => { + runQueryContainer: async ({ invocationId }: { invocationId: string }) => { launches.push(invocationId); const invocationDir = path.join(String(config.workDir), invocationId); fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); return { exitCode: 0, timedOut: false }; }, - } as unknown as { runProbeContainer: (params: never) => Promise }; + } as unknown as { runQueryContainer: (params: never) => Promise }; const { broker, audit } = build(runner); const results = await Promise.all(Array.from({ length: 5 }, () => invoke(broker, validRequest()))); @@ -447,7 +447,7 @@ describe('sealed-probe broker', () => { }); it('records failure reasons only in the protected audit log, never in the response', async () => { - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.unlinkSync(path.join(invocationDir, 'out')); }); const { broker, audit } = build(runner); @@ -476,7 +476,7 @@ describe('sealed-probe broker', () => { describe('per-repository bit ledger (no per-query cap)', () => { it("debits an invocation's exact schema charge before copying a seed or launching Python", async () => { - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); const { broker } = build(runner); @@ -488,8 +488,8 @@ describe('sealed-probe broker', () => { }); it('never debits the ledger for a request rejected before validation succeeds', async () => { - const runner = probeRunner(() => { - throw new Error('probe must not launch'); + const runner = queryRunner(() => { + throw new Error('query must not launch'); }); const { broker } = build(runner); @@ -499,8 +499,8 @@ describe('sealed-probe broker', () => { }); it('denies (without launching) an invocation whose schema charge exceeds the remaining balance', async () => { - const runner = probeRunner(() => { - throw new Error('probe must not launch: charge exceeds confidential (8-bit) budget'); + const runner = queryRunner(() => { + throw new Error('query must not launch: charge exceeds confidential (8-bit) budget'); }); const { broker, audit } = build(runner); @@ -515,11 +515,11 @@ describe('sealed-probe broker', () => { }); it('a sealed-sensitivity repository (0-bit run budget) can never afford even the cheapest schema', async () => { - const sealedSeedMap = new Map([['octo/sealed', { seedId: seedIdA, sensitivity: 'sealed' }]]); - const runner = probeRunner(() => { - throw new Error('a sealed repo must never launch a probe'); + const zeroBudgetSeedMap = new Map([['octo/sealed', { seedId: seedIdA, sensitivity: 'sealed' }]]); + const runner = queryRunner(() => { + throw new Error('a sealed repo must never launch a query'); }); - const { broker, audit } = build(runner, { seeds: sealedSeedMap }); + const { broker, audit } = build(runner, { seeds: zeroBudgetSeedMap }); // The cheapest possible schema (const) still costs 1 + 0 + 3 = 4 bits > 0. const response = await invoke(broker, { @@ -536,7 +536,7 @@ describe('sealed-probe broker', () => { it('a public-sensitivity repository is unmetered and never runs out of budget', async () => { const publicSeedMap = new Map([['octo/public', { seedId: seedIdA, sensitivity: 'public' }]]); config.maxInvocations = 20; - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { fs.writeFileSync(path.join(invocationDir, 'out'), '[0,0,0,0,0,0,0,0]'); }); const { broker } = build(runner, { seeds: publicSeedMap }); @@ -562,7 +562,7 @@ describe('sealed-probe broker', () => { describe('response-timing bucketing (fake monotonic clock — no real time elapses)', () => { it('buckets a fast-completing invocation to the smallest boundary at or after elapsed processing time', async () => { const { clock, advance, sleeps } = createFakeClock(); - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { advance(50); // Simulate 50ms of processing — falls in the 100ms bucket. fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); @@ -578,7 +578,7 @@ describe('sealed-probe broker', () => { it('does not wait at all when processing already lands exactly on a bucket boundary', async () => { const { clock, advance, sleeps } = createFakeClock(); - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { advance(10); // Exactly the smallest bucket. fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); @@ -592,7 +592,7 @@ describe('sealed-probe broker', () => { it('buckets a failure response exactly like a success response', async () => { const { clock, advance } = createFakeClock(); - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { advance(500); // Falls in the 1000ms bucket. fs.writeFileSync(path.join(invocationDir, 'out'), 'not valid json'); }); @@ -609,7 +609,7 @@ describe('sealed-probe broker', () => { it('includes workspace cleanup latency when selecting the timing bucket', async () => { const { clock, advance, sleeps } = createFakeClock(); - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { advance(5); fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); @@ -630,10 +630,10 @@ describe('sealed-probe broker', () => { it('fails closed with the canonical error when processing overruns every configured bucket, even for an otherwise-valid result', async () => { const { clock, advance } = createFakeClock(); - const runner = probeRunner((invocationDir) => { + const runner = queryRunner((invocationDir) => { // Pathological infrastructure latency far beyond the largest bucket // (600_000ms) — never possible from the script itself, which is - // capped at sealedProbes.timeout <= 540s by preflight.ts. + // capped at boundedQueries.timeout <= 540s by preflight.ts. advance(TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] + 1); fs.writeFileSync(path.join(invocationDir, 'out'), '{"result":"YES"}'); }); @@ -645,36 +645,36 @@ describe('sealed-probe broker', () => { }); }); -describe('probe container arguments', () => { +describe('query container arguments', () => { const config = { hostWorkDir: '/daemon/work', - probeMountDir: '/probe', - probeScriptPath: '/awf/probe-script.py', - probeSeccompPath: '/opt/awf/probe-seccomp.json', - probeImage: 'ghcr.io/example/sealed-probe:1', + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + querySeccompPath: '/opt/awf/query-seccomp.json', + queryImage: 'ghcr.io/example/bounded-query:1', dockerRuntime: '', memoryLimit: '256m', - probeUid: 65534, - probeGid: 65534, + queryUid: 65534, + queryGid: 65534, }; function args(overrides: Record = {}): string[] { - return buildProbeArgs({ + return buildQueryArgs({ config: { ...config, ...overrides }, runId: 'run-1', invocationId: 'inv-1', - containerName: 'awf-probe-inv-1', + containerName: 'awf-query-inv-1', }); } - it('isolates the probe: no network, read-only rootfs, non-root, no capabilities', () => { + it('isolates the query: no network, read-only rootfs, non-root, no capabilities', () => { const joined = args().join(' '); expect(joined).toContain('--network none'); expect(joined).toContain('--read-only'); expect(joined).toContain('--user 65534:65534'); expect(joined).toContain('--cap-drop ALL'); expect(joined).toContain('--security-opt no-new-privileges:true'); - expect(joined).toContain('--security-opt seccomp=/opt/awf/probe-seccomp.json'); + expect(joined).toContain('--security-opt seccomp=/opt/awf/query-seccomp.json'); }); it('bounds memory, CPU, PIDs, file size, and descriptors', () => { @@ -695,16 +695,16 @@ describe('probe container arguments', () => { expect(mounts).toEqual([ '/daemon/work/inv-1/repo:/awf/seed:ro', - '/daemon/work/inv-1/out:/probe/out:rw', - '/daemon/work/inv-1/script.py:/awf/probe-script.py:ro', + '/daemon/work/inv-1/out:/query/out:rw', + '/daemon/work/inv-1/script.py:/awf/query-script.py:ro', ]); }); - it('backs /probe with a size-limited tmpfs for aggregate storage enforcement', () => { + it('backs /query with a size-limited tmpfs for aggregate storage enforcement', () => { const joined = args().join(' '); - expect(joined).toMatch(/--tmpfs \/probe:rw,nosuid,nodev,size=\d+,uid=65534,gid=65534,mode=0700/); - expect(joined).not.toContain(':/probe:rw'); - expect(joined).not.toContain(':/probe/repo:rw'); + expect(joined).toMatch(/--tmpfs \/query:rw,nosuid,nodev,size=\d+,uid=65534,gid=65534,mode=0700/); + expect(joined).not.toContain(':/query:rw'); + expect(joined).not.toContain(':/query/repo:rw'); }); it('never mounts the Docker socket, the seeds root, or a workspace', () => { @@ -718,13 +718,13 @@ describe('probe container arguments', () => { it('runs the fixed entrypoint that materializes the writable repo before the script', () => { const argv = args(); expect(argv).toContain('--entrypoint'); - expect(argv[argv.indexOf('--entrypoint') + 1]).toBe('/usr/local/bin/run-probe'); - expect(argv[argv.length - 1]).toBe('ghcr.io/example/sealed-probe:1'); + expect(argv[argv.indexOf('--entrypoint') + 1]).toBe('/usr/local/bin/run-query'); + expect(argv[argv.length - 1]).toBe('ghcr.io/example/bounded-query:1'); expect(argv).not.toContain('-I'); }); it('labels the container for orphan cleanup', () => { - expect(args().join(' ')).toContain('--label awf.sealed-probe.run=run-1'); + expect(args().join(' ')).toContain('--label awf.bounded-query.run=run-1'); }); it('passes an explicit OCI runtime only when one is configured', () => { @@ -740,7 +740,7 @@ describe('request framing (protocol v2)', () => { const schema = { type: 'boolean' }; const headers = { - 'x-awf-probe-version': '2', + 'x-awf-query-version': '2', 'x-awf-repo': 'octo/alpha', 'x-awf-schema-b64': base64url(JSON.stringify(schema)), }; @@ -753,7 +753,7 @@ describe('request framing (protocol v2)', () => { }); it('rejects an unsupported protocol version', () => { - expect(buildRequestFromFrame({ ...headers, 'x-awf-probe-version': '1' }, rawHeaders, 'x').error) + expect(buildRequestFromFrame({ ...headers, 'x-awf-query-version': '1' }, rawHeaders, 'x').error) .toMatch(/protocol version/); }); diff --git a/src/sealed-probe/end-to-end.test.ts b/src/bounded-query/end-to-end.test.ts similarity index 89% rename from src/sealed-probe/end-to-end.test.ts rename to src/bounded-query/end-to-end.test.ts index cfac00eea..9ef8b2bdb 100644 --- a/src/sealed-probe/end-to-end.test.ts +++ b/src/bounded-query/end-to-end.test.ts @@ -7,8 +7,8 @@ import type { Server } from 'http'; /** * End-to-end exercise of the whole agent-visible path: * - * real `sealed-probe` wrapper → real Unix socket → real broker server → - * real workspace/seed handling → (mocked) probe container. + * real `bounded-query` wrapper → real Unix socket → real broker server → + * real workspace/seed handling → (mocked) query container. * * Only the Docker launch is mocked, so this covers the v2 framing (repo + * base64url schema header), the finite schema DSL, the writable-copy @@ -18,13 +18,13 @@ import type { Server } from 'http'; * private repository. */ /* eslint-disable @typescript-eslint/no-require-imports */ -const brokerDir = path.join(__dirname, '..', '..', 'containers', 'sealed-probe', 'broker'); +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker'); const { createBroker } = require(path.join(brokerDir, 'broker.js')); const { createServer, listenOnSocket } = require(path.join(brokerDir, 'server.js')); const workspace = require(path.join(brokerDir, 'workspace.js')); /* eslint-enable @typescript-eslint/no-require-imports */ -const WRAPPER = path.join(__dirname, '..', '..', 'containers', 'agent', 'sealed-probe-wrapper.sh'); +const WRAPPER = path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-query-wrapper.sh'); const CANONICAL_ERROR = '{"status":"error"}'; const OUTCOME_SCHEMA = JSON.stringify({ type: 'enum', values: ['YES', 'NO'] }); @@ -34,10 +34,10 @@ interface WrapperResult { status: number | null; } -function runWrapper(socketPath: string, args: string[], script = 'probe'): Promise { +function runWrapper(socketPath: string, args: string[], script = 'query'): Promise { return new Promise((resolve, reject) => { const child = spawn('sh', [WRAPPER, ...args], { - env: { PATH: process.env.PATH ?? '/usr/bin:/bin', AWF_SEALED_PROBE_SOCKET: socketPath }, + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', AWF_BOUNDED_QUERY_SOCKET: socketPath }, }); let stdout = ''; let stderr = ''; @@ -52,7 +52,7 @@ function runWrapper(socketPath: string, args: string[], script = 'probe'): Promi }); } -describe('sealed probe end-to-end (wrapper → socket → broker)', () => { +describe('bounded query end-to-end (wrapper → socket → broker)', () => { let root: string; let server: Server; let socketPath: string; @@ -61,9 +61,9 @@ describe('sealed probe end-to-end (wrapper → socket → broker)', () => { const seedIdB = 'b'.repeat(32); const audit: Array> = []; - /** The mocked probe: reads the repo copy and writes a declared outcome. */ + /** The mocked query: reads the repo copy and writes a declared outcome. */ const runner = { - runProbeContainer: async ({ invocationId }: { invocationId: string }) => { + runQueryContainer: async ({ invocationId }: { invocationId: string }) => { const invocationDir = path.join(String(config.workDir), invocationId); const readme = fs.readFileSync(path.join(invocationDir, 'repo', 'README.md'), 'utf8'); // Write the answer to the pre-created output file, conforming to the enum schema above. @@ -98,16 +98,16 @@ describe('sealed probe end-to-end (wrapper → socket → broker)', () => { hostWorkDir: '/daemon/work', socketDir: root, socketPath, - probeMountDir: '/probe', - probeScriptPath: '/awf/probe-script.py', - probeSeccompPath: '/opt/awf/probe-seccomp.json', - probeImage: 'sealed-probe:test', + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + querySeccompPath: '/opt/awf/query-seccomp.json', + queryImage: 'bounded-query:test', dockerRuntime: '', memoryLimit: '512m', timeoutSeconds: 30, maxInvocations: 2, - probeUid: process.getuid?.() ?? 0, - probeGid: process.getgid?.() ?? 0, + queryUid: process.getuid?.() ?? 0, + queryGid: process.getgid?.() ?? 0, socketUid: process.getuid?.() ?? 0, socketGid: process.getgid?.() ?? 0, }; @@ -151,7 +151,7 @@ describe('sealed probe end-to-end (wrapper → socket → broker)', () => { const args = (repo: string, schema = OUTCOME_SCHEMA) => ['--repo', repo, '--schema', schema]; - it('returns the outcome the probe computed from its own repository copy', async () => { + it('returns the outcome the query computed from its own repository copy', async () => { const result = await runWrapper(socketPath, args('octo/alpha')); expect(result.stdout).toBe('{"status":"ok","result":"YES"}\n'); @@ -163,7 +163,7 @@ describe('sealed probe end-to-end (wrapper → socket → broker)', () => { expect((await runWrapper(socketPath, args('octo/beta'))).stdout).toBe('{"status":"ok","result":"NO"}\n'); }); - it('leaves the immutable seed untouched after the probe mutates its copy', async () => { + it('leaves the immutable seed untouched after the query mutates its copy', async () => { await runWrapper(socketPath, args('octo/alpha')); expect(fs.readFileSync(path.join(String(config.seedsDir), seedIdA, 'README.md'), 'utf8')) @@ -217,9 +217,9 @@ describe('sealed probe end-to-end (wrapper → socket → broker)', () => { expect(exhausted.stdout).toBe(`${CANONICAL_ERROR}\n`); }); - it('rejects a request whose probe output does not conform to its own declared schema', async () => { + it('rejects a request whose query output does not conform to its own declared schema', async () => { const nonConformingRunner = { - runProbeContainer: async ({ invocationId }: { invocationId: string }) => { + runQueryContainer: async ({ invocationId }: { invocationId: string }) => { const invocationDir = path.join(String(config.workDir), invocationId); fs.writeFileSync(path.join(invocationDir, 'out'), '"MAYBE"'); // not in the declared enum return { exitCode: 0, timedOut: false }; diff --git a/src/sealed-probe/ledger.test.ts b/src/bounded-query/ledger.test.ts similarity index 99% rename from src/sealed-probe/ledger.test.ts rename to src/bounded-query/ledger.test.ts index d92616d15..de5ae3aa4 100644 --- a/src/sealed-probe/ledger.test.ts +++ b/src/bounded-query/ledger.test.ts @@ -9,7 +9,7 @@ import * as path from 'path'; * the ledger in isolation, independent of the broker's orchestration. */ /* eslint-disable @typescript-eslint/no-require-imports */ -const brokerDir = path.join(__dirname, '..', '..', 'containers', 'sealed-probe', 'broker'); +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker'); const { createLedger } = require(path.join(brokerDir, 'ledger.js')); /* eslint-enable @typescript-eslint/no-require-imports */ diff --git a/src/sealed-probe/manager.test.ts b/src/bounded-query/manager.test.ts similarity index 57% rename from src/sealed-probe/manager.test.ts rename to src/bounded-query/manager.test.ts index 606d9f6aa..0003c5a94 100644 --- a/src/sealed-probe/manager.test.ts +++ b/src/bounded-query/manager.test.ts @@ -2,14 +2,14 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import execa from 'execa'; -import type { SealedProbesConfig, WrapperConfig } from '../types'; -import { resolveSealedProbePaths } from './paths'; +import type { BoundedQueriesConfig, WrapperConfig } from '../types'; +import { resolveBoundedQueryPaths } from './paths'; import { - SEALED_PROBE_RUN_LABEL, - isSealedProbesEnabled, + BOUNDED_QUERY_RUN_LABEL, + isBoundedQueriesEnabled, managerTestHelpers, - prepareSealedProbes, - teardownSealedProbes, + prepareBoundedQueries, + teardownBoundedQueries, } from './manager'; import { releaseSeedPermissions, type GitRunner } from './staging'; @@ -24,7 +24,7 @@ jest.mock('./staging', () => { const mockExeca = execa as unknown as jest.Mock; const mockReleaseSeedPermissions = releaseSeedPermissions as jest.MockedFunction; -const sealedProbes: SealedProbesConfig = { +const boundedQueries: BoundedQueriesConfig = { enabled: true, privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], runtime: 'docker', @@ -46,19 +46,19 @@ const gitRunner: GitRunner = async (args) => { return { stdout: '' }; }; -function buildConfig(workDir: string, overrides: Partial = {}): WrapperConfig { - return { workDir, sealedProbes: { ...sealedProbes, ...overrides } } as unknown as WrapperConfig; +function buildConfig(workDir: string, overrides: Partial = {}): WrapperConfig { + return { workDir, boundedQueries: { ...boundedQueries, ...overrides } } as unknown as WrapperConfig; } -describe('isSealedProbesEnabled', () => { +describe('isBoundedQueriesEnabled', () => { it('is true only for an explicitly enabled config', () => { - expect(isSealedProbesEnabled({} as WrapperConfig)).toBe(false); - expect(isSealedProbesEnabled(buildConfig('/tmp/x', { enabled: false }))).toBe(false); - expect(isSealedProbesEnabled(buildConfig('/tmp/x'))).toBe(true); + expect(isBoundedQueriesEnabled({} as WrapperConfig)).toBe(false); + expect(isBoundedQueriesEnabled(buildConfig('/tmp/x', { enabled: false }))).toBe(false); + expect(isBoundedQueriesEnabled(buildConfig('/tmp/x'))).toBe(true); }); }); -describe('prepareSealedProbes', () => { +describe('prepareBoundedQueries', () => { let workDir: string; beforeEach(() => { @@ -67,22 +67,22 @@ describe('prepareSealedProbes', () => { mockReleaseSeedPermissions.mockImplementation( jest.requireActual('./staging').releaseSeedPermissions, ); - workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-manager-')); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-manager-')); }); afterEach(() => { - releaseSeedPermissions(resolveSealedProbePaths(workDir).seedsDir); + releaseSeedPermissions(resolveBoundedQueryPaths(workDir).seedsDir); fs.rmSync(workDir, { recursive: true, force: true }); }); - it('does nothing when sealed probes are disabled', async () => { - await prepareSealedProbes(buildConfig(workDir, { enabled: false }), { env: { GH_TOKEN: 't' }, gitRunner }); - expect(fs.existsSync(resolveSealedProbePaths(workDir).root)).toBe(false); + it('does nothing when bounded queries are disabled', async () => { + await prepareBoundedQueries(buildConfig(workDir, { enabled: false }), { env: { GH_TOKEN: 't' }, gitRunner }); + expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false); }); it('creates the directory layout, seed map, and skill artifact', async () => { - await prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); - const paths = resolveSealedProbePaths(workDir); + await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); + const paths = resolveBoundedQueryPaths(workDir); expect(fs.existsSync(paths.seedsDir)).toBe(true); expect(fs.existsSync(paths.workDir)).toBe(true); @@ -100,16 +100,16 @@ describe('prepareSealedProbes', () => { }); it('keeps the seed map free of host paths and credentials', async () => { - await prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 'ghs_secret' }, gitRunner }); - const raw = fs.readFileSync(resolveSealedProbePaths(workDir).seedMapPath, 'utf8'); + await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 'ghs_secret' }, gitRunner }); + const raw = fs.readFileSync(resolveBoundedQueryPaths(workDir).seedMapPath, 'utf8'); expect(raw).not.toContain('ghs_secret'); expect(raw).not.toContain(workDir); }); it('protects broker-only directories and shares only the run/agent directories', async () => { - await prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); - const paths = resolveSealedProbePaths(workDir); + await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); + const paths = resolveBoundedQueryPaths(workDir); expect(fs.statSync(paths.seedsDir).mode & 0o777).toBe(0o700); expect(fs.statSync(paths.auditDir).mode & 0o777).toBe(0o700); @@ -119,13 +119,13 @@ describe('prepareSealedProbes', () => { it('aborts when the configuration is invalid', async () => { await expect( - prepareSealedProbes(buildConfig(workDir, { privateRepos: [] }), { env: { GH_TOKEN: 't' }, gitRunner }), + prepareBoundedQueries(buildConfig(workDir, { privateRepos: [] }), { env: { GH_TOKEN: 't' }, gitRunner }), ).rejects.toThrow(/configuration is invalid/); }); it('aborts when no staging credential is available', async () => { await expect( - prepareSealedProbes(buildConfig(workDir), { env: {}, gitRunner }), + prepareBoundedQueries(buildConfig(workDir), { env: {}, gitRunner }), ).rejects.toThrow(/GH_TOKEN or GITHUB_TOKEN/); }); @@ -138,16 +138,16 @@ describe('prepareSealedProbes', () => { }, } as NodeJS.ProcessEnv; - await expect(prepareSealedProbes(buildConfig(workDir), { env, gitRunner })) + await expect(prepareBoundedQueries(buildConfig(workDir), { env, gitRunner })) .rejects.toThrow(/credential disappeared/); }); it('rejects a symlink work directory before staging', async () => { - const target = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-manager-target-')); - const link = path.join(os.tmpdir(), `awf-sealed-manager-link-${process.pid}-${Date.now()}`); + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-manager-target-')); + const link = path.join(os.tmpdir(), `awf-bounded-query-manager-link-${process.pid}-${Date.now()}`); fs.symlinkSync(target, link); try { - await expect(prepareSealedProbes(buildConfig(link), { env: { GH_TOKEN: 't' }, gitRunner })) + await expect(prepareBoundedQueries(buildConfig(link), { env: { GH_TOKEN: 't' }, gitRunner })) .rejects.toThrow(/symlink work directory/); } finally { fs.unlinkSync(link); @@ -161,96 +161,96 @@ describe('prepareSealedProbes', () => { }; await expect( - prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner: failing }), + prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner: failing }), ).rejects.toThrow(/staging failed/); }); }); -describe('teardownSealedProbes', () => { +describe('teardownBoundedQueries', () => { beforeEach(() => { mockExeca.mockReset(); mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' }); }); - it('is a no-op when sealed probes were never enabled', async () => { - await expect(teardownSealedProbes({ workDir: '/nonexistent' } as WrapperConfig)).resolves.toBeUndefined(); + it('is a no-op when bounded queries were never enabled', async () => { + await expect(teardownBoundedQueries({ workDir: '/nonexistent' } as WrapperConfig)).resolves.toBeUndefined(); }); it('restores seed write permissions so generic cleanup can remove them', async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-teardown-')); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-teardown-')); try { - await prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); - const paths = resolveSealedProbePaths(workDir); + await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); + const paths = resolveBoundedQueryPaths(workDir); expect(() => fs.rmSync(paths.seedsDir, { recursive: true })).toThrow(); - // No probe containers exist for this run, so the docker lookup is a + // No query containers exist for this run, so the docker lookup is a // no-op; the permission restore is what must happen. - await teardownSealedProbes(buildConfig(workDir)); + await teardownBoundedQueries(buildConfig(workDir)); expect(() => fs.rmSync(paths.seedsDir, { recursive: true })).not.toThrow(); } finally { - releaseSeedPermissions(resolveSealedProbePaths(workDir).seedsDir); + releaseSeedPermissions(resolveBoundedQueryPaths(workDir).seedsDir); fs.rmSync(workDir, { recursive: true, force: true }); } }); it('leaves the seeds read-only under --keep-containers', async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-keep-')); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-keep-')); try { - await prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); - const paths = resolveSealedProbePaths(workDir); + await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); + const paths = resolveBoundedQueryPaths(workDir); - await teardownSealedProbes({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig); + await teardownBoundedQueries({ ...buildConfig(workDir), keepContainers: true } as WrapperConfig); expect(() => fs.rmSync(paths.seedsDir, { recursive: true })).toThrow(); } finally { - releaseSeedPermissions(resolveSealedProbePaths(workDir).seedsDir); + releaseSeedPermissions(resolveBoundedQueryPaths(workDir).seedsDir); fs.rmSync(workDir, { recursive: true, force: true }); } }); - it('removes every orphaned probe container for the staged run', async () => { + it('removes every orphaned query container for the staged run', async () => { mockExeca - .mockResolvedValueOnce({ exitCode: 0, stdout: 'probe-a\nprobe-b\n' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'query-a\nquery-b\n' }) .mockResolvedValueOnce({ exitCode: 0, stdout: '' }); - await managerTestHelpers.removeOrphanProbeContainers('run-id'); + await managerTestHelpers.removeOrphanQueryContainers('run-id'); expect(mockExeca).toHaveBeenNthCalledWith( 1, 'docker', - ['ps', '-aq', '--filter', `label=${SEALED_PROBE_RUN_LABEL}=run-id`], + ['ps', '-aq', '--filter', `label=${BOUNDED_QUERY_RUN_LABEL}=run-id`], expect.objectContaining({ reject: false }), ); expect(mockExeca).toHaveBeenNthCalledWith( 2, 'docker', - ['rm', '-f', 'probe-a', 'probe-b'], + ['rm', '-f', 'query-a', 'query-b'], expect.objectContaining({ reject: false }), ); }); it('does not remove containers when the Docker listing fails', async () => { - mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: 'probe-a' }); + mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: 'query-a' }); - await managerTestHelpers.removeOrphanProbeContainers('run-id'); + await managerTestHelpers.removeOrphanQueryContainers('run-id'); expect(mockExeca).toHaveBeenCalledTimes(1); }); - it('is a no-op when the sealed-probe root is absent', async () => { - await teardownSealedProbes(buildConfig('/nonexistent/sealed-probe-work-dir')); + it('is a no-op when the bounded-query root is absent', async () => { + await teardownBoundedQueries(buildConfig('/nonexistent/bounded-query-work-dir')); expect(mockExeca).not.toHaveBeenCalled(); }); it('handles unreadable or unusable seed maps without attempting Docker cleanup', async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-bad-map-')); - const paths = resolveSealedProbePaths(workDir); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-bad-map-')); + const paths = resolveBoundedQueryPaths(workDir); fs.mkdirSync(paths.root, { recursive: true }); fs.writeFileSync(paths.seedMapPath, '{bad json'); try { - await teardownSealedProbes(buildConfig(workDir)); + await teardownBoundedQueries(buildConfig(workDir)); expect(mockExeca).not.toHaveBeenCalled(); } finally { fs.rmSync(workDir, { recursive: true, force: true }); @@ -258,22 +258,22 @@ describe('teardownSealedProbes', () => { }); it('continues cleanup when orphan container removal fails', async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-orphan-failure-')); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-orphan-failure-')); try { - await prepareSealedProbes(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); + await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); mockExeca.mockRejectedValueOnce(new Error('docker unavailable')); - await expect(teardownSealedProbes(buildConfig(workDir))).resolves.toBeUndefined(); - expect(() => fs.rmSync(resolveSealedProbePaths(workDir).seedsDir, { recursive: true })).not.toThrow(); + await expect(teardownBoundedQueries(buildConfig(workDir))).resolves.toBeUndefined(); + expect(() => fs.rmSync(resolveBoundedQueryPaths(workDir).seedsDir, { recursive: true })).not.toThrow(); } finally { - releaseSeedPermissions(resolveSealedProbePaths(workDir).seedsDir); + releaseSeedPermissions(resolveBoundedQueryPaths(workDir).seedsDir); fs.rmSync(workDir, { recursive: true, force: true }); } }); it('does not fail teardown when seed permissions cannot be restored', async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-permission-failure-')); - const paths = resolveSealedProbePaths(workDir); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-permission-failure-')); + const paths = resolveBoundedQueryPaths(workDir); try { fs.mkdirSync(paths.root, { recursive: true }); fs.writeFileSync(paths.seedMapPath, JSON.stringify({ runId: '' })); @@ -282,7 +282,7 @@ describe('teardownSealedProbes', () => { throw new Error('permission denied'); }); - await expect(teardownSealedProbes(buildConfig(workDir))).resolves.toBeUndefined(); + await expect(teardownBoundedQueries(buildConfig(workDir))).resolves.toBeUndefined(); expect(mockReleaseSeedPermissions).toHaveBeenCalledWith(paths.seedsDir); } finally { fs.rmSync(workDir, { recursive: true, force: true }); diff --git a/src/sealed-probe/manager.ts b/src/bounded-query/manager.ts similarity index 63% rename from src/sealed-probe/manager.ts rename to src/bounded-query/manager.ts index 9ceb2933b..3aa734ecf 100644 --- a/src/sealed-probe/manager.ts +++ b/src/bounded-query/manager.ts @@ -5,37 +5,37 @@ import { getLocalDockerEnv } from '../host-env'; import { getSafeHostUid, getSafeHostGid } from '../host-identity'; import type { WrapperConfig } from '../types'; import { - generateSealedProbeRunId, - resolveSealedProbePaths, - type SealedProbePaths, + generateBoundedQueryRunId, + resolveBoundedQueryPaths, + type BoundedQueryPaths, } from './paths'; -import { assertProbeRuntimeAvailable, validateSealedProbeConfig } from './preflight'; -import { writeSealedProbeSkill } from './skill'; -import { releaseSeedPermissions, resolveStagingToken, stageSealedProbeSeeds, type GitRunner } from './staging'; -import { SEALED_PROBE_SEED_MAP_VERSION, type SealedProbeSeedMap } from './types'; +import { assertQueryRuntimeAvailable, validateBoundedQueryConfig } from './preflight'; +import { writeBoundedQuerySkill } from './skill'; +import { releaseSeedPermissions, resolveStagingToken, stageBoundedQuerySeeds, type GitRunner } from './staging'; +import { BOUNDED_QUERY_SEED_MAP_VERSION, type BoundedQuerySeedMap } from './types'; /** - * Sealed-probe lifecycle orchestration. + * Bounded-query lifecycle orchestration. * - * `prepareSealedProbes` runs entirely on the trusted AWF host **before** any + * `prepareBoundedQueries` runs entirely on the trusted AWF host **before** any * configuration is generated or any container is started, so that: * * - the primary agent never starts when staging fails; * - the staging credential is consumed and discarded before the broker, the - * agent, and any probe exist; + * agent, and any query exist; * - compose generation can rely on the on-disk layout already being present. * - * `teardownSealedProbes` removes orphaned probe containers and restores write + * `teardownBoundedQueries` removes orphaned query containers and restores write * permissions on the immutable seeds so AWF's generic work-directory cleanup * can delete them. */ -/** Docker label applied to every probe container, used for orphan cleanup. */ -export const SEALED_PROBE_RUN_LABEL = 'awf.sealed-probe.run'; +/** Docker label applied to every query container, used for orphan cleanup. */ +export const BOUNDED_QUERY_RUN_LABEL = 'awf.bounded-query.run'; /** Returns true when this run must stage seeds and start the broker. */ -export function isSealedProbesEnabled(config: WrapperConfig): boolean { - return config.sealedProbes?.enabled === true; +export function isBoundedQueriesEnabled(config: WrapperConfig): boolean { + return config.boundedQueries?.enabled === true; } /** Creates a directory with an exact mode, independent of the process umask. */ @@ -45,19 +45,19 @@ function ensureModeDirectory(target: string, mode: number): void { } /** - * Creates the sealed-probe directory layout. + * Creates the bounded-query directory layout. * * The socket and skill directories are handed to the host user because the * agent process runs under the host UID/GID; everything else stays * root-owned (0700) inside the already-hardened work directory. * * The mask directory is created as an empty, read-only-to-others directory. - * It is bind-mounted into the agent at the sealed-probe root path, replacing - * the agent's view of the entire sealed-probe subtree (including seeds, work, + * It is bind-mounted into the agent at the bounded-query root path, replacing + * the agent's view of the entire bounded-query subtree (including seeds, work, * and audit) through the broad `/tmp` bind mount. Only the socket and skill * (mounted at separate container paths) remain agent-visible. */ -function prepareDirectories(paths: SealedProbePaths): void { +function prepareDirectories(paths: BoundedQueryPaths): void { ensureModeDirectory(paths.root, 0o700); ensureModeDirectory(paths.seedsDir, 0o700); ensureModeDirectory(paths.workDir, 0o700); @@ -77,7 +77,7 @@ function prepareDirectories(paths: SealedProbePaths): void { } /** Writes the broker's repo → opaque seed map. */ -function writeSeedMap(paths: SealedProbePaths, seedMap: SealedProbeSeedMap): void { +function writeSeedMap(paths: BoundedQueryPaths, seedMap: BoundedQuerySeedMap): void { const content = JSON.stringify(seedMap, null, 2) + '\n'; // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file // is already at this path (insecure-temp-file guard). @@ -94,7 +94,7 @@ function writeSeedMap(paths: SealedProbePaths, seedMap: SealedProbeSeedMap): voi } } -export interface PrepareSealedProbesDeps { +export interface PrepareBoundedQueriesDeps { /** Override the git runner (tests). */ gitRunner?: GitRunner; /** Override the host environment the staging credential is read from. */ @@ -107,29 +107,29 @@ export interface PrepareSealedProbesDeps { * * Throws on any failure — the caller must abort the run. */ -export async function prepareSealedProbes( +export async function prepareBoundedQueries( config: WrapperConfig, - deps: PrepareSealedProbesDeps = {}, + deps: PrepareBoundedQueriesDeps = {}, ): Promise { - const sealedProbes = config.sealedProbes; - if (!sealedProbes?.enabled) return; + const boundedQueries = config.boundedQueries; + if (!boundedQueries?.enabled) return; const env = deps.env ?? process.env; - const errors = validateSealedProbeConfig(config, env); + const errors = validateBoundedQueryConfig(config, env); if (errors.length > 0) { - throw new Error(`Sealed-probe configuration is invalid:\n - ${errors.join('\n - ')}`); + throw new Error(`Bounded-query configuration is invalid:\n - ${errors.join('\n - ')}`); } - await assertProbeRuntimeAvailable(sealedProbes); + await assertQueryRuntimeAvailable(boundedQueries); const token = resolveStagingToken(env); if (!token) { - // Already covered by validateSealedProbeConfig; re-checked so the token is + // Already covered by validateBoundedQueryConfig; re-checked so the token is // never `undefined!`-asserted into the staging call. - throw new Error('Sealed-probe staging credential disappeared between validation and staging'); + throw new Error('Bounded-query staging credential disappeared between validation and staging'); } - const paths = resolveSealedProbePaths(config.workDir); + const paths = resolveBoundedQueryPaths(config.workDir); // Guard against symlink injection before writing any credential-bearing state. // The generic work-directory check in config-writer.ts runs later (during @@ -149,9 +149,9 @@ export async function prepareSealedProbes( prepareDirectories(paths); - const runId = generateSealedProbeRunId(); - const staging = await stageSealedProbeSeeds({ - repos: sealedProbes.privateRepos, + const runId = generateBoundedQueryRunId(); + const staging = await stageBoundedQuerySeeds({ + repos: boundedQueries.privateRepos, paths, runId, token, @@ -159,7 +159,7 @@ export async function prepareSealedProbes( }); writeSeedMap(paths, { - version: SEALED_PROBE_SEED_MAP_VERSION, + version: BOUNDED_QUERY_SEED_MAP_VERSION, runId: staging.runId, seeds: staging.seeds.map((seed) => ({ repo: seed.repoKey, @@ -168,30 +168,30 @@ export async function prepareSealedProbes( })), }); - writeSealedProbeSkill(paths, { - repos: sealedProbes.privateRepos, - timeoutSeconds: sealedProbes.timeout, - maxInvocations: sealedProbes.maxInvocations, + writeBoundedQuerySkill(paths, { + repos: boundedQueries.privateRepos, + timeoutSeconds: boundedQueries.timeout, + maxInvocations: boundedQueries.maxInvocations, }); logger.info( - `Sealed probes: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`, + `Bounded queries: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`, ); } /** Reads back the run id recorded during staging, if it is still available. */ -function readRunId(paths: SealedProbePaths): string | undefined { +function readRunId(paths: BoundedQueryPaths): string | undefined { try { - const parsed = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as SealedProbeSeedMap; + const parsed = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as BoundedQuerySeedMap; return typeof parsed.runId === 'string' && parsed.runId.length > 0 ? parsed.runId : undefined; } catch { return undefined; } } -/** Force-removes any probe container still labelled with this run. */ -async function removeOrphanProbeContainers(runId: string): Promise { - const filter = `label=${SEALED_PROBE_RUN_LABEL}=${runId}`; +/** Force-removes any query container still labelled with this run. */ +async function removeOrphanQueryContainers(runId: string): Promise { + const filter = `label=${BOUNDED_QUERY_RUN_LABEL}=${runId}`; const listed = await execa('docker', ['ps', '-aq', '--filter', filter], { env: getLocalDockerEnv(), reject: false, @@ -202,7 +202,7 @@ async function removeOrphanProbeContainers(runId: string): Promise { const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean); if (ids.length === 0) return; - logger.debug(`Sealed probes: removing ${ids.length} orphaned probe container(s)`); + logger.debug(`Bounded queries: removing ${ids.length} orphaned query container(s)`); await execa('docker', ['rm', '-f', ...ids], { env: getLocalDockerEnv(), reject: false, @@ -211,9 +211,9 @@ async function removeOrphanProbeContainers(runId: string): Promise { } /** - * Tears down sealed-probe state. + * Tears down bounded-query state. * - * Orphaned probe containers are always removed: they are ephemeral, hold a + * Orphaned query containers are always removed: they are ephemeral, hold a * private copy of repository contents, and are never useful for debugging. * * Restoring seed permissions is skipped under `--keep-containers`, where the @@ -222,30 +222,30 @@ async function removeOrphanProbeContainers(runId: string): Promise { * deliberately read-only, and `rm -rf` cannot unlink entries inside a * directory whose write bit was stripped. */ -export async function teardownSealedProbes(config: WrapperConfig): Promise { - if (!isSealedProbesEnabled(config)) return; +export async function teardownBoundedQueries(config: WrapperConfig): Promise { + if (!isBoundedQueriesEnabled(config)) return; - const paths = resolveSealedProbePaths(config.workDir); + const paths = resolveBoundedQueryPaths(config.workDir); if (!fs.existsSync(paths.root)) return; const runId = readRunId(paths); if (runId) { try { - await removeOrphanProbeContainers(runId); + await removeOrphanQueryContainers(runId); } catch (error) { - logger.warn('Sealed probes: failed to remove orphaned probe containers', error); + logger.warn('Bounded queries: failed to remove orphaned query containers', error); } } if (config.keepContainers) { - logger.info(`Sealed-probe seeds preserved (read-only) at: ${paths.seedsDir}`); + logger.info(`Bounded-query seeds preserved (read-only) at: ${paths.seedsDir}`); return; } try { releaseSeedPermissions(paths.seedsDir); } catch (error) { - logger.warn('Sealed probes: failed to restore seed permissions before cleanup', error); + logger.warn('Bounded queries: failed to restore seed permissions before cleanup', error); } } @@ -255,5 +255,5 @@ export const managerTestHelpers = { prepareDirectories, writeSeedMap, readRunId, - removeOrphanProbeContainers, + removeOrphanQueryContainers, }; diff --git a/src/bounded-query/naming.test.ts b/src/bounded-query/naming.test.ts new file mode 100644 index 000000000..538f3afa1 --- /dev/null +++ b/src/bounded-query/naming.test.ts @@ -0,0 +1,41 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('bounded-query naming', () => { + it('does not retain the previous feature name in tracked paths or text', () => { + const repositoryRoot = path.resolve(__dirname, '..', '..'); + const oldPrefix = 'sealed'; + const oldNoun = 'probe'; + const forbidden = [ + new RegExp(`${oldPrefix}[-_ ]${oldNoun}s?`, 'i'), + new RegExp(`${oldPrefix}${oldNoun}`, 'i'), + new RegExp(`${oldPrefix}[-_ ]query`, 'i'), + ]; + const trackedFiles = execFileSync('git', ['ls-files', '-z'], { + cwd: repositoryRoot, + encoding: 'utf8', + }) + .split('\0') + .filter(Boolean); + const matches: string[] = []; + + for (const relativePath of trackedFiles) { + if (forbidden.some((pattern) => pattern.test(relativePath))) { + matches.push(relativePath); + continue; + } + + const absolutePath = path.join(repositoryRoot, relativePath); + if (!fs.lstatSync(absolutePath).isFile()) continue; + const contents = fs.readFileSync(absolutePath); + if (contents.includes(0)) continue; + const text = contents.toString('utf8'); + if (forbidden.some((pattern) => pattern.test(text))) { + matches.push(relativePath); + } + } + + expect(matches).toEqual([]); + }); +}); diff --git a/src/sealed-probe/paths.test.ts b/src/bounded-query/paths.test.ts similarity index 79% rename from src/sealed-probe/paths.test.ts rename to src/bounded-query/paths.test.ts index cfd6a1fd2..a12903a03 100644 --- a/src/sealed-probe/paths.test.ts +++ b/src/bounded-query/paths.test.ts @@ -5,25 +5,25 @@ import { AGENT_SOCKET_DIR, AGENT_SOCKET_PATH, deriveSeedId, - generateSealedProbeRunId, + generateBoundedQueryRunId, normalizeRepoKey, - resolveSealedProbePaths, + resolveBoundedQueryPaths, } from './paths'; -describe('sealed-probe paths', () => { +describe('bounded-query paths', () => { const workDir = '/tmp/awf-12345'; - it('derives every artifact path under a single sealed-probes subtree', () => { - const paths = resolveSealedProbePaths(workDir); + it('derives every artifact path under a single bounded-queries subtree', () => { + const paths = resolveBoundedQueryPaths(workDir); - expect(paths.root).toBe(path.join(workDir, 'sealed-probes')); + expect(paths.root).toBe(path.join(workDir, 'bounded-queries')); for (const value of Object.values(paths)) { expect(value.startsWith(paths.root)).toBe(true); } }); it('places the socket and skill inside their advertised directories', () => { - const paths = resolveSealedProbePaths(workDir); + const paths = resolveBoundedQueryPaths(workDir); expect(paths.socketPath).toBe(path.join(paths.runDir, 'broker.sock')); expect(paths.skillPath).toBe(path.join(paths.agentDir, 'SKILL.md')); @@ -32,7 +32,7 @@ describe('sealed-probe paths', () => { }); it('keeps the run and agent directories separate so the skill can be read-only', () => { - const paths = resolveSealedProbePaths(workDir); + const paths = resolveBoundedQueryPaths(workDir); expect(paths.runDir).not.toBe(paths.agentDir); expect(AGENT_SOCKET_DIR).not.toBe(AGENT_SKILL_DIR); }); @@ -70,10 +70,10 @@ describe('deriveSeedId', () => { }); }); -describe('generateSealedProbeRunId', () => { +describe('generateBoundedQueryRunId', () => { it('produces a fresh 128-bit hex identifier', () => { - const first = generateSealedProbeRunId(); - const second = generateSealedProbeRunId(); + const first = generateBoundedQueryRunId(); + const second = generateBoundedQueryRunId(); expect(first).toMatch(/^[0-9a-f]{32}$/); expect(first).not.toBe(second); }); diff --git a/src/sealed-probe/paths.ts b/src/bounded-query/paths.ts similarity index 68% rename from src/sealed-probe/paths.ts rename to src/bounded-query/paths.ts index 106a48dcc..f30001171 100644 --- a/src/sealed-probe/paths.ts +++ b/src/bounded-query/paths.ts @@ -2,16 +2,16 @@ import * as crypto from 'crypto'; import * as path from 'path'; /** - * Filesystem layout and fixed container paths for the sealed-probe subsystem. + * Filesystem layout and fixed container paths for the bounded-query subsystem. * - * Everything sealed probes need lives under a single run-unique subtree of + * Everything bounded queries need lives under a single run-unique subtree of * `config.workDir` so that the existing work-directory hardening (0700, * symlink rejection, end-of-run removal) applies to it unchanged. * * Layout (host side): * * ```text - * /sealed-probes/ + * /bounded-queries/ * seeds// immutable, read-only repository seed (one per repo) * work/ broker-owned per-invocation writable copies * run/ broker Unix socket, shared read-write with the agent @@ -20,8 +20,8 @@ import * as path from 'path'; * seed-map.json normalized repo -> opaque seed id map (broker input) * ``` */ -export interface SealedProbePaths { - /** `/sealed-probes` — parent of every sealed-probe artifact. */ +export interface BoundedQueryPaths { + /** `/bounded-queries` — parent of every bounded-query artifact. */ root: string; /** Immutable per-repository seeds. Mounted read-only into the broker. */ seedsDir: string; @@ -31,7 +31,7 @@ export interface SealedProbePaths { runDir: string; /** Directory holding agent-visible artifacts (the generated SKILL.md). */ agentDir: string; - /** Protected broker diagnostics. Never mounted into the agent or a probe. */ + /** Protected broker diagnostics. Never mounted into the agent or a query. */ auditDir: string; /** Repo → seed map consumed by the broker. */ seedMapPath: string; @@ -40,23 +40,23 @@ export interface SealedProbePaths { /** Host path of the generated skill document. */ skillPath: string; /** - * Empty directory used to mask the entire sealed-probe root from the agent's + * Empty directory used to mask the entire bounded-query root from the agent's * broad `/tmp` bind mount. * * The agent receives `run/` (socket) and `agent/` (skill) as separate, * more-specific bind mounts at different container paths. The parent - * `/sealed-probes/` is masked with this empty directory so the + * `/bounded-queries/` is masked with this empty directory so the * agent cannot enumerate seeds, work, audit, or the seed-map through `/tmp`. - * Located OUTSIDE the sealed-probe root to avoid self-referential masking. + * Located OUTSIDE the bounded-query root to avoid self-referential masking. */ maskDir: string; } -/** Name of the broker's Unix domain socket inside {@link SealedProbePaths.runDir}. */ -export const SEALED_PROBE_SOCKET_FILENAME = 'broker.sock'; +/** Name of the broker's Unix domain socket inside {@link BoundedQueryPaths.runDir}. */ +export const BOUNDED_QUERY_SOCKET_FILENAME = 'broker.sock'; -/** Name of the generated skill document inside {@link SealedProbePaths.agentDir}. */ -export const SEALED_PROBE_SKILL_FILENAME = 'SKILL.md'; +/** Name of the generated skill document inside {@link BoundedQueryPaths.agentDir}. */ +export const BOUNDED_QUERY_SKILL_FILENAME = 'SKILL.md'; // ── Fixed container paths ──────────────────────────────────────────────────── // @@ -65,16 +65,16 @@ export const SEALED_PROBE_SKILL_FILENAME = 'SKILL.md'; // centralized here rather than duplicated across shell/JS/TS. /** Directory the broker socket is mounted at inside the agent container. */ -export const AGENT_SOCKET_DIR = '/run/awf-sealed-probe'; +export const AGENT_SOCKET_DIR = '/run/awf-bounded-query'; /** Full socket path as seen from inside the agent container. */ -export const AGENT_SOCKET_PATH = `${AGENT_SOCKET_DIR}/${SEALED_PROBE_SOCKET_FILENAME}`; +export const AGENT_SOCKET_PATH = `${AGENT_SOCKET_DIR}/${BOUNDED_QUERY_SOCKET_FILENAME}`; /** Directory the generated skill is mounted at inside the agent container. */ -export const AGENT_SKILL_DIR = '/run/awf-sealed-probe-skill'; +export const AGENT_SKILL_DIR = '/run/awf-bounded-query-skill'; /** Full skill path as seen from inside the agent container. */ -export const AGENT_SKILL_PATH = `${AGENT_SKILL_DIR}/${SEALED_PROBE_SKILL_FILENAME}`; +export const AGENT_SKILL_PATH = `${AGENT_SKILL_DIR}/${BOUNDED_QUERY_SKILL_FILENAME}`; /** Seeds mount point inside the broker container (read-only). */ export const BROKER_SEEDS_DIR = '/srv/awf/seeds'; @@ -86,23 +86,23 @@ export const BROKER_WORK_DIR = '/srv/awf/work'; export const BROKER_SEED_MAP_PATH = '/srv/awf/seed-map.json'; /** Socket directory inside the broker container. */ -export const BROKER_SOCKET_DIR = '/run/awf-sealed-probe'; +export const BROKER_SOCKET_DIR = '/run/awf-bounded-query'; /** Protected diagnostics directory inside the broker container. */ -export const BROKER_AUDIT_DIR = '/var/log/awf-sealed-probe'; +export const BROKER_AUDIT_DIR = '/var/log/awf-bounded-query'; /** Docker socket mount point inside the broker container. */ export const BROKER_DOCKER_SOCKET_PATH = '/var/run/docker.sock'; -/** Writable working directory mounted into each probe container. */ -export const PROBE_MOUNT_DIR = '/probe'; +/** Writable working directory mounted into each query container. */ +export const QUERY_MOUNT_DIR = '/query'; -/** Fixed read-only path the submitted probe script is mounted at. */ -export const PROBE_SCRIPT_PATH = '/awf/probe-script.py'; +/** Fixed read-only path the submitted query script is mounted at. */ +export const QUERY_SCRIPT_PATH = '/awf/query-script.py'; -/** Derives every sealed-probe path from the AWF work directory. */ -export function resolveSealedProbePaths(awfWorkDir: string): SealedProbePaths { - const root = path.join(awfWorkDir, 'sealed-probes'); +/** Derives every bounded-query path from the AWF work directory. */ +export function resolveBoundedQueryPaths(awfWorkDir: string): BoundedQueryPaths { + const root = path.join(awfWorkDir, 'bounded-queries'); const runDir = path.join(root, 'run'); const agentDir = path.join(root, 'agent'); return { @@ -113,11 +113,11 @@ export function resolveSealedProbePaths(awfWorkDir: string): SealedProbePaths { agentDir, auditDir: path.join(root, 'audit'), seedMapPath: path.join(root, 'seed-map.json'), - socketPath: path.join(runDir, SEALED_PROBE_SOCKET_FILENAME), - skillPath: path.join(agentDir, SEALED_PROBE_SKILL_FILENAME), - // Sibling of the sealed-probe root — never inside it — so the mask mount + socketPath: path.join(runDir, BOUNDED_QUERY_SOCKET_FILENAME), + skillPath: path.join(agentDir, BOUNDED_QUERY_SKILL_FILENAME), + // Sibling of the bounded-query root — never inside it — so the mask mount // does not accidentally mask itself. - maskDir: path.join(awfWorkDir, 'sealed-probes-mask'), + maskDir: path.join(awfWorkDir, 'bounded-queries-mask'), }; } @@ -133,7 +133,7 @@ export function normalizeRepoKey(repo: string): string { } /** Generates the random, run-unique identifier used to derive opaque seed ids. */ -export function generateSealedProbeRunId(): string { +export function generateBoundedQueryRunId(): string { return crypto.randomBytes(16).toString('hex'); } diff --git a/src/sealed-probe/preflight.test.ts b/src/bounded-query/preflight.test.ts similarity index 56% rename from src/sealed-probe/preflight.test.ts rename to src/bounded-query/preflight.test.ts index a5a1ab200..46db821fb 100644 --- a/src/sealed-probe/preflight.test.ts +++ b/src/bounded-query/preflight.test.ts @@ -1,17 +1,17 @@ import type { WrapperConfig } from '../types'; import execa from 'execa'; -import { assertProbeRuntimeAvailable, preflightTestHelpers, validateSealedProbeConfig } from './preflight'; -import type { SealedProbesConfig } from '../types'; -import type { SealedProbeRepository } from '../types/sealed-probe-options'; +import { assertQueryRuntimeAvailable, preflightTestHelpers, validateBoundedQueryConfig } from './preflight'; +import type { BoundedQueriesConfig } from '../types'; +import type { BoundedQueryRepository } from '../types/bounded-query-options'; jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); const mockExeca = execa as unknown as jest.Mock; -function repo(name: string, sensitivity: SealedProbeRepository['sensitivity'] = 'internal'): SealedProbeRepository { +function repo(name: string, sensitivity: BoundedQueryRepository['sensitivity'] = 'internal'): BoundedQueryRepository { return { repo: name, sensitivity }; } -const baseSealedProbes: SealedProbesConfig = { +const baseBoundedQueries: BoundedQueriesConfig = { enabled: true, privateRepos: [repo('octo/private')], runtime: 'docker', @@ -21,28 +21,28 @@ const baseSealedProbes: SealedProbesConfig = { maxInvocations: 32, }; -function buildConfig(overrides: Partial = {}, config: Partial = {}): WrapperConfig { +function buildConfig(overrides: Partial = {}, config: Partial = {}): WrapperConfig { return { workDir: '/tmp/awf-test', - sealedProbes: { ...baseSealedProbes, ...overrides }, + boundedQueries: { ...baseBoundedQueries, ...overrides }, ...config, } as unknown as WrapperConfig; } const envWithToken: NodeJS.ProcessEnv = { GH_TOKEN: 'ghs_example' }; -describe('validateSealedProbeConfig', () => { +describe('validateBoundedQueryConfig', () => { it('accepts a well-formed enabled configuration', () => { - expect(validateSealedProbeConfig(buildConfig(), envWithToken)).toEqual([]); + expect(validateBoundedQueryConfig(buildConfig(), envWithToken)).toEqual([]); }); - it('returns no errors when sealed probes are absent or disabled', () => { - expect(validateSealedProbeConfig({ workDir: '/tmp/x' } as unknown as WrapperConfig, {})).toEqual([]); - expect(validateSealedProbeConfig(buildConfig({ enabled: false }), {})).toEqual([]); + it('returns no errors when bounded queries are absent or disabled', () => { + expect(validateBoundedQueryConfig({ workDir: '/tmp/x' } as unknown as WrapperConfig, {})).toEqual([]); + expect(validateBoundedQueryConfig(buildConfig({ enabled: false }), {})).toEqual([]); }); it('rejects an enabled configuration with no repositories', () => { - const errors = validateSealedProbeConfig(buildConfig({ privateRepos: [] }), envWithToken); + const errors = validateBoundedQueryConfig(buildConfig({ privateRepos: [] }), envWithToken); expect(errors.join('\n')).toContain('privateRepos is empty'); }); @@ -55,50 +55,50 @@ describe('validateSealedProbeConfig', () => { ['user:token@octo/private', 'credentials'], ['octo/private/extra', 'extra path segment'], ])('rejects unsafe repository slug %s (%s)', (repoSlug) => { - const errors = validateSealedProbeConfig(buildConfig({ privateRepos: [repo(repoSlug)] }), envWithToken); + const errors = validateBoundedQueryConfig(buildConfig({ privateRepos: [repo(repoSlug)] }), envWithToken); expect(errors.join('\n')).toContain('is not a bare owner/repo slug'); }); it('rejects case-insensitive duplicates', () => { - const errors = validateSealedProbeConfig( + const errors = validateBoundedQueryConfig( buildConfig({ privateRepos: [repo('octo/private'), repo('Octo/Private')] }), envWithToken, ); expect(errors.join('\n')).toContain('duplicate entry'); }); - it('fails closed for an unsupported probe runtime instead of downgrading', () => { + it('fails closed for an unsupported query runtime instead of downgrading', () => { // Cast to bypass the type check — JSON parsing at runtime can produce any string. - const errors = validateSealedProbeConfig(buildConfig({ runtime: 'vmware' as 'docker' }), envWithToken); + const errors = validateBoundedQueryConfig(buildConfig({ runtime: 'vmware' as 'docker' }), envWithToken); expect(errors.join('\n')).toContain('is not supported'); expect(errors.join('\n')).toContain('never downgrade'); }); - it('accepts the gvisor probe runtime at the configuration layer', () => { - expect(validateSealedProbeConfig(buildConfig({ runtime: 'gvisor' }), envWithToken)).toEqual([]); + it('accepts the gvisor query runtime at the configuration layer', () => { + expect(validateBoundedQueryConfig(buildConfig({ runtime: 'gvisor' }), envWithToken)).toEqual([]); }); it('rejects a microVM primary agent runtime, which cannot receive the socket', () => { - const errors = validateSealedProbeConfig(buildConfig({}, { containerRuntime: 'sbx' }), envWithToken); + const errors = validateBoundedQueryConfig(buildConfig({}, { containerRuntime: 'sbx' }), envWithToken); expect(errors.join('\n')).toContain('cannot be exposed to a "sbx" primary agent'); }); it('allows a gvisor primary agent runtime (still a Compose service)', () => { - expect(validateSealedProbeConfig(buildConfig({}, { containerRuntime: 'gvisor' }), envWithToken)).toEqual([]); + expect(validateBoundedQueryConfig(buildConfig({}, { containerRuntime: 'gvisor' }), envWithToken)).toEqual([]); }); it('requires a staging credential on the AWF host', () => { - const errors = validateSealedProbeConfig(buildConfig(), {}); + const errors = validateBoundedQueryConfig(buildConfig(), {}); expect(errors.join('\n')).toContain('GH_TOKEN or GITHUB_TOKEN'); }); it('rejects a TCP Docker host, which a network-less broker cannot reach', () => { - const errors = validateSealedProbeConfig(buildConfig({}, { awfDockerHost: 'tcp://localhost:2375' }), envWithToken); + const errors = validateBoundedQueryConfig(buildConfig({}, { awfDockerHost: 'tcp://localhost:2375' }), envWithToken); expect(errors.join('\n')).toContain('require a Unix-socket Docker host'); }); it('rejects a TCP DOCKER_HOST inherited from the environment', () => { - const errors = validateSealedProbeConfig(buildConfig(), { + const errors = validateBoundedQueryConfig(buildConfig(), { ...envWithToken, DOCKER_HOST: 'tcp://127.0.0.1:2375', }); @@ -107,16 +107,16 @@ describe('validateSealedProbeConfig', () => { it('accepts an explicit Unix-socket Docker host', () => { expect( - validateSealedProbeConfig(buildConfig({}, { awfDockerHost: 'unix:///run/user/1001/docker.sock' }), envWithToken), + validateBoundedQueryConfig(buildConfig({}, { awfDockerHost: 'unix:///run/user/1001/docker.sock' }), envWithToken), ).toEqual([]); }); it('accepts GITHUB_TOKEN as the staging credential', () => { - expect(validateSealedProbeConfig(buildConfig(), { GITHUB_TOKEN: 'ghs_x' })).toEqual([]); + expect(validateBoundedQueryConfig(buildConfig(), { GITHUB_TOKEN: 'ghs_x' })).toEqual([]); }); it('rejects out-of-range or malformed limits', () => { - const errors = validateSealedProbeConfig( + const errors = validateBoundedQueryConfig( buildConfig({ timeout: 0, maxInvocations: 0, memoryLimit: 'lots' }), envWithToken, ); @@ -126,49 +126,49 @@ describe('validateSealedProbeConfig', () => { }); it('accepts a timeout that preserves the final one-minute processing margin (540s)', () => { - expect(validateSealedProbeConfig(buildConfig({ timeout: 540 }), envWithToken)).toEqual([]); + expect(validateBoundedQueryConfig(buildConfig({ timeout: 540 }), envWithToken)).toEqual([]); }); it('rejects a timeout that consumes the final timing bucket processing margin', () => { - const errors = validateSealedProbeConfig(buildConfig({ timeout: 541 }), envWithToken); + const errors = validateBoundedQueryConfig(buildConfig({ timeout: 541 }), envWithToken); expect(errors.join('\n')).toContain('timeout must be at most 540 seconds'); expect(errors.join('\n')).toContain('reserves its final minute'); }); it('rejects an unsupported interpreter', () => { - const errors = validateSealedProbeConfig( - buildConfig({ interpreter: 'ruby' as unknown as SealedProbesConfig['interpreter'] }), + const errors = validateBoundedQueryConfig( + buildConfig({ interpreter: 'ruby' as unknown as BoundedQueriesConfig['interpreter'] }), envWithToken, ); expect(errors.join('\n')).toContain('interpreter "ruby" is not supported'); }); }); -describe('assertProbeRuntimeAvailable', () => { - it('does not probe Docker for the default runtime', async () => { - const probe = jest.fn(); - await expect(assertProbeRuntimeAvailable(baseSealedProbes, probe)).resolves.toBeUndefined(); - expect(probe).not.toHaveBeenCalled(); +describe('assertQueryRuntimeAvailable', () => { + it('does not query Docker for the default runtime', async () => { + const query = jest.fn(); + await expect(assertQueryRuntimeAvailable(baseBoundedQueries, query)).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); }); it('accepts gvisor when runsc is registered with the daemon', async () => { - const probe = jest.fn().mockResolvedValue(true); + const query = jest.fn().mockResolvedValue(true); await expect( - assertProbeRuntimeAvailable({ ...baseSealedProbes, runtime: 'gvisor' }, probe), + assertQueryRuntimeAvailable({ ...baseBoundedQueries, runtime: 'gvisor' }, query), ).resolves.toBeUndefined(); - expect(probe).toHaveBeenCalledWith('runsc'); + expect(query).toHaveBeenCalledWith('runsc'); }); it('fails closed when runsc is unavailable', async () => { - const probe = jest.fn().mockResolvedValue(false); + const query = jest.fn().mockResolvedValue(false); await expect( - assertProbeRuntimeAvailable({ ...baseSealedProbes, runtime: 'gvisor' }, probe), + assertQueryRuntimeAvailable({ ...baseBoundedQueries, runtime: 'gvisor' }, query), ).rejects.toThrow(/runsc.*not available|not available.*fall back/s); }); it('detects registered runtimes through Docker info', async () => { mockExeca.mockResolvedValue({ exitCode: 0, stdout: '{"runc":{},"runsc":{}}' }); - await expect(preflightTestHelpers.defaultDockerRuntimeProbe('runsc')).resolves.toBe(true); + await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(true); expect(mockExeca).toHaveBeenCalledWith( 'docker', ['info', '--format', '{{json .Runtimes}}'], @@ -178,9 +178,9 @@ describe('assertProbeRuntimeAvailable', () => { it('fails closed when Docker info fails or returns malformed JSON', async () => { mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '' }); - await expect(preflightTestHelpers.defaultDockerRuntimeProbe('runsc')).resolves.toBe(false); + await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(false); mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'not-json' }); - await expect(preflightTestHelpers.defaultDockerRuntimeProbe('runsc')).resolves.toBe(false); + await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(false); }); }); diff --git a/src/bounded-query/preflight.ts b/src/bounded-query/preflight.ts new file mode 100644 index 000000000..1c3a46c64 --- /dev/null +++ b/src/bounded-query/preflight.ts @@ -0,0 +1,168 @@ +import execa from 'execa'; +import { getLocalDockerEnv } from '../host-env'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import type { BoundedQueriesConfig, WrapperConfig } from '../types'; +import { normalizeRepoKey } from './paths'; +import { MAX_QUERY_TIMEOUT_SECONDS, BOUNDED_QUERY_REPO_PATTERN } from './protocol'; +import { resolveStagingToken } from './staging'; + +/** + * Fail-closed preflight for bounded queries. + * + * JSON Schema already constrains the *shape* of `boundedQueries`. This module + * covers everything the schema cannot: credential availability, sandbox + * runtime availability, and combinations of AWF settings under which bounded + * queries cannot be exposed securely. + * + * Every check here is fatal — a bounded-query run that cannot satisfy its + * isolation guarantees must abort before the primary agent starts rather than + * silently downgrading. + */ + +/** Query sandbox runtimes with a safe, implemented no-network launcher. */ +const SUPPORTED_QUERY_RUNTIMES = new Set(['docker', 'gvisor']); + +/** Docker OCI runtime name required for the `gvisor` query runtime. */ +const GVISOR_DOCKER_RUNTIME = 'runsc'; + +/** Detects whether the Docker daemon exposes a named OCI runtime. */ +export type DockerRuntimeQuery = (runtimeName: string) => Promise; + +const defaultDockerRuntimeQuery: DockerRuntimeQuery = async (runtimeName) => { + const result = await execa('docker', ['info', '--format', '{{json .Runtimes}}'], { + env: getLocalDockerEnv(), + reject: false, + timeout: 30_000, + }); + if (result.exitCode !== 0) return false; + try { + const runtimes = JSON.parse(result.stdout) as Record; + return Object.prototype.hasOwnProperty.call(runtimes, runtimeName); + } catch { + return false; + } +}; + +/** + * Validates everything about a bounded-query configuration that can be decided + * without touching Docker or the network. + * + * @returns human-readable errors; empty when the configuration is acceptable. + */ +export function validateBoundedQueryConfig( + config: WrapperConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const boundedQueries = config.boundedQueries; + if (!boundedQueries?.enabled) return []; + + const errors: string[] = []; + + if (boundedQueries.privateRepos.length === 0) { + errors.push('boundedQueries.enabled is true but boundedQueries.privateRepos is empty'); + } + + const seenKeys = new Set(); + for (const entry of boundedQueries.privateRepos) { + const repo = entry.repo; + if (!BOUNDED_QUERY_REPO_PATTERN.test(repo)) { + errors.push( + `boundedQueries.privateRepos entry "${repo}" is not a bare owner/repo slug ` + + '(no scheme, host, credentials, path traversal, query, fragment, or wildcard)', + ); + continue; + } + const key = normalizeRepoKey(repo); + if (seenKeys.has(key)) { + errors.push(`boundedQueries.privateRepos contains a duplicate entry: "${repo}"`); + } + seenKeys.add(key); + } + + if (!SUPPORTED_QUERY_RUNTIMES.has(boundedQueries.runtime)) { + errors.push( + `boundedQueries.runtime "${boundedQueries.runtime}" is not supported. ` + + 'AWF has no no-network, per-invocation bounded-query launcher for it, and bounded queries ' + + 'never downgrade to a weaker runtime. Use "docker" or "gvisor".', + ); + } + + if (boundedQueries.interpreter !== 'python3') { + errors.push(`boundedQueries.interpreter "${boundedQueries.interpreter}" is not supported`); + } + + // Reserve the final minute of the 10-minute response bucket for Docker + // termination, result validation, container removal, and workspace cleanup. + // The script timeout cannot consume the entire observable boundary. + if (!Number.isInteger(boundedQueries.timeout) || boundedQueries.timeout < 1) { + errors.push('boundedQueries.timeout must be a positive integer number of seconds'); + } else if (boundedQueries.timeout > MAX_QUERY_TIMEOUT_SECONDS) { + errors.push( + `boundedQueries.timeout must be at most ${MAX_QUERY_TIMEOUT_SECONDS} seconds ` + + '(the 10-minute response bucket reserves its final minute for termination, validation, and cleanup)', + ); + } + + if (!Number.isInteger(boundedQueries.maxInvocations) || boundedQueries.maxInvocations < 1) { + errors.push('boundedQueries.maxInvocations must be a positive integer'); + } + + if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(boundedQueries.memoryLimit)) { + errors.push(`boundedQueries.memoryLimit "${boundedQueries.memoryLimit}" is not a Docker memory limit`); + } + + if (!runtimeUsesComposeAgent(config.containerRuntime)) { + errors.push( + `bounded queries cannot be exposed to a "${config.containerRuntime}" primary agent: ` + + 'the broker socket is shared through a Docker Compose bind mount, which a microVM agent ' + + 'does not receive. Disable boundedQueries or use a Compose-based container runtime.', + ); + } + + const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST; + if (dockerHost && !dockerHost.startsWith('unix://')) { + errors.push( + `bounded queries require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` + + 'The broker runs with network_mode: none so it can only reach the daemon over a bind-mounted ' + + 'socket, and AWF will not weaken that isolation to reach a TCP daemon.', + ); + } + + if (!resolveStagingToken(env)) { + errors.push( + 'bounded queries require a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host ' + + '(it is used only by the trusted staging phase and never reaches the agent, broker, or query)', + ); + } + + return errors; +} + +/** + * Verifies that the requested query sandbox runtime is actually available. + * + * Only reached after {@link validateBoundedQueryConfig} accepted the runtime + * name, so the only remaining question is daemon support. + */ +export async function assertQueryRuntimeAvailable( + boundedQueries: BoundedQueriesConfig, + queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, +): Promise { + if (boundedQueries.runtime !== 'gvisor') return; + + if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { + throw new Error( + `boundedQueries.runtime "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + + 'registered with the Docker daemon. It is not available, and bounded queries never fall back ' + + 'to a weaker runtime.', + ); + } +} + +/** @internal Exported for focused unit tests. */ +// ts-prune-ignore-next +export const preflightTestHelpers = { + SUPPORTED_QUERY_RUNTIMES, + GVISOR_DOCKER_RUNTIME, + defaultDockerRuntimeQuery, +}; diff --git a/src/sealed-probe/protocol-parity.test.ts b/src/bounded-query/protocol-parity.test.ts similarity index 91% rename from src/sealed-probe/protocol-parity.test.ts rename to src/bounded-query/protocol-parity.test.ts index d0eb0f16e..027e56d56 100644 --- a/src/sealed-probe/protocol-parity.test.ts +++ b/src/bounded-query/protocol-parity.test.ts @@ -5,7 +5,7 @@ import { MAX_ENUM_VALUES, MAX_OBJECT_FIELDS, MAX_PRIVATE_REPO_LENGTH, - MAX_PROBE_TIMEOUT_SECONDS, + MAX_QUERY_TIMEOUT_SECONDS, MAX_RESULT_BYTES, MAX_SCHEMA_BYTES, MAX_SCHEMA_DEPTH, @@ -13,32 +13,32 @@ import { MAX_SCRIPT_BYTES, MAX_TUPLE_ITEMS, MAX_UNION_VARIANTS, - PROBE_PROTOCOL_VERSION, + QUERY_PROTOCOL_VERSION, RESULT_STATUS_BIT_COST, FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS, - SEALED_PROBE_REPO_PATTERN, + BOUNDED_QUERY_REPO_PATTERN, TIMING_BUCKETS_MS, TIMING_BUCKET_BITS, canonicalOkJson, canonicalizeSchemaValue, ceilLog2BigInt, - parseAndValidateProbeOutput, + parseAndValidateQueryOutput, queryBitsForSchema, schemaCardinality, strictParseJson, validateSchema, - validateSealedProbeRequest, + validateBoundedQueryRequest, validateValueAgainstSchema, - type SealedProbeSchemaNode, + type BoundedQuerySchemaNode, } from './protocol'; import { - SEALED_PROBE_SENSITIVITIES, - SEALED_PROBE_SENSITIVITY_RUN_BITS, -} from '../types/sealed-probe-options'; + BOUNDED_QUERY_SENSITIVITIES, + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, +} from '../types/bounded-query-options'; /** * The broker runs in its own container image and cannot import AWF's - * TypeScript sources, so `containers/sealed-probe/broker/protocol.js` + * TypeScript sources, so `containers/bounded-query/broker/protocol.js` * restates the entire v2 protocol (finite schema algebra, cardinality/bit * charge, strict JSON parsing, request/result validation, canonicalization). * This suite runs one shared vector table through *both* implementations and @@ -46,11 +46,11 @@ import { */ // eslint-disable-next-line @typescript-eslint/no-require-imports const brokerProtocol = require( - path.join(__dirname, '..', '..', 'containers', 'sealed-probe', 'broker', 'protocol.js'), + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'protocol.js'), ); // eslint-disable-next-line @typescript-eslint/no-require-imports const brokerSensitivity = require( - path.join(__dirname, '..', '..', 'containers', 'sealed-probe', 'broker', 'sensitivity.js'), + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'sensitivity.js'), ); const SCHEMA_VECTORS: Array<{ name: string; schema: unknown }> = [ @@ -150,7 +150,7 @@ const SCHEMA_VECTORS: Array<{ name: string; schema: unknown }> = [ const VALID_SCHEMAS_FOR_VALUE_TESTS: Array<{ name: string; - schema: SealedProbeSchemaNode; + schema: BoundedQuerySchemaNode; values: unknown[]; }> = [ { name: 'const', schema: { type: 'const', value: 'ok' }, values: ['ok', 'not-ok', 1, null] }, @@ -217,7 +217,7 @@ const REQUEST_VECTORS: Array<{ name: string; request: unknown }> = [ { name: 'script at exact size cap', request: { privateRepo: 'octo/private', schema: { type: 'boolean' }, script: 'x'.repeat(MAX_SCRIPT_BYTES) } }, ]; -const RESULT_VECTORS: Array<{ name: string; schema: SealedProbeSchemaNode; raw: string }> = [ +const RESULT_VECTORS: Array<{ name: string; schema: BoundedQuerySchemaNode; raw: string }> = [ { name: 'valid enum result', schema: { type: 'enum', values: ['YES', 'NO', 'UNKNOWN'] }, raw: '{"result":"YES"}' }, { name: 'whitespace tolerant', @@ -254,9 +254,9 @@ const RESULT_VECTORS: Array<{ name: string; schema: SealedProbeSchemaNode; raw: }, ]; -describe('sealed-probe protocol parity (TypeScript vs broker JavaScript)', () => { +describe('bounded-query protocol parity (TypeScript vs broker JavaScript)', () => { it('exposes identical protocol constants', () => { - expect(brokerProtocol.PROBE_PROTOCOL_VERSION).toBe(PROBE_PROTOCOL_VERSION); + expect(brokerProtocol.QUERY_PROTOCOL_VERSION).toBe(QUERY_PROTOCOL_VERSION); expect(brokerProtocol.MAX_SCHEMA_BYTES).toBe(MAX_SCHEMA_BYTES); expect(brokerProtocol.MAX_SCHEMA_DEPTH).toBe(MAX_SCHEMA_DEPTH); expect(brokerProtocol.MAX_SCHEMA_NODES).toBe(MAX_SCHEMA_NODES); @@ -271,17 +271,17 @@ describe('sealed-probe protocol parity (TypeScript vs broker JavaScript)', () => expect(brokerProtocol.TIMING_BUCKETS_MS).toEqual(TIMING_BUCKETS_MS); expect(brokerProtocol.FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) .toBe(FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS); - expect(brokerProtocol.MAX_PROBE_TIMEOUT_SECONDS).toBe(MAX_PROBE_TIMEOUT_SECONDS); + expect(brokerProtocol.MAX_QUERY_TIMEOUT_SECONDS).toBe(MAX_QUERY_TIMEOUT_SECONDS); expect(brokerProtocol.TIMING_BUCKET_BITS).toBe(TIMING_BUCKET_BITS); expect(brokerProtocol.RESULT_STATUS_BIT_COST).toBe(RESULT_STATUS_BIT_COST); - expect(brokerProtocol.SEALED_PROBE_REPO_PATTERN.source).toBe(SEALED_PROBE_REPO_PATTERN.source); + expect(brokerProtocol.BOUNDED_QUERY_REPO_PATTERN.source).toBe(BOUNDED_QUERY_REPO_PATTERN.source); expect(brokerProtocol.CANONICAL_ERROR_JSON).toBe(CANONICAL_ERROR_JSON); }); it('keeps broker sensitivity categories and run budgets aligned with host policy', () => { - expect(brokerSensitivity.SEALED_PROBE_SENSITIVITIES).toEqual(SEALED_PROBE_SENSITIVITIES); - expect(brokerSensitivity.SEALED_PROBE_SENSITIVITY_RUN_BITS).toEqual( - SEALED_PROBE_SENSITIVITY_RUN_BITS, + expect(brokerSensitivity.BOUNDED_QUERY_SENSITIVITIES).toEqual(BOUNDED_QUERY_SENSITIVITIES); + expect(brokerSensitivity.BOUNDED_QUERY_SENSITIVITY_RUN_BITS).toEqual( + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, ); }); @@ -326,8 +326,8 @@ describe('sealed-probe protocol parity (TypeScript vs broker JavaScript)', () => }); it.each(REQUEST_VECTORS)('agrees on request validity: $name', ({ request }) => { - const ts = validateSealedProbeRequest(request); - const js = brokerProtocol.validateSealedProbeRequest(request); + const ts = validateBoundedQueryRequest(request); + const js = brokerProtocol.validateBoundedQueryRequest(request); expect(js.valid).toBe(ts.valid); if (!ts.valid && !js.valid) { @@ -335,9 +335,9 @@ describe('sealed-probe protocol parity (TypeScript vs broker JavaScript)', () => } }); - it.each(RESULT_VECTORS)('agrees on probe output parsing/validation: $name', ({ schema, raw }) => { - const ts = parseAndValidateProbeOutput(raw, schema); - const js = brokerProtocol.parseAndValidateProbeOutput(raw, schema); + it.each(RESULT_VECTORS)('agrees on query output parsing/validation: $name', ({ schema, raw }) => { + const ts = parseAndValidateQueryOutput(raw, schema); + const js = brokerProtocol.parseAndValidateQueryOutput(raw, schema); expect(js).toEqual(ts); }); diff --git a/src/sealed-probe/protocol.test.ts b/src/bounded-query/protocol.test.ts similarity index 85% rename from src/sealed-probe/protocol.test.ts rename to src/bounded-query/protocol.test.ts index 4b769a9e0..6fc3a668c 100644 --- a/src/sealed-probe/protocol.test.ts +++ b/src/bounded-query/protocol.test.ts @@ -12,32 +12,32 @@ import { MAX_SCRIPT_BYTES, MAX_TUPLE_ITEMS, MAX_UNION_VARIANTS, - PROBE_PROTOCOL_VERSION, + QUERY_PROTOCOL_VERSION, RESULT_STATUS_BIT_COST, - SEALED_PROBE_REPO_PATTERN, + BOUNDED_QUERY_REPO_PATTERN, TIMING_BUCKETS_MS, TIMING_BUCKET_BITS, canonicalOkJson, canonicalizeSchemaValue, ceilLog2BigInt, - parseAndValidateProbeOutput, + parseAndValidateQueryOutput, queryBitsForSchema, schemaCardinality, strictParseJson, validateSchema, - validateSealedProbeRequest, + validateBoundedQueryRequest, validateValueAgainstSchema, - type SealedProbeSchemaNode, + type BoundedQuerySchemaNode, } from './protocol'; import { - SEALED_PROBE_DEFAULTS as EXPORTED_DEFAULTS, - SEALED_PROBE_SENSITIVITIES as EXPORTED_SENSITIVITIES, - SEALED_PROBE_SENSITIVITY_RUN_BITS as EXPORTED_RUN_BITS, + BOUNDED_QUERY_DEFAULTS as EXPORTED_DEFAULTS, + BOUNDED_QUERY_SENSITIVITIES as EXPORTED_SENSITIVITIES, + BOUNDED_QUERY_SENSITIVITY_RUN_BITS as EXPORTED_RUN_BITS, } from '../types'; describe('protocol constants', () => { it('fixes the wire protocol version at 2', () => { - expect(PROBE_PROTOCOL_VERSION).toBe(2); + expect(QUERY_PROTOCOL_VERSION).toBe(2); }); it('has exactly six timing buckets and 3 timing bits', () => { @@ -49,18 +49,18 @@ describe('protocol constants', () => { expect(RESULT_STATUS_BIT_COST).toBe(1); }); - it('exposes sealed-probe policy constants through the public types barrel', () => { + it('exposes bounded-query policy constants through the public types barrel', () => { expect(EXPORTED_DEFAULTS.timeout).toBe(30); expect(EXPORTED_SENSITIVITIES).toEqual(['public', 'internal', 'confidential', 'sealed']); expect(EXPORTED_RUN_BITS).toEqual({ public: null, internal: 64, confidential: 8, sealed: 0 }); }); }); -describe('SEALED_PROBE_REPO_PATTERN', () => { +describe('BOUNDED_QUERY_REPO_PATTERN', () => { it.each(['octo/repo', 'octo-org/octo-repo', 'my-org/my.repo-name_2', 'a/b'])( 'accepts a valid owner/repo slug: %s', (slug) => { - expect(SEALED_PROBE_REPO_PATTERN.test(slug)).toBe(true); + expect(BOUNDED_QUERY_REPO_PATTERN.test(slug)).toBe(true); }, ); @@ -79,7 +79,7 @@ describe('SEALED_PROBE_REPO_PATTERN', () => { ['leading slash owner', '/octo/repo'], ['owner starting with dot', './repo'], ])('rejects %s', (_label, slug) => { - expect(SEALED_PROBE_REPO_PATTERN.test(slug)).toBe(false); + expect(BOUNDED_QUERY_REPO_PATTERN.test(slug)).toBe(false); }); }); @@ -294,31 +294,31 @@ describe('validateSchema', () => { describe('schemaCardinality and queryBitsForSchema', () => { it('computes cardinality 1 for const (0 bits)', () => { - const schema: SealedProbeSchemaNode = { type: 'const', value: 'ok' }; + const schema: BoundedQuerySchemaNode = { type: 'const', value: 'ok' }; expect(schemaCardinality(schema)).toBe(1n); expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 0 + TIMING_BUCKET_BITS); }); it('computes cardinality 2 for boolean (1 bit)', () => { - const schema: SealedProbeSchemaNode = { type: 'boolean' }; + const schema: BoundedQuerySchemaNode = { type: 'boolean' }; expect(schemaCardinality(schema)).toBe(2n); expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 1 + TIMING_BUCKET_BITS); }); it('computes cardinality equal to the enum length', () => { - const schema: SealedProbeSchemaNode = { type: 'enum', values: ['a', 'b', 'c', 'd'] }; + const schema: BoundedQuerySchemaNode = { type: 'enum', values: ['a', 'b', 'c', 'd'] }; expect(schemaCardinality(schema)).toBe(4n); expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 2 + TIMING_BUCKET_BITS); }); it('computes cardinality as the inclusive integer range size', () => { - const schema: SealedProbeSchemaNode = { type: 'integer', minimum: 0, maximum: 255 }; + const schema: BoundedQuerySchemaNode = { type: 'integer', minimum: 0, maximum: 255 }; expect(schemaCardinality(schema)).toBe(256n); expect(queryBitsForSchema(schema)).toBe(RESULT_STATUS_BIT_COST + 8 + TIMING_BUCKET_BITS); }); it('multiplies cardinality across object fields', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'object', fields: [ { name: 'a', schema: { type: 'boolean' } }, @@ -330,7 +330,7 @@ describe('schemaCardinality and queryBitsForSchema', () => { }); it('multiplies cardinality across tuple items', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }, { type: 'boolean' }], }; @@ -338,17 +338,17 @@ describe('schemaCardinality and queryBitsForSchema', () => { }); it('raises item cardinality to the fixed array length', () => { - const schema: SealedProbeSchemaNode = { type: 'array', items: { type: 'boolean' }, length: 10 }; + const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 10 }; expect(schemaCardinality(schema)).toBe(1024n); }); it('handles a zero-length array as cardinality 1', () => { - const schema: SealedProbeSchemaNode = { type: 'array', items: { type: 'boolean' }, length: 0 }; + const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 0 }; expect(schemaCardinality(schema)).toBe(1n); }); it('sums cardinality across disjoint union variants', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'union', variants: [ { tag: 'a', schema: { type: 'boolean' } }, @@ -361,7 +361,7 @@ describe('schemaCardinality and queryBitsForSchema', () => { it('never overflows even for a schema near the configured bounds', () => { // Cardinality far beyond Number.MAX_SAFE_INTEGER — must stay exact as a BigInt. - const schema: SealedProbeSchemaNode = { type: 'array', items: { type: 'integer', minimum: 0, maximum: 65535 }, length: 8 }; + const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'integer', minimum: 0, maximum: 65535 }, length: 8 }; const expected = 65536n ** 8n; expect(schemaCardinality(schema)).toBe(expected); expect(queryBitsForSchema(schema)).toBe( @@ -385,7 +385,7 @@ describe('validateValueAgainstSchema', () => { }); it('validates boolean by strict type', () => { - const schema: SealedProbeSchemaNode = { type: 'boolean' }; + const schema: BoundedQuerySchemaNode = { type: 'boolean' }; expect(validateValueAgainstSchema(schema, true)).toBe(true); expect(validateValueAgainstSchema(schema, false)).toBe(true); expect(validateValueAgainstSchema(schema, 1)).toBe(false); @@ -393,13 +393,13 @@ describe('validateValueAgainstSchema', () => { }); it('validates enum membership only, rejecting unknown members', () => { - const schema: SealedProbeSchemaNode = { type: 'enum', values: ['a', 'b'] }; + const schema: BoundedQuerySchemaNode = { type: 'enum', values: ['a', 'b'] }; expect(validateValueAgainstSchema(schema, 'a')).toBe(true); expect(validateValueAgainstSchema(schema, 'c')).toBe(false); }); it('validates integer range and rejects non-integers', () => { - const schema: SealedProbeSchemaNode = { type: 'integer', minimum: 0, maximum: 10 }; + const schema: BoundedQuerySchemaNode = { type: 'integer', minimum: 0, maximum: 10 }; expect(validateValueAgainstSchema(schema, 5)).toBe(true); expect(validateValueAgainstSchema(schema, 0)).toBe(true); expect(validateValueAgainstSchema(schema, 10)).toBe(true); @@ -409,7 +409,7 @@ describe('validateValueAgainstSchema', () => { }); it('validates fixed object shape: no missing, no extra fields', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'object', fields: [{ name: 'ok', schema: { type: 'boolean' } }], }; @@ -422,21 +422,21 @@ describe('validateValueAgainstSchema', () => { }); it('validates fixed-length tuples exactly', () => { - const schema: SealedProbeSchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] }; + const schema: BoundedQuerySchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] }; expect(validateValueAgainstSchema(schema, [true, false])).toBe(true); expect(validateValueAgainstSchema(schema, [true])).toBe(false); expect(validateValueAgainstSchema(schema, [true, false, true])).toBe(false); }); it('validates fixed-length arrays exactly', () => { - const schema: SealedProbeSchemaNode = { type: 'array', items: { type: 'boolean' }, length: 2 }; + const schema: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 2 }; expect(validateValueAgainstSchema(schema, [true, false])).toBe(true); expect(validateValueAgainstSchema(schema, [true])).toBe(false); expect(validateValueAgainstSchema(schema, [true, false, true])).toBe(false); }); it('validates a tagged union: exact tag/value shape, no untagged escape', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'union', variants: [ { tag: 'a', schema: { type: 'boolean' } }, @@ -464,7 +464,7 @@ describe('canonicalizeSchemaValue', () => { }); it('re-serializes an object in declared field order regardless of input key order', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'object', fields: [ { name: 'b', schema: { type: 'boolean' } }, @@ -475,15 +475,15 @@ describe('canonicalizeSchemaValue', () => { }); it('re-serializes tuples and arrays positionally', () => { - const tuple: SealedProbeSchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] }; + const tuple: BoundedQuerySchemaNode = { type: 'tuple', items: [{ type: 'boolean' }, { type: 'boolean' }] }; expect(canonicalizeSchemaValue(tuple, [true, false])).toBe('[true,false]'); - const array: SealedProbeSchemaNode = { type: 'array', items: { type: 'boolean' }, length: 2 }; + const array: BoundedQuerySchemaNode = { type: 'array', items: { type: 'boolean' }, length: 2 }; expect(canonicalizeSchemaValue(array, [false, true])).toBe('[false,true]'); }); it('re-serializes a tagged union as {"tag":...,"value":...}', () => { - const schema: SealedProbeSchemaNode = { + const schema: BoundedQuerySchemaNode = { type: 'union', variants: [{ tag: 'a', schema: { type: 'boolean' } }], }; @@ -564,7 +564,7 @@ describe('strictParseJson', () => { ); }); -describe('validateSealedProbeRequest', () => { +describe('validateBoundedQueryRequest', () => { const validRequest = { privateRepo: 'octo/repo', schema: { type: 'boolean' }, @@ -572,7 +572,7 @@ describe('validateSealedProbeRequest', () => { }; it('accepts a well-formed request', () => { - const result = validateSealedProbeRequest(validRequest); + const result = validateBoundedQueryRequest(validRequest); expect(result).toEqual({ valid: true, request: { privateRepo: 'octo/repo', schema: { type: 'boolean' }, script: 'print("hello")' }, @@ -580,30 +580,30 @@ describe('validateSealedProbeRequest', () => { }); it('rejects non-object requests', () => { - expect(validateSealedProbeRequest(null).valid).toBe(false); - expect(validateSealedProbeRequest('string').valid).toBe(false); - expect(validateSealedProbeRequest([1, 2, 3]).valid).toBe(false); + expect(validateBoundedQueryRequest(null).valid).toBe(false); + expect(validateBoundedQueryRequest('string').valid).toBe(false); + expect(validateBoundedQueryRequest([1, 2, 3]).valid).toBe(false); }); it('rejects a privateRepo that looks like a URL', () => { - const result = validateSealedProbeRequest({ ...validRequest, privateRepo: 'https://github.com/octo/repo' }); + const result = validateBoundedQueryRequest({ ...validRequest, privateRepo: 'https://github.com/octo/repo' }); expect(result.valid).toBe(false); }); it(`rejects a privateRepo exceeding ${MAX_PRIVATE_REPO_LENGTH} characters`, () => { const long = `octo/${'r'.repeat(MAX_PRIVATE_REPO_LENGTH)}`; - const result = validateSealedProbeRequest({ ...validRequest, privateRepo: long }); + const result = validateBoundedQueryRequest({ ...validRequest, privateRepo: long }); expect(result.valid).toBe(false); }); it('rejects a missing privateRepo', () => { const rest: Record = { ...validRequest }; delete rest.privateRepo; - expect(validateSealedProbeRequest(rest).valid).toBe(false); + expect(validateBoundedQueryRequest(rest).valid).toBe(false); }); it('rejects an invalid schema', () => { - const result = validateSealedProbeRequest({ ...validRequest, schema: { type: 'nope' } }); + const result = validateBoundedQueryRequest({ ...validRequest, schema: { type: 'nope' } }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors.some((e) => e.startsWith('schema:'))).toBe(true); @@ -611,11 +611,11 @@ describe('validateSealedProbeRequest', () => { }); it('rejects an empty script', () => { - expect(validateSealedProbeRequest({ ...validRequest, script: '' }).valid).toBe(false); + expect(validateBoundedQueryRequest({ ...validRequest, script: '' }).valid).toBe(false); }); it('rejects a script exceeding the size cap', () => { - const result = validateSealedProbeRequest({ ...validRequest, script: 'x'.repeat(MAX_SCRIPT_BYTES + 1) }); + const result = validateBoundedQueryRequest({ ...validRequest, script: 'x'.repeat(MAX_SCRIPT_BYTES + 1) }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors.some((e) => e.includes('script must be at most'))).toBe(true); @@ -623,17 +623,17 @@ describe('validateSealedProbeRequest', () => { }); it('accepts a script at exactly the size cap', () => { - expect(validateSealedProbeRequest({ ...validRequest, script: 'x'.repeat(MAX_SCRIPT_BYTES) }).valid).toBe(true); + expect(validateBoundedQueryRequest({ ...validRequest, script: 'x'.repeat(MAX_SCRIPT_BYTES) }).valid).toBe(true); }); it('accepts an escape-heavy script at the raw script cap', () => { expect( - validateSealedProbeRequest({ ...validRequest, script: '\n'.repeat(MAX_SCRIPT_BYTES) }).valid, + validateBoundedQueryRequest({ ...validRequest, script: '\n'.repeat(MAX_SCRIPT_BYTES) }).valid, ).toBe(true); }); it('rejects unsupported request fields before launch', () => { - const result = validateSealedProbeRequest({ ...validRequest, runtime: 'docker' }); + const result = validateBoundedQueryRequest({ ...validRequest, runtime: 'docker' }); expect(result).toEqual({ valid: false, errors: expect.arrayContaining(['request.runtime is not supported']), @@ -643,7 +643,7 @@ describe('validateSealedProbeRequest', () => { it('rejects a cyclic request through its unsupported field', () => { const cyclic: Record = { ...validRequest }; cyclic.self = cyclic; - const result = validateSealedProbeRequest(cyclic); + const result = validateBoundedQueryRequest(cyclic); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors).toEqual(expect.arrayContaining(['request.self is not supported'])); @@ -651,7 +651,7 @@ describe('validateSealedProbeRequest', () => { }); it('aggregates errors across multiple invalid fields', () => { - const result = validateSealedProbeRequest({ privateRepo: '', schema: { type: 'nope' }, script: '' }); + const result = validateBoundedQueryRequest({ privateRepo: '', schema: { type: 'nope' }, script: '' }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors.length).toBeGreaterThan(1); @@ -670,52 +670,52 @@ describe('canonical envelopes', () => { }); }); -describe('parseAndValidateProbeOutput', () => { - const schema: SealedProbeSchemaNode = { type: 'enum', values: ['success', 'timeout', 'blocked'] }; +describe('parseAndValidateQueryOutput', () => { + const schema: BoundedQuerySchemaNode = { type: 'enum', values: ['success', 'timeout', 'blocked'] }; it('accepts and canonicalizes a valid result', () => { - expect(parseAndValidateProbeOutput('{"result":"success"}', { type: 'object', fields: [{ name: 'result', schema }] })) + expect(parseAndValidateQueryOutput('{"result":"success"}', { type: 'object', fields: [{ name: 'result', schema }] })) .toEqual({ ok: true, canonical: '{"result":"success"}' }); }); it('accepts a bare schema value directly (no envelope object required by the schema itself)', () => { - expect(parseAndValidateProbeOutput('"success"', schema)).toEqual({ ok: true, canonical: '"success"' }); + expect(parseAndValidateQueryOutput('"success"', schema)).toEqual({ ok: true, canonical: '"success"' }); }); it('rejects malformed JSON', () => { - expect(parseAndValidateProbeOutput('not json', schema)).toEqual({ ok: false }); + expect(parseAndValidateQueryOutput('not json', schema)).toEqual({ ok: false }); }); it('rejects a value outside the enum', () => { - expect(parseAndValidateProbeOutput('"not-a-declared-outcome"', schema)).toEqual({ ok: false }); + expect(parseAndValidateQueryOutput('"not-a-declared-outcome"', schema)).toEqual({ ok: false }); }); it(`rejects output exceeding ${MAX_RESULT_BYTES} bytes`, () => { const oversized = `"${'x'.repeat(MAX_RESULT_BYTES)}"`; - expect(parseAndValidateProbeOutput(oversized, { type: 'enum', values: [oversized.slice(1, -1)] })).toEqual({ + expect(parseAndValidateQueryOutput(oversized, { type: 'enum', values: [oversized.slice(1, -1)] })).toEqual({ ok: false, }); }); it('rejects duplicate-key JSON', () => { expect( - parseAndValidateProbeOutput('{"a":1,"a":2}', { type: 'object', fields: [{ name: 'a', schema: { type: 'boolean' } }] }), + parseAndValidateQueryOutput('{"a":1,"a":2}', { type: 'object', fields: [{ name: 'a', schema: { type: 'boolean' } }] }), ).toEqual({ ok: false }); }); it('rejects an empty string', () => { - expect(parseAndValidateProbeOutput('', schema)).toEqual({ ok: false }); + expect(parseAndValidateQueryOutput('', schema)).toEqual({ ok: false }); }); it('normalizes canonical output regardless of source whitespace/key order', () => { - const objSchema: SealedProbeSchemaNode = { + const objSchema: BoundedQuerySchemaNode = { type: 'object', fields: [ { name: 'a', schema: { type: 'boolean' } }, { name: 'b', schema: { type: 'boolean' } }, ], }; - expect(parseAndValidateProbeOutput('{ "b" : true , "a" : false }', objSchema)).toEqual({ + expect(parseAndValidateQueryOutput('{ "b" : true , "a" : false }', objSchema)).toEqual({ ok: true, canonical: '{"a":false,"b":true}', }); diff --git a/src/sealed-probe/protocol.ts b/src/bounded-query/protocol.ts similarity index 92% rename from src/sealed-probe/protocol.ts rename to src/bounded-query/protocol.ts index 0973a7878..f8d7b0271 100644 --- a/src/sealed-probe/protocol.ts +++ b/src/bounded-query/protocol.ts @@ -1,9 +1,9 @@ /** - * Sealed-probe request/result protocol v2: a deliberately finite, + * Bounded-query request/result protocol v2: a deliberately finite, * agent-authored response-schema algebra plus request/result validation and * canonicalization. * - * This module defines the wire protocol for sealed probes independently of + * This module defines the wire protocol for bounded queries independently of * any broker or sandbox runtime. * * Protocol summary: @@ -32,18 +32,18 @@ * JSON Schema validator, nor `JSON.parse`. Both use small, hand-written, * linear-time (no backtracking) recursive-descent parsers below, bounded by * fixed depth/node/size limits, so nothing attacker-influenced (schema text - * or probe output) can grow an unbounded parse tree, and duplicate object + * or query output) can grow an unbounded parse tree, and duplicate object * keys — which `JSON.parse` would silently collapse — are rejected outright. * - * `containers/sealed-probe/broker/protocol.js` is a deliberate, + * `containers/bounded-query/broker/protocol.js` is a deliberate, * behaviour-identical mirror of this module for the broker's container * image, which cannot import AWF's TypeScript sources. Keep both in sync; - * `src/sealed-probe/protocol-parity.test.ts` runs shared vectors through + * `src/bounded-query/protocol-parity.test.ts` runs shared vectors through * both and fails the moment they disagree. */ /** Wire protocol version. Only this exact value is accepted. */ -export const PROBE_PROTOCOL_VERSION = 2; +export const QUERY_PROTOCOL_VERSION = 2; /** Maximum size, in UTF-8 bytes, of a serialized agent-authored schema. */ export const MAX_SCHEMA_BYTES = 4096; @@ -72,10 +72,10 @@ export const MAX_ARRAY_LENGTH = 64; /** Maximum number of variants in one `union` schema. */ export const MAX_UNION_VARIANTS = 16; -/** Maximum size, in UTF-8 bytes, of a probe script. */ +/** Maximum size, in UTF-8 bytes, of a query script. */ export const MAX_SCRIPT_BYTES = 64 * 1024; -/** Maximum size, in UTF-8 bytes, of the probe's raw output file. */ +/** Maximum size, in UTF-8 bytes, of the query's raw output file. */ export const MAX_RESULT_BYTES = 8 * 1024; /** Maximum length of a `privateRepo` "owner/repo" slug. */ @@ -92,7 +92,7 @@ export const TIMING_BUCKETS_MS: readonly number[] = [10, 100, 1_000, 10_000, 60_ export const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000; /** Largest configurable script timeout while preserving the final-bucket margin. */ -export const MAX_PROBE_TIMEOUT_SECONDS = +export const MAX_QUERY_TIMEOUT_SECONDS = (TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000; /** @@ -110,11 +110,11 @@ export const RESULT_STATUS_BIT_COST = 1; * traversal (`..`), no query string or fragment (`?`/`#`), no wildcard * (`*`), and no extra path segments (only one `/` is allowed). * - * Keep in sync with `sealedProbes.privateRepos.items` in + * Keep in sync with `boundedQueries.privateRepos.items` in * `docs/awf-config.schema.json` (JSON Schema cannot share a regex constant * with TypeScript source). */ -export const SEALED_PROBE_REPO_PATTERN = +export const BOUNDED_QUERY_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/; /** Bounded ASCII identifier accepted for object field names and union tags. */ @@ -160,20 +160,20 @@ export interface IntegerSchemaNode { } export interface ObjectSchemaNode { readonly type: 'object'; - readonly fields: readonly { name: string; schema: SealedProbeSchemaNode }[]; + readonly fields: readonly { name: string; schema: BoundedQuerySchemaNode }[]; } export interface TupleSchemaNode { readonly type: 'tuple'; - readonly items: readonly SealedProbeSchemaNode[]; + readonly items: readonly BoundedQuerySchemaNode[]; } export interface ArraySchemaNode { readonly type: 'array'; - readonly items: SealedProbeSchemaNode; + readonly items: BoundedQuerySchemaNode; readonly length: number; } export interface UnionSchemaNode { readonly type: 'union'; - readonly variants: readonly { tag: string; schema: SealedProbeSchemaNode }[]; + readonly variants: readonly { tag: string; schema: BoundedQuerySchemaNode }[]; } /** @@ -184,7 +184,7 @@ export interface UnionSchemaNode { * literal sizes). Cardinality, value validation, and canonical serialization * below all assume that. */ -export type SealedProbeSchemaNode = +export type BoundedQuerySchemaNode = | ConstSchemaNode | BooleanSchemaNode | EnumSchemaNode @@ -194,8 +194,8 @@ export type SealedProbeSchemaNode = | ArraySchemaNode | UnionSchemaNode; -export type SealedProbeSchemaValidation = - | { valid: true; schema: SealedProbeSchemaNode } +export type BoundedQuerySchemaValidation = + | { valid: true; schema: BoundedQuerySchemaNode } | { valid: false; errors: string[] }; function isValidLiteral(value: unknown): value is JsonLiteral { @@ -223,11 +223,11 @@ function failSchema(ctx: SchemaParseContext, message: string): undefined { } /** - * Builds one validated {@link SealedProbeSchemaNode}, enforcing every finite + * Builds one validated {@link BoundedQuerySchemaNode}, enforcing every finite * bound as it recurses. Stops at the first violation (`ctx.errors` becomes * non-empty) rather than continuing to build a tree that will be discarded. */ -function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): SealedProbeSchemaNode | undefined { +function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): BoundedQuerySchemaNode | undefined { if (ctx.errors.length > 0) return undefined; if (depth > MAX_SCHEMA_DEPTH) { return failSchema(ctx, `schema exceeds maximum depth of ${MAX_SCHEMA_DEPTH}`); @@ -320,7 +320,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): return failSchema(ctx, `object field name "${name}" is not a bounded ASCII identifier`); } } - const fields: { name: string; schema: SealedProbeSchemaNode }[] = []; + const fields: { name: string; schema: BoundedQuerySchemaNode }[] = []; for (const name of fieldNames) { const child = buildSchemaNode((fieldsRaw as Record)[name], ctx, depth + 1); if (!child) return undefined; @@ -339,7 +339,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): if (itemsRaw.length > MAX_TUPLE_ITEMS) { return failSchema(ctx, `tuple schema must declare at most ${MAX_TUPLE_ITEMS} items`); } - const items: SealedProbeSchemaNode[] = []; + const items: BoundedQuerySchemaNode[] = []; for (const itemRaw of itemsRaw) { const child = buildSchemaNode(itemRaw, ctx, depth + 1); if (!child) return undefined; @@ -379,7 +379,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): return failSchema(ctx, `union tag "${tag}" is not a bounded ASCII identifier`); } } - const variants: { tag: string; schema: SealedProbeSchemaNode }[] = []; + const variants: { tag: string; schema: BoundedQuerySchemaNode }[] = []; for (const tag of tags) { const child = buildSchemaNode((variantsRaw as Record)[tag], ctx, depth + 1); if (!child) return undefined; @@ -404,7 +404,7 @@ function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): * untagged unions are all structurally impossible to express, so they are * rejected by construction rather than by a separate deny-list. */ -export function validateSchema(raw: unknown): SealedProbeSchemaValidation { +export function validateSchema(raw: unknown): BoundedQuerySchemaValidation { let serialized: string; try { serialized = JSON.stringify(raw) ?? ''; @@ -440,7 +440,7 @@ export function ceilLog2BigInt(n: bigint): number { * distinguishable valid values) as a `BigInt`, so it can never silently * overflow even for schemas near the configured bounds. */ -export function schemaCardinality(schema: SealedProbeSchemaNode): bigint { +export function schemaCardinality(schema: BoundedQuerySchemaNode): bigint { switch (schema.type) { case 'const': return 1n; @@ -473,7 +473,7 @@ export function schemaCardinality(schema: SealedProbeSchemaNode): bigint { * copying a seed or launching Python — never refunded, regardless of the * actual result or completion bucket. */ -export function queryBitsForSchema(schema: SealedProbeSchemaNode): number { +export function queryBitsForSchema(schema: BoundedQuerySchemaNode): number { return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; } @@ -489,7 +489,7 @@ function jsonLiteralEquals(value: unknown, literal: JsonLiteral): boolean { * object/tuple/array shape (no extras, no missing fields, exact length), and * an explicit tagged-union variant. Never coerces. */ -export function validateValueAgainstSchema(schema: SealedProbeSchemaNode, value: unknown): boolean { +export function validateValueAgainstSchema(schema: BoundedQuerySchemaNode, value: unknown): boolean { switch (schema.type) { case 'const': return jsonLiteralEquals(value, schema.value); @@ -542,11 +542,11 @@ export function validateValueAgainstSchema(schema: SealedProbeSchemaNode, value: * Canonically re-serializes an already-validated value. * * The broker calls this on its own parsed representation — never on the raw - * bytes a probe wrote — so two different serializations of the same + * bytes a query wrote — so two different serializations of the same * semantic value (whitespace, key order, numeric formatting) collapse to the * identical observable transcript. */ -export function canonicalizeSchemaValue(schema: SealedProbeSchemaNode, value: unknown): string { +export function canonicalizeSchemaValue(schema: BoundedQuerySchemaNode, value: unknown): string { switch (schema.type) { case 'const': return JSON.stringify(schema.value); @@ -744,26 +744,26 @@ export function strictParseJson(text: string): { value: unknown } | undefined { // ── Request/result validation and canonical envelopes ─────────────────────── -/** A sealed-probe execution request, already assembled from wire framing. */ -export interface SealedProbeRequest { - /** Private repository (`owner/repo`) the probe script runs against. */ +/** A bounded-query execution request, already assembled from wire framing. */ +export interface BoundedQueryRequest { + /** Private repository (`owner/repo`) the query script runs against. */ privateRepo: string; /** The agent-authored, AWF-bounded finite response schema. */ - schema: SealedProbeSchemaNode; - /** The probe script source. */ + schema: BoundedQuerySchemaNode; + /** The query script source. */ script: string; } -export type SealedProbeValidation = - | { valid: true; request: SealedProbeRequest } +export type BoundedQueryValidation = + | { valid: true; request: BoundedQueryRequest } | { valid: false; errors: string[] }; /** - * Validates an unknown value as a {@link SealedProbeRequest}: field shape, + * Validates an unknown value as a {@link BoundedQueryRequest}: field shape, * the `privateRepo` slug pattern, the finite response schema, and the * script size cap. */ -export function validateSealedProbeRequest(raw: unknown): SealedProbeValidation { +export function validateBoundedQueryRequest(raw: unknown): BoundedQueryValidation { if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { return { valid: false, errors: ['request must be a JSON object'] }; } @@ -778,7 +778,7 @@ export function validateSealedProbeRequest(raw: unknown): SealedProbeValidation if (typeof privateRepo !== 'string' || privateRepo.length === 0) { errors.push('privateRepo must be a non-empty string'); - } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !SEALED_PROBE_REPO_PATTERN.test(privateRepo)) { + } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { errors.push( 'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)', ); @@ -816,7 +816,7 @@ export function canonicalOkJson(canonicalResultJson: string): string { } /** - * Parses and validates a probe's raw output file contents against the + * Parses and validates a query's raw output file contents against the * request's approved schema, returning the broker's own canonical * re-serialization of the value on success. * @@ -825,9 +825,9 @@ export function canonicalOkJson(canonicalResultJson: string): string { * fields, wrong tuple/array length, unknown union tag — maps to the same * `{ ok: false }`, which callers turn into {@link CANONICAL_ERROR_JSON}. */ -export function parseAndValidateProbeOutput( +export function parseAndValidateQueryOutput( raw: string, - schema: SealedProbeSchemaNode, + schema: BoundedQuerySchemaNode, ): { ok: true; canonical: string } | { ok: false } { if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false }; const parsed = strictParseJson(raw); diff --git a/src/sealed-probe/probe-seccomp.test.ts b/src/bounded-query/query-seccomp.test.ts similarity index 77% rename from src/sealed-probe/probe-seccomp.test.ts rename to src/bounded-query/query-seccomp.test.ts index a69bd69fc..a5eb5b836 100644 --- a/src/sealed-probe/probe-seccomp.test.ts +++ b/src/bounded-query/query-seccomp.test.ts @@ -2,10 +2,10 @@ import * as fs from 'fs'; import * as path from 'path'; /** - * Invariants for the probe sandbox seccomp profile. + * Invariants for the query sandbox seccomp profile. * - * `containers/sealed-probe/probe-seccomp.json` is derived from the agent - * profile minus the syscalls a stdlib-only python3 probe never needs. These + * `containers/bounded-query/query-seccomp.json` is derived from the agent + * profile minus the syscalls a stdlib-only python3 query never needs. These * assertions keep the derivation honest if either profile is regenerated. */ @@ -21,7 +21,7 @@ function load(file: string): SeccompProfile { return JSON.parse(fs.readFileSync(file, 'utf8')) as SeccompProfile; } -const probeProfile = load(path.join(CONTAINERS, 'sealed-probe', 'probe-seccomp.json')); +const queryProfile = load(path.join(CONTAINERS, 'bounded-query', 'query-seccomp.json')); const agentProfile = load(path.join(CONTAINERS, 'agent', 'seccomp-profile.json')); function allowedNames(profile: SeccompProfile): Set { @@ -33,18 +33,18 @@ function allowedNames(profile: SeccompProfile): Set { return names; } -describe('probe seccomp profile', () => { +describe('query seccomp profile', () => { it('denies by default', () => { - expect(probeProfile.defaultAction).toBe('SCMP_ACT_ERRNO'); + expect(queryProfile.defaultAction).toBe('SCMP_ACT_ERRNO'); }); it('covers the same architectures as the agent profile', () => { - expect(probeProfile.architectures).toEqual(agentProfile.architectures); + expect(queryProfile.architectures).toEqual(agentProfile.architectures); }); it('allows no syscall the agent profile does not already allow', () => { const agentAllowed = allowedNames(agentProfile); - const extra = [...allowedNames(probeProfile)].filter((name) => !agentAllowed.has(name)); + const extra = [...allowedNames(queryProfile)].filter((name) => !agentAllowed.has(name)); expect(extra).toEqual([]); }); @@ -74,12 +74,12 @@ describe('probe seccomp profile', () => { 'open_by_handle_at', 'userfaultfd', ])('never allows %s', (syscall) => { - expect(allowedNames(probeProfile).has(syscall)).toBe(false); + expect(allowedNames(queryProfile).has(syscall)).toBe(false); }); it('explicitly denies those syscalls in addition to the default action', () => { const denied = new Set( - probeProfile.syscalls + queryProfile.syscalls .filter((block) => block.action === 'SCMP_ACT_ERRNO') .flatMap((block) => block.names), ); @@ -89,7 +89,7 @@ describe('probe seccomp profile', () => { }); it('still allows the syscalls a python3 interpreter needs to start and read files', () => { - const allowed = allowedNames(probeProfile); + const allowed = allowedNames(queryProfile); for (const syscall of ['execve', 'openat', 'read', 'write', 'mmap', 'brk', 'getdents64', 'exit_group']) { expect(allowed.has(syscall)).toBe(true); } diff --git a/src/sealed-probe/scheduler.test.ts b/src/bounded-query/scheduler.test.ts similarity index 98% rename from src/sealed-probe/scheduler.test.ts rename to src/bounded-query/scheduler.test.ts index 25dcbba97..18572fe53 100644 --- a/src/sealed-probe/scheduler.test.ts +++ b/src/bounded-query/scheduler.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; /** * Unit tests for response-timing bucketing. * - * A probe's raw completion latency is itself a secret-dependent signal; the + * A query's raw completion latency is itself a secret-dependent signal; the * broker must make every launched invocation's *observable* response time * land on one of six fixed boundaries (10ms, 100ms, 1s, 10s, 1m, 10m), * using a monotonic clock, regardless of how long the underlying work took. @@ -11,7 +11,7 @@ import * as path from 'path'; * to avoid any flakiness from real-time assertions. */ /* eslint-disable @typescript-eslint/no-require-imports */ -const brokerDir = path.join(__dirname, '..', '..', 'containers', 'sealed-probe', 'broker'); +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker'); const { TIMING_BUCKETS_MS, TIMER_WAKE_TOLERANCE_MS, diff --git a/src/sealed-probe/skill.test.ts b/src/bounded-query/skill.test.ts similarity index 82% rename from src/sealed-probe/skill.test.ts rename to src/bounded-query/skill.test.ts index 909ca59cc..fa70a6025 100644 --- a/src/sealed-probe/skill.test.ts +++ b/src/bounded-query/skill.test.ts @@ -1,11 +1,11 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { AGENT_SKILL_PATH, resolveSealedProbePaths } from './paths'; -import { generateSealedProbeSkill, writeSealedProbeSkill } from './skill'; +import { AGENT_SKILL_PATH, resolveBoundedQueryPaths } from './paths'; +import { generateBoundedQuerySkill, writeBoundedQuerySkill } from './skill'; -describe('generateSealedProbeSkill', () => { - const skill = generateSealedProbeSkill({ +describe('generateBoundedQuerySkill', () => { + const skill = generateBoundedQuerySkill({ repos: [ { repo: 'octo/alpha', sensitivity: 'internal' }, { repo: 'octo/beta', sensitivity: 'confidential' }, @@ -18,7 +18,7 @@ describe('generateSealedProbeSkill', () => { it('carries skill frontmatter so the document is self-describing', () => { expect(skill.startsWith('---\n')).toBe(true); - expect(skill).toContain('name: sealed-probe'); + expect(skill).toContain('name: bounded-query'); expect(skill).toContain('description:'); }); @@ -45,9 +45,9 @@ describe('generateSealedProbeSkill', () => { expect(skill).toContain('not** general\nJSON Schema'); }); - it('documents the script contract against /probe/repo and /probe/out', () => { - expect(skill).toContain('/probe/repo'); - expect(skill).toContain('/probe/out'); + it('documents the script contract against /query/repo and /query/out', () => { + expect(skill).toContain('/query/repo'); + expect(skill).toContain('/query/out'); expect(skill).toContain('standard library only'); }); @@ -67,11 +67,11 @@ describe('generateSealedProbeSkill', () => { }); }); -describe('writeSealedProbeSkill', () => { +describe('writeBoundedQuerySkill', () => { let workDir: string; beforeEach(() => { - workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-skill-')); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-skill-')); }); afterEach(() => { @@ -79,8 +79,8 @@ describe('writeSealedProbeSkill', () => { }); it('writes the skill into the AWF-owned agent artifact directory only', () => { - const paths = resolveSealedProbePaths(workDir); - const containerPath = writeSealedProbeSkill(paths, { + const paths = resolveBoundedQueryPaths(workDir); + const containerPath = writeBoundedQuerySkill(paths, { repos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], timeoutSeconds: 30, maxInvocations: 32, diff --git a/src/sealed-probe/skill.ts b/src/bounded-query/skill.ts similarity index 85% rename from src/sealed-probe/skill.ts rename to src/bounded-query/skill.ts index 797cd62b0..85925ce53 100644 --- a/src/sealed-probe/skill.ts +++ b/src/bounded-query/skill.ts @@ -1,58 +1,58 @@ import * as fs from 'fs'; -import type { SealedProbeRepository } from '../types/sealed-probe-options'; -import { SEALED_PROBE_SENSITIVITY_RUN_BITS } from '../types/sealed-probe-options'; +import type { BoundedQueryRepository } from '../types/bounded-query-options'; +import { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } from '../types/bounded-query-options'; import { AGENT_SKILL_PATH, - PROBE_MOUNT_DIR, - type SealedProbePaths, + QUERY_MOUNT_DIR, + type BoundedQueryPaths, } from './paths'; import { CANONICAL_ERROR_JSON, MAX_SCRIPT_BYTES, RESULT_STATUS_BIT_COST, TIMING_BUCKETS_MS, TIMING_BUCKET_BITS } from './protocol'; /** - * Generates the sealed-probe skill document handed to the primary agent. + * Generates the bounded-query skill document handed to the primary agent. * * The document is *guidance*, not a security boundary: every rule it states is - * independently enforced by the `sealed-probe` wrapper and by the trusted + * independently enforced by the `bounded-query` wrapper and by the trusted * broker. Its job is to tell the agent which repositories exist (and at what * confidentiality budget), the v2 request contract (agent-authored finite * schema plus script), and the observable canonical result envelope. */ -interface SealedProbeSkillParams { +interface BoundedQuerySkillParams { /** Configured repositories, in configuration order. */ - repos: SealedProbeRepository[]; + repos: BoundedQueryRepository[]; /** Per-invocation wall-clock limit, in seconds. */ timeoutSeconds: number; /** Per-run invocation budget (an independent operational cap; see "Budget" below). */ maxInvocations: number; } -function formatRunBudget(repo: SealedProbeRepository): string { - const bits = SEALED_PROBE_SENSITIVITY_RUN_BITS[repo.sensitivity]; +function formatRunBudget(repo: BoundedQueryRepository): string { + const bits = BOUNDED_QUERY_SENSITIVITY_RUN_BITS[repo.sensitivity]; if (bits === null) return `unmetered (\`${repo.sensitivity}\`)`; if (bits === 0) return `0 bits/run (\`${repo.sensitivity}\` — never runs a script)`; return `${bits} bits/run (\`${repo.sensitivity}\`)`; } -export function generateSealedProbeSkill(params: SealedProbeSkillParams): string { +export function generateBoundedQuerySkill(params: BoundedQuerySkillParams): string { const { repos, timeoutSeconds, maxInvocations } = params; const repoList = repos.map((repo) => `- \`${repo.repo}\` — ${formatRunBudget(repo)}`).join('\n'); const bucketList = TIMING_BUCKETS_MS.map((ms) => (ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`)).join(', '); return `--- -name: sealed-probe +name: bounded-query description: >- Run a short Python 3 script against one pre-approved private repository - inside a sealed, offline sandbox and get back a value conforming to a + inside an isolated, offline sandbox and get back a value conforming to a finite response schema you declare up front. Use when you must answer a bounded question about private repository contents that you are not allowed to read, and only when your remaining per-repository information budget can afford the answer's schema. --- -# Sealed probe +# Bounded query -A sealed probe runs an agent-authored Python 3 script against a fresh, writable +A bounded query runs an agent-authored Python 3 script against a fresh, writable copy of exactly one pre-approved private repository. The sandbox has no network, no credentials, no host access, and no access to this workspace. @@ -78,10 +78,10 @@ are fixed by AWF configuration; a request cannot choose or override them. ## Invoking \`\`\`bash -sealed-probe \\ +bounded-query \\ --repo owner/repo \\ --schema '{"type":"boolean"}' \\ - < probe.py + < query.py \`\`\` Rules enforced by the CLI: @@ -128,18 +128,18 @@ Example — a bounded integer count: The script runs as \`python3\` with the **standard library only** (no third-party packages, no package installation, no network). It may read and freely modify -\`${PROBE_MOUNT_DIR}/repo\`; every mutation is discarded when the probe ends. +\`${QUERY_MOUNT_DIR}/repo\`; every mutation is discarded when the query ends. -It must write its answer to \`${PROBE_MOUNT_DIR}/out\` as a single JSON value +It must write its answer to \`${QUERY_MOUNT_DIR}/out\` as a single JSON value conforming exactly to your declared schema: \`\`\`python import json from pathlib import Path -repo = Path("${PROBE_MOUNT_DIR}/repo") +repo = Path("${QUERY_MOUNT_DIR}/repo") found = any(repo.rglob("Dockerfile")) -Path("${PROBE_MOUNT_DIR}/out").write_text(json.dumps(found)) +Path("${QUERY_MOUNT_DIR}/out").write_text(json.dumps(found)) \`\`\` Anything else in the output — wrong type, out-of-range value, unknown enum @@ -177,7 +177,7 @@ like any other signal. \`${CANONICAL_ERROR_JSON}\` without running anything. - A repository whose remaining budget cannot afford even the cheapest possible schema (a \`const\` schema, minimum charge - ${RESULT_STATUS_BIT_COST + TIMING_BUCKET_BITS} bits) can no longer be probed + ${RESULT_STATUS_BIT_COST + TIMING_BUCKET_BITS} bits) can no longer be queryd at all for the rest of this run. Design one high-value, low-cardinality question per invocation. @@ -191,9 +191,9 @@ Design one high-value, low-cardinality question per invocation. * `workDir`, then made 0644 for the agent's read-only bind mount. Nothing is * written to the host user's home directory or to the workspace. */ -export function writeSealedProbeSkill(paths: SealedProbePaths, params: SealedProbeSkillParams): string { +export function writeBoundedQuerySkill(paths: BoundedQueryPaths, params: BoundedQuerySkillParams): string { fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o755 }); - const content = generateSealedProbeSkill(params); + const content = generateBoundedQuerySkill(params); // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file // is already at this path (insecure-temp-file guard). const fd = fs.openSync( diff --git a/src/sealed-probe/staging.test.ts b/src/bounded-query/staging.test.ts similarity index 92% rename from src/sealed-probe/staging.test.ts rename to src/bounded-query/staging.test.ts index 2c42cb417..4580a704c 100644 --- a/src/sealed-probe/staging.test.ts +++ b/src/bounded-query/staging.test.ts @@ -2,16 +2,16 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import execa from 'execa'; -import type { SealedProbeRepository } from '../types/sealed-probe-options'; -import { resolveSealedProbePaths, type SealedProbePaths } from './paths'; +import type { BoundedQueryRepository } from '../types/bounded-query-options'; +import { resolveBoundedQueryPaths, type BoundedQueryPaths } from './paths'; import { buildCloneUrl, buildStagingGitEnv, releaseSeedPermissions, resolveStagingToken, scrubSeed, - SealedProbeStagingError, - stageSealedProbeSeeds, + BoundedQueryStagingError, + stageBoundedQuerySeeds, stagingTestHelpers, type GitRunner, } from './staging'; @@ -22,7 +22,7 @@ const mockExeca = execa as unknown as jest.Mock; const TOKEN = 'ghs_super_secret_value'; function makeTempWorkDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-staging-')); + return fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-staging-')); } /** @@ -80,11 +80,11 @@ function createFakeGit(options: { onClone?: (dest: string) => void } = {}): { async function stage( workDir: string, runner: GitRunner, - repos: SealedProbeRepository[] = [{ repo: 'octo/private', sensitivity: 'internal' }], -): Promise<{ paths: SealedProbePaths; result: Awaited> }> { - const paths = resolveSealedProbePaths(workDir); + repos: BoundedQueryRepository[] = [{ repo: 'octo/private', sensitivity: 'internal' }], +): Promise<{ paths: BoundedQueryPaths; result: Awaited> }> { + const paths = resolveBoundedQueryPaths(workDir); fs.mkdirSync(paths.root, { recursive: true, mode: 0o700 }); - const result = await stageSealedProbeSeeds({ + const result = await stageBoundedQuerySeeds({ repos, paths, runId: 'f'.repeat(32), @@ -126,8 +126,8 @@ describe('buildCloneUrl', () => { }); it('refuses to build a URL for an unsafe slug', () => { - expect(() => buildCloneUrl('octo/private?a=1')).toThrow(SealedProbeStagingError); - expect(() => buildCloneUrl('https://evil.example/x')).toThrow(SealedProbeStagingError); + expect(() => buildCloneUrl('octo/private?a=1')).toThrow(BoundedQueryStagingError); + expect(() => buildCloneUrl('https://evil.example/x')).toThrow(BoundedQueryStagingError); }); }); @@ -187,7 +187,7 @@ describe('buildStagingGitEnv', () => { }); }); -describe('stageSealedProbeSeeds', () => { +describe('stageBoundedQuerySeeds', () => { let workDir: string; beforeEach(() => { @@ -196,7 +196,7 @@ describe('stageSealedProbeSeeds', () => { }); afterEach(() => { - releaseSeedPermissions(resolveSealedProbePaths(workDir).seedsDir); + releaseSeedPermissions(resolveBoundedQueryPaths(workDir).seedsDir); fs.rmSync(workDir, { recursive: true, force: true }); }); @@ -243,7 +243,7 @@ describe('stageSealedProbeSeeds', () => { expect(fs.existsSync(path.join(paths.root, 'staging-home'))).toBe(false); }); - it('leaves no file under the sealed-probe root containing the credential', async () => { + it('leaves no file under the bounded-query root containing the credential', async () => { const { runner } = createFakeGit(); const { paths } = await stage(workDir, runner); releaseSeedPermissions(paths.seedsDir); @@ -293,7 +293,7 @@ describe('stageSealedProbeSeeds', () => { expect(fs.existsSync(path.join(gitDir, 'FETCH_HEAD'))).toBe(false); expect(fs.existsSync(path.join(gitDir, 'refs', 'remotes'))).toBe(false); expect(fs.readFileSync(path.join(gitDir, 'packed-refs'), 'utf8')).not.toContain('refs/remotes/'); - expect(paths.seedsDir).toContain('sealed-probes'); + expect(paths.seedsDir).toContain('bounded-queries'); }); it('records the staged commit and an opaque seed id', async () => { @@ -330,7 +330,7 @@ describe('stageSealedProbeSeeds', () => { }); await expect(stage(workDir, runner)).rejects.toThrow(/submodule/i); - expect(fs.existsSync(resolveSealedProbePaths(workDir).seedsDir)).toBe(false); + expect(fs.existsSync(resolveBoundedQueryPaths(workDir).seedsDir)).toBe(false); }); it('rejects a clone whose .git is a symlink', async () => { @@ -378,10 +378,10 @@ describe('stageSealedProbeSeeds', () => { return { stdout: `${'a'.repeat(40)}\n` }; }); - const paths = resolveSealedProbePaths(workDir); + const paths = resolveBoundedQueryPaths(workDir); fs.mkdirSync(paths.root, { recursive: true, mode: 0o700 }); - const result = await stageSealedProbeSeeds({ + const result = await stageBoundedQuerySeeds({ repos: [{ repo: 'octo/private', sensitivity: 'internal' }], paths, runId: 'f'.repeat(32), @@ -413,13 +413,13 @@ describe('releaseSeedPermissions', () => { releaseSeedPermissions(paths.seedsDir); expect(() => fs.rmSync(paths.seedsDir, { recursive: true })).not.toThrow(); } finally { - releaseSeedPermissions(resolveSealedProbePaths(workDir).seedsDir); + releaseSeedPermissions(resolveBoundedQueryPaths(workDir).seedsDir); fs.rmSync(workDir, { recursive: true, force: true }); } }); it('is a no-op for a missing directory', () => { - expect(() => releaseSeedPermissions('/nonexistent/awf-sealed-probe-seeds')).not.toThrow(); + expect(() => releaseSeedPermissions('/nonexistent/awf-bounded-query-seeds')).not.toThrow(); }); }); diff --git a/src/sealed-probe/staging.ts b/src/bounded-query/staging.ts similarity index 83% rename from src/sealed-probe/staging.ts rename to src/bounded-query/staging.ts index 6bdf45d20..45baa4ce2 100644 --- a/src/sealed-probe/staging.ts +++ b/src/bounded-query/staging.ts @@ -2,10 +2,10 @@ import * as fs from 'fs'; import * as path from 'path'; import execa from 'execa'; import { logger } from '../logger'; -import type { SealedProbeRepository } from '../types/sealed-probe-options'; -import { deriveSeedId, normalizeRepoKey, type SealedProbePaths } from './paths'; -import { SEALED_PROBE_REPO_PATTERN } from './protocol'; -import type { SealedProbeSeed, SealedProbeStagingResult } from './types'; +import type { BoundedQueryRepository } from '../types/bounded-query-options'; +import { deriveSeedId, normalizeRepoKey, type BoundedQueryPaths } from './paths'; +import { BOUNDED_QUERY_REPO_PATTERN } from './protocol'; +import type { BoundedQuerySeed, BoundedQueryStagingResult } from './types'; /** * Trusted host-side staging: materializes one immutable, credential-free seed @@ -16,7 +16,7 @@ import type { SealedProbeSeed, SealedProbeStagingResult } from './types'; * - The staging token is read from the AWF host environment and passed to * `git` **only** through the child process environment plus a `GIT_ASKPASS` * helper. It never appears in argv, in a clone URL, in the compose file, in - * the agent/broker/probe environment, or in any log line. + * the agent/broker/query environment, or in any log line. * - Each seed is scrubbed of every credential- or escape-bearing artifact * (remotes, credential helpers, hooks, alternates, worktree links, reflogs) * and rejected outright if it declares submodules. @@ -27,7 +27,7 @@ import type { SealedProbeSeed, SealedProbeStagingResult } from './types'; */ /** Environment variable the askpass helper reads the token file path from. */ -const ASKPASS_TOKEN_FILE_ENV = 'AWF_SEALED_PROBE_STAGING_TOKEN_FILE'; +const ASKPASS_TOKEN_FILE_ENV = 'AWF_BOUNDED_QUERY_STAGING_TOKEN_FILE'; /** Username git sends alongside a GitHub token over HTTPS Basic auth. */ const TOKEN_USERNAME = 'x-access-token'; @@ -82,11 +82,11 @@ const defaultGitRunner: GitRunner = async (args, options) => { return { stdout: result.stdout }; }; -export interface StageSealedProbeSeedsParams { +export interface StageBoundedQuerySeedsParams { /** Trusted repository descriptors exactly as configured (already schema-validated). */ - repos: SealedProbeRepository[]; - /** Resolved sealed-probe filesystem layout. */ - paths: SealedProbePaths; + repos: BoundedQueryRepository[]; + /** Resolved bounded-query filesystem layout. */ + paths: BoundedQueryPaths; /** Run-unique id used to derive opaque seed directory names. */ runId: string; /** Staging credential. Never logged, never forwarded past this module. */ @@ -96,10 +96,10 @@ export interface StageSealedProbeSeedsParams { } /** Thrown for every staging failure. Messages never contain the token. */ -export class SealedProbeStagingError extends Error { +export class BoundedQueryStagingError extends Error { constructor(message: string) { super(message); - this.name = 'SealedProbeStagingError'; + this.name = 'BoundedQueryStagingError'; } } @@ -123,15 +123,15 @@ export function resolveStagingToken(env: NodeJS.ProcessEnv = process.env): strin * own error messages. */ export function buildCloneUrl(repo: string): string { - if (!SEALED_PROBE_REPO_PATTERN.test(repo)) { - throw new SealedProbeStagingError(`Refusing to stage unsafe repository slug: ${repo}`); + if (!BOUNDED_QUERY_REPO_PATTERN.test(repo)) { + throw new BoundedQueryStagingError(`Refusing to stage unsafe repository slug: ${repo}`); } return `https://github.com/${repo}.git`; } /** - * Writes the staging token to a 0o600 file inside the (already 0o700) sealed - * probe root. + * Writes the staging token to a 0o600 file inside the (already 0o700) protected + * query root. * * Placing the credential in a file (rather than a child-process environment * variable) prevents it from appearing in `/proc//environ` for the @@ -150,13 +150,13 @@ function writeTokenFile(root: string, token: string): string { * * The helper reads the token from a file whose PATH is in its environment; * the token itself never appears in the environment or in argv. - * The file is created inside the (already 0o700) sealed probe root. + * The file is created inside the (already 0o700) bounded query root. */ function writeAskpassHelper(root: string): string { const askpassPath = path.join(root, 'askpass.sh'); const script = [ '#!/bin/sh', - '# Generated by AWF sealed-probe staging. Reads the credential from a', + '# Generated by AWF bounded-query staging. Reads the credential from a', '# file so the token itself never appears in the environment or in argv.', 'case "$1" in', ` Username*) printf '%s' '${TOKEN_USERNAME}' ;;`, @@ -251,7 +251,7 @@ function verifySeedReadOnly(seedPath: string): void { visit(seedPath); if (offenders.length > 0) { - throw new SealedProbeStagingError( + throw new BoundedQueryStagingError( `Seed is not read-only after staging (${offenders.length} writable path(s), first: ${offenders[0]})`, ); } @@ -261,21 +261,21 @@ function verifySeedReadOnly(seedPath: string): void { * Rejects a checkout that declares submodules. * * v1 policy is reject-not-omit: a submodule implies an external reference the - * probe sandbox must never be able to resolve, and silently dropping it would - * hand the probe a repository that does not match what the operator approved. + * query sandbox must never be able to resolve, and silently dropping it would + * hand the query a repository that does not match what the operator approved. */ function assertNoSubmodules(seedPath: string): void { const gitmodules = path.join(seedPath, '.gitmodules'); if (fs.existsSync(gitmodules)) { - throw new SealedProbeStagingError( - 'Repository declares submodules (.gitmodules); sealed probes reject submodule-bearing repositories', + throw new BoundedQueryStagingError( + 'Repository declares submodules (.gitmodules); bounded queries reject submodule-bearing repositories', ); } const modulesDir = path.join(seedPath, '.git', 'modules'); if (fs.existsSync(modulesDir)) { - throw new SealedProbeStagingError( - 'Repository contains .git/modules; sealed probes reject submodule-bearing repositories', + throw new BoundedQueryStagingError( + 'Repository contains .git/modules; bounded queries reject submodule-bearing repositories', ); } } @@ -313,12 +313,12 @@ export function scrubSeed(seedPath: string): void { const gitDirPath = path.join(seedPath, '.git'); const gitDirStat = fs.lstatSync(gitDirPath); if (gitDirStat.isSymbolicLink()) { - throw new SealedProbeStagingError('Refusing to stage a seed whose .git is a symlink'); + throw new BoundedQueryStagingError('Refusing to stage a seed whose .git is a symlink'); } if (!gitDirStat.isDirectory()) { // A file-form `.git` is a gitdir pointer into an external repository — - // exactly the external reference sealed probes must never resolve. - throw new SealedProbeStagingError('Refusing to stage a seed whose .git is not a directory'); + // exactly the external reference bounded queries must never resolve. + throw new BoundedQueryStagingError('Refusing to stage a seed whose .git is not a directory'); } assertNoSubmodules(seedPath); @@ -332,19 +332,19 @@ export function scrubSeed(seedPath: string): void { } async function stageOneSeed( - repository: SealedProbeRepository, - params: Required> & { + repository: BoundedQueryRepository, + params: Required> & { gitRunner: GitRunner; gitEnv: NodeJS.ProcessEnv; }, -): Promise { +): Promise { const { paths, runId, gitRunner, gitEnv } = params; const { repo, sensitivity } = repository; const seedId = deriveSeedId(runId, repo); const seedPath = path.join(paths.seedsDir, seedId); if (fs.existsSync(seedPath)) { - throw new SealedProbeStagingError(`Seed directory already exists: ${seedId}`); + throw new BoundedQueryStagingError(`Seed directory already exists: ${seedId}`); } fs.mkdirSync(seedPath, { recursive: true, mode: 0o700 }); @@ -388,12 +388,12 @@ async function stageOneSeed( * Materializes an immutable seed for every configured repository. * * On any failure the partially-staged tree is released and removed, and a - * {@link SealedProbeStagingError} is thrown so the caller aborts before the + * {@link BoundedQueryStagingError} is thrown so the caller aborts before the * primary agent starts. */ -export async function stageSealedProbeSeeds( - params: StageSealedProbeSeedsParams, -): Promise { +export async function stageBoundedQuerySeeds( + params: StageBoundedQuerySeedsParams, +): Promise { const { repos, paths, runId, token } = params; const gitRunner = params.gitRunner ?? defaultGitRunner; @@ -406,17 +406,17 @@ export async function stageSealedProbeSeeds( const tokenFilePath = writeTokenFile(paths.root, token); const gitEnv = buildStagingGitEnv({ tokenFilePath, askpassPath, isolatedHome }); - const seeds: SealedProbeSeed[] = []; + const seeds: BoundedQuerySeed[] = []; try { for (const repository of repos) { - logger.info(`Sealed probes: staging seed for ${repository.repo} (sensitivity: ${repository.sensitivity})...`); + logger.info(`Bounded queries: staging seed for ${repository.repo} (sensitivity: ${repository.sensitivity})...`); seeds.push(await stageOneSeed(repository, { paths, runId, gitRunner, gitEnv })); } } catch (error) { releaseSeedPermissions(paths.seedsDir); fs.rmSync(paths.seedsDir, { recursive: true, force: true }); const message = error instanceof Error ? error.message : String(error); - throw new SealedProbeStagingError(`Sealed-probe staging failed: ${message}`); + throw new BoundedQueryStagingError(`Bounded-query staging failed: ${message}`); } finally { // The helper, token file, and isolated HOME are only needed for the // duration of staging. Removing them leaves no staging artifact behind @@ -427,7 +427,7 @@ export async function stageSealedProbeSeeds( } for (const seed of seeds) { - logger.debug(`Sealed probes: staged ${seed.repo} at ${seed.commit} (seed ${seed.seedId})`); + logger.debug(`Bounded queries: staged ${seed.repo} at ${seed.commit} (seed ${seed.seedId})`); } return { runId, seeds }; diff --git a/src/sealed-probe/types.ts b/src/bounded-query/types.ts similarity index 66% rename from src/sealed-probe/types.ts rename to src/bounded-query/types.ts index 9ad146a81..7c7e26e69 100644 --- a/src/sealed-probe/types.ts +++ b/src/bounded-query/types.ts @@ -1,24 +1,24 @@ /** - * Runtime (non-protocol) types for the sealed-probe subsystem. + * Runtime (non-protocol) types for the bounded-query subsystem. * * The request/result wire protocol lives in `./protocol.ts`; this module * describes the host-side staging output and the seed map the trusted broker * consumes. */ -import type { SealedProbeSensitivity } from '../types/sealed-probe-options'; +import type { BoundedQuerySensitivity } from '../types/bounded-query-options'; /** * Version of the on-disk seed-map document. * * v2 adds trusted `sensitivity` metadata to every entry (see - * {@link SealedProbeSeedMap}) so the broker can derive each repository's + * {@link BoundedQuerySeedMap}) so the broker can derive each repository's * per-run information budget without trusting anything the agent sends. */ -export const SEALED_PROBE_SEED_MAP_VERSION = 2; +export const BOUNDED_QUERY_SEED_MAP_VERSION = 2; /** One staged, immutable repository seed. */ -export interface SealedProbeSeed { +export interface BoundedQuerySeed { /** Normalized (lowercased) `owner/repo` lookup key. */ repoKey: string; /** Repository slug exactly as configured, used for clone-URL construction. */ @@ -30,11 +30,11 @@ export interface SealedProbeSeed { /** Commit the seed was materialized at, recorded for protected audit state. */ commit: string; /** Trusted confidentiality category, carried unmodified into the seed map. */ - sensitivity: SealedProbeSensitivity; + sensitivity: BoundedQuerySensitivity; } /** - * The document written to `/sealed-probes/seed-map.json` and mounted + * The document written to `/bounded-queries/seed-map.json` and mounted * read-only into the broker. * * It intentionally contains only what the broker needs: the mapping from a @@ -42,16 +42,16 @@ export interface SealedProbeSeed { * trusted sensitivity, and the run id used for container labelling/orphan * cleanup. No credentials, no absolute host paths, and no caller-controllable * fields — in particular, `sensitivity` is trusted AWF configuration state - * that a probe request can never choose or override. + * that a query request can never choose or override. */ -export interface SealedProbeSeedMap { - version: typeof SEALED_PROBE_SEED_MAP_VERSION; +export interface BoundedQuerySeedMap { + version: typeof BOUNDED_QUERY_SEED_MAP_VERSION; runId: string; - seeds: Array<{ repo: string; seedId: string; sensitivity: SealedProbeSensitivity }>; + seeds: Array<{ repo: string; seedId: string; sensitivity: BoundedQuerySensitivity }>; } /** Result of the trusted host staging phase. */ -export interface SealedProbeStagingResult { +export interface BoundedQueryStagingResult { runId: string; - seeds: SealedProbeSeed[]; + seeds: BoundedQuerySeed[]; } diff --git a/src/sealed-probe/workflow-integration.test.ts b/src/bounded-query/workflow-integration.test.ts similarity index 67% rename from src/sealed-probe/workflow-integration.test.ts rename to src/bounded-query/workflow-integration.test.ts index 9492d1e83..ff4792d56 100644 --- a/src/sealed-probe/workflow-integration.test.ts +++ b/src/bounded-query/workflow-integration.test.ts @@ -1,5 +1,5 @@ import { runMainWorkflow } from '../cli-workflow'; -import type { SealedProbesConfig, WrapperConfig } from '../types'; +import type { BoundedQueriesConfig, WrapperConfig } from '../types'; jest.mock('../topology', () => ({ TOPOLOGY_NETWORK_NAME: 'awf-net', @@ -15,14 +15,14 @@ jest.mock('../container-runtime', () => ({ })); /** - * Lifecycle ordering guarantees for sealed probes. + * Lifecycle ordering guarantees for bounded queries. * * Staging is credential-bearing and must complete before anything untrusted * exists, so it runs ahead of config generation and container startup — and a * staging failure must stop the run before the primary agent is invoked. */ -const sealedProbes: SealedProbesConfig = { +const boundedQueries: BoundedQueriesConfig = { enabled: true, privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], runtime: 'docker', @@ -37,7 +37,7 @@ const baseConfig: WrapperConfig = { agentCommand: 'echo hi', logLevel: 'info', keepContainers: false, - workDir: '/tmp/awf-sealed-workflow', + workDir: '/tmp/awf-bounded-query-workflow', imageRegistry: 'registry', imageTag: 'latest', buildLocal: false, @@ -62,8 +62,8 @@ function createDeps(callOrder: string[], overrides: Record = {} callOrder.push('runAgentCommand'); return { exitCode: 0 }; }), - prepareSealedProbes: jest.fn().mockImplementation(async () => { - callOrder.push('prepareSealedProbes'); + prepareBoundedQueries: jest.fn().mockImplementation(async () => { + callOrder.push('prepareBoundedQueries'); }), ...overrides, } as unknown as Parameters[1]; @@ -76,36 +76,36 @@ function createOptions() { } as unknown as Parameters[2]; } -describe('sealed-probe staging in the main workflow', () => { +describe('bounded-query staging in the main workflow', () => { it('stages seeds before configs are written and containers start', async () => { const callOrder: string[] = []; const deps = createDeps(callOrder); - await runMainWorkflow({ ...baseConfig, sealedProbes }, deps, createOptions()); + await runMainWorkflow({ ...baseConfig, boundedQueries }, deps, createOptions()); - expect(callOrder[0]).toBe('prepareSealedProbes'); - expect(callOrder.indexOf('prepareSealedProbes')).toBeLessThan(callOrder.indexOf('writeConfigs')); - expect(callOrder.indexOf('prepareSealedProbes')).toBeLessThan(callOrder.indexOf('startContainers')); + expect(callOrder[0]).toBe('prepareBoundedQueries'); + expect(callOrder.indexOf('prepareBoundedQueries')).toBeLessThan(callOrder.indexOf('writeConfigs')); + expect(callOrder.indexOf('prepareBoundedQueries')).toBeLessThan(callOrder.indexOf('startContainers')); }); - it('does not stage anything when sealed probes are disabled', async () => { + it('does not stage anything when bounded queries are disabled', async () => { const callOrder: string[] = []; const deps = createDeps(callOrder); await runMainWorkflow(baseConfig, deps, createOptions()); - expect(callOrder).not.toContain('prepareSealedProbes'); - expect((deps as unknown as { prepareSealedProbes: jest.Mock }).prepareSealedProbes).not.toHaveBeenCalled(); + expect(callOrder).not.toContain('prepareBoundedQueries'); + expect((deps as unknown as { prepareBoundedQueries: jest.Mock }).prepareBoundedQueries).not.toHaveBeenCalled(); }); it('aborts before the primary agent runs when staging fails', async () => { const callOrder: string[] = []; const deps = createDeps(callOrder, { - prepareSealedProbes: jest.fn().mockRejectedValue(new Error('seed unavailable')), + prepareBoundedQueries: jest.fn().mockRejectedValue(new Error('seed unavailable')), }); await expect( - runMainWorkflow({ ...baseConfig, sealedProbes }, deps, createOptions()), + runMainWorkflow({ ...baseConfig, boundedQueries }, deps, createOptions()), ).rejects.toThrow('seed unavailable'); expect(callOrder).toEqual([]); @@ -118,17 +118,17 @@ describe('sealed-probe staging in the main workflow', () => { const callOrder: string[] = []; const deps = createDeps(callOrder); - await runMainWorkflow({ ...baseConfig, sealedProbes }, deps, createOptions()); + await runMainWorkflow({ ...baseConfig, boundedQueries }, deps, createOptions()); - expect(callOrder.indexOf('prepareSealedProbes')).toBeLessThan(callOrder.indexOf('ensureFirewallNetwork')); + expect(callOrder.indexOf('prepareBoundedQueries')).toBeLessThan(callOrder.indexOf('ensureFirewallNetwork')); }); - it('refuses to run when sealed probes are enabled but no staging implementation was injected', async () => { + it('refuses to run when bounded queries are enabled but no staging implementation was injected', async () => { const callOrder: string[] = []; - const deps = createDeps(callOrder, { prepareSealedProbes: undefined }); + const deps = createDeps(callOrder, { prepareBoundedQueries: undefined }); await expect( - runMainWorkflow({ ...baseConfig, sealedProbes }, deps, createOptions()), + runMainWorkflow({ ...baseConfig, boundedQueries }, deps, createOptions()), ).rejects.toThrow(/no staging implementation/); expect(callOrder).toEqual([]); diff --git a/src/sealed-probe/wrapper.test.ts b/src/bounded-query/wrapper.test.ts similarity index 94% rename from src/sealed-probe/wrapper.test.ts rename to src/bounded-query/wrapper.test.ts index f5caa4986..7af334133 100644 --- a/src/sealed-probe/wrapper.test.ts +++ b/src/bounded-query/wrapper.test.ts @@ -5,8 +5,8 @@ import * as os from 'os'; import * as path from 'path'; /** - * Behavioural tests for `containers/agent/sealed-probe-wrapper.sh`, the only - * sealed-probe capability the agent receives (protocol v2). + * Behavioural tests for `containers/agent/bounded-query-wrapper.sh`, the only + * bounded-query capability the agent receives (protocol v2). * * The wrapper is executed for real against a stub broker on a Unix socket, so * these assertions cover the actual shell semantics: accepted options @@ -16,7 +16,7 @@ import * as path from 'path'; * `{"status":"error"}` on stdout, nothing on stderr, and exit status 0. */ -const WRAPPER = path.join(__dirname, '..', '..', 'containers', 'agent', 'sealed-probe-wrapper.sh'); +const WRAPPER = path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-query-wrapper.sh'); const CANONICAL_ERROR = '{"status":"error"}'; const BOOLEAN_SCHEMA = '{"type":"boolean"}'; @@ -93,7 +93,7 @@ function runWrapper( const child = spawn('sh', [WRAPPER, ...args], { env: { PATH: process.env.PATH ?? '/usr/bin:/bin', - AWF_SEALED_PROBE_SOCKET: options.socketPath ?? '/nonexistent/awf-sealed-probe.sock', + AWF_BOUNDED_QUERY_SOCKET: options.socketPath ?? '/nonexistent/awf-bounded-query.sock', // Deliberately hostile proxy settings: the wrapper must ignore them. HTTP_PROXY: 'http://127.0.0.1:1', HTTPS_PROXY: 'http://127.0.0.1:1', @@ -110,13 +110,13 @@ function runWrapper( child.on('close', (status) => resolve({ stdout, stderr, status })); child.stdin.on('error', () => { /* the wrapper may exit before reading stdin */ }); - child.stdin.end(options.script ?? 'print("probe")\n'); + child.stdin.end(options.script ?? 'print("query")\n'); }); } const VALID_ARGS = ['--repo', 'octo/private', '--schema', BOOLEAN_SCHEMA]; -describe('sealed-probe wrapper', () => { +describe('bounded-query wrapper', () => { it('forwards a valid request and prints the broker result verbatim', async () => { const harness = await startStubBroker(() => '{"status":"ok","result":true}'); try { @@ -138,14 +138,14 @@ describe('sealed-probe wrapper', () => { expect(harness.requests).toHaveLength(1); const request = harness.requests[0]; expect(request.method).toBe('POST'); - expect(request.url).toBe('/probe'); + expect(request.url).toBe('/query'); expect(request.body).toBe('import json\n'); - expect(request.headers['x-awf-probe-version']).toBe('2'); + expect(request.headers['x-awf-query-version']).toBe('2'); expect(request.headers['x-awf-repo']).toBe('octo/private'); expect(request.headers['x-awf-schema-b64']).toBe(base64url(BOOLEAN_SCHEMA)); const awfHeaders = Object.keys(request.headers).filter((name) => name.startsWith('x-awf-')); - expect(awfHeaders.sort()).toEqual(['x-awf-probe-version', 'x-awf-repo', 'x-awf-schema-b64']); + expect(awfHeaders.sort()).toEqual(['x-awf-query-version', 'x-awf-repo', 'x-awf-schema-b64']); } finally { await harness.close(); } diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 61fcae79b..ca2150246 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -31,14 +31,14 @@ interface WorkflowDependencies { ) => Promise<{ exitCode: number }>; collectDiagnosticLogs?: (workDir: string) => Promise; /** - * Trusted sealed-probe staging. Runs before any configuration is generated + * Trusted bounded-query staging. Runs before any configuration is generated * and before any container exists, so the staging credential is consumed and * discarded before the broker, the agent, or a probe can observe anything. * * Rejecting aborts the run: the primary agent must never start when staging * failed. */ - prepareSealedProbes?: (config: WrapperConfig) => Promise; + prepareBoundedQueries?: (config: WrapperConfig) => Promise; /** * Fail-stop preflight for network-isolation mode. Aborts (process exit) when * topology enforcement cannot be supported on the current platform. @@ -78,7 +78,7 @@ export async function runMainWorkflow( ): Promise { const { logger, performCleanup, onHostIptablesSetup, onContainersStarted } = options; - // Step -1: Sealed-probe staging (trusted, host-side, credential-bearing). + // Step -1: Bounded-query staging (trusted, host-side, credential-bearing). // // Runs first so that: // - a staging failure aborts before any container is created; @@ -86,16 +86,16 @@ export async function runMainWorkflow( // probe exists; // - compose generation (Step 1) can rely on the seed/socket/skill layout // already being present on disk. - if (config.sealedProbes?.enabled) { - if (!dependencies.prepareSealedProbes) { + if (config.boundedQueries?.enabled) { + if (!dependencies.prepareBoundedQueries) { // Fail loudly rather than generating a broker service whose seeds and - // socket were never staged — sealed probes are never half-enabled. + // socket were never staged — bounded queries are never half-enabled. throw new Error( - 'Sealed probes are enabled but no staging implementation was provided to runMainWorkflow', + 'Bounded queries are enabled but no staging implementation was provided to runMainWorkflow', ); } - logger.info('Staging sealed-probe repository seeds...'); - await dependencies.prepareSealedProbes(config); + logger.info('Staging bounded-query repository seeds...'); + await dependencies.prepareBoundedQueries(config); } // Step 0: Setup host-level network and iptables diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 556bea183..cb06a0338 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -430,16 +430,16 @@ describe('buildConfig', () => { expect(config.modelAliases).toEqual(aliases); }); - it('should leave sealedProbes undefined when not set in options', () => { + it('should leave boundedQueries undefined when not set in options', () => { const config = buildConfig(makeInputs()); - expect(config.sealedProbes).toBeUndefined(); + expect(config.boundedQueries).toBeUndefined(); }); - it('should normalize sealedProbes with centralized defaults when present', () => { + it('should normalize boundedQueries with centralized defaults when present', () => { const config = buildConfig(makeInputs({ - options: { ...makeInputs().options, sealedProbes: {} }, + options: { ...makeInputs().options, boundedQueries: {} }, })); - expect(config.sealedProbes).toEqual({ + expect(config.boundedQueries).toEqual({ enabled: false, privateRepos: [], runtime: 'docker', @@ -450,11 +450,11 @@ describe('buildConfig', () => { }); }); - it('should preserve explicit sealedProbes values over defaults', () => { + it('should preserve explicit boundedQueries values over defaults', () => { const config = buildConfig(makeInputs({ options: { ...makeInputs().options, - sealedProbes: { + boundedQueries: { enabled: true, privateRepos: [{ repo: 'octo/repo', sensitivity: 'confidential' }], runtime: 'sbx', @@ -465,7 +465,7 @@ describe('buildConfig', () => { }, }, })); - expect(config.sealedProbes).toEqual({ + expect(config.boundedQueries).toEqual({ enabled: true, privateRepos: [{ repo: 'octo/repo', sensitivity: 'confidential' }], runtime: 'sbx', diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 00d39ceaf..3c698c783 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -1,7 +1,7 @@ import { WrapperConfig, LogLevel, UpstreamProxyConfig } from '../types'; import type { AwfFileConfig } from '../config-file'; import { resolveApiCredentials } from './resolve-credentials'; -import { normalizeSealedProbesConfig } from '../parsers/sealed-probe-parser'; +import { normalizeBoundedQueriesConfig } from '../parsers/bounded-query-parser'; import { logger } from '../logger'; /** @@ -215,8 +215,8 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { chrootBinariesSourcePath: options.chrootBinariesSourcePath as string | undefined, chrootIdentity, dind, - sealedProbes: normalizeSealedProbesConfig( - options.sealedProbes as AwfFileConfig['sealedProbes'] | undefined, + boundedQueries: normalizeBoundedQueriesConfig( + options.boundedQueries as AwfFileConfig['boundedQueries'] | undefined, ), }; } diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 911504b8e..963c233a1 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -28,7 +28,7 @@ import { assertTopologySupported, connectTopologyContainers } from '../topology' import { runDindBootstrap } from '../dind-bootstrap'; import { runtimeUsesComposeAgent } from '../container-runtime'; import { createSandbox, execInSandbox, removeSandbox, isSbxAvailable, SBX_DEFAULT_NAME } from '../sbx-manager'; -import { prepareSealedProbes, teardownSealedProbes } from '../sealed-probe/manager'; +import { prepareBoundedQueries, teardownBoundedQueries } from '../bounded-query/manager'; import type { WrapperConfig } from '../types'; import { buildAgentEnvironment } from '../services/agent-service'; import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env'; @@ -127,7 +127,7 @@ function buildCleanupFn( // write permissions on the immutable seeds. Must run before the generic // work-directory cleanup: `rm -rf` cannot unlink entries inside a // directory whose write bit was stripped during staging. - await teardownSealedProbes(config); + await teardownBoundedQueries(config); if (!config.keepContainers) { await cleanup( @@ -413,7 +413,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { collectDiagnosticLogs, assertTopologySupported, connectTopologyContainers, - prepareSealedProbes, + prepareBoundedQueries, }, { logger, diff --git a/src/config-file-sealed-probes-validation.test.ts b/src/config-file-bounded-queries-validation.test.ts similarity index 51% rename from src/config-file-sealed-probes-validation.test.ts rename to src/config-file-bounded-queries-validation.test.ts index 54250aa6a..2b698c8ad 100644 --- a/src/config-file-sealed-probes-validation.test.ts +++ b/src/config-file-bounded-queries-validation.test.ts @@ -1,13 +1,13 @@ import { validateAwfFileConfig } from './config-file'; -describe('validateAwfFileConfig — sealedProbes', () => { - it('accepts an empty sealedProbes section', () => { - expect(validateAwfFileConfig({ sealedProbes: {} })).toEqual([]); +describe('validateAwfFileConfig — boundedQueries', () => { + it('accepts an empty boundedQueries section', () => { + expect(validateAwfFileConfig({ boundedQueries: {} })).toEqual([]); }); - it('accepts a fully-specified valid sealedProbes section using object-form privateRepos', () => { + it('accepts a fully-specified valid boundedQueries section using object-form privateRepos', () => { const errors = validateAwfFileConfig({ - sealedProbes: { + boundedQueries: { enabled: true, privateRepos: [ { repo: 'octo-org/octo-repo', sensitivity: 'internal' }, @@ -25,12 +25,12 @@ describe('validateAwfFileConfig — sealedProbes', () => { }); it('accepts a legacy bare-string privateRepos entry (one-release compatibility)', () => { - expect(validateAwfFileConfig({ sealedProbes: { privateRepos: ['octo-org/octo-repo'] } })).toEqual([]); + expect(validateAwfFileConfig({ boundedQueries: { privateRepos: ['octo-org/octo-repo'] } })).toEqual([]); }); it('accepts a mix of legacy string and object-form privateRepos entries', () => { const errors = validateAwfFileConfig({ - sealedProbes: { + boundedQueries: { privateRepos: ['octo/legacy', { repo: 'octo/object-form', sensitivity: 'sealed' }], }, }); @@ -39,37 +39,37 @@ describe('validateAwfFileConfig — sealedProbes', () => { it('rejects an object-form privateRepos entry with an invalid sensitivity value', () => { const errors = validateAwfFileConfig({ - sealedProbes: { privateRepos: [{ repo: 'octo/repo', sensitivity: 'top-secret' }] }, + boundedQueries: { privateRepos: [{ repo: 'octo/repo', sensitivity: 'top-secret' }] }, }); expect(errors.length).toBeGreaterThan(0); }); it('rejects an object-form privateRepos entry missing sensitivity', () => { const errors = validateAwfFileConfig({ - sealedProbes: { privateRepos: [{ repo: 'octo/repo' }] }, + boundedQueries: { privateRepos: [{ repo: 'octo/repo' }] }, }); expect(errors.length).toBeGreaterThan(0); }); it('rejects an object-form privateRepos entry with unsupported extra properties', () => { const errors = validateAwfFileConfig({ - sealedProbes: { privateRepos: [{ repo: 'octo/repo', sensitivity: 'internal', extra: true }] }, + boundedQueries: { privateRepos: [{ repo: 'octo/repo', sensitivity: 'internal', extra: true }] }, }); expect(errors.length).toBeGreaterThan(0); }); it('accepts privateRepos without enabled (not required unless enabled)', () => { - expect(validateAwfFileConfig({ sealedProbes: { privateRepos: ['octo/repo'] } })).toEqual([]); + expect(validateAwfFileConfig({ boundedQueries: { privateRepos: ['octo/repo'] } })).toEqual([]); }); it('requires non-empty privateRepos when enabled is true', () => { - expect(validateAwfFileConfig({ sealedProbes: { enabled: true } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { enabled: true, privateRepos: [] } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { enabled: true } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { enabled: true, privateRepos: [] } }).length).toBeGreaterThan(0); }); // Duplicate-entry rejection depends on comparing normalized repo keys // across entries (which may mix legacy strings and objects), so it lives - // in `src/sealed-probe/preflight.ts` (see preflight.test.ts) rather than + // in `src/bounded-query/preflight.ts` (see preflight.test.ts) rather than // in the raw JSON Schema, which validates one array item at a time. it.each([ @@ -83,64 +83,64 @@ describe('validateAwfFileConfig — sealedProbes', () => { ['no owner', '/repo'], ['no slash at all', 'octorepo'], ])('rejects a privateRepos entry that is %s', (_label, entry) => { - const errors = validateAwfFileConfig({ sealedProbes: { privateRepos: [entry] } }); + const errors = validateAwfFileConfig({ boundedQueries: { privateRepos: [entry] } }); expect(errors.length).toBeGreaterThan(0); }); it('accepts owner/repo slugs with dots, dashes, and underscores in the repo segment', () => { const errors = validateAwfFileConfig({ - sealedProbes: { privateRepos: ['my-org-1/my.repo-name_2'] }, + boundedQueries: { privateRepos: ['my-org-1/my.repo-name_2'] }, }); expect(errors).toEqual([]); }); it('rejects an invalid runtime value', () => { - const errors = validateAwfFileConfig({ sealedProbes: { runtime: 'vmware' } }); + const errors = validateAwfFileConfig({ boundedQueries: { runtime: 'vmware' } }); expect(errors.length).toBeGreaterThan(0); }); it.each(['docker', 'gvisor'])('accepts runtime %s', (runtime) => { - expect(validateAwfFileConfig({ sealedProbes: { runtime } })).toEqual([]); + expect(validateAwfFileConfig({ boundedQueries: { runtime } })).toEqual([]); }); it('rejects a non-positive or out-of-bounds timeout', () => { - expect(validateAwfFileConfig({ sealedProbes: { timeout: 0 } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { timeout: -5 } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { timeout: 1.5 } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { timeout: 999999 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { timeout: 0 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { timeout: -5 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { timeout: 1.5 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { timeout: 999999 } }).length).toBeGreaterThan(0); }); it('accepts a timeout within bounds', () => { - expect(validateAwfFileConfig({ sealedProbes: { timeout: 30 } })).toEqual([]); + expect(validateAwfFileConfig({ boundedQueries: { timeout: 30 } })).toEqual([]); }); it('accepts the maximum timeout of 540 seconds and rejects one second above it', () => { - expect(validateAwfFileConfig({ sealedProbes: { timeout: 540 } })).toEqual([]); - expect(validateAwfFileConfig({ sealedProbes: { timeout: 541 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { timeout: 540 } })).toEqual([]); + expect(validateAwfFileConfig({ boundedQueries: { timeout: 541 } }).length).toBeGreaterThan(0); }); it('rejects an invalid memoryLimit format', () => { - expect(validateAwfFileConfig({ sealedProbes: { memoryLimit: '512' } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { memoryLimit: '0m' } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { memoryLimit: '1gb' } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { memoryLimit: '512' } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { memoryLimit: '0m' } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { memoryLimit: '1gb' } }).length).toBeGreaterThan(0); }); it.each(['512m', '1g', '2G', '256M'])('accepts memoryLimit %s', (memoryLimit) => { - expect(validateAwfFileConfig({ sealedProbes: { memoryLimit } })).toEqual([]); + expect(validateAwfFileConfig({ boundedQueries: { memoryLimit } })).toEqual([]); }); it('rejects an interpreter other than python3', () => { - expect(validateAwfFileConfig({ sealedProbes: { interpreter: 'node' } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { interpreter: 'node' } }).length).toBeGreaterThan(0); }); it('rejects a non-positive or out-of-bounds maxInvocations', () => { - expect(validateAwfFileConfig({ sealedProbes: { maxInvocations: 0 } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { maxInvocations: -1 } }).length).toBeGreaterThan(0); - expect(validateAwfFileConfig({ sealedProbes: { maxInvocations: 100000 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { maxInvocations: 0 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { maxInvocations: -1 } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ boundedQueries: { maxInvocations: 100000 } }).length).toBeGreaterThan(0); }); - it('rejects unknown properties inside sealedProbes', () => { - const errors = validateAwfFileConfig({ sealedProbes: { foo: 'bar' } }); - expect(errors).toContain('config.sealedProbes.foo is not supported'); + it('rejects unknown properties inside boundedQueries', () => { + const errors = validateAwfFileConfig({ boundedQueries: { foo: 'bar' } }); + expect(errors).toContain('config.boundedQueries.foo is not supported'); }); }); diff --git a/src/config-file-loading.test.ts b/src/config-file-loading.test.ts index add1b55d4..358b1c77b 100644 --- a/src/config-file-loading.test.ts +++ b/src/config-file-loading.test.ts @@ -49,13 +49,13 @@ describe('loadAwfFileConfig', () => { expect(result.network?.allowDomains).toEqual(['github.com']); }); - it('loads sealedProbes config from stdin (proves the generic stdin path needs no special-casing)', () => { + it('loads boundedQueries config from stdin (proves the generic stdin path needs no special-casing)', () => { const result = loadAwfFileConfig( '-', - () => '{"sealedProbes":{"enabled":true,"privateRepos":["octo/repo"],"runtime":"gvisor"}}', + () => '{"boundedQueries":{"enabled":true,"privateRepos":["octo/repo"],"runtime":"gvisor"}}', ); - expect(result.sealedProbes).toEqual({ + expect(result.boundedQueries).toEqual({ enabled: true, privateRepos: ['octo/repo'], runtime: 'gvisor', diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index a1c7635e6..b3087b542 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -585,8 +585,8 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.runnerTopology).toBeUndefined(); }); - it('passes sealedProbes through unchanged (no CLI flags exist for it)', () => { - const sealedProbes = { + it('passes boundedQueries through unchanged (no CLI flags exist for it)', () => { + const boundedQueries = { enabled: true, privateRepos: ['octo/repo'], runtime: 'gvisor' as const, @@ -595,12 +595,12 @@ describe('mapAwfFileConfigToCliOptions', () => { interpreter: 'python3' as const, maxInvocations: 10, }; - const result = mapAwfFileConfigToCliOptions({ sealedProbes }); - expect(result.sealedProbes).toEqual(sealedProbes); + const result = mapAwfFileConfigToCliOptions({ boundedQueries }); + expect(result.boundedQueries).toEqual(boundedQueries); }); - it('leaves sealedProbes undefined when not set', () => { + it('leaves boundedQueries undefined when not set', () => { const result = mapAwfFileConfigToCliOptions({}); - expect(result.sealedProbes).toBeUndefined(); + expect(result.boundedQueries).toBeUndefined(); }); }); diff --git a/src/config-file.ts b/src/config-file.ts index aec8512ba..26798cdd7 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -164,12 +164,12 @@ export interface AwfFileConfig { sysrootImage?: string; }; /** - * Sealed-probe sandbox configuration. + * Bounded-query sandbox configuration. * * Foundation only — configuration/protocol surface, no broker or sandbox * runtime is implemented yet. See docs/awf-config-spec.md §14. */ - sealedProbes?: { + boundedQueries?: { enabled?: boolean; /** * Each entry is either a trusted repository descriptor, or (for one diff --git a/src/config-mapper.ts b/src/config-mapper.ts index e8e21bc83..fb605324e 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -145,8 +145,8 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', - ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy', 'awf-cli-proxy', 'awf-sealed-probe-broker'], + ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy', 'awf-cli-proxy', 'awf-bounded-query-broker'], expect.objectContaining({ reject: false }) ); }); diff --git a/src/image-tag.test.ts b/src/image-tag.test.ts index 5b500afc5..18ffa50ff 100644 --- a/src/image-tag.test.ts +++ b/src/image-tag.test.ts @@ -1,7 +1,16 @@ import path from 'path'; import { parseImageTag, buildRuntimeImageRef, assignImageSource } from './image-tag'; -const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'sealed-probe'] as const; +const IMAGE_DIGEST_KEYS = [ + 'squid', + 'agent', + 'agent-act', + 'api-proxy', + 'cli-proxy', + 'build-tools', + 'bounded-query', + 'bounded-query-broker', +] as const; const VALID_DIGEST = 'sha256:' + 'a'.repeat(64); diff --git a/src/image-tag.ts b/src/image-tag.ts index 87f44ca9c..b49f634ef 100644 --- a/src/image-tag.ts +++ b/src/image-tag.ts @@ -1,6 +1,6 @@ import path from 'path'; -const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'sealed-probe', 'sealed-probe-broker'] as const; +const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'bounded-query', 'bounded-query-broker'] as const; type ImageDigestKey = typeof IMAGE_DIGEST_KEYS[number]; diff --git a/src/parsers/sealed-probe-parser.test.ts b/src/parsers/bounded-query-parser.test.ts similarity index 62% rename from src/parsers/sealed-probe-parser.test.ts rename to src/parsers/bounded-query-parser.test.ts index b3367a8ad..d360e649e 100644 --- a/src/parsers/sealed-probe-parser.test.ts +++ b/src/parsers/bounded-query-parser.test.ts @@ -1,43 +1,43 @@ -import { normalizeSealedProbesConfig } from './sealed-probe-parser'; -import { SEALED_PROBE_DEFAULTS } from '../types/sealed-probe-options'; +import { normalizeBoundedQueriesConfig } from './bounded-query-parser'; +import { BOUNDED_QUERY_DEFAULTS } from '../types/bounded-query-options'; -describe('normalizeSealedProbesConfig', () => { - it('returns undefined when raw is undefined (no sealedProbes section)', () => { - expect(normalizeSealedProbesConfig(undefined)).toBeUndefined(); +describe('normalizeBoundedQueriesConfig', () => { + it('returns undefined when raw is undefined (no boundedQueries section)', () => { + expect(normalizeBoundedQueriesConfig(undefined)).toBeUndefined(); }); it('applies all centralized defaults when given an empty section', () => { - expect(normalizeSealedProbesConfig({})).toEqual({ + expect(normalizeBoundedQueriesConfig({})).toEqual({ enabled: false, privateRepos: [], - runtime: SEALED_PROBE_DEFAULTS.runtime, - timeout: SEALED_PROBE_DEFAULTS.timeout, - memoryLimit: SEALED_PROBE_DEFAULTS.memoryLimit, - interpreter: SEALED_PROBE_DEFAULTS.interpreter, - maxInvocations: SEALED_PROBE_DEFAULTS.maxInvocations, + runtime: BOUNDED_QUERY_DEFAULTS.runtime, + timeout: BOUNDED_QUERY_DEFAULTS.timeout, + memoryLimit: BOUNDED_QUERY_DEFAULTS.memoryLimit, + interpreter: BOUNDED_QUERY_DEFAULTS.interpreter, + maxInvocations: BOUNDED_QUERY_DEFAULTS.maxInvocations, }); }); it('normalizes enabled to false unless explicitly true', () => { - expect(normalizeSealedProbesConfig({ enabled: false })?.enabled).toBe(false); + expect(normalizeBoundedQueriesConfig({ enabled: false })?.enabled).toBe(false); // Only exact `true` enables; anything else normalizes away. - expect(normalizeSealedProbesConfig({})?.enabled).toBe(false); + expect(normalizeBoundedQueriesConfig({})?.enabled).toBe(false); }); it('preserves an explicit enabled: true', () => { expect( - normalizeSealedProbesConfig({ enabled: true, privateRepos: [{ repo: 'octo/repo', sensitivity: 'internal' }] }) + normalizeBoundedQueriesConfig({ enabled: true, privateRepos: [{ repo: 'octo/repo', sensitivity: 'internal' }] }) ?.enabled, ).toBe(true); }); it('defaults privateRepos to an empty array when omitted', () => { - expect(normalizeSealedProbesConfig({})?.privateRepos).toEqual([]); + expect(normalizeBoundedQueriesConfig({})?.privateRepos).toEqual([]); }); it('passes through object-form privateRepos entries unchanged, in order, without deduplicating', () => { const warn = jest.fn(); - const config = normalizeSealedProbesConfig( + const config = normalizeBoundedQueriesConfig( { privateRepos: [ { repo: 'octo/repo', sensitivity: 'internal' }, @@ -52,7 +52,7 @@ describe('normalizeSealedProbesConfig', () => { { repo: 'octo/repo', sensitivity: 'internal' }, { repo: 'octo/other', sensitivity: 'confidential' }, ]); - // Duplicate rejection is `src/sealed-probe/preflight.ts`'s job, not the + // Duplicate rejection is `src/bounded-query/preflight.ts`'s job, not the // normalizer's — the normalizer only fills defaults and migrates legacy // strings, so it must not silently drop or warn about anything here. expect(warn).not.toHaveBeenCalled(); @@ -60,7 +60,7 @@ describe('normalizeSealedProbesConfig', () => { it('normalizes a legacy bare-string entry to {repo, sensitivity: "internal"} and warns once', () => { const warn = jest.fn(); - const config = normalizeSealedProbesConfig({ privateRepos: ['octo/legacy'] }, { warn }); + const config = normalizeBoundedQueriesConfig({ privateRepos: ['octo/legacy'] }, { warn }); expect(config?.privateRepos).toEqual([{ repo: 'octo/legacy', sensitivity: 'internal' }]); expect(warn).toHaveBeenCalledTimes(1); @@ -70,7 +70,7 @@ describe('normalizeSealedProbesConfig', () => { it('warns once per legacy string entry, independently, in a mixed list', () => { const warn = jest.fn(); - const config = normalizeSealedProbesConfig( + const config = normalizeBoundedQueriesConfig( { privateRepos: ['octo/legacy-one', { repo: 'octo/object-form', sensitivity: 'sealed' }, 'octo/legacy-two'], }, @@ -86,11 +86,11 @@ describe('normalizeSealedProbesConfig', () => { }); it('defaults to logger.warn when no warn override is supplied (does not throw)', () => { - expect(() => normalizeSealedProbesConfig({ privateRepos: ['octo/legacy'] })).not.toThrow(); + expect(() => normalizeBoundedQueriesConfig({ privateRepos: ['octo/legacy'] })).not.toThrow(); }); it('preserves explicitly-set fields over defaults', () => { - const config = normalizeSealedProbesConfig({ + const config = normalizeBoundedQueriesConfig({ enabled: true, privateRepos: [{ repo: 'octo/repo', sensitivity: 'confidential' }], runtime: 'gvisor', @@ -112,11 +112,11 @@ describe('normalizeSealedProbesConfig', () => { }); it('applies defaults field-by-field (partial overrides)', () => { - const config = normalizeSealedProbesConfig({ runtime: 'gvisor' }); + const config = normalizeBoundedQueriesConfig({ runtime: 'gvisor' }); expect(config?.runtime).toBe('gvisor'); - expect(config?.timeout).toBe(SEALED_PROBE_DEFAULTS.timeout); - expect(config?.memoryLimit).toBe(SEALED_PROBE_DEFAULTS.memoryLimit); - expect(config?.interpreter).toBe(SEALED_PROBE_DEFAULTS.interpreter); - expect(config?.maxInvocations).toBe(SEALED_PROBE_DEFAULTS.maxInvocations); + expect(config?.timeout).toBe(BOUNDED_QUERY_DEFAULTS.timeout); + expect(config?.memoryLimit).toBe(BOUNDED_QUERY_DEFAULTS.memoryLimit); + expect(config?.interpreter).toBe(BOUNDED_QUERY_DEFAULTS.interpreter); + expect(config?.maxInvocations).toBe(BOUNDED_QUERY_DEFAULTS.maxInvocations); }); }); diff --git a/src/parsers/sealed-probe-parser.ts b/src/parsers/bounded-query-parser.ts similarity index 59% rename from src/parsers/sealed-probe-parser.ts rename to src/parsers/bounded-query-parser.ts index b334a47f4..cdd92c39a 100644 --- a/src/parsers/sealed-probe-parser.ts +++ b/src/parsers/bounded-query-parser.ts @@ -1,22 +1,22 @@ import type { AwfFileConfig } from '../config-file'; import { logger } from '../logger'; import { - SEALED_PROBE_DEFAULTS, - type SealedProbeRepository, - type SealedProbesConfig, -} from '../types/sealed-probe-options'; + BOUNDED_QUERY_DEFAULTS, + type BoundedQueryRepository, + type BoundedQueriesConfig, +} from '../types/bounded-query-options'; /** Sensitivity legacy bare-string `privateRepos` entries are normalized to. */ const LEGACY_REPO_DEFAULT_SENSITIVITY = 'internal'; -type RawPrivateRepoEntry = NonNullable['privateRepos'] extends +type RawPrivateRepoEntry = NonNullable['privateRepos'] extends | Array | undefined ? T : never; /** - * Normalizes one `privateRepos` entry to a {@link SealedProbeRepository}. + * Normalizes one `privateRepos` entry to a {@link BoundedQueryRepository}. * * A bare string is a one-release compatibility path: it is accepted and * normalized to `{ repo, sensitivity: 'internal' }`, with a warning, so @@ -27,10 +27,10 @@ type RawPrivateRepoEntry = NonNullable['privateRe function normalizePrivateRepoEntry( entry: RawPrivateRepoEntry, warn: (message: string) => void, -): SealedProbeRepository { +): BoundedQueryRepository { if (typeof entry === 'string') { warn( - `sealedProbes.privateRepos entry "${entry}" is a legacy bare string. It is being normalized to ` + + `boundedQueries.privateRepos entry "${entry}" is a legacy bare string. It is being normalized to ` + `{ repo: "${entry}", sensitivity: "${LEGACY_REPO_DEFAULT_SENSITIVITY}" } for this release only. ` + 'Update your AWF configuration to the explicit object form before the next release, when this ' + 'compatibility path is removed.', @@ -40,15 +40,15 @@ function normalizePrivateRepoEntry( return { repo: entry.repo, sensitivity: entry.sensitivity }; } -export interface NormalizeSealedProbesConfigOptions { +export interface NormalizeBoundedQueriesConfigOptions { /** Overrides how legacy-string-entry warnings are emitted. Defaults to `logger.warn`. */ warn?: (message: string) => void; } /** - * Normalizes the raw `sealedProbes` section of an AWF config file into a - * fully-resolved {@link SealedProbesConfig}, applying - * {@link SEALED_PROBE_DEFAULTS} for any field left unset. + * Normalizes the raw `boundedQueries` section of an AWF config file into a + * fully-resolved {@link BoundedQueriesConfig}, applying + * {@link BOUNDED_QUERY_DEFAULTS} for any field left unset. * * By the time this runs, `raw` has already passed schema validation * (`validateAwfFileConfig` / docs/awf-config.schema.json), so bounds, @@ -56,33 +56,33 @@ export interface NormalizeSealedProbesConfigOptions { * to already hold. This function only fills in defaults and normalizes the * legacy-string `privateRepos` compatibility path — it does not re-validate * repository shape, uniqueness, or sensitivity (see - * `src/sealed-probe/preflight.ts` for the fail-closed checks that require + * `src/bounded-query/preflight.ts` for the fail-closed checks that require * comparing multiple entries at once). * * Returns `undefined` when `raw` is `undefined`, i.e. the config file did - * not include a `sealedProbes` section at all. When the section is present + * not include a `boundedQueries` section at all. When the section is present * (even as `{}`), a fully-defaulted config is always returned. */ -export function normalizeSealedProbesConfig( - raw: AwfFileConfig['sealedProbes'] | undefined, - options: NormalizeSealedProbesConfigOptions = {}, -): SealedProbesConfig | undefined { +export function normalizeBoundedQueriesConfig( + raw: AwfFileConfig['boundedQueries'] | undefined, + options: NormalizeBoundedQueriesConfigOptions = {}, +): BoundedQueriesConfig | undefined { if (!raw) return undefined; const warn = options.warn ?? ((message: string) => logger.warn(message)); - const privateRepos: SealedProbeRepository[] = (raw.privateRepos ?? []).map((entry) => + const privateRepos: BoundedQueryRepository[] = (raw.privateRepos ?? []).map((entry) => normalizePrivateRepoEntry(entry, warn), ); return { - // Only an explicit `true` enables sealed probes; anything else (including + // Only an explicit `true` enables bounded queries; anything else (including // omission) normalizes to disabled. enabled: raw.enabled === true, privateRepos, - runtime: raw.runtime ?? SEALED_PROBE_DEFAULTS.runtime, - timeout: raw.timeout ?? SEALED_PROBE_DEFAULTS.timeout, - memoryLimit: raw.memoryLimit ?? SEALED_PROBE_DEFAULTS.memoryLimit, - interpreter: raw.interpreter ?? SEALED_PROBE_DEFAULTS.interpreter, - maxInvocations: raw.maxInvocations ?? SEALED_PROBE_DEFAULTS.maxInvocations, + runtime: raw.runtime ?? BOUNDED_QUERY_DEFAULTS.runtime, + timeout: raw.timeout ?? BOUNDED_QUERY_DEFAULTS.timeout, + memoryLimit: raw.memoryLimit ?? BOUNDED_QUERY_DEFAULTS.memoryLimit, + interpreter: raw.interpreter ?? BOUNDED_QUERY_DEFAULTS.interpreter, + maxInvocations: raw.maxInvocations ?? BOUNDED_QUERY_DEFAULTS.maxInvocations, }; } diff --git a/src/schema.test.ts b/src/schema.test.ts index 95247a2f7..377b7734a 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -43,7 +43,7 @@ describe('awf-config.schema.json', () => { 'logging', 'rateLimiting', 'platform', - 'sealedProbes', + 'boundedQueries', ]) ); }); diff --git a/src/sealed-probe/preflight.ts b/src/sealed-probe/preflight.ts deleted file mode 100644 index bfcad0093..000000000 --- a/src/sealed-probe/preflight.ts +++ /dev/null @@ -1,168 +0,0 @@ -import execa from 'execa'; -import { getLocalDockerEnv } from '../host-env'; -import { runtimeUsesComposeAgent } from '../container-runtime'; -import type { SealedProbesConfig, WrapperConfig } from '../types'; -import { normalizeRepoKey } from './paths'; -import { MAX_PROBE_TIMEOUT_SECONDS, SEALED_PROBE_REPO_PATTERN } from './protocol'; -import { resolveStagingToken } from './staging'; - -/** - * Fail-closed preflight for sealed probes. - * - * JSON Schema already constrains the *shape* of `sealedProbes`. This module - * covers everything the schema cannot: credential availability, sandbox - * runtime availability, and combinations of AWF settings under which sealed - * probes cannot be exposed securely. - * - * Every check here is fatal — a sealed-probe run that cannot satisfy its - * isolation guarantees must abort before the primary agent starts rather than - * silently downgrading. - */ - -/** Probe sandbox runtimes with a safe, implemented no-network launcher. */ -const SUPPORTED_PROBE_RUNTIMES = new Set(['docker', 'gvisor']); - -/** Docker OCI runtime name required for the `gvisor` probe runtime. */ -const GVISOR_DOCKER_RUNTIME = 'runsc'; - -/** Detects whether the Docker daemon exposes a named OCI runtime. */ -export type DockerRuntimeProbe = (runtimeName: string) => Promise; - -const defaultDockerRuntimeProbe: DockerRuntimeProbe = async (runtimeName) => { - const result = await execa('docker', ['info', '--format', '{{json .Runtimes}}'], { - env: getLocalDockerEnv(), - reject: false, - timeout: 30_000, - }); - if (result.exitCode !== 0) return false; - try { - const runtimes = JSON.parse(result.stdout) as Record; - return Object.prototype.hasOwnProperty.call(runtimes, runtimeName); - } catch { - return false; - } -}; - -/** - * Validates everything about a sealed-probe configuration that can be decided - * without touching Docker or the network. - * - * @returns human-readable errors; empty when the configuration is acceptable. - */ -export function validateSealedProbeConfig( - config: WrapperConfig, - env: NodeJS.ProcessEnv = process.env, -): string[] { - const sealedProbes = config.sealedProbes; - if (!sealedProbes?.enabled) return []; - - const errors: string[] = []; - - if (sealedProbes.privateRepos.length === 0) { - errors.push('sealedProbes.enabled is true but sealedProbes.privateRepos is empty'); - } - - const seenKeys = new Set(); - for (const entry of sealedProbes.privateRepos) { - const repo = entry.repo; - if (!SEALED_PROBE_REPO_PATTERN.test(repo)) { - errors.push( - `sealedProbes.privateRepos entry "${repo}" is not a bare owner/repo slug ` + - '(no scheme, host, credentials, path traversal, query, fragment, or wildcard)', - ); - continue; - } - const key = normalizeRepoKey(repo); - if (seenKeys.has(key)) { - errors.push(`sealedProbes.privateRepos contains a duplicate entry: "${repo}"`); - } - seenKeys.add(key); - } - - if (!SUPPORTED_PROBE_RUNTIMES.has(sealedProbes.runtime)) { - errors.push( - `sealedProbes.runtime "${sealedProbes.runtime}" is not supported. ` + - 'AWF has no no-network, per-invocation sealed-probe launcher for it, and sealed probes ' + - 'never downgrade to a weaker runtime. Use "docker" or "gvisor".', - ); - } - - if (sealedProbes.interpreter !== 'python3') { - errors.push(`sealedProbes.interpreter "${sealedProbes.interpreter}" is not supported`); - } - - // Reserve the final minute of the 10-minute response bucket for Docker - // termination, result validation, container removal, and workspace cleanup. - // The script timeout cannot consume the entire observable boundary. - if (!Number.isInteger(sealedProbes.timeout) || sealedProbes.timeout < 1) { - errors.push('sealedProbes.timeout must be a positive integer number of seconds'); - } else if (sealedProbes.timeout > MAX_PROBE_TIMEOUT_SECONDS) { - errors.push( - `sealedProbes.timeout must be at most ${MAX_PROBE_TIMEOUT_SECONDS} seconds ` + - '(the 10-minute response bucket reserves its final minute for termination, validation, and cleanup)', - ); - } - - if (!Number.isInteger(sealedProbes.maxInvocations) || sealedProbes.maxInvocations < 1) { - errors.push('sealedProbes.maxInvocations must be a positive integer'); - } - - if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(sealedProbes.memoryLimit)) { - errors.push(`sealedProbes.memoryLimit "${sealedProbes.memoryLimit}" is not a Docker memory limit`); - } - - if (!runtimeUsesComposeAgent(config.containerRuntime)) { - errors.push( - `sealed probes cannot be exposed to a "${config.containerRuntime}" primary agent: ` + - 'the broker socket is shared through a Docker Compose bind mount, which a microVM agent ' + - 'does not receive. Disable sealedProbes or use a Compose-based container runtime.', - ); - } - - const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST; - if (dockerHost && !dockerHost.startsWith('unix://')) { - errors.push( - `sealed probes require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` + - 'The broker runs with network_mode: none so it can only reach the daemon over a bind-mounted ' + - 'socket, and AWF will not weaken that isolation to reach a TCP daemon.', - ); - } - - if (!resolveStagingToken(env)) { - errors.push( - 'sealed probes require a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host ' + - '(it is used only by the trusted staging phase and never reaches the agent, broker, or probe)', - ); - } - - return errors; -} - -/** - * Verifies that the requested probe sandbox runtime is actually available. - * - * Only reached after {@link validateSealedProbeConfig} accepted the runtime - * name, so the only remaining question is daemon support. - */ -export async function assertProbeRuntimeAvailable( - sealedProbes: SealedProbesConfig, - probeDockerRuntime: DockerRuntimeProbe = defaultDockerRuntimeProbe, -): Promise { - if (sealedProbes.runtime !== 'gvisor') return; - - if (!(await probeDockerRuntime(GVISOR_DOCKER_RUNTIME))) { - throw new Error( - `sealedProbes.runtime "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + - 'registered with the Docker daemon. It is not available, and sealed probes never fall back ' + - 'to a weaker runtime.', - ); - } -} - -/** @internal Exported for focused unit tests. */ -// ts-prune-ignore-next -export const preflightTestHelpers = { - SUPPORTED_PROBE_RUNTIMES, - GVISOR_DOCKER_RUNTIME, - defaultDockerRuntimeProbe, -}; diff --git a/src/services/agent-environment/excluded-vars.test.ts b/src/services/agent-environment/excluded-vars.test.ts index 6c0e7f83a..52eed4097 100644 --- a/src/services/agent-environment/excluded-vars.test.ts +++ b/src/services/agent-environment/excluded-vars.test.ts @@ -199,8 +199,8 @@ describe('buildExclusionSet', () => { }); }); - describe('when sealed probes are enabled (repository credential isolation)', () => { - const sealedProbes = { + describe('when bounded queries are enabled (repository credential isolation)', () => { + const boundedQueries = { enabled: true, privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' as const }], runtime: 'docker' as const, @@ -213,16 +213,16 @@ describe('buildExclusionSet', () => { it.each(['GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN'])( 'should exclude %s even without the API or DIFC proxies', (name) => { - const config = makeConfig({ sealedProbes, enableApiProxy: false, difcProxyHost: undefined }); + const config = makeConfig({ boundedQueries, enableApiProxy: false, difcProxyHost: undefined }); expect(buildExclusionSet(config).has(name)).toBe(true); }, ); it.each(['GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN'])( - 'should NOT exclude %s when sealed probes are configured but disabled', + 'should NOT exclude %s when bounded queries are configured but disabled', (name) => { const config = makeConfig({ - sealedProbes: { ...sealedProbes, enabled: false }, + boundedQueries: { ...boundedQueries, enabled: false }, enableApiProxy: false, difcProxyHost: undefined, }); diff --git a/src/services/agent-environment/excluded-vars.ts b/src/services/agent-environment/excluded-vars.ts index 44176b022..9df3adde5 100644 --- a/src/services/agent-environment/excluded-vars.ts +++ b/src/services/agent-environment/excluded-vars.ts @@ -54,12 +54,12 @@ export function buildExclusionSet(config: WrapperConfig): Set { excludedEnvVars.add('GITHUB_PERSONAL_ACCESS_TOKEN'); } - if (config.sealedProbes?.enabled) { - // Sealed probes read private repositories on the agent's behalf precisely + if (config.boundedQueries?.enabled) { + // Bounded queries read private repositories on the agent's behalf precisely // because the agent is not trusted with access to them. A GitHub token in // the agent environment would let the agent read those repositories // directly, defeating the subsystem — so the tokens are stripped whenever - // sealed probes are enabled, independently of the API/DIFC proxies. + // bounded queries are enabled, independently of the API/DIFC proxies. excludedEnvVars.add('GITHUB_TOKEN'); excludedEnvVars.add('GH_TOKEN'); excludedEnvVars.add('GITHUB_PERSONAL_ACCESS_TOKEN'); diff --git a/src/services/agent-volumes/docker-socket.ts b/src/services/agent-volumes/docker-socket.ts index 06c3ec488..e31bf990d 100644 --- a/src/services/agent-volumes/docker-socket.ts +++ b/src/services/agent-volumes/docker-socket.ts @@ -6,7 +6,7 @@ const DEFAULT_DOCKER_SOCKET_PATH = '/var/run/docker.sock'; /** * Resolves the host path of the Docker socket AWF itself talks to. * - * Shared with the sealed-probe broker service, which needs the same daemon — + * Shared with the bounded-query broker service, which needs the same daemon — * and must never leak that path into the agent when `--enable-dind` is off. */ export function resolveDockerSocketPath(config: WrapperConfig): string { diff --git a/src/services/sealed-probe-compose.test.ts b/src/services/bounded-query-compose.test.ts similarity index 61% rename from src/services/sealed-probe-compose.test.ts rename to src/services/bounded-query-compose.test.ts index cbbb7618d..2b16ea1c0 100644 --- a/src/services/sealed-probe-compose.test.ts +++ b/src/services/bounded-query-compose.test.ts @@ -1,5 +1,5 @@ import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir, withEnv } from './service-test-setup.test-utils'; -import type { SealedProbesConfig } from '../types'; +import type { BoundedQueriesConfig } from '../types'; // Mock execa module (must remain per-file — jest.mock() is hoisted before imports) // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -7,7 +7,7 @@ jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaM let mockConfig: WrapperConfig; -const sealedProbes: SealedProbesConfig = { +const boundedQueries: BoundedQueriesConfig = { enabled: true, privateRepos: [{ repo: 'octo/alpha', sensitivity: 'internal' }], runtime: 'docker', @@ -18,12 +18,12 @@ const sealedProbes: SealedProbesConfig = { }; /** - * End-to-end compose assembly checks for sealed probes: the broker must appear + * End-to-end compose assembly checks for bounded queries: the broker must appear * as an optional, network-less service, gate the agent, and inject exactly two * mounts plus three environment variables into the agent — and nothing at all * when the feature is off. */ -describe('sealed-probe broker in generated Docker Compose', () => { +describe('bounded-query broker in generated Docker Compose', () => { useTempWorkDir( baseConfig, (config) => { @@ -32,51 +32,51 @@ describe('sealed-probe broker in generated Docker Compose', () => { () => mockConfig, ); - const enabled = (): WrapperConfig => ({ ...mockConfig, sealedProbes }); + const enabled = (): WrapperConfig => ({ ...mockConfig, boundedQueries }); - describe('when sealed probes are disabled', () => { + describe('when bounded queries are disabled', () => { it('adds no broker service, agent dependency, mount, or environment variable', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services['agent'] as unknown as Record; - expect(result.services['sealed-probe-broker']).toBeUndefined(); - expect(result.services['sealed-probe-image']).toBeUndefined(); - expect((agent.depends_on as Record)['sealed-probe-broker']).toBeUndefined(); - expect(JSON.stringify(agent.volumes)).not.toContain('sealed-probe'); - expect(JSON.stringify(agent.environment)).not.toContain('SEALED_PROBE'); + expect(result.services['bounded-query-broker']).toBeUndefined(); + expect(result.services['bounded-query-image']).toBeUndefined(); + expect((agent.depends_on as Record)['bounded-query-broker']).toBeUndefined(); + expect(JSON.stringify(agent.volumes)).not.toContain('bounded-query'); + expect(JSON.stringify(agent.environment)).not.toContain('BOUNDED_QUERY'); }); it('adds nothing when the section is present but not enabled', () => { const result = generateDockerCompose( - { ...mockConfig, sealedProbes: { ...sealedProbes, enabled: false } }, + { ...mockConfig, boundedQueries: { ...boundedQueries, enabled: false } }, mockNetworkConfig, ); - expect(result.services['sealed-probe-broker']).toBeUndefined(); - expect(result.services['sealed-probe-image']).toBeUndefined(); + expect(result.services['bounded-query-broker']).toBeUndefined(); + expect(result.services['bounded-query-image']).toBeUndefined(); }); }); - describe('when sealed probes are enabled', () => { + describe('when bounded queries are enabled', () => { it('adds a broker service with no network', () => { const result = generateDockerCompose(enabled(), mockNetworkConfig); - const broker = result.services['sealed-probe-broker'] as unknown as Record; + const broker = result.services['bounded-query-broker'] as unknown as Record; expect(broker).toBeDefined(); - expect(broker.container_name).toBe('awf-sealed-probe-broker'); + expect(broker.container_name).toBe('awf-bounded-query-broker'); expect(broker.network_mode).toBe('none'); expect(broker.networks).toBeUndefined(); }); - it('adds a one-shot networkless probe-image dependency', () => { + it('adds a one-shot networkless query-image dependency', () => { const result = generateDockerCompose(enabled(), mockNetworkConfig); - const imageService = result.services['sealed-probe-image'] as unknown as Record; - const broker = result.services['sealed-probe-broker'] as unknown as Record; + const imageService = result.services['bounded-query-image'] as unknown as Record; + const broker = result.services['bounded-query-broker'] as unknown as Record; expect(imageService.network_mode).toBe('none'); expect(imageService.entrypoint).toEqual(['/bin/true']); expect(imageService.volumes).toBeUndefined(); expect(broker.depends_on).toEqual({ - 'sealed-probe-image': { condition: 'service_completed_successfully' }, + 'bounded-query-image': { condition: 'service_completed_successfully' }, }); }); @@ -84,31 +84,31 @@ describe('sealed-probe broker in generated Docker Compose', () => { const result = generateDockerCompose(enabled(), mockNetworkConfig); const agent = result.services['agent'] as unknown as Record; - expect((agent.depends_on as Record)['sealed-probe-broker'].condition) + expect((agent.depends_on as Record)['bounded-query-broker'].condition) .toBe('service_healthy'); }); - it('gives the agent the socket and skill mounts and nothing else sealed-probe related', () => { + it('gives the agent the socket and skill mounts and nothing else bounded-query related', () => { const result = generateDockerCompose(enabled(), mockNetworkConfig); const agent = result.services['agent'] as unknown as Record; - const sealedMounts = (agent.volumes as string[]).filter((v) => v.includes('sealed-probe')); + const boundedQueryMounts = (agent.volumes as string[]).filter((v) => v.includes('/bounded-queries')); - // 2 masking mounts (hide the sealed-probe root) + 2 socket mounts + 2 skill mounts = 6 - expect(sealedMounts).toHaveLength(6); + // 2 masking mounts (hide the bounded-query root) + 2 socket mounts + 2 skill mounts = 6 + expect(boundedQueryMounts).toHaveLength(6); // Masking mounts are read-only; socket mounts are read-write; skill mounts are read-only - expect(sealedMounts.filter((v) => v.endsWith(':rw'))).toHaveLength(2); - expect(sealedMounts.filter((v) => v.endsWith(':ro'))).toHaveLength(4); - expect(sealedMounts.join(' ')).not.toContain('/seeds'); - expect(sealedMounts.join(' ')).not.toContain('docker.sock'); + expect(boundedQueryMounts.filter((v) => v.endsWith(':rw'))).toHaveLength(2); + expect(boundedQueryMounts.filter((v) => v.endsWith(':ro'))).toHaveLength(4); + expect(boundedQueryMounts.join(' ')).not.toContain('/seeds'); + expect(boundedQueryMounts.join(' ')).not.toContain('docker.sock'); }); it('tells the agent where the socket and skill are, and which repos exist', () => { const result = generateDockerCompose(enabled(), mockNetworkConfig); const environment = (result.services['agent'] as unknown as Record>).environment; - expect(environment.AWF_SEALED_PROBE_SOCKET).toBe('/run/awf-sealed-probe/broker.sock'); - expect(environment.AWF_SEALED_PROBE_SKILL).toBe('/run/awf-sealed-probe-skill/SKILL.md'); - expect(environment.AWF_SEALED_PROBE_REPOS).toBe('octo/alpha'); + expect(environment.AWF_BOUNDED_QUERY_SOCKET).toBe('/run/awf-bounded-query/broker.sock'); + expect(environment.AWF_BOUNDED_QUERY_SKILL).toBe('/run/awf-bounded-query-skill/SKILL.md'); + expect(environment.AWF_BOUNDED_QUERY_REPOS).toBe('octo/alpha'); }); it('strips GitHub credentials from the agent even without the API or DIFC proxies', () => { @@ -134,7 +134,7 @@ describe('sealed-probe broker in generated Docker Compose', () => { it('keeps the broker off the topology networks in network-isolation mode', () => { const result = generateDockerCompose({ ...enabled(), networkIsolation: true }, mockNetworkConfig); - const broker = result.services['sealed-probe-broker'] as unknown as Record; + const broker = result.services['bounded-query-broker'] as unknown as Record; expect(broker.network_mode).toBe('none'); expect(broker.networks).toBeUndefined(); diff --git a/src/services/sealed-probe-service.test.ts b/src/services/bounded-query-service.test.ts similarity index 62% rename from src/services/sealed-probe-service.test.ts rename to src/services/bounded-query-service.test.ts index 36324ed5e..29673a6dd 100644 --- a/src/services/sealed-probe-service.test.ts +++ b/src/services/bounded-query-service.test.ts @@ -1,17 +1,17 @@ import * as path from 'path'; -import type { SealedProbesConfig, WrapperConfig } from '../types'; +import type { BoundedQueriesConfig, WrapperConfig } from '../types'; import { parseImageTag } from '../image-tag'; -import { AGENT_SKILL_DIR, AGENT_SOCKET_DIR, AGENT_SOCKET_PATH, resolveSealedProbePaths } from '../sealed-probe/paths'; +import { AGENT_SKILL_DIR, AGENT_SOCKET_DIR, AGENT_SOCKET_PATH, resolveBoundedQueryPaths } from '../bounded-query/paths'; import { - buildSealedProbeService, - isSealedProbeAgentMount, - sealedProbeServiceTestHelpers, -} from './sealed-probe-service'; + buildBoundedQueryService, + isBoundedQueryAgentMount, + boundedQueryServiceTestHelpers, +} from './bounded-query-service'; import type { ImageBuildConfig } from './squid-service'; const WORK_DIR = '/tmp/awf-1700000000'; -const sealedProbes: SealedProbesConfig = { +const boundedQueries: BoundedQueriesConfig = { enabled: true, privateRepos: [ { repo: 'octo/alpha', sensitivity: 'internal' }, @@ -24,10 +24,10 @@ const sealedProbes: SealedProbesConfig = { maxInvocations: 9, }; -function buildConfig(overrides: Partial = {}, probes: Partial = {}): WrapperConfig { +function buildConfig(overrides: Partial = {}, queries: Partial = {}): WrapperConfig { return { workDir: WORK_DIR, - sealedProbes: { ...sealedProbes, ...probes }, + boundedQueries: { ...boundedQueries, ...queries }, ...overrides, } as unknown as WrapperConfig; } @@ -41,17 +41,17 @@ function imageConfig(useGHCR = true): ImageBuildConfig { }; } -const paths = resolveSealedProbePaths(WORK_DIR); +const paths = resolveBoundedQueryPaths(WORK_DIR); -describe('buildSealedProbeService', () => { - it('refuses to build when sealed probes are not enabled', () => { +describe('buildBoundedQueryService', () => { + it('refuses to build when bounded queries are not enabled', () => { expect(() => - buildSealedProbeService({ config: buildConfig({}, { enabled: false }), imageConfig: imageConfig() }), + buildBoundedQueryService({ config: buildConfig({}, { enabled: false }), imageConfig: imageConfig() }), ).toThrow(/must be enabled/); }); describe('broker service', () => { - const { probeImageService, service } = buildSealedProbeService({ + const { queryImageService, service } = buildBoundedQueryService({ config: buildConfig(), imageConfig: imageConfig(), }); @@ -67,7 +67,7 @@ describe('buildSealedProbeService', () => { }); it('mounts the broker socket directory read-write so the agent can connect', () => { - expect(volumes).toContain(`${paths.runDir}:/run/awf-sealed-probe:rw`); + expect(volumes).toContain(`${paths.runDir}:/run/awf-bounded-query:rw`); }); it('mounts the seeds read-only and the seed map read-only', () => { @@ -75,25 +75,25 @@ describe('buildSealedProbeService', () => { expect(volumes).toContain(`${paths.seedMapPath}:/srv/awf/seed-map.json:ro`); }); - it('mounts the Docker socket so it can launch probes', () => { + it('mounts the Docker socket so it can launch queries', () => { expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock:rw'); }); it('keeps protected diagnostics on a broker-only mount', () => { - expect(volumes).toContain(`${paths.auditDir}:/var/log/awf-sealed-probe:rw`); + expect(volumes).toContain(`${paths.auditDir}:/var/log/awf-bounded-query:rw`); }); it('mounts nothing else', () => { expect(volumes).toHaveLength(6); }); - it('passes only AWF-chosen limits and the resolved probe image', () => { - expect(environment.AWF_SEALED_PROBE_IMAGE).toBe('ghcr.io/github/gh-aw-firewall/sealed-probe:v1.2.3'); - expect(environment.AWF_SEALED_PROBE_TIMEOUT).toBe('45'); - expect(environment.AWF_SEALED_PROBE_MEMORY).toBe('256m'); - expect(environment.AWF_SEALED_PROBE_MAX_INVOCATIONS).toBe('9'); - expect(environment.AWF_SEALED_PROBE_RUNTIME).toBe(''); - expect(environment.AWF_SEALED_PROBE_HOST_WORK_DIR).toBe(paths.workDir); + it('passes only AWF-chosen limits and the resolved query image', () => { + expect(environment.AWF_BOUNDED_QUERY_IMAGE).toBe('ghcr.io/github/gh-aw-firewall/bounded-query:v1.2.3'); + expect(environment.AWF_BOUNDED_QUERY_TIMEOUT).toBe('45'); + expect(environment.AWF_BOUNDED_QUERY_MEMORY).toBe('256m'); + expect(environment.AWF_BOUNDED_QUERY_MAX_INVOCATIONS).toBe('9'); + expect(environment.AWF_BOUNDED_QUERY_RUNTIME).toBe(''); + expect(environment.AWF_BOUNDED_QUERY_HOST_WORK_DIR).toBe(paths.workDir); }); it('carries no credential in its environment', () => { @@ -113,30 +113,30 @@ describe('buildSealedProbeService', () => { ]); }); - it('waits for a networkless one-shot service to make the probe image available', () => { + it('waits for a networkless one-shot service to make the query image available', () => { expect(service.depends_on).toEqual({ - 'sealed-probe-image': { condition: 'service_completed_successfully' }, + 'bounded-query-image': { condition: 'service_completed_successfully' }, }); - expect(probeImageService).toMatchObject({ - image: 'ghcr.io/github/gh-aw-firewall/sealed-probe:v1.2.3', + expect(queryImageService).toMatchObject({ + image: 'ghcr.io/github/gh-aw-firewall/bounded-query:v1.2.3', network_mode: 'none', entrypoint: ['/bin/true'], cap_drop: ['ALL'], restart: 'no', }); - expect(probeImageService).not.toHaveProperty('volumes'); + expect(queryImageService).not.toHaveProperty('volumes'); }); - it('maps the gvisor runtime to the runsc OCI runtime for probes', () => { - const { service: gvisorService } = buildSealedProbeService({ + it('maps the gvisor runtime to the runsc OCI runtime for queries', () => { + const { service: gvisorService } = buildBoundedQueryService({ config: buildConfig({}, { runtime: 'gvisor' }), imageConfig: imageConfig(), }); - expect((gvisorService.environment as Record).AWF_SEALED_PROBE_RUNTIME).toBe('runsc'); + expect((gvisorService.environment as Record).AWF_BOUNDED_QUERY_RUNTIME).toBe('runsc'); }); it('uses the AWF Docker host socket when overridden, without leaking it to the agent', () => { - const result = buildSealedProbeService({ + const result = buildBoundedQueryService({ config: buildConfig({ awfDockerHost: 'unix:///run/user/1001/docker.sock' }), imageConfig: imageConfig(), }); @@ -148,55 +148,55 @@ describe('buildSealedProbeService', () => { }); it('pins a deterministic local image tag when building from source', () => { - const { probeImageService: localProbeService, service: localService } = buildSealedProbeService({ + const { queryImageService: localQueryService, service: localService } = buildBoundedQueryService({ config: buildConfig(), imageConfig: imageConfig(false), }); - expect(localService.image).toBe('awf-sealed-probe-broker:local'); + expect(localService.image).toBe('awf-bounded-query-broker:local'); expect(localService.build).toEqual({ - context: path.join('/opt/awf', 'containers', 'sealed-probe'), + context: path.join('/opt/awf', 'containers', 'bounded-query'), dockerfile: 'Dockerfile', target: 'broker', }); - expect((localService.environment as Record).AWF_SEALED_PROBE_IMAGE) - .toBe('awf-sealed-probe:local'); - expect(localProbeService).toMatchObject({ - image: 'awf-sealed-probe:local', + expect((localService.environment as Record).AWF_BOUNDED_QUERY_IMAGE) + .toBe('awf-bounded-query:local'); + expect(localQueryService).toMatchObject({ + image: 'awf-bounded-query:local', build: { - context: path.join('/opt/awf', 'containers', 'sealed-probe'), + context: path.join('/opt/awf', 'containers', 'bounded-query'), dockerfile: 'Dockerfile', - target: 'probe', + target: 'query', }, entrypoint: ['/bin/true'], }); }); it('keeps the legacy image helper aligned with the split image resolver', () => { - expect(sealedProbeServiceTestHelpers.resolveSealedProbeImage(imageConfig())).toEqual({ - imageRef: 'ghcr.io/github/gh-aw-firewall/sealed-probe:v1.2.3', - source: { image: 'ghcr.io/github/gh-aw-firewall/sealed-probe-broker:v1.2.3' }, + expect(boundedQueryServiceTestHelpers.resolveBoundedQueryImage(imageConfig())).toEqual({ + imageRef: 'ghcr.io/github/gh-aw-firewall/bounded-query:v1.2.3', + source: { image: 'ghcr.io/github/gh-aw-firewall/bounded-query-broker:v1.2.3' }, }); }); }); describe('agent wiring', () => { - const { agentEnvAdditions, agentVolumes } = buildSealedProbeService({ + const { agentEnvAdditions, agentVolumes } = buildBoundedQueryService({ config: buildConfig(), imageConfig: imageConfig(), }); it('exposes only the socket path, skill path, and repository list', () => { expect(agentEnvAdditions).toEqual({ - AWF_SEALED_PROBE_SOCKET: AGENT_SOCKET_PATH, - AWF_SEALED_PROBE_SKILL: '/run/awf-sealed-probe-skill/SKILL.md', - AWF_SEALED_PROBE_REPOS: 'octo/alpha,octo/beta', + AWF_BOUNDED_QUERY_SOCKET: AGENT_SOCKET_PATH, + AWF_BOUNDED_QUERY_SKILL: '/run/awf-bounded-query-skill/SKILL.md', + AWF_BOUNDED_QUERY_REPOS: 'octo/alpha,octo/beta', }); }); it('mounts the socket read-write and the skill read-only, for chroot and non-chroot paths', () => { expect(agentVolumes).toEqual([ - // Masking mounts first — hide the sealed-probe root visible through /tmp. + // Masking mounts first — hide the bounded-query root visible through /tmp. `${paths.maskDir}:${paths.root}:ro`, `${paths.maskDir}:/host${paths.root}:ro`, // Socket mounts. @@ -218,7 +218,7 @@ describe('buildSealedProbeService', () => { }); describe('ARC/DinD host path translation', () => { - const { service, agentVolumes } = buildSealedProbeService({ + const { service, agentVolumes } = buildBoundedQueryService({ config: buildConfig({ dockerHostPathPrefix: '/host' }), imageConfig: imageConfig(), }); @@ -234,24 +234,24 @@ describe('buildSealedProbeService', () => { expect(agentVolumes[4]).toBe(`/host${paths.agentDir}:${AGENT_SKILL_DIR}:ro`); }); - it('hands the daemon-visible work directory to the broker for probe mounts', () => { - expect((service.environment as Record).AWF_SEALED_PROBE_HOST_WORK_DIR) + it('hands the daemon-visible work directory to the broker for query mounts', () => { + expect((service.environment as Record).AWF_BOUNDED_QUERY_HOST_WORK_DIR) .toBe(`/host${paths.workDir}`); }); }); }); -describe('isSealedProbeAgentMount', () => { +describe('isBoundedQueryAgentMount', () => { it('recognises the socket and skill mounts in both chroot spellings', () => { - expect(isSealedProbeAgentMount(`${paths.runDir}:${AGENT_SOCKET_DIR}:rw`)).toBe(true); - expect(isSealedProbeAgentMount(`${paths.runDir}:/host${AGENT_SOCKET_DIR}:rw`)).toBe(true); - expect(isSealedProbeAgentMount(`${paths.agentDir}:${AGENT_SKILL_DIR}:ro`)).toBe(true); - expect(isSealedProbeAgentMount(`${paths.agentDir}:/host${AGENT_SKILL_DIR}:ro`)).toBe(true); + expect(isBoundedQueryAgentMount(`${paths.runDir}:${AGENT_SOCKET_DIR}:rw`)).toBe(true); + expect(isBoundedQueryAgentMount(`${paths.runDir}:/host${AGENT_SOCKET_DIR}:rw`)).toBe(true); + expect(isBoundedQueryAgentMount(`${paths.agentDir}:${AGENT_SKILL_DIR}:ro`)).toBe(true); + expect(isBoundedQueryAgentMount(`${paths.agentDir}:/host${AGENT_SKILL_DIR}:ro`)).toBe(true); }); it('does not match unrelated mounts', () => { - expect(isSealedProbeAgentMount('/tmp:/host/tmp:rw')).toBe(false); - expect(isSealedProbeAgentMount(`${paths.seedsDir}:/host/seeds:ro`)).toBe(false); - expect(isSealedProbeAgentMount('malformed')).toBe(false); + expect(isBoundedQueryAgentMount('/tmp:/host/tmp:rw')).toBe(false); + expect(isBoundedQueryAgentMount(`${paths.seedsDir}:/host/seeds:ro`)).toBe(false); + expect(isBoundedQueryAgentMount('malformed')).toBe(false); }); }); diff --git a/src/services/sealed-probe-service.ts b/src/services/bounded-query-service.ts similarity index 60% rename from src/services/sealed-probe-service.ts rename to src/services/bounded-query-service.ts index 450297a8e..c206f9a6b 100644 --- a/src/services/sealed-probe-service.ts +++ b/src/services/bounded-query-service.ts @@ -2,7 +2,7 @@ import { logger } from '../logger'; import { buildRuntimeImageRef } from '../image-tag'; import { resolveDockerRuntime } from '../container-runtime'; import { getSafeHostGid, getSafeHostUid } from '../host-identity'; -import { SEALED_PROBE_BROKER_CONTAINER_NAME } from '../constants'; +import { BOUNDED_QUERY_BROKER_CONTAINER_NAME } from '../constants'; import type { WrapperConfig } from '../types'; import { AGENT_SKILL_DIR, @@ -15,15 +15,15 @@ import { BROKER_SEEDS_DIR, BROKER_SOCKET_DIR, BROKER_WORK_DIR, - resolveSealedProbePaths, -} from '../sealed-probe/paths'; + resolveBoundedQueryPaths, +} from '../bounded-query/paths'; import { resolveDockerSocketPath } from './agent-volumes/docker-socket'; import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; import type { ImageBuildConfig } from './squid-service'; /** - * Compose assembly for the trusted sealed-probe broker. + * Compose assembly for the trusted bounded-query broker. * * The broker is deliberately the *only* AWF sidecar with `network_mode: none`: * it has no interface on `awf-net`, no external bridge, no DNS, no Squid @@ -31,37 +31,37 @@ import type { ImageBuildConfig } from './squid-service'; * Its whole surface is a single Unix socket shared with the agent through a * tightly scoped bind mount. * - * It does receive the Docker socket, because it launches ephemeral probe + * It does receive the Docker socket, because it launches ephemeral query * containers. That is the subsystem's most privileged component, so: * * - its API accepts only a repository selector, three outcome labels, and * script bytes — never a path, image, command, mount, or runtime flag; * - the Docker socket path is never placed in the agent's environment or * volumes (the agent only ever sees `${AGENT_SOCKET_PATH}`); - * - every probe container is launched with a fixed, AWF-authored argument + * - every query container is launched with a fixed, AWF-authored argument * vector. */ -/** Local image tag used when building the sealed-probe broker image from source. */ -const LOCAL_SEALED_PROBE_BROKER_IMAGE = 'awf-sealed-probe-broker:local'; +/** Local image tag used when building the bounded-query broker image from source. */ +const LOCAL_BOUNDED_QUERY_BROKER_IMAGE = 'awf-bounded-query-broker:local'; -/** Local image tag used when building the sealed-probe probe image from source. */ -const LOCAL_SEALED_PROBE_IMAGE = 'awf-sealed-probe:local'; +/** Local image tag used when building the bounded-query sandbox image from source. */ +const LOCAL_BOUNDED_QUERY_IMAGE = 'awf-bounded-query:local'; /** Broker image name published to the container registry. */ -const SEALED_PROBE_BROKER_IMAGE_NAME = 'sealed-probe-broker'; +const BOUNDED_QUERY_BROKER_IMAGE_NAME = 'bounded-query-broker'; -/** Probe image name published to the container registry. */ -const SEALED_PROBE_IMAGE_NAME = 'sealed-probe'; +/** Query image name published to the container registry. */ +const BOUNDED_QUERY_IMAGE_NAME = 'bounded-query'; -interface SealedProbeServiceParams { +interface BoundedQueryServiceParams { config: WrapperConfig; imageConfig: ImageBuildConfig; } -interface SealedProbeBuildResult { - /** One-shot service that makes the probe image locally available. */ - probeImageService: Record; +interface BoundedQueryBuildResult { + /** One-shot service that makes the query image locally available. */ + queryImageService: Record; /** Compose service definition for the broker. */ service: Record; /** Environment additions merged into the agent container. */ @@ -71,28 +71,28 @@ interface SealedProbeBuildResult { } /** - * Resolves the image references for the broker and probe sandbox separately. + * Resolves the image references for the broker and query sandbox separately. * - * The broker and probe are built from separate Dockerfile stages and published - * as separate images (`sealed-probe-broker` and `sealed-probe`). Using two - * images keeps the probe environment minimal (Python 3 only — no Node, no - * docker-cli) while still guaranteeing the probe image is local when the + * The broker and query are built from separate Dockerfile stages and published + * as separate images (`bounded-query-broker` and `bounded-query`). Using two + * images keeps the query environment minimal (Python 3 only — no Node, no + * docker-cli) while still guaranteeing the query image is local when the * broker starts: the release workflow pushes both and compose pulls the broker - * image which declares a dependency on the probe image. + * image which declares a dependency on the query image. */ -function resolveSealedProbeImages(imageConfig: ImageBuildConfig): { - probeImageRef: string; - probeSource: Record; +function resolveBoundedQueryImages(imageConfig: ImageBuildConfig): { + queryImageRef: string; + querySource: Record; brokerSource: Record; } { const { useGHCR, registry, parsedTag, projectRoot } = imageConfig; if (useGHCR) { - const probeImageRef = buildRuntimeImageRef(registry, SEALED_PROBE_IMAGE_NAME, parsedTag); - const brokerImageRef = buildRuntimeImageRef(registry, SEALED_PROBE_BROKER_IMAGE_NAME, parsedTag); + const queryImageRef = buildRuntimeImageRef(registry, BOUNDED_QUERY_IMAGE_NAME, parsedTag); + const brokerImageRef = buildRuntimeImageRef(registry, BOUNDED_QUERY_BROKER_IMAGE_NAME, parsedTag); return { - probeImageRef, - probeSource: { image: probeImageRef }, + queryImageRef, + querySource: { image: queryImageRef }, brokerSource: { image: brokerImageRef }, }; } @@ -100,21 +100,21 @@ function resolveSealedProbeImages(imageConfig: ImageBuildConfig): { // Local builds pin an explicit `image:` alongside `build:` so the built // image gets a deterministic tag the broker can pass to `docker run`. return { - probeImageRef: LOCAL_SEALED_PROBE_IMAGE, - probeSource: { - image: LOCAL_SEALED_PROBE_IMAGE, + queryImageRef: LOCAL_BOUNDED_QUERY_IMAGE, + querySource: { + image: LOCAL_BOUNDED_QUERY_IMAGE, build: { - context: `${projectRoot}/containers/sealed-probe`, + context: `${projectRoot}/containers/bounded-query`, dockerfile: 'Dockerfile', - target: 'probe', + target: 'query', }, }, brokerSource: { - image: LOCAL_SEALED_PROBE_BROKER_IMAGE, + image: LOCAL_BOUNDED_QUERY_BROKER_IMAGE, build: { - context: `${projectRoot}/containers/sealed-probe`, + context: `${projectRoot}/containers/bounded-query`, dockerfile: 'Dockerfile', - // Build the broker (default) stage; probe is a separate target. + // Build the broker (default) stage; query is a separate target. target: 'broker', }, }, @@ -122,23 +122,23 @@ function resolveSealedProbeImages(imageConfig: ImageBuildConfig): { } /** - * Resolves the image reference for the probe image only (legacy single-image + * Resolves the image reference for the query image only (legacy single-image * helper preserved for the test-helpers export). * * @internal */ -function resolveSealedProbeImage(imageConfig: ImageBuildConfig): { +function resolveBoundedQueryImage(imageConfig: ImageBuildConfig): { imageRef: string; source: Record; } { - const { probeImageRef, brokerSource } = resolveSealedProbeImages(imageConfig); - return { imageRef: probeImageRef, source: brokerSource }; + const { queryImageRef, brokerSource } = resolveBoundedQueryImages(imageConfig); + return { imageRef: queryImageRef, source: brokerSource }; } /** * Translates a host directory into the path the Docker daemon resolves it at. * - * The broker passes probe bind-mount sources straight to the daemon, so those + * The broker passes query bind-mount sources straight to the daemon, so those * sources must already be expressed in the daemon's filesystem view (ARC/DinD * split filesystems). */ @@ -148,23 +148,23 @@ function toDaemonVisiblePath(hostPath: string, dockerHostPathPrefix: string | un } /** Builds the broker compose service plus the agent's socket/skill wiring. */ -export function buildSealedProbeService(params: SealedProbeServiceParams): SealedProbeBuildResult { +export function buildBoundedQueryService(params: BoundedQueryServiceParams): BoundedQueryBuildResult { const { config, imageConfig } = params; - const sealedProbes = config.sealedProbes; + const boundedQueries = config.boundedQueries; - if (!sealedProbes?.enabled) { - throw new Error('buildSealedProbeService: sealedProbes must be enabled'); + if (!boundedQueries?.enabled) { + throw new Error('buildBoundedQueryService: boundedQueries must be enabled'); } - const paths = resolveSealedProbePaths(config.workDir); - const { probeImageRef, probeSource, brokerSource } = resolveSealedProbeImages(imageConfig); + const paths = resolveBoundedQueryPaths(config.workDir); + const { queryImageRef, querySource, brokerSource } = resolveBoundedQueryImages(imageConfig); const dockerSocketPath = resolveDockerSocketPath(config); - // Compose must pull/build the probe target before starting the offline + // Compose must pull/build the query target before starting the offline // broker. The one-shot service has no mounts or network and exits only after // Docker has made the exact image reference available to the daemon. - const probeImageService: Record = { - ...probeSource, + const queryImageService: Record = { + ...querySource, network_mode: 'none', entrypoint: ['/bin/true'], ...buildContainerSecurityHardening({ memLimit: '32m', pidsLimit: 16, cpuShares: 64 }), @@ -172,7 +172,7 @@ export function buildSealedProbeService(params: SealedProbeServiceParams): Seale }; const service: Record = { - container_name: SEALED_PROBE_BROKER_CONTAINER_NAME, + container_name: BOUNDED_QUERY_BROKER_CONTAINER_NAME, ...brokerSource, // SECURITY: no networks key at all. `none` gives the broker a loopback-only // namespace: no awf-net, no awf-ext, no DNS, no Squid, no host gateway. @@ -189,25 +189,25 @@ export function buildSealedProbeService(params: SealedProbeServiceParams): Seale config.dockerHostPathPrefix, ), environment: { - AWF_SEALED_PROBE_IMAGE: probeImageRef, + AWF_BOUNDED_QUERY_IMAGE: queryImageRef, // "docker" means the daemon's default OCI runtime. Passing // `--runtime docker` would fail because Docker has no runtime by that // name; only non-default runtimes get an explicit value. - AWF_SEALED_PROBE_RUNTIME: - sealedProbes.runtime === 'docker' + AWF_BOUNDED_QUERY_RUNTIME: + boundedQueries.runtime === 'docker' ? '' - : resolveDockerRuntime(sealedProbes.runtime) ?? '', - AWF_SEALED_PROBE_TIMEOUT: String(sealedProbes.timeout), - AWF_SEALED_PROBE_MEMORY: sealedProbes.memoryLimit, - AWF_SEALED_PROBE_MAX_INVOCATIONS: String(sealedProbes.maxInvocations), - // Probe bind-mount sources are handed to the daemon, not opened by the + : resolveDockerRuntime(boundedQueries.runtime) ?? '', + AWF_BOUNDED_QUERY_TIMEOUT: String(boundedQueries.timeout), + AWF_BOUNDED_QUERY_MEMORY: boundedQueries.memoryLimit, + AWF_BOUNDED_QUERY_MAX_INVOCATIONS: String(boundedQueries.maxInvocations), + // Query bind-mount sources are handed to the daemon, not opened by the // broker, so they must be daemon-visible paths. - AWF_SEALED_PROBE_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), - AWF_SEALED_PROBE_SOCKET_UID: getSafeHostUid(), - AWF_SEALED_PROBE_SOCKET_GID: getSafeHostGid(), + AWF_BOUNDED_QUERY_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), + AWF_BOUNDED_QUERY_SOCKET_UID: getSafeHostUid(), + AWF_BOUNDED_QUERY_SOCKET_GID: getSafeHostGid(), }, depends_on: { - 'sealed-probe-image': { + 'bounded-query-image': { condition: 'service_completed_successfully', }, }, @@ -220,7 +220,7 @@ export function buildSealedProbeService(params: SealedProbeServiceParams): Seale }, ...buildContainerSecurityHardening({ memLimit: '256m', pidsLimit: 100, cpuShares: 256 }), // The broker is root only to copy host-owned read-only seeds into private - // workspaces and hand those workspaces to the unprivileged probe uid. + // workspaces and hand those workspaces to the unprivileged query uid. // Keep the default set dropped and restore only those filesystem duties. cap_add: ['CHOWN', 'DAC_OVERRIDE', 'FOWNER'], restart: 'no', @@ -228,14 +228,14 @@ export function buildSealedProbeService(params: SealedProbeServiceParams): Seale }; const agentEnvAdditions: Record = { - AWF_SEALED_PROBE_SOCKET: AGENT_SOCKET_PATH, - AWF_SEALED_PROBE_SKILL: AGENT_SKILL_PATH, - AWF_SEALED_PROBE_REPOS: sealedProbes.privateRepos.map((repository) => repository.repo).join(','), + AWF_BOUNDED_QUERY_SOCKET: AGENT_SOCKET_PATH, + AWF_BOUNDED_QUERY_SKILL: AGENT_SKILL_PATH, + AWF_BOUNDED_QUERY_REPOS: boundedQueries.privateRepos.map((repository) => repository.repo).join(','), }; - // The agent receives four sealed-probe mounts: + // The agent receives four bounded-query mounts: // - // 1+2. Masking mounts: an empty directory is mounted at the sealed-probe + // 1+2. Masking mounts: an empty directory is mounted at the bounded-query // root as seen through the agent's broad /tmp bind mount. This hides // seeds, work, audit, and the seed-map from the agent even in rootless // mode where directory permissions alone are insufficient. @@ -249,7 +249,7 @@ export function buildSealedProbeService(params: SealedProbeServiceParams): Seale // in order, so the more-specific socket/skill mounts take precedence. const agentVolumes = applyHostPathPrefixToVolumes( [ - // Masking mounts — cover the sealed-probe root visible through /tmp. + // Masking mounts — cover the bounded-query root visible through /tmp. `${paths.maskDir}:${paths.root}:ro`, `${paths.maskDir}:/host${paths.root}:ro`, // Socket mounts. @@ -263,40 +263,40 @@ export function buildSealedProbeService(params: SealedProbeServiceParams): Seale ); logger.info( - `Sealed probes enabled - offline broker (network_mode: none) exposed to the agent at ${AGENT_SOCKET_PATH}`, + `Bounded queries enabled - offline broker (network_mode: none) exposed to the agent at ${AGENT_SOCKET_PATH}`, ); - return { probeImageService, service, agentEnvAdditions, agentVolumes }; + return { queryImageService, service, agentEnvAdditions, agentVolumes }; } /** - * True when a volume entry is one of the sealed-probe agent mounts. + * True when a volume entry is one of the bounded-query agent mounts. * * The ARC/DinD sysroot filter drops bind mounts sourced from `workDir`; the - * sealed-probe socket, skill, and masking mounts are sourced there but are + * bounded-query socket, skill, and masking mounts are sourced there but are * mandatory, so they are exempted explicitly rather than silently disappearing. */ -export function isSealedProbeAgentMount(volume: string): boolean { +export function isBoundedQueryAgentMount(volume: string): boolean { const target = volume.split(':')[1]; if (!target) return false; const normalized = target.startsWith('/host') ? target.slice('/host'.length) : target; return ( normalized === AGENT_SOCKET_DIR || normalized === AGENT_SKILL_DIR || - // The masking mount's target is the sealed-probe root itself (paths.root). + // The masking mount's target is the bounded-query root itself (paths.root). // We check by suffix since paths.root includes the dynamic workDir prefix. - normalized.endsWith('/sealed-probes') + normalized.endsWith('/bounded-queries') ); } /** @internal Exported for focused unit tests. */ // ts-prune-ignore-next -export const sealedProbeServiceTestHelpers = { - LOCAL_SEALED_PROBE_IMAGE, - LOCAL_SEALED_PROBE_BROKER_IMAGE, - SEALED_PROBE_IMAGE_NAME, - SEALED_PROBE_BROKER_IMAGE_NAME, - resolveSealedProbeImages, - resolveSealedProbeImage, +export const boundedQueryServiceTestHelpers = { + LOCAL_BOUNDED_QUERY_IMAGE, + LOCAL_BOUNDED_QUERY_BROKER_IMAGE, + BOUNDED_QUERY_IMAGE_NAME, + BOUNDED_QUERY_BROKER_IMAGE_NAME, + resolveBoundedQueryImages, + resolveBoundedQueryImage, toDaemonVisiblePath, }; diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 0289a123b..7547383d6 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -5,7 +5,7 @@ import { buildIptablesInitService } from './agent-service'; import { buildApiProxyService } from './api-proxy-service'; import { buildDohProxyService } from './doh-proxy-service'; import { buildCliProxyService } from './cli-proxy-service'; -import { buildSealedProbeService, isSealedProbeAgentMount } from './sealed-probe-service'; +import { buildBoundedQueryService, isBoundedQueryAgentMount } from './bounded-query-service'; import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service'; import { resolveDockerHostGateway } from './host-gateway'; import { runtimeUsesIptables } from '../container-runtime'; @@ -77,10 +77,10 @@ function filterAgentVolumesForSysroot( const source = parts[0]; const target = parts[1]; - // Sealed-probe mounts are sourced from workDir but are mandatory: dropping - // them would leave sealed probes half-enabled (wrapper present, broker + // Bounded-query mounts are sourced from workDir but are mandatory: dropping + // them would leave bounded queries half-enabled (wrapper present, broker // unreachable) instead of failing loudly. - if (isSealedProbeAgentMount(volume)) return true; + if (isBoundedQueryAgentMount(volume)) return true; // Drop sysroot-shadowed targets (system binaries provided by volume) if (sysrootShadowedTargets.has(target)) return false; @@ -233,26 +233,26 @@ function assembleCliProxyService(params: AssembleOptionalServicesParams): void { }; } -function assembleSealedProbeService(params: AssembleOptionalServicesParams): void { +function assembleBoundedQueryService(params: AssembleOptionalServicesParams): void { const { services, agentService, agentVolumes, environment, config, imageConfig } = params; - if (!config.sealedProbes?.enabled) return; + if (!config.boundedQueries?.enabled) return; const { - probeImageService, + queryImageService, service, agentEnvAdditions, - agentVolumes: probeVolumes, - } = buildSealedProbeService({ + agentVolumes: queryVolumes, + } = buildBoundedQueryService({ config, imageConfig, }); - services['sealed-probe-image'] = probeImageService; - services['sealed-probe-broker'] = service; + services['bounded-query-image'] = queryImageService; + services['bounded-query-broker'] = service; Object.assign(environment, agentEnvAdditions); - agentVolumes.push(...probeVolumes); - agentService.depends_on['sealed-probe-broker'] = { + agentVolumes.push(...queryVolumes); + agentService.depends_on['bounded-query-broker'] = { condition: 'service_healthy', }; } @@ -295,7 +295,7 @@ export function assembleOptionalServices( presetSidecarIpEnvVars(environment, config, networkConfig); if (includeComposeAgent) { - assembleSealedProbeService(params); + assembleBoundedQueryService(params); assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); assembleIptablesInitService(params, skipIptables); } diff --git a/src/types/sealed-probe-options.ts b/src/types/bounded-query-options.ts similarity index 60% rename from src/types/sealed-probe-options.ts rename to src/types/bounded-query-options.ts index 72c905a2b..703a2dd1c 100644 --- a/src/types/sealed-probe-options.ts +++ b/src/types/bounded-query-options.ts @@ -1,33 +1,33 @@ /** - * Sealed-probe sandbox configuration types. + * Bounded-query sandbox configuration types. * * This is the configuration/protocol foundation only. It defines the shape - * of `sealedProbes` config, its normalized runtime representation, and the + * of `boundedQueries` config, its normalized runtime representation, and the * centralized defaults applied when a field is not explicitly set. No * broker or sandbox runtime is implemented yet — see docs/awf-config-spec.md - * §14 and src/sealed-probe/protocol.ts for the request/result protocol. + * §14 and src/bounded-query/protocol.ts for the request/result protocol. */ -/** Sandbox runtime backends supported for sealed-probe execution. */ -export type SealedProbeRuntime = 'docker' | 'gvisor'; +/** Sandbox runtime backends supported for bounded-query execution. */ +export type BoundedQueryRuntime = 'docker' | 'gvisor'; -/** Script interpreters supported for sealed-probe execution. */ -export type SealedProbeInterpreter = 'python3'; +/** Script interpreters supported for bounded-query execution. */ +export type BoundedQueryInterpreter = 'python3'; /** * Repository confidentiality categories. * * Each category has a fixed, immutable maximum number of bits the broker may * reveal about that repository across an entire AWF run (not per query — see - * {@link SEALED_PROBE_SENSITIVITY_RUN_BITS}). Users select a category; they + * {@link BOUNDED_QUERY_SENSITIVITY_RUN_BITS}). Users select a category; they * cannot raise its numeric limit. A future release may add a *reducing* * numeric override, but no category may ever be granted more than its listed * maximum. */ -export type SealedProbeSensitivity = 'public' | 'internal' | 'confidential' | 'sealed'; +export type BoundedQuerySensitivity = 'public' | 'internal' | 'confidential' | 'sealed'; /** Every supported sensitivity value, for schema/validation enumeration. */ -export const SEALED_PROBE_SENSITIVITIES: readonly SealedProbeSensitivity[] = [ +export const BOUNDED_QUERY_SENSITIVITIES: readonly BoundedQuerySensitivity[] = [ 'public', 'internal', 'confidential', @@ -50,7 +50,7 @@ export const SEALED_PROBE_SENSITIVITIES: readonly SealedProbeSensitivity[] = [ * identity or storage across runs, so this is deliberately not a * "lifetime" budget. */ -export const SEALED_PROBE_SENSITIVITY_RUN_BITS: Readonly> = { +export const BOUNDED_QUERY_SENSITIVITY_RUN_BITS: Readonly> = { public: null, internal: 64, confidential: 8, @@ -64,21 +64,21 @@ export const SEALED_PROBE_SENSITIVITY_RUN_BITS: Readonly +export const BOUNDED_QUERY_DEFAULTS: Readonly< + Pick > = { enabled: false, runtime: 'docker', @@ -157,15 +157,15 @@ export const SEALED_PROBE_DEFAULTS: Readonly< maxInvocations: 32, }; -export interface SealedProbeOptions { +export interface BoundedQueryOptions { /** - * Normalized sealed-probe sandbox configuration. + * Normalized bounded-query sandbox configuration. * - * `undefined` when the AWF config file did not include a `sealedProbes` + * `undefined` when the AWF config file did not include a `boundedQueries` * section at all. Present (with defaults applied) whenever the section * was included, regardless of whether `enabled` is `true`. * * @default undefined */ - sealedProbes?: SealedProbesConfig; + boundedQueries?: BoundedQueriesConfig; } diff --git a/src/types/index.ts b/src/types/index.ts index c2e2c42b4..d5090ea6f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -42,13 +42,13 @@ export { } from './pid'; export { - type SealedProbeRuntime, - type SealedProbeInterpreter, - type SealedProbeSensitivity, - type SealedProbeRepository, - type SealedProbesConfig, - type SealedProbeOptions, - SEALED_PROBE_DEFAULTS, - SEALED_PROBE_SENSITIVITIES, - SEALED_PROBE_SENSITIVITY_RUN_BITS, -} from './sealed-probe-options'; + type BoundedQueryRuntime, + type BoundedQueryInterpreter, + type BoundedQuerySensitivity, + type BoundedQueryRepository, + type BoundedQueriesConfig, + type BoundedQueryOptions, + BOUNDED_QUERY_DEFAULTS, + BOUNDED_QUERY_SENSITIVITIES, + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, +} from './bounded-query-options'; diff --git a/src/types/wrapper-config.ts b/src/types/wrapper-config.ts index 9b14e2d9b..464da0f61 100644 --- a/src/types/wrapper-config.ts +++ b/src/types/wrapper-config.ts @@ -15,7 +15,7 @@ import type { RateLimitOptions } from './rate-limit-options'; import type { RuntimeOptions } from './runtime-options'; import type { PlatformOptions } from './platform-options'; import type { RunnerOptions } from './runner-options'; -import type { SealedProbeOptions } from './sealed-probe-options'; +import type { BoundedQueryOptions } from './bounded-query-options'; export type WrapperConfig = ContainerImageOptions @@ -28,4 +28,4 @@ export type WrapperConfig = & RuntimeOptions & PlatformOptions & RunnerOptions - & SealedProbeOptions; + & BoundedQueryOptions; diff --git a/tests/integration/sealed-probe-isolation.test.ts b/tests/integration/bounded-query-isolation.test.ts similarity index 76% rename from tests/integration/sealed-probe-isolation.test.ts rename to tests/integration/bounded-query-isolation.test.ts index 849db8961..8a9128557 100644 --- a/tests/integration/sealed-probe-isolation.test.ts +++ b/tests/integration/bounded-query-isolation.test.ts @@ -4,25 +4,25 @@ import * as os from 'os'; import * as path from 'path'; /* eslint-disable @typescript-eslint/no-require-imports */ -const { buildProbeArgs } = require('../../containers/sealed-probe/broker/probe-runner.js'); +const { buildQueryArgs } = require('../../containers/bounded-query/broker/query-runner.js'); /* eslint-enable @typescript-eslint/no-require-imports */ -describe('sealed-probe Docker isolation', () => { - const image = `awf-sealed-probe-integration:${process.pid}`; +describe('bounded-query Docker isolation', () => { + const image = `awf-bounded-query-integration:${process.pid}`; let root: string; beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sealed-probe-integration-')); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-query-integration-')); execFileSync( 'docker', [ 'build', '--quiet', '--target', - 'probe', + 'query', '--tag', image, - path.resolve(__dirname, '../../containers/sealed-probe'), + path.resolve(__dirname, '../../containers/bounded-query'), ], { stdio: 'pipe', timeout: 120_000 }, ); @@ -54,7 +54,7 @@ describe('sealed-probe Docker isolation', () => { 'import socket', 'from pathlib import Path', '', - 'repo = Path("/probe/repo")', + 'repo = Path("/query/repo")', '(repo / "mutation.txt").write_text("ephemeral")', 'network_blocked = False', 'try:', @@ -70,27 +70,27 @@ describe('sealed-probe Docker isolation', () => { 'valid = (repo / "private.txt").read_text() == "assigned repository\\n"', 'valid = valid and (repo / "mutation.txt").read_text() == "ephemeral"', 'result = "YES" if valid and network_blocked and root_read_only and tools_absent else "NO"', - 'Path("/probe/out").write_text(json.dumps({"result": result}, separators=(",", ":")))', + 'Path("/query/out").write_text(json.dumps({"result": result}, separators=(",", ":")))', ].join('\n'), { mode: 0o444 }, ); fs.chmodSync(scriptPath, 0o444); - const args = buildProbeArgs({ + const args = buildQueryArgs({ config: { hostWorkDir: root, - probeMountDir: '/probe', - probeScriptPath: '/awf/probe-script.py', - probeSeccompPath: path.resolve(__dirname, '../../containers/sealed-probe/probe-seccomp.json'), - probeImage: image, + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + querySeccompPath: path.resolve(__dirname, '../../containers/bounded-query/query-seccomp.json'), + queryImage: image, dockerRuntime: '', memoryLimit: '256m', - probeUid: 65534, - probeGid: 65534, + queryUid: 65534, + queryGid: 65534, }, runId: 'integration-run', invocationId, - containerName: `awf-probe-integration-${process.pid}`, + containerName: `awf-query-integration-${process.pid}`, }); execFileSync('docker', args, { stdio: 'pipe', timeout: 30_000 }); From c79939bada52c5b0522a3e30b077b1867674e920 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 29 Jul 2026 16:24:01 -0700 Subject: [PATCH 2/3] fix: avoid naming test filesystem race Use Git commands to inspect tracked paths and text without a check-then-read sequence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ca8d9d74-46ab-48db-b05f-640cbc6d47be --- src/bounded-query/naming.test.ts | 54 ++++++++++++++++---------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/bounded-query/naming.test.ts b/src/bounded-query/naming.test.ts index 538f3afa1..cf21f54a9 100644 --- a/src/bounded-query/naming.test.ts +++ b/src/bounded-query/naming.test.ts @@ -1,41 +1,41 @@ -import { execFileSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; +import { spawnSync } from 'child_process'; describe('bounded-query naming', () => { it('does not retain the previous feature name in tracked paths or text', () => { - const repositoryRoot = path.resolve(__dirname, '..', '..'); const oldPrefix = 'sealed'; const oldNoun = 'probe'; - const forbidden = [ - new RegExp(`${oldPrefix}[-_ ]${oldNoun}s?`, 'i'), - new RegExp(`${oldPrefix}${oldNoun}`, 'i'), - new RegExp(`${oldPrefix}[-_ ]query`, 'i'), + const forbiddenFragments = [ + `${oldPrefix}-${oldNoun}`, + `${oldPrefix}_${oldNoun}`, + `${oldPrefix} ${oldNoun}`, + `${oldPrefix}${oldNoun}`, + `${oldPrefix}-query`, + `${oldPrefix}_query`, + `${oldPrefix} query`, ]; - const trackedFiles = execFileSync('git', ['ls-files', '-z'], { - cwd: repositoryRoot, + const trackedFilesResult = spawnSync('git', ['ls-files', '-z'], { encoding: 'utf8', - }) + }); + expect(trackedFilesResult.status).toBe(0); + const trackedFiles = trackedFilesResult.stdout .split('\0') .filter(Boolean); - const matches: string[] = []; + const pathMatches = trackedFiles.filter((file) => { + const normalized = file.toLowerCase(); + return forbiddenFragments.some((fragment) => normalized.includes(fragment)); + }); - for (const relativePath of trackedFiles) { - if (forbidden.some((pattern) => pattern.test(relativePath))) { - matches.push(relativePath); - continue; - } - - const absolutePath = path.join(repositoryRoot, relativePath); - if (!fs.lstatSync(absolutePath).isFile()) continue; - const contents = fs.readFileSync(absolutePath); - if (contents.includes(0)) continue; - const text = contents.toString('utf8'); - if (forbidden.some((pattern) => pattern.test(text))) { - matches.push(relativePath); - } + const grepArgs = ['grep', '-I', '-l', '-i', '-z']; + for (const fragment of forbiddenFragments) { + grepArgs.push('-e', fragment); } + grepArgs.push('--'); + const contentMatchesResult = spawnSync('git', grepArgs, { encoding: 'utf8' }); + expect([0, 1]).toContain(contentMatchesResult.status); + const contentMatches = contentMatchesResult.status === 0 + ? contentMatchesResult.stdout.split('\0').filter(Boolean) + : []; - expect(matches).toEqual([]); + expect([...new Set([...pathMatches, ...contentMatches])]).toEqual([]); }); }); From 92e395a17d7016c3f03f6782dfc5e865a8e8bea7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:40:22 +0000 Subject: [PATCH 3/3] docs: address PR review feedback on bounded-query documentation - config-file.ts: replace stale "not implemented yet" JSDoc with accurate description of the broker/sandbox subsystem - CLAUDE.md/AGENTS.md line 36: clarify that broker and query sandbox are separate published images; one-shot Compose service ensures sandbox image is local before the networkless broker starts - CLAUDE.md/AGENTS.md line 38: correct agent command path from /usr/local/bin/bounded-query to /tmp/awf-lib/bounded-query (inside chroot), noting /usr/local/bin/bounded-query-wrapper.sh as source - CLAUDE.md/AGENTS.md line 40: remove incorrect "two-bit disclosure bound"; use "per-repository information-budget accounting" instead --- CLAUDE.md | 6 +++--- src/config-file.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f95570caa..eb8f4768a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,11 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - The only AWF service with `network_mode: none`: no `awf-net`, no external bridge, no DNS, no Squid, no host gateway - Reachable only through one Unix socket in `/bounded-queries/run/`, bind-mounted into the agent at `/run/awf-bounded-query/broker.sock` - Receives the resolved Docker socket so it can launch per-invocation query containers; that path never enters the agent's env or volumes -- The same image is used for the query sandbox, which guarantees the query image is already local (the broker cannot pull — it has no network) +- The broker (`bounded-query-broker`) and query sandbox (`bounded-query`) are separate published images; a one-shot networkless Compose service pulls the sandbox image before broker startup so the broker (which has no network) can launch query containers - Queries run `python3` with `--network none`, `--read-only`, non-root, `--cap-drop ALL`, `no-new-privileges`, a seccomp profile, and time/memory/CPU/PID/file-size bounds -- Agent surface: `/usr/local/bin/bounded-query` (from `containers/agent/bounded-query-wrapper.sh`) plus a generated read-only `SKILL.md`; the wrapper always prints one canonical JSON line, writes nothing to stderr, and exits `0` +- Agent surface: `bounded-query` command at `/tmp/awf-lib/bounded-query` (inside chroot, added to PATH by `entrypoint.sh`; the source file in the container is `/usr/local/bin/bounded-query-wrapper.sh`) plus a generated read-only `SKILL.md`; the wrapper always prints one canonical JSON line, writes nothing to stderr, and exits `0` - Trusted host staging (`src/bounded-query/staging.ts`) materializes an immutable seed per configured repo *before* the agent starts, using `GH_TOKEN`/`GITHUB_TOKEN` only in a child-process env — never in argv, a URL, a log, or the compose file -- See [docs/awf-config-spec.md](docs/awf-config-spec.md) §14 for the full model, including the two-bit disclosure bound and residual channels +- See [docs/awf-config-spec.md](docs/awf-config-spec.md) §14 for the full model, including per-repository information-budget accounting and residual channels ### Documentation Files diff --git a/src/config-file.ts b/src/config-file.ts index 26798cdd7..0cd850652 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -166,8 +166,12 @@ export interface AwfFileConfig { /** * Bounded-query sandbox configuration. * - * Foundation only — configuration/protocol surface, no broker or sandbox - * runtime is implemented yet. See docs/awf-config-spec.md §14. + * When enabled, AWF starts a network-isolated broker container that + * executes per-invocation query sandboxes on behalf of the agent. The + * broker has no network access and communicates with the agent through a + * Unix socket. See docs/awf-config-spec.md §14 for the full model, + * including the per-repository information-budget accounting and residual + * channels. */ boundedQueries?: { enabled?: boolean;