Compare commits
2 commits
c2886b2f77
...
d477c3b5d9
| Author | SHA1 | Date | |
|---|---|---|---|
| d477c3b5d9 | |||
| d69827aaa2 |
18 changed files with 1192 additions and 31 deletions
|
|
@ -22,6 +22,7 @@
|
|||
| workplan | SAND-WP-0012 | finished | — | workplans/SAND-WP-0012-packer-orchestration.md |
|
||||
| workplan | SAND-WP-0013 | finished | — | workplans/SAND-WP-0013-bwrap-extension.md |
|
||||
| workplan | SAND-WP-0014 | active | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||
| workplan | SAND-WP-0015 | blocked | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||
| task | SAND-WP-0001-T01 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
|
||||
| task | SAND-WP-0001-T02 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
|
||||
| task | SAND-WP-0001-T03 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md |
|
||||
|
|
@ -117,3 +118,7 @@
|
|||
| task | SAND-WP-0014-T03 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||
| task | SAND-WP-0014-T04 | done | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||
| task | SAND-WP-0014-T05 | wait | — | workplans/SAND-WP-0014-owner-mediated-execution.md |
|
||||
| task | SAND-WP-0015-T01 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||
| task | SAND-WP-0015-T02 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||
| task | SAND-WP-0015-T03 | done | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||
| task | SAND-WP-0015-T04 | wait | — | workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md |
|
||||
|
|
|
|||
39
docs/bwrap-egress.md
Normal file
39
docs/bwrap-egress.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Bwrap HTTPS egress
|
||||
|
||||
Owner extension config can set `allowed_egress: [api.anthropic.com:443]`.
|
||||
A profile must declare a subset in `network.egress`, with `default: deny`.
|
||||
Defaults remain empty. Only exact lowercase DNS names ending in `:443`
|
||||
are accepted; no wildcards, IP literals, URLs or other ports.
|
||||
|
||||
Each opted-in sandbox gets a separate host-side Unix CONNECT proxy. Only that
|
||||
socket is mounted into the sandbox; an in-namespace loopback bridge provides
|
||||
HTTPS_PROXY/https_proxy to child processes. The namespace retains only lo,
|
||||
without a host interface or direct DNS/network access. The owner proxy resolves
|
||||
the declared hostname, rejects any non-global result, and connects to a checked
|
||||
numeric address without a second lookup. TLS stays between client and provider;
|
||||
system CA certificates are mounted read-only. No TLS interception or body logging.
|
||||
|
||||
This enforces connection destinations, not HTTP paths, provider account identity,
|
||||
or TLS SNI on shared hosting. A client can send arbitrary bytes to an allowed
|
||||
server. It is not an application firewall or a defense against an allowed
|
||||
provider's own forwarding features. A future stronger policy needs separate
|
||||
application enforcement. DNS resolution uses the trusted host resolver.
|
||||
|
||||
Up to 16 concurrent tunnels per sandbox, 8 KiB CONNECT headers, 10-second
|
||||
socket operations, 30-second idle and 900-second tunnel lifetime. Unsupported
|
||||
methods, hosts, ports and request framing close without upstream dialing.
|
||||
Teardown kills the dedicated proxy process and removes its socket directory.
|
||||
The current host owner must remain trusted; no caller-selected proxy config.
|
||||
|
||||
Validation: `scripts/smoke-bwrap-egress.py` made a credential-free TLS GET to
|
||||
api.anthropic.com from sandbox e290e788 and received HTTP 404. Undeclared
|
||||
example.com and direct 1.1.1.1:443 were denied, only lo existed, and proxy plus
|
||||
workspace teardown passed. This is transport evidence, not model authentication.
|
||||
Unit tests cover invalid destinations, private and mapped-loopback DNS,
|
||||
checked-address dialing, CONNECT framing and owner/profile allowlist separation.
|
||||
|
||||
Claude documents HTTPS_PROXY at https://code.claude.com/docs/en/network-config.
|
||||
The pinned Claude executable must still pass its own proxy/startup compatibility
|
||||
proof. The existing production profiles are unchanged. CCR-2026-0016 custody is
|
||||
complete at version 2, but owner machine authentication and protected credential
|
||||
delivery remain outstanding under SAND-WP-0015-T04 and GLAS-WP-0012-T02.
|
||||
124
docs/bwrap-runtime.md
Normal file
124
docs/bwrap-runtime.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Bwrap runtime and private state
|
||||
|
||||
`ext.bwrap` can mount a standalone Python runtime selected by the owner's
|
||||
extension configuration. A create/exec caller cannot select host paths or
|
||||
replace the sanitized child environment.
|
||||
|
||||
```yaml
|
||||
config:
|
||||
runtime:
|
||||
path: /srv/sandboxer/runtimes/rein-aharness-example
|
||||
sha256: <reviewed-complete-artifact-digest>
|
||||
```
|
||||
|
||||
The path must be a canonical absolute directory with `pyvenv.cfg` and
|
||||
`bin/python3`. Its complete tree digest includes file contents, modes, directory
|
||||
entries, and internal symlinks. Absolute/escaping symlinks and special files
|
||||
are refused. Missing files, added files, changed permissions, or changed
|
||||
content invalidate the pin. The runtime must not overlap the source checkout
|
||||
or writable sandbox workspace.
|
||||
|
||||
The owner mounts the artifact read-only at `/opt/sandboxer/runtime` and prepends
|
||||
its `bin` directory to the child PATH. Console entrypoints must use that fixed
|
||||
mount prefix. Artifacts remain an owner trust boundary: publish them in a
|
||||
protected location and do not mutate them while sandboxes use them. Digest
|
||||
validation occurs at creation; it is not a mechanism for making a mutable host
|
||||
directory immutable to its owner.
|
||||
|
||||
## Building and testing the Python rein bundle
|
||||
|
||||
From this repository, with committed rein/llm-connect source checkouts:
|
||||
|
||||
```bash
|
||||
uv run python scripts/build-rein-runtime.py \
|
||||
--output /tmp/rein-runtime-candidate \
|
||||
--rein-source ../rein-aharness \
|
||||
--llm-source ../llm-connect
|
||||
```
|
||||
|
||||
The builder creates a standalone system-Python venv, installs both packages
|
||||
non-editably with their dependencies, rewrites Python console entrypoint
|
||||
shebangs to the fixed mount path, and records source revisions plus resolved
|
||||
package versions in `build-info.json`. It prints the resulting path/digest.
|
||||
Dependency versions are resolved during this candidate build; the digest pins
|
||||
the produced artifact, not a promise that another build will be bit-identical.
|
||||
No interactive home, credentials, or provider login is copied into the bundle.
|
||||
|
||||
Prove the artifact using the returned values:
|
||||
|
||||
```bash
|
||||
uv run python scripts/smoke-bwrap-runtime.py \
|
||||
--runtime-path /tmp/rein-runtime-candidate \
|
||||
--runtime-sha256 <returned-digest>
|
||||
```
|
||||
|
||||
The smoke uses an isolated owner extension configuration and the real broker.
|
||||
It imports the rein's Claude adapter and llm-connect, runs `rein-aharness
|
||||
--help`, proves the runtime rejects writes, writes private state outside the
|
||||
worktree, verifies source absence and a clean Git tree, checks loopback-only
|
||||
networking, and tears down. This is real CLI startup, not a model task.
|
||||
|
||||
The Python bundle does not package or pin the separate Claude executable.
|
||||
That executable's release, startup behavior, and authentication still require
|
||||
review in the eventual production runtime. No committed profile selects a
|
||||
temporary `/tmp` build as its production runtime.
|
||||
|
||||
## Private writable state
|
||||
|
||||
The broker creates mode-0700 directories in the namespace's temporary root:
|
||||
|
||||
| Environment variable | Location |
|
||||
|---|---|
|
||||
| `HOME` | `/run/sandboxer/state/home` |
|
||||
| `XDG_CONFIG_HOME` | `/run/sandboxer/state/config` |
|
||||
| `XDG_CACHE_HOME` | `/run/sandboxer/state/cache` |
|
||||
| `XDG_STATE_HOME` | `/run/sandboxer/state/data` |
|
||||
| `TMPDIR` | `/run/sandboxer/state/tmp` |
|
||||
|
||||
These directories persist between exec requests in the same sandbox, remain
|
||||
outside the Git worktree, and disappear with namespace teardown. Python user
|
||||
site packages and bytecode writes are disabled. The owner's child environment
|
||||
continues to carry exact consumer identity and value-free route references.
|
||||
|
||||
## Network and credentials remain separate gates
|
||||
|
||||
Bwrap refuses `network.default: allow` and setup secret references. Network
|
||||
egress is opt-in through the owner allowlist described in bwrap-egress.md;
|
||||
empty-egress profiles retain loopback-only networking. There is still no
|
||||
credential-delivery mechanism. Returning a
|
||||
declared egress list as evidence would not make that list enforced or usable.
|
||||
Exec credential route references continue to be labels, not credential values
|
||||
or delivery grants.
|
||||
|
||||
On 2026-09-05, `warden route find anthropic --json` and `warden route find
|
||||
claude-code --json` returned no matching workload routes. The generic OpenBao
|
||||
entry is explicitly a routing template, not an executable credential lane.
|
||||
The OpenRouter AppRole route belongs to the other rein and is not a substitute
|
||||
for Claude authentication.
|
||||
|
||||
`SAND-WP-0015-T04` / `SAND-WP-0014-T05` and `GLAS-WP-0012-T02` retain the
|
||||
production requirements: a concrete owner-approved Claude credential route,
|
||||
delivery and revocation semantics, a pinned Claude runtime, and explicitly
|
||||
enforced provider egress with negative tests. Glas local profiles remain
|
||||
blocked until those requirements and the real-model acceptance pass.
|
||||
|
||||
## 2026-09-05 candidate evidence
|
||||
|
||||
Runtime SHA-256:
|
||||
`4c316737ec2715936a12c4f49621a5e4be3d1f4fe4739130393f28cdda66fbd9`.
|
||||
Candidate path: `/tmp/sandboxer-rein-aharness-runtime-20260905-v2`.
|
||||
Source revisions: rein-aharness `1429db5ad4c83331b6375349ffde1eb13af9575b`,
|
||||
llm-connect `00560945f81ba6ff1f5cacd9fe99c7fe756cc4b1`; Python 3.12.3.
|
||||
|
||||
Sandbox `f333fb66` passed the real CLI/import/read-only/private-state smoke.
|
||||
`sys.prefix` was `/opt/sandboxer/runtime`, HOME was outside the worktree with
|
||||
mode 0700, Git stayed clean, source was absent, only `lo` existed, credential
|
||||
references were empty, and the workspace was removed. No model request or
|
||||
credential acquisition occurred.
|
||||
|
||||
Final validation: `make check` passed lint and 132 tests. Authenticated owner
|
||||
API smoke `223db65b` returned HTTP 200 with exact identity/stdin and complete
|
||||
teardown. Follow-up runtime smoke `d4de9531` repeated the real CLI/startup
|
||||
checks and proved private state survives a second exec in the same namespace.
|
||||
The default unconfigured `profile.bwrap-local` still has no selected rein
|
||||
bundle; candidate startup does not constitute production deployment.
|
||||
67
scripts/build-rein-runtime.py
Normal file
67
scripts/build-rein-runtime.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Build a standalone rein-aharness runtime for the fixed bwrap mount path.
|
||||
|
||||
Only package installation occurs here. No credentials or model calls are used.
|
||||
Run with the sand-boxer Python environment; uv must be available on PATH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest
|
||||
|
||||
|
||||
def checked(command: list[str]) -> str:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"runtime build command failed: {Path(command[0]).name}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def build(output: Path, rein_source: Path, llm_source: Path) -> dict:
|
||||
revisions = {}
|
||||
for name, source in (("rein-aharness", rein_source), ("llm-connect", llm_source)):
|
||||
if checked(["git", "-C", str(source), "status", "--porcelain"]):
|
||||
raise ValueError(f"{name} source must be committed before building")
|
||||
revisions[name] = checked(["git", "-C", str(source), "rev-parse", "HEAD"])
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
checked(["/usr/bin/python3", "-m", "venv", "--copies", "--without-pip", str(output)])
|
||||
checked(["uv", "pip", "install", "--python", str(output / "bin/python3"),
|
||||
str(rein_source), str(llm_source)])
|
||||
# Console entrypoints must reference the in-sandbox mount, not the build host.
|
||||
for path in (output / "bin").iterdir():
|
||||
if not path.is_file() or path.is_symlink():
|
||||
continue
|
||||
with path.open("rb") as stream:
|
||||
first_line = stream.readline(4096)
|
||||
if first_line.startswith(f"#!{output}/bin/python".encode()):
|
||||
body = path.read_bytes().partition(b"\n")[2]
|
||||
path.write_bytes(f"#!{RUNTIME_MOUNT}/bin/python3\n".encode() + body)
|
||||
metadata = json.loads(checked([
|
||||
str(output / "bin/python3"), "-c",
|
||||
"import importlib.metadata,json,platform; "
|
||||
"print(json.dumps({'python':platform.python_version(),'packages':"
|
||||
"{d.metadata['Name']:d.version for d in importlib.metadata.distributions()}}))",
|
||||
]))
|
||||
metadata["source_revisions"] = revisions
|
||||
(output / "build-info.json").write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
return {"runtime": {"path": str(output), "sha256": runtime_digest(output)},
|
||||
"build": metadata}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--rein-source", type=Path, required=True)
|
||||
parser.add_argument("--llm-source", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = build(args.output.resolve(), args.rein_source.resolve(), args.llm_source.resolve())
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
70
scripts/smoke-bwrap-egress-manager.py
Normal file
70
scripts/smoke-bwrap-egress-manager.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
from sandboxer.lifecycle.store import SandboxStore
|
||||
from sandboxer.models import Extension, Profile, SandboxCreateRequest, SandboxExecRequest
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"proof", Path(__file__).with_name("smoke-bwrap-egress.py")
|
||||
)
|
||||
proof = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(proof)
|
||||
os.environ["SANDBOXER_NO_STATE_HUB"] = "1"
|
||||
with tempfile.TemporaryDirectory(prefix="glas-managed-egress-") as directory:
|
||||
root = Path(directory)
|
||||
profile = Profile(
|
||||
id="profile.egress-proof",
|
||||
version="1",
|
||||
extension="ext.bwrap",
|
||||
network={"default": "deny", "egress": ["api.anthropic.com:443"]},
|
||||
)
|
||||
ext = Extension(
|
||||
id="ext.bwrap",
|
||||
title="proof",
|
||||
handler="sandboxer.extensions.bwrap:BwrapExtension",
|
||||
config={"base_dir": str(root / "sandboxes"), "allowed_egress": ["api.anthropic.com:443"]},
|
||||
)
|
||||
consumer = {"actor": "agt", "project": "glas-harness", "run_id": "egress-proof"}
|
||||
with (
|
||||
patch("sandboxer.core.manager.load_profile", return_value=profile),
|
||||
patch("sandboxer.core.manager.resolve_extension", return_value=ext),
|
||||
patch("sandboxer.core.manager.load_extension", return_value=ext),
|
||||
):
|
||||
store = SandboxStore(path=root / "sandboxes.json")
|
||||
mgr = SandboxManager(store=store)
|
||||
created = mgr.create(
|
||||
SandboxCreateRequest(profile=profile.id, consumer=consumer), host="localhost"
|
||||
)
|
||||
try:
|
||||
# Different manager and store instances reconstruct the persisted owner handle.
|
||||
resumed = SandboxManager(store=SandboxStore(path=root / "sandboxes.json"))
|
||||
result = resumed.execute(
|
||||
created.sandbox_id,
|
||||
SandboxExecRequest(
|
||||
command=["python3", "-c", proof.PROBE], consumer=consumer, timeout_seconds=40
|
||||
),
|
||||
)
|
||||
assert result.exit_code == 0, result.stderr
|
||||
finally:
|
||||
SandboxManager(store=SandboxStore(path=root / "sandboxes.json")).destroy(
|
||||
created.sandbox_id
|
||||
)
|
||||
assert not Path(created.inputs["egress_dir"]).exists()
|
||||
assert not BwrapExtension._pid_alive(int(created.inputs["egress_pid"]))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"sandbox_id": created.sandbox_id,
|
||||
"manager_restart": True,
|
||||
"proof": json.loads(result.stdout),
|
||||
"proxy_removed": True,
|
||||
"model_call": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
89
scripts/smoke-bwrap-egress.py
Normal file
89
scripts/smoke-bwrap-egress.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Non-secret HTTPS reachability and denied egress proof."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
from sandboxer.models import Profile
|
||||
|
||||
PROBE = r"""
|
||||
import json, os, socket, ssl, urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
proxy = os.environ['HTTPS_PROXY']
|
||||
# GET without any credential: TLS and a provider HTTP response suffice.
|
||||
client = urllib.request.build_opener(urllib.request.ProxyHandler({'https': proxy}))
|
||||
try:
|
||||
response = client.open('https://api.anthropic.com/', timeout=20)
|
||||
status = response.status
|
||||
response.close()
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
assert 100 <= status <= 599
|
||||
try:
|
||||
client.open('https://example.com/', timeout=5)
|
||||
except (urllib.error.URLError, OSError):
|
||||
denied = True
|
||||
else:
|
||||
raise AssertionError('undeclared destination reachable')
|
||||
try:
|
||||
socket.create_connection(('1.1.1.1',443),timeout=2)
|
||||
except OSError:
|
||||
direct_denied = True
|
||||
else:
|
||||
raise AssertionError('direct network reachable')
|
||||
interfaces = Path('/proc/net/dev').read_text().splitlines()[2:]
|
||||
assert [line.split(':')[0].strip() for line in interfaces] == ['lo']
|
||||
print(json.dumps({'provider_http_status':status, 'tls_verified':True,
|
||||
'undeclared_host_denied':denied, 'direct_network_denied':direct_denied}))
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory(prefix="sandboxer-egress-proof-") as directory:
|
||||
extension = BwrapExtension(
|
||||
{
|
||||
"base_dir": directory,
|
||||
"allowed_egress": ["api.anthropic.com:443"],
|
||||
"ro_binds": ["/usr", "/bin", "/lib", "/lib64", "/etc/ssl/certs"],
|
||||
}
|
||||
)
|
||||
profile = Profile(
|
||||
id="profile.egress-proof",
|
||||
version="1",
|
||||
extension="ext.bwrap",
|
||||
network={"default": "deny", "egress": ["api.anthropic.com:443"]},
|
||||
)
|
||||
handle = extension.provision(profile, {}, "localhost")
|
||||
try:
|
||||
extension.wait_ready(handle)
|
||||
result = extension.execute(
|
||||
handle,
|
||||
["python3", "-c", PROBE],
|
||||
credential_route_refs=[],
|
||||
execution_context={},
|
||||
timeout_seconds=40,
|
||||
max_output_bytes=4096,
|
||||
)
|
||||
finally:
|
||||
teardown = extension.teardown(handle)
|
||||
assert result["exit_code"] == 0, result
|
||||
assert teardown["workspace_removed"] == "True"
|
||||
assert not Path(handle["egress_dir"]).exists()
|
||||
assert not extension._pid_alive(int(handle["egress_pid"]))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"sandbox_id": handle["sandbox_id"],
|
||||
"proof": json.loads(result["stdout"]),
|
||||
"proxy_removed": True,
|
||||
"workspace_removed": True,
|
||||
"model_call": False,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
105
scripts/smoke-bwrap-runtime.py
Normal file
105
scripts/smoke-bwrap-runtime.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Prove a real rein CLI starts in a pinned, read-only owner runtime.
|
||||
|
||||
No credential acquisition or model request is performed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
from sandboxer.models import Profile
|
||||
|
||||
PROBE = r'''
|
||||
import json, os, subprocess, sys
|
||||
from pathlib import Path
|
||||
import rein_aharness.adapter
|
||||
import llm_connect
|
||||
source = Path(sys.argv[1])
|
||||
runtime = Path('/opt/sandboxer/runtime')
|
||||
home = Path(os.environ['HOME'])
|
||||
private = home / 'private-state-proof'
|
||||
private.write_text('non-secret private state\n')
|
||||
assert not home.is_relative_to(Path.cwd())
|
||||
assert (home.stat().st_mode & 0o777) == 0o700
|
||||
assert private.is_file()
|
||||
try:
|
||||
(runtime / 'write-probe').write_text('must be refused')
|
||||
except OSError:
|
||||
readonly = True
|
||||
else:
|
||||
readonly = False
|
||||
assert readonly
|
||||
assert not source.exists()
|
||||
help_result = subprocess.run(['rein-aharness', '--help'], capture_output=True, timeout=15)
|
||||
assert help_result.returncode == 0
|
||||
assert b'usage:' in help_result.stdout
|
||||
assert subprocess.check_output(['git', 'status', '--porcelain', '--ignored=matching']) == b''
|
||||
interfaces = [line.split(':', 1)[0].strip()
|
||||
for line in Path('/proc/net/dev').read_text().splitlines()[2:]]
|
||||
assert interfaces == ['lo']
|
||||
print(json.dumps({'rein_cli_started': True, 'adapter_imported': True,
|
||||
'runtime_readonly': readonly, 'source_absent': True,
|
||||
'home_outside_workspace': True, 'home_mode': '0700',
|
||||
'worktree_clean': True, 'interfaces': interfaces,
|
||||
'python_prefix': sys.prefix,
|
||||
'credential_refs': json.loads(os.environ['SANDBOXER_CREDENTIAL_ROUTE_REFS'])}))
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--runtime-path", required=True)
|
||||
parser.add_argument("--runtime-sha256", required=True)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="sandboxer-runtime-proof-") as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
||||
extension = BwrapExtension({
|
||||
"base_dir": str(root / "sandboxes"),
|
||||
"runtime": {"path": args.runtime_path, "sha256": args.runtime_sha256},
|
||||
})
|
||||
profile = Profile(id="profile.runtime-proof", version="1", extension="ext.bwrap")
|
||||
handle = extension.provision(profile, {"repo": str(source)}, "localhost")
|
||||
try:
|
||||
extension.wait_ready(handle)
|
||||
result = extension.execute(
|
||||
handle, ["python3", "-c", PROBE, str(source)],
|
||||
credential_route_refs=[],
|
||||
execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof",
|
||||
"run_id": "sand-wp-0015-proof"},
|
||||
timeout_seconds=30, max_output_bytes=65536,
|
||||
)
|
||||
persistence = extension.execute(
|
||||
handle, ["python3", "-c",
|
||||
"import os; from pathlib import Path; "
|
||||
"assert (Path(os.environ['HOME']) / 'private-state-proof').is_file()"],
|
||||
credential_route_refs=[],
|
||||
execution_context={"actor": "agt", "project": "sand-boxer-runtime-proof",
|
||||
"run_id": "sand-wp-0015-proof"},
|
||||
timeout_seconds=15, max_output_bytes=1024,
|
||||
)
|
||||
finally:
|
||||
teardown = extension.teardown(handle)
|
||||
passed = result["exit_code"] == 0 and not result["timed_out"]
|
||||
facts = json.loads(result["stdout"]) if passed else {}
|
||||
private_state_persisted = persistence["exit_code"] == 0
|
||||
passed = passed and private_state_persisted and teardown["workspace_removed"] == "True"
|
||||
print(json.dumps({
|
||||
"ok": passed, "sandbox_id": handle["sandbox_id"],
|
||||
"runtime_sha256": args.runtime_sha256, "proof": facts,
|
||||
"workspace_removed": teardown["workspace_removed"] == "True",
|
||||
"exit_code": result["exit_code"], "model_run_proven": False,
|
||||
"private_state_persisted": private_state_persisted,
|
||||
}, indent=2))
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -75,6 +75,8 @@ class SandboxManager:
|
|||
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
|
||||
"provider": status.inputs.get("provider", ""),
|
||||
"pid": status.inputs.get("pid", ""),
|
||||
"egress_pid": status.inputs.get("egress_pid", ""),
|
||||
"egress_dir": status.inputs.get("egress_dir", ""),
|
||||
"workspace_dir": status.inputs.get("workspace_dir", ""),
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +168,8 @@ class SandboxManager:
|
|||
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
|
||||
status.inputs["provider"] = handle.get("provider", "")
|
||||
status.inputs["pid"] = handle.get("pid", "")
|
||||
status.inputs["egress_pid"] = handle.get("egress_pid", "")
|
||||
status.inputs["egress_dir"] = handle.get("egress_dir", "")
|
||||
status.inputs["workspace_dir"] = handle.get("workspace_dir", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ import shutil
|
|||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.extensions.egress import destinations
|
||||
from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
|
|
@ -35,11 +38,9 @@ class BwrapExtension(SandboxExtension):
|
|||
cfg = self.config
|
||||
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap")
|
||||
self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap")
|
||||
self.control_socket_name: str = cfg.get(
|
||||
"control_socket_name", ".sandboxer-owner.sock"
|
||||
)
|
||||
self.control_socket_name: str = cfg.get("control_socket_name", ".sandboxer-owner.sock")
|
||||
self.ro_binds: list[str] = cfg.get(
|
||||
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"]
|
||||
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs"]
|
||||
)
|
||||
|
||||
def _bwrap_bin(self) -> str:
|
||||
|
|
@ -48,7 +49,9 @@ class BwrapExtension(SandboxExtension):
|
|||
def _existing_ro_binds(self) -> list[str]:
|
||||
return [path for path in self.ro_binds if Path(path).exists()]
|
||||
|
||||
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
|
||||
def _bwrap_argv(
|
||||
self, workspace_dir: str, *, info_fd: int | None = None, egress_socket: str | None = None
|
||||
) -> list[str]:
|
||||
argv = [
|
||||
self._bwrap_bin(),
|
||||
"--unshare-user",
|
||||
|
|
@ -72,6 +75,17 @@ class BwrapExtension(SandboxExtension):
|
|||
runner = Path(__file__).with_name("bwrap_runner.py")
|
||||
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
|
||||
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
|
||||
if egress_socket:
|
||||
egress_module = Path(__file__).with_name("egress.py")
|
||||
argv += ["--ro-bind", str(egress_module), "/run/sandboxer/egress.py"]
|
||||
argv += ["--ro-bind", egress_socket, "/run/sandboxer/egress.sock"]
|
||||
runtime = verified_runtime(self.config)
|
||||
if runtime is not None:
|
||||
workspace = Path(workspace_dir).resolve()
|
||||
if runtime.is_relative_to(workspace) or workspace.is_relative_to(runtime):
|
||||
raise ValueError("runtime and workspace must not overlap")
|
||||
argv += ["--dir", "/opt", "--dir", "/opt/sandboxer"]
|
||||
argv += ["--ro-bind", str(runtime), RUNTIME_MOUNT]
|
||||
argv += ["--bind", workspace_dir, workspace_dir]
|
||||
argv += ["--chdir", workspace_dir]
|
||||
argv += [
|
||||
|
|
@ -80,6 +94,10 @@ class BwrapExtension(SandboxExtension):
|
|||
workspace_dir,
|
||||
f"{workspace_dir}/{self.control_socket_name}",
|
||||
]
|
||||
if runtime is not None:
|
||||
argv.append("--runtime")
|
||||
if egress_socket:
|
||||
argv.append("--egress")
|
||||
return argv
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -99,9 +117,20 @@ class BwrapExtension(SandboxExtension):
|
|||
raise RuntimeError("bwrap reported an invalid namespace child pid")
|
||||
return child_pid
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
def provision(self, profile: Profile, inputs: dict[str, str], host: str) -> dict[str, str]:
|
||||
if profile.network.default != "deny":
|
||||
raise ValueError("bwrap requires default-deny networking")
|
||||
requested = destinations(profile.network.egress)
|
||||
allowed = destinations(self.config.get("allowed_egress", []))
|
||||
if not requested.issubset(allowed):
|
||||
raise ValueError("profile egress exceeds owner allowlist")
|
||||
if profile.setup.secret_refs:
|
||||
raise ValueError("bwrap has no setup credential delivery contract")
|
||||
runtime = verified_runtime(self.config)
|
||||
if runtime is not None and inputs.get("repo"):
|
||||
source = Path(inputs["repo"]).resolve()
|
||||
if source.is_relative_to(runtime) or runtime.is_relative_to(source):
|
||||
raise ValueError("runtime and source checkout must not overlap")
|
||||
sandbox_id = self.new_sandbox_id(inputs)
|
||||
workspace_dir = f"{self.base_dir}/{sandbox_id}"
|
||||
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -114,22 +143,60 @@ class BwrapExtension(SandboxExtension):
|
|||
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
|
||||
Path(workspace_dir).chmod(0o700)
|
||||
|
||||
info_read_fd, info_write_fd = os.pipe()
|
||||
try:
|
||||
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
proxy = None
|
||||
proxy_dir = None
|
||||
egress_socket = None
|
||||
if requested:
|
||||
import tempfile
|
||||
|
||||
proxy_dir = tempfile.mkdtemp(prefix="sandboxer-egress-")
|
||||
egress_socket = str(Path(proxy_dir) / "proxy.sock")
|
||||
proxy = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("egress.py")),
|
||||
egress_socket,
|
||||
*sorted(requested),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
pass_fds=(info_write_fd,),
|
||||
)
|
||||
finally:
|
||||
os.close(info_write_fd)
|
||||
deadline = time.monotonic() + 5
|
||||
while not Path(egress_socket).is_socket():
|
||||
if proxy.poll() is not None or time.monotonic() >= deadline:
|
||||
proxy.kill()
|
||||
proxy.wait()
|
||||
shutil.rmtree(proxy_dir)
|
||||
raise RuntimeError("egress proxy failed to start")
|
||||
time.sleep(0.02)
|
||||
|
||||
try:
|
||||
child_pid = self._read_child_pid(proc, info_read_fd)
|
||||
finally:
|
||||
os.close(info_read_fd)
|
||||
info_read_fd, info_write_fd = os.pipe()
|
||||
try:
|
||||
argv = self._bwrap_argv(
|
||||
workspace_dir, info_fd=info_write_fd, egress_socket=egress_socket
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
pass_fds=(info_write_fd,),
|
||||
)
|
||||
finally:
|
||||
os.close(info_write_fd)
|
||||
try:
|
||||
child_pid = self._read_child_pid(proc, info_read_fd)
|
||||
finally:
|
||||
os.close(info_read_fd)
|
||||
except BaseException:
|
||||
if proxy:
|
||||
proxy.kill()
|
||||
proxy.wait()
|
||||
if proxy_dir:
|
||||
shutil.rmtree(proxy_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
|
|
@ -137,9 +204,18 @@ class BwrapExtension(SandboxExtension):
|
|||
"pid": str(child_pid),
|
||||
"supervisor_pid": str(proc.pid),
|
||||
"workspace_dir": workspace_dir,
|
||||
**({"egress_pid": str(proxy.pid), "egress_dir": proxy_dir} if proxy else {}),
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
try:
|
||||
return self._wait_ready(handle)
|
||||
except Exception:
|
||||
if handle.get("egress_pid"):
|
||||
self.teardown(handle)
|
||||
raise
|
||||
|
||||
def _wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
pid = int(handle["pid"])
|
||||
if not self._pid_alive(pid):
|
||||
raise RuntimeError(f"bwrap process {pid} is not running")
|
||||
|
|
@ -233,6 +309,13 @@ class BwrapExtension(SandboxExtension):
|
|||
os.kill(pid, signal.SIGKILL)
|
||||
killed = True
|
||||
|
||||
if handle.get("egress_pid"):
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(int(handle["egress_pid"]), signal.SIGKILL)
|
||||
with suppress(ChildProcessError):
|
||||
os.waitpid(int(handle["egress_pid"]), 0)
|
||||
if handle.get("egress_dir"):
|
||||
shutil.rmtree(handle["egress_dir"], ignore_errors=True)
|
||||
workspace_dir = handle.get("workspace_dir", "")
|
||||
removed = False
|
||||
if workspace_dir and Path(workspace_dir).exists():
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
|
|||
return raw.decode("utf-8", errors="replace"), truncated
|
||||
|
||||
|
||||
def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
||||
def _run(
|
||||
payload: dict, workspace: Path, *, runtime_enabled: bool = False, proxy_port: int | None = None
|
||||
) -> dict[str, object]:
|
||||
command = payload["command"]
|
||||
timeout_seconds = int(payload["timeout_seconds"])
|
||||
max_output_bytes = int(payload["max_output_bytes"])
|
||||
|
|
@ -31,12 +33,23 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
|||
context = payload.get("execution_context", {})
|
||||
stdin_text = payload.get("stdin_text")
|
||||
child_env = {
|
||||
"HOME": str(workspace),
|
||||
"HOME": "/run/sandboxer/state/home",
|
||||
"XDG_CONFIG_HOME": "/run/sandboxer/state/config",
|
||||
"XDG_CACHE_HOME": "/run/sandboxer/state/cache",
|
||||
"XDG_STATE_HOME": "/run/sandboxer/state/data",
|
||||
"TMPDIR": "/run/sandboxer/state/tmp",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONNOUSERSITE": "1",
|
||||
"LANG": "C.UTF-8",
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"SANDBOXER_CREDENTIAL_ROUTE_REFS": json.dumps(credential_refs),
|
||||
**{f"SANDBOXER_{key.upper()}": value for key, value in context.items()},
|
||||
}
|
||||
if runtime_enabled:
|
||||
child_env["PATH"] = "/opt/sandboxer/runtime/bin:" + child_env["PATH"]
|
||||
if proxy_port is not None:
|
||||
child_env["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
|
||||
child_env["https_proxy"] = child_env["HTTPS_PROXY"]
|
||||
timed_out = False
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
|
|
@ -49,9 +62,7 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
|||
)
|
||||
try:
|
||||
stdin_bytes = stdin_text.encode("utf-8") if stdin_text is not None else None
|
||||
stdout_raw, stderr_raw = process.communicate(
|
||||
input=stdin_bytes, timeout=timeout_seconds
|
||||
)
|
||||
stdout_raw, stderr_raw = process.communicate(input=stdin_bytes, timeout=timeout_seconds)
|
||||
exit_code = process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
|
|
@ -73,6 +84,18 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
|
|||
def main() -> int:
|
||||
workspace = Path(sys.argv[1]).resolve(strict=True)
|
||||
socket_path = Path(sys.argv[2])
|
||||
runtime_enabled = "--runtime" in sys.argv[3:]
|
||||
if any(flag not in {"--runtime", "--egress"} for flag in sys.argv[3:]):
|
||||
raise ValueError("invalid owner runtime mode")
|
||||
proxy_port = None
|
||||
if "--egress" in sys.argv[3:]:
|
||||
from egress import bridge
|
||||
|
||||
proxy_port = bridge("/run/sandboxer/egress.sock")
|
||||
state = Path("/run/sandboxer/state")
|
||||
state.mkdir(mode=0o700)
|
||||
for name in ("home", "config", "cache", "data", "tmp"):
|
||||
(state / name).mkdir(mode=0o700)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||
server.bind(str(socket_path))
|
||||
|
|
@ -95,7 +118,12 @@ def main() -> int:
|
|||
try:
|
||||
if not chunks:
|
||||
raise ValueError("empty or oversized execution request")
|
||||
response = _run(json.loads(b"".join(chunks)), workspace)
|
||||
response = _run(
|
||||
json.loads(b"".join(chunks)),
|
||||
workspace,
|
||||
runtime_enabled=runtime_enabled,
|
||||
proxy_port=proxy_port,
|
||||
)
|
||||
except Exception as exc:
|
||||
response = {"boundary_error": str(exc)}
|
||||
connection.sendall(json.dumps(response).encode("utf-8"))
|
||||
|
|
|
|||
130
src/sandboxer/extensions/egress.py
Normal file
130
src/sandboxer/extensions/egress.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Bounded HTTPS CONNECT egress; host-side destination enforcement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def destinations(entries: list[str]) -> frozenset[str]:
|
||||
"""Only exact lowercase DNS names with an explicit TLS port."""
|
||||
import re
|
||||
|
||||
if any(
|
||||
not re.fullmatch(r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}:443", x)
|
||||
for x in entries
|
||||
):
|
||||
raise ValueError("egress requires exact lowercase DNS names with :443")
|
||||
return frozenset(entries)
|
||||
|
||||
|
||||
def connect_public(host: str) -> socket.socket:
|
||||
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
||||
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
||||
raise ValueError("non-public destination")
|
||||
# Connect to the already checked numeric address; never resolve a second time.
|
||||
for family, kind, proto, _, address in addresses:
|
||||
connection = socket.socket(family, kind, proto)
|
||||
connection.settimeout(10)
|
||||
try:
|
||||
connection.connect(address)
|
||||
return connection
|
||||
except OSError:
|
||||
connection.close()
|
||||
raise OSError("destination unavailable")
|
||||
|
||||
|
||||
def relay(left: socket.socket, right: socket.socket) -> None:
|
||||
deadline = time.monotonic() + 900
|
||||
for connection in (left, right):
|
||||
connection.settimeout(10)
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([left, right], [], [], 30)
|
||||
if not ready:
|
||||
return
|
||||
for source in ready:
|
||||
chunk = source.recv(65536)
|
||||
if not chunk:
|
||||
return
|
||||
(right if source is left else left).sendall(chunk)
|
||||
|
||||
|
||||
def tunnel(client: socket.socket, allowed: frozenset[str]) -> None:
|
||||
client.settimeout(10)
|
||||
header = bytearray()
|
||||
# Do not consume any TLS bytes following CONNECT headers.
|
||||
while not header.endswith(b"\r\n\r\n"):
|
||||
byte = client.recv(1)
|
||||
if not byte or len(header) >= 8192:
|
||||
raise ValueError("invalid CONNECT header")
|
||||
header.extend(byte)
|
||||
lines = header.decode("ascii").split("\r\n")
|
||||
request = lines[0].split(" ")
|
||||
if len(request) != 3 or request[0] != "CONNECT" or request[2] != "HTTP/1.1":
|
||||
raise ValueError("CONNECT required")
|
||||
target = request[1]
|
||||
if target not in allowed:
|
||||
raise ValueError("destination denied")
|
||||
# No request body or transfer framing on CONNECT.
|
||||
for line in lines[1:-2]:
|
||||
name, separator, value = line.partition(":")
|
||||
if not separator or name.lower() not in {"host", "proxy-connection", "user-agent"}:
|
||||
raise ValueError("unsupported CONNECT header")
|
||||
if name.lower() == "host" and value.strip() != target:
|
||||
raise ValueError("inconsistent host")
|
||||
with connect_public(target[:-4]) as upstream:
|
||||
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
relay(client, upstream)
|
||||
|
||||
|
||||
def serve(listener: socket.socket, handler) -> None:
|
||||
slots = threading.BoundedSemaphore(16)
|
||||
|
||||
def worker(client):
|
||||
try:
|
||||
with client, suppress(OSError, ValueError, UnicodeError):
|
||||
handler(client)
|
||||
finally:
|
||||
slots.release()
|
||||
|
||||
listener.listen(16)
|
||||
while True:
|
||||
client, _ = listener.accept()
|
||||
if not slots.acquire(blocking=False):
|
||||
client.close()
|
||||
continue
|
||||
threading.Thread(target=worker, args=(client,), daemon=True).start()
|
||||
|
||||
|
||||
def bridge(unix_path: str) -> int:
|
||||
listener = socket.socket()
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
|
||||
def forward(client):
|
||||
with socket.socket(socket.AF_UNIX) as upstream:
|
||||
upstream.connect(unix_path)
|
||||
relay(client, upstream)
|
||||
|
||||
threading.Thread(target=serve, args=(listener, forward), daemon=True).start()
|
||||
return port
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
allowed = destinations(sys.argv[2:])
|
||||
with socket.socket(socket.AF_UNIX) as listener:
|
||||
listener.bind(str(path))
|
||||
path.chmod(0o600)
|
||||
serve(listener, lambda client: tunnel(client, allowed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
52
src/sandboxer/extensions/runtime.py
Normal file
52
src/sandboxer/extensions/runtime.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Digest-pinned, owner-configured Python runtimes for bwrap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
RUNTIME_MOUNT = "/opt/sandboxer/runtime"
|
||||
|
||||
|
||||
def runtime_digest(root: Path) -> str:
|
||||
"""Hash the complete artifact, including modes and internal symlink targets."""
|
||||
if not root.is_absolute() or root.resolve(strict=True) != root or not root.is_dir():
|
||||
raise ValueError("runtime path must be a canonical absolute directory")
|
||||
records = []
|
||||
for path in sorted(root.rglob("*")):
|
||||
mode = path.lstat().st_mode
|
||||
if stat.S_ISLNK(mode):
|
||||
target = os.readlink(path)
|
||||
if Path(target).is_absolute() or not path.resolve(strict=True).is_relative_to(root):
|
||||
raise ValueError("runtime symlink escapes the artifact")
|
||||
value = ["symlink", target]
|
||||
elif stat.S_ISREG(mode):
|
||||
with path.open("rb") as stream:
|
||||
value = ["file", hashlib.file_digest(stream, "sha256").hexdigest()]
|
||||
elif stat.S_ISDIR(mode):
|
||||
value = ["directory"]
|
||||
else:
|
||||
raise ValueError("runtime contains an unsupported special file")
|
||||
records.append([path.relative_to(root).as_posix(), stat.S_IMODE(mode), *value])
|
||||
return hashlib.sha256(json.dumps(records, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def verified_runtime(config: dict) -> Path | None:
|
||||
"""Only owner extension configuration can select a runtime artifact."""
|
||||
runtime = config.get("runtime")
|
||||
if runtime is None:
|
||||
return None
|
||||
if not isinstance(runtime, dict) or set(runtime) != {"path", "sha256"}:
|
||||
raise ValueError("runtime requires exactly path and sha256")
|
||||
if not isinstance(runtime["path"], str) or not isinstance(runtime["sha256"], str):
|
||||
raise ValueError("runtime path and sha256 must be strings")
|
||||
root = Path(runtime["path"])
|
||||
if (not (root / "pyvenv.cfg").is_file() or not (root / "bin/python3").is_file()
|
||||
or (root / ".git").exists()):
|
||||
raise ValueError("runtime must be a standalone Python environment, not a checkout")
|
||||
if runtime_digest(root) != runtime["sha256"]:
|
||||
raise ValueError("runtime artifact digest does not match owner configuration")
|
||||
return root
|
||||
|
|
@ -188,8 +188,8 @@ class Reachability(BaseModel):
|
|||
tunnel_via: str | None = None
|
||||
identity: str | None = None
|
||||
# Local (no-SSH-hop) descriptor — populated for same-host extensions
|
||||
# like ext.bwrap. A consumer execs into the sandbox directly
|
||||
# (e.g. `nsenter --target <pid> ...`) rather than over SSH.
|
||||
# like ext.bwrap. Commands enter through owner-mediated execute;
|
||||
# the pid is lifecycle metadata, not consumer setns authority.
|
||||
pid: str | None = None
|
||||
workspace_dir: str | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -298,12 +298,20 @@ def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
|
|||
]
|
||||
assert set(child_env) == {
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"TMPDIR",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
"PYTHONNOUSERSITE",
|
||||
"LANG",
|
||||
"PATH",
|
||||
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
|
||||
"SANDBOXER_ACTOR",
|
||||
"SANDBOXER_RUN_ID",
|
||||
}
|
||||
assert child_env["HOME"] == "/run/sandboxer/state/home"
|
||||
assert child_env["HOME"] != str(tmp_path)
|
||||
assert result["stdout"] == "ok\n"
|
||||
|
||||
|
||||
|
|
|
|||
116
tests/test_bwrap_runtime.py
Normal file
116
tests/test_bwrap_runtime.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import socket
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
from sandboxer.extensions.bwrap_runner import _run
|
||||
from sandboxer.extensions.runtime import RUNTIME_MOUNT, runtime_digest, verified_runtime
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifact(tmp_path):
|
||||
root = tmp_path / "runtime"
|
||||
(root / "bin").mkdir(parents=True)
|
||||
(root / "pyvenv.cfg").write_text("home = /usr/bin\n")
|
||||
(root / "bin/python3").write_bytes(b"test-only-python")
|
||||
(root / "bin/python3").chmod(0o755)
|
||||
return root
|
||||
|
||||
|
||||
def test_runtime_mount_requires_matching_complete_digest(artifact, tmp_path):
|
||||
config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}}
|
||||
assert verified_runtime(config) == artifact
|
||||
argv = BwrapExtension(config)._bwrap_argv(str(tmp_path / "workspace"))
|
||||
position = argv.index(str(artifact))
|
||||
assert argv[position - 1:position + 2] == ["--ro-bind", str(artifact), RUNTIME_MOUNT]
|
||||
assert argv[-1] == "--runtime"
|
||||
(artifact / "bin/python3").write_bytes(b"modified")
|
||||
with pytest.raises(ValueError, match="digest"):
|
||||
BwrapExtension(config)._bwrap_argv(str(tmp_path / "workspace"))
|
||||
|
||||
|
||||
def test_mode_change_invalidates_runtime_pin(artifact):
|
||||
config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}}
|
||||
(artifact / "bin/python3").chmod(0o777)
|
||||
with pytest.raises(ValueError, match="digest"):
|
||||
verified_runtime(config)
|
||||
|
||||
|
||||
def test_unexpected_file_invalidates_runtime_pin(artifact):
|
||||
config = {"runtime": {"path": str(artifact), "sha256": runtime_digest(artifact)}}
|
||||
(artifact / "injected.py").write_text("unexpected")
|
||||
with pytest.raises(ValueError, match="digest"):
|
||||
verified_runtime(config)
|
||||
|
||||
|
||||
def test_runtime_symlinks_must_stay_inside_artifact(artifact, tmp_path):
|
||||
(artifact / "python").symlink_to("bin/python3")
|
||||
runtime_digest(artifact)
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_text("host data")
|
||||
(artifact / "escape").symlink_to(outside)
|
||||
with pytest.raises(ValueError, match="symlink escapes"):
|
||||
runtime_digest(artifact)
|
||||
|
||||
|
||||
def test_runtime_rejects_socket(artifact):
|
||||
with socket.socket(socket.AF_UNIX) as control:
|
||||
control.bind(str(artifact / "host.sock"))
|
||||
with pytest.raises(ValueError, match="special file"):
|
||||
runtime_digest(artifact)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runtime", [{}, {"path": "/tmp"}, {"path": 3, "sha256": "x"}])
|
||||
def test_incomplete_runtime_config_refuses(runtime):
|
||||
with pytest.raises(ValueError):
|
||||
verified_runtime({"runtime": runtime})
|
||||
|
||||
|
||||
def test_source_and_runtime_overlap_refuses_before_copy(artifact):
|
||||
ext = BwrapExtension({"runtime": {"path": str(artifact),
|
||||
"sha256": runtime_digest(artifact)}})
|
||||
profile = Profile(id="profile.test", version="1", extension="ext.bwrap")
|
||||
with (
|
||||
patch("sandboxer.extensions.bwrap.shutil.copytree") as copy,
|
||||
pytest.raises(ValueError, match="source checkout"),
|
||||
):
|
||||
ext.provision(profile, {"repo": str(artifact.parent)}, "localhost")
|
||||
copy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("network", [
|
||||
{"default": "allow", "egress": []},
|
||||
{"default": "deny", "egress": ["api.example.invalid:443"]},
|
||||
])
|
||||
def test_unsupported_network_refuses_before_provisioning(tmp_path, network):
|
||||
profile = Profile(id="profile.test", version="1", extension="ext.bwrap", network=network)
|
||||
base = tmp_path / "sandboxes"
|
||||
with pytest.raises(ValueError, match="default-deny|owner allowlist"):
|
||||
BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost")
|
||||
assert not base.exists()
|
||||
|
||||
|
||||
def test_runtime_path_is_not_taken_from_command_payload(tmp_path):
|
||||
process = MagicMock(returncode=0)
|
||||
process.communicate.return_value = (b"", b"")
|
||||
payload = {"command": ["rein-aharness", "--help"], "timeout_seconds": 10,
|
||||
"max_output_bytes": 100, "runtime_path": "/host/source", "env": {"HOME": "/host"}}
|
||||
with patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process) as run:
|
||||
_run(payload, tmp_path, runtime_enabled=True)
|
||||
env = run.call_args.kwargs["env"]
|
||||
assert env["PATH"].split(":")[0] == RUNTIME_MOUNT + "/bin"
|
||||
assert not Path(env["HOME"]).is_relative_to(tmp_path)
|
||||
assert "/host" not in str(env)
|
||||
assert env["PYTHONNOUSERSITE"] == "1"
|
||||
|
||||
|
||||
def test_setup_credentials_refuse_before_provisioning(tmp_path):
|
||||
profile = Profile(id="profile.test", version="1", extension="ext.bwrap",
|
||||
setup={"secret_refs": ["test-route"]})
|
||||
base = tmp_path / "sandboxes"
|
||||
with pytest.raises(ValueError, match="credential delivery contract"):
|
||||
BwrapExtension({"base_dir": str(base)}).provision(profile, {}, "localhost")
|
||||
assert not base.exists()
|
||||
115
tests/test_egress.py
Normal file
115
tests/test_egress.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.extensions.egress import connect_public, destinations, tunnel
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry",
|
||||
[
|
||||
"*",
|
||||
"api.anthropic.com",
|
||||
"api.anthropic.com:80",
|
||||
"127.0.0.1:443",
|
||||
"API.anthropic.com:443",
|
||||
"api.anthropic.com.evil:443/path",
|
||||
"x@:443",
|
||||
],
|
||||
)
|
||||
def test_invalid_destination(entry):
|
||||
with pytest.raises(ValueError):
|
||||
destinations([entry])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "::ffff:127.0.0.1"]
|
||||
)
|
||||
def test_nonpublic_dns_refused(address):
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(socket.AF_INET, 1, 6, "", (address, 443))]),
|
||||
patch("socket.socket") as factory,
|
||||
pytest.raises(ValueError),
|
||||
):
|
||||
connect_public("api.anthropic.com")
|
||||
factory.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wire_request",
|
||||
[
|
||||
b"CONNECT evil.example:443 HTTP/1.1\r\n\r\n",
|
||||
b"CONNECT api.anthropic.com:80 HTTP/1.1\r\n\r\n",
|
||||
b"GET https://api.anthropic.com/ HTTP/1.1\r\n\r\n",
|
||||
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: evil.example:443\r\n\r\n",
|
||||
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nContent-Length: 1\r\n\r\n",
|
||||
],
|
||||
)
|
||||
def test_denied_connect_never_dials(wire_request):
|
||||
left, right = socket.socketpair()
|
||||
with left, right, patch("sandboxer.extensions.egress.connect_public") as connect:
|
||||
left.sendall(wire_request)
|
||||
with pytest.raises(ValueError):
|
||||
tunnel(right, destinations(["api.anthropic.com:443"]))
|
||||
connect.assert_not_called()
|
||||
|
||||
|
||||
def test_valid_connect_preserves_tls_bytes():
|
||||
left, right = socket.socketpair()
|
||||
upstream, peer = socket.socketpair()
|
||||
with left, right, upstream, peer:
|
||||
left.sendall(
|
||||
b"CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: api.anthropic.com:443\r\n\r\nTLS"
|
||||
)
|
||||
with (
|
||||
patch("sandboxer.extensions.egress.connect_public", return_value=upstream) as connect,
|
||||
patch("sandboxer.extensions.egress.relay") as relay,
|
||||
):
|
||||
tunnel(right, destinations(["api.anthropic.com:443"]))
|
||||
connect.assert_called_once_with("api.anthropic.com")
|
||||
relay.assert_called_once_with(right, upstream)
|
||||
assert right.recv(3) == b"TLS"
|
||||
assert b"200 Connection Established" in left.recv(100)
|
||||
|
||||
|
||||
def test_dns_result_is_used_without_second_resolution():
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443))],
|
||||
) as dns,
|
||||
patch("socket.socket") as factory,
|
||||
):
|
||||
assert connect_public("api.anthropic.com") is factory.return_value
|
||||
dns.assert_called_once()
|
||||
factory.return_value.connect.assert_called_once_with(("1.1.1.1", 443))
|
||||
|
||||
|
||||
def test_profile_cannot_expand_owner_allowlist(tmp_path):
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
from sandboxer.models import Profile
|
||||
|
||||
ext = BwrapExtension(
|
||||
{"base_dir": str(tmp_path / "unused"), "allowed_egress": ["api.anthropic.com:443"]}
|
||||
)
|
||||
profile = Profile(
|
||||
id="test",
|
||||
version="1",
|
||||
extension="ext.bwrap",
|
||||
network={"default": "deny", "egress": ["example.com:443"]},
|
||||
)
|
||||
with pytest.raises(ValueError, match="owner allowlist"):
|
||||
ext.provision(profile, {}, "localhost")
|
||||
assert not (tmp_path / "unused").exists()
|
||||
|
||||
|
||||
def test_failed_broker_readiness_removes_egress():
|
||||
from sandboxer.extensions.bwrap import BwrapExtension
|
||||
ext = BwrapExtension()
|
||||
handle = {"egress_pid": "123"}
|
||||
with patch.object(ext, "_wait_ready", side_effect=RuntimeError("startup failed")), \
|
||||
patch.object(ext, "teardown") as cleanup:
|
||||
with pytest.raises(RuntimeError, match="startup failed"):
|
||||
ext.wait_ready(handle)
|
||||
cleanup.assert_called_once_with(handle)
|
||||
|
|
@ -8,7 +8,7 @@ status: active
|
|||
owner: codex
|
||||
topic_slug: owner-mediated-execution
|
||||
created: "2026-09-04"
|
||||
updated: "2026-09-04"
|
||||
updated: "2026-09-05"
|
||||
state_hub_workstream_id: "b616d1cd-208f-5ecf-a4a0-a028396422c4"
|
||||
---
|
||||
|
||||
|
|
@ -112,9 +112,16 @@ destroy the workspace, then update Glas readiness and Activity Core
|
|||
`ACTIVITY-WP-0032-T05`. Do not trigger the production pilot before readiness
|
||||
changes.
|
||||
|
||||
This task depends on a reviewed Glas profile revision, the owner-fronted
|
||||
`rein-openweights-openrouter-approle` read, and a deployed sand-boxer owner
|
||||
service. No credential value belongs in this workplan or State Hub.
|
||||
This task depends on a reviewed Glas profile revision and its matching
|
||||
credential/egress/runtime contract. Glas selected the Claude route first in
|
||||
GLAS-WP-0012; the earlier OpenRouter AppRole dependency applies only to the
|
||||
separate open-weight profile and does not establish Claude authentication.
|
||||
|
||||
2026-09-05: SAND-WP-0015 implements pinned Python runtime mounts and private
|
||||
namespace state, with a real rein CLI startup proof. Claude workload credential
|
||||
routing, enforced provider egress, pinning/deploying the Claude executable,
|
||||
and the real-model acceptance remain open in SAND-WP-0015-T04. No credential
|
||||
value belongs in this workplan or State Hub.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
|
|
|
|||
119
workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md
Normal file
119
workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
id: SAND-WP-0015
|
||||
type: workplan
|
||||
title: "Provide a pinned bwrap rein runtime and private state"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: blocked
|
||||
owner: codex
|
||||
topic_slug: bwrap-runtime-and-private-state
|
||||
created: "2026-09-05"
|
||||
updated: "2026-09-05"
|
||||
state_hub_workstream_id: "d3f12387-fd23-58f0-b979-9c811507614d"
|
||||
---
|
||||
|
||||
# Provide a pinned bwrap rein runtime and private state
|
||||
|
||||
Implement the runtime prerequisites returned by Glas in `GLAS-WP-0012` and
|
||||
`docs/local-profile-acceptance.md`. Continue owner work `SAND-WP-0014-T05` and
|
||||
live residual `GLAS-IN-0002`. Runtime startup is a separate acceptance gate
|
||||
from credential delivery, enforced provider egress, and a real model task.
|
||||
|
||||
## Mount an owner-selected, digest-pinned Python runtime
|
||||
|
||||
```task
|
||||
id: SAND-WP-0015-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "93e4aad8-ba9b-5850-93d4-b5bf78ac8978"
|
||||
```
|
||||
|
||||
Add exact artifact verification to trusted extension configuration and mount
|
||||
the standalone runtime read-only at a fixed namespace path. Reject altered
|
||||
content/modes, unexpected files, escaping symlinks, special files, and source
|
||||
or workspace overlap. A caller cannot choose runtime paths through exec input.
|
||||
Provide a non-editable rein-aharness/llm-connect bundle builder with recorded
|
||||
source revisions and resolved dependency versions.
|
||||
|
||||
Completed 2026-09-05. `extensions/runtime.py` verifies complete artifact
|
||||
contents/modes before a read-only mount; the builder installed committed
|
||||
rein-aharness `1429db5` and llm-connect `0056094` without editable source paths.
|
||||
Candidate digest and source/package evidence are in `docs/bwrap-runtime.md`.
|
||||
|
||||
## Keep writable runtime state outside the repository
|
||||
|
||||
```task
|
||||
id: SAND-WP-0015-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c8a689f8-bdf2-57a2-8521-a1c0ca44e750"
|
||||
```
|
||||
|
||||
Create private mode-0700 HOME/config/cache/state/tmp directories within the
|
||||
namespace, preserve them across exec requests, and remove them with teardown.
|
||||
Keep the command environment sanitized and disable Python user-site/bytecode
|
||||
writes. Fail closed on profile network/setup-credential declarations that the
|
||||
current bwrap implementation cannot honor.
|
||||
|
||||
Completed 2026-09-05. Mode-0700 namespace HOME and XDG/TMP directories sit
|
||||
outside the copied Git tree. Explicit runtime PATH selection remains owner
|
||||
controlled. Unsupported egress/default-allow and setup credentials now refuse
|
||||
before workspace creation; regression tests cover these boundaries.
|
||||
|
||||
## Verify the real rein runtime and owner regression paths
|
||||
|
||||
```task
|
||||
id: SAND-WP-0015-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1a3002f6-c6a8-59d0-9d38-942028419de5"
|
||||
```
|
||||
|
||||
Run `make check`, the existing authenticated cross-request owner smoke, and a
|
||||
real runtime startup smoke. Verify the actual rein CLI and adapter imports,
|
||||
read-only runtime, private HOME, clean worktree, absent source, loopback-only
|
||||
network, and teardown. Keep the production Glas profile blocked.
|
||||
|
||||
Completed 2026-09-05. `make check`: lint clean, 132 tests passed. Authenticated
|
||||
owner API smoke `223db65b` returned HTTP 200 and proved exact consumer identity,
|
||||
stdin delivery, absent source, loopback-only network and complete teardown.
|
||||
Pinned runtime smoke `d4de9531` ran the actual rein CLI and imported its Claude
|
||||
adapter, proved read-only runtime/private HOME/clean worktree, retained private
|
||||
state across a second exec, and removed the workspace. No model call or
|
||||
credential acquisition occurred; T04 remains waiting.
|
||||
|
||||
## Resolve Claude credentials, enforced egress, and production acceptance
|
||||
|
||||
```task
|
||||
id: SAND-WP-0015-T04
|
||||
status: wait
|
||||
priority: high
|
||||
state_hub_task_id: "58817ef6-76d9-5e34-908f-c024e6c99f93"
|
||||
```
|
||||
|
||||
The 2026-09-05 routing lookup found no concrete Anthropic/Claude workload lane.
|
||||
The generic OpenBao template is not a delegable lane; the OpenRouter AppRole
|
||||
belongs to another rein. Credential owner railiance-platform/OpenBao plus
|
||||
rein-aharness must establish a concrete Claude-compatible route and delivery/
|
||||
revocation contract before values can be requested or supplied to the runtime.
|
||||
|
||||
Sand-boxer must then implement the matching enforced provider egress contract
|
||||
(including DNS/TLS and denied-destination proof) and pin/deploy the actual
|
||||
Claude executable. No unrestricted-network or interactive-login substitute is
|
||||
permitted. Review the deployed runtime/profile with Glas, run its real-rein
|
||||
acceptance fixture, and update only the proven profile's readiness. No
|
||||
production schedule is enabled by the runtime-startup smoke.
|
||||
|
||||
This task and SAND-WP-0014-T05 remain open until those gates pass. The detailed
|
||||
return contract and runtime proof are in `docs/bwrap-runtime.md`; the live
|
||||
cross-repo residual remains `GLAS-IN-0002`.
|
||||
|
||||
## 2026-09-05 transport implementation evidence
|
||||
|
||||
T04 remains waiting on owner machine authentication, protected credential
|
||||
delivery, pinned Claude startup and the real model proof. CCR-2026-0016 custody
|
||||
is confirmed by a metadata-only check of live version 2; no value was read.
|
||||
HTTPS egress is now implemented as an exact owner/profile allowlisted CONNECT
|
||||
proxy while retaining an isolated network namespace. Non-secret live smoke
|
||||
sandbox e290e788 verified provider TLS response, undeclared destination and
|
||||
direct-IP denial, and proxy/workspace teardown. See docs/bwrap-egress.md.
|
||||
Loading…
Add table
Add a link
Reference in a new issue