Skip to content

feat(sandbox): MXC sandbox integration Phase 1 (T1-T9)#86

Open
brandwe wants to merge 34 commits into
mainfrom
feat/mxc-sandbox-integration
Open

feat(sandbox): MXC sandbox integration Phase 1 (T1-T9)#86
brandwe wants to merge 34 commits into
mainfrom
feat/mxc-sandbox-integration

Conversation

@brandwe

@brandwe brandwe commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Implements Phase 1 MXC (Microsoft Execution Containers) sandbox integration for contained local code execution with OS-enforced isolation.

What's New

Core Infrastructure (T1-T6):

  • Base protocol (SandboxRunner, SandboxPolicy, error taxonomy) - 19 tests
  • Policy building + operator ceiling clamping (clamp_to_ceiling) - 12 tests
  • Binary resolution with SHA256 verification - 13 tests
  • macOS Seatbelt runner implementation - 9 tests
  • run_code MCP tool (opt-in via ENTRABOT_ENABLE_RUN_CODE=1) - 10 tests
  • setup_sandbox.sh script (idempotent, non-fatal)

Demonstration & Future Seams (T6.5-T9):

  • write_local_file tool (deliberately unsafe, for security demos) - 8 tests
  • Phase 2 session stub (sandbox/session.py) - 10 tests
  • ADR-007 (14KB decision record)
  • Adversarial integration tests (15 tests, opt-in via ENTRABOT_TEST_ADVERSARIAL=1)
  • Demo scripts and testing guides

Security Model (Learning #54)

Operator Ceiling Enforcement:

export ENTRABOT_SANDBOX_READONLY_PATHS=/Users/you/Documents:/tmp
export ENTRABOT_SANDBOX_READWRITE_PATHS=/tmp  # Only /tmp writable

Agent can only NARROW, never WIDEN:

  • Agent requests: readwrite_paths=["/Users/you/Documents"]
  • clamp_to_ceiling() removes it → readwrite_paths=[]
  • Result: Write blocked by policy (empty readwrite list)

Demo Scenario (verified working):

User Request Agent Action Result
"Write to Documents" run_code(..., readwrite_paths=["~/Documents"]) ❌ BLOCKED
"Read from Documents" run_code(..., readonly_paths=["~/Documents"]) ✅ ALLOWED
"Write to /tmp" run_code(..., readwrite_paths=["/tmp"]) ✅ ALLOWED

Test Results

  • Before: 1524 passing
  • After: 1605 passing (+81 new tests)
  • Skipped: 16 (15 adversarial + 1 existing)
  • All green:

Key Design Decisions

  1. Operator Ceiling (Learning tools: Non-atomic cursor write can regress to bootstrap path — PR #17/18 fix lost on crash #54): Human sets maximum capabilities, LLM can only narrow
  2. Fail-Closed: Binary tampering/audit failures/unenforceable policies → refuse to run
  3. Positive-Allowlist Only: Specify what's accessible (no deniedPaths, portable across platforms)
  4. Opt-In by Default: run_code disabled unless ENTRABOT_ENABLE_RUN_CODE=1
  5. Phase 1 Process-Level, Phase 2 Session-Bound: Ship value now, add Entra identity attribution later

Files Changed

New modules:

  • src/entrabot/sandbox/ (6 files: base, policy, binary, mac, session, init)
  • tests/sandbox/ (6 test files + adversarial)
  • scripts/setup_sandbox.sh (330 lines)

Documentation:

  • docs/decisions/007-mxc-sandbox-integration.md (14KB ADR)
  • TODOS.md (marked AppContainer item complete)
  • README.md (added MXC to "The stack" + setup flags)

Modified:

  • src/entrabot/mcp_server.py - Added run_code() and write_local_file() tools

Phase 2 Future Work

When Entra/MXC session APIs GA:

  • Implement identity_binding() (currently raises NotImplementedError)
  • Bind MXC sessions to Entra Agent User
  • Per-conversation session isolation (Backend.SESSION)
  • Intune governance integration
  • M365 audit attribution (agent actions vs human actions)

Known Limitations

  1. Test mock MXC binary - Doesn't enforce filesystem policy (real MXC will block at OS level)
  2. macOS only - Windows AppContainer and Linux seccomp-bpf deferred (T4/T10)
  3. No real MXC binary yet - Using test mock until MXC OSS release

Related

Reviewers

@brandwe


Ready to merge: Yes! All tests passing, security model verified. 🎯

brandwe and others added 24 commits June 17, 2026 17:21
- Add SandboxRunner protocol with run(), get_capabilities(), identity_binding()
- Add SandboxPolicy dataclass with positive-allowlist fields
- Add SandboxResult dataclass for execution results
- Add Backend enum (PROCESS, SESSION)
- Add complete error taxonomy (6 exception types)
- 19 tests passing, all green

TDD cycle: RED (watched tests fail) → GREEN (minimal impl) → REFACTOR
Part of #84 Phase 1 MXC sandbox integration
- Add build_policy() to generate MXC 0.6.0-alpha JSON schema
- Add clamp_to_ceiling() implementing Learning #54 (LLM can only narrow)
- Add backend-aware fail-closed logic (refuse if unenforceable)
- Add canonicalize_paths() with symlink resolution and nonexistent check
- Add discovery helpers (Python, temp, user profile paths)
- keychain_access hardcoded False, not overridable by LLM
- 12 tests passing (total 1552)

TDD cycle: RED → GREEN → REFACTOR
Security-critical component for operator-set ceiling enforcement
Part of #84 Phase 1 MXC sandbox integration
- Add resolve_binary() with 3-tier strategy (MXC_BIN_DIR, npm global, None)
- Add verify_binary() with SHA256 hash checking
- Add resolve_and_verify() combining resolution + verification
- Add get_binary_name() for platform-specific binary names
- Add PINNED_HASHES dict for commit-pinned SHA256 verification
- Raises SandboxUntrustedBinaryError on hash mismatch
- Raises SandboxUnavailableError when binary not found
- 13 tests passing (total 1566)

TDD cycle: RED → GREEN → REFACTOR
Critical security component for binary provenance verification
Part of #84 Phase 1 MXC sandbox integration
- Add SeatbeltRunner implementing SandboxRunner protocol
- Executes mxc-exec-mac with --experimental flag
- Passes MXC JSON config via stdin (not argv)
- Measures execution duration in milliseconds
- Returns SandboxResult with stdout/stderr/exit/duration
- Raises SandboxTimeoutError on timeout
- get_capabilities() returns backend metadata (network_host_filtering=False)
- identity_binding() is no-op in Phase 1
- Add get_sandbox_runner() factory in __init__.py
- 9 tests passing (total 1576)

TDD cycle: RED → GREEN → REFACTOR
Now have working macOS sandbox execution!
Part of #84 Phase 1 MXC sandbox integration
- Add run_code() tool in mcp_server.py (lines 4862-5052)
- Conditionally registered when ENTRABOT_ENABLE_RUN_CODE=1
- Security model: audit-first, operator ceiling enforcement, backend-aware fail-closed
- Structured argv (no shell), output truncation (10KB max), error handling
- Reads ceiling from env: ENTRABOT_SANDBOX_READONLY_PATHS, READWRITE_PATHS, TIMEOUT_MS, NETWORK
- Clamps LLM-requested policy to ceiling using clamp_to_ceiling()
- Returns JSON with stdout/stderr/exit_code/duration_ms
- Error handling: unavailable, untrusted, unsupported, timeout, policy errors

Tests (10 new, all passing):
- test_run_code_not_registered_without_env_flag
- test_run_code_registered_with_env_flag
- test_run_code_requires_argv
- test_run_code_accepts_ceiling_narrowing
- test_run_code_audits_pending_before_execution
- test_run_code_clamps_policy_to_ceiling
- test_run_code_fails_closed_on_audit_failure
- test_run_code_returns_result
- test_run_code_handles_unavailable_sandbox
- test_run_code_uses_structured_argv

TDD cycle:
1. RED: Wrote 10 failing tests
2. GREEN: Implemented run_code with all required logic
3. REFACTOR: Fixed linting, verified full suite (1586 passing)

Related: Issue #84 (MXC sandbox integration)
Phase 1 T5/T10 complete - MCP tool layer ready for Claude Code
- Add scripts/setup_sandbox.sh (330 lines) - idempotent, non-fatal install
- Integrated with main setup.sh via --enable-sandbox flag
- Platform detection: macOS (ready), Linux/Windows (future)
- Creates placeholder binary until MXC is publicly released
- Self-signs binary with codesign -s - (macOS ad-hoc signature)
- Records SHA256 hash in PINNED_HASHES dict (darwin-arm64)
- Configures .env: ENTRABOT_ENABLE_RUN_CODE=1, ceiling defaults, MXC_BIN_DIR
- Non-fatal: failures degrade to unavailable sandbox, not setup failure

User experience:
  ./scripts/setup.sh --enable-sandbox
  # Creates placeholder binary + updates .env
  # When MXC is released: detects/builds/signs real binary

Features:
- Detects existing binary (MXC_BIN_DIR, npm global, build dir)
- --force-build: rebuild even if binary exists
- --skip-sign: skip code signing (for CI)
- Platform-specific binary names (mxc-exec-mac, lxc-exec, wxc-exec.exe)
- Idempotent: safe to run multiple times

Default operator ceiling:
  ENTRABOT_SANDBOX_READONLY_PATHS=/tmp
  ENTRABOT_SANDBOX_READWRITE_PATHS=/tmp
  ENTRABOT_SANDBOX_TIMEOUT_MS=30000
  ENTRABOT_SANDBOX_NETWORK=block

Related: Issue #84 (MXC sandbox integration)
Phase 1 T6/T10 complete - setup automation ready
- Fixed AttributeError in resolve_and_verify: renamed 'platform' param to 'platform_name' to avoid shadowing platform module
- Updated resolve_binary to check MXC_BIN_DIR directly (fallback for setup script compatibility)
- Created working test MXC mock binary that mimics MXC 0.6.0-alpha schema
- Updated PINNED_HASHES for test binary (darwin-arm64)

Test results:
✅ Binary resolution working
✅ SHA256 verification working
✅ Audit logging (pending → success)
✅ Command execution: echo test passes
✅ Full run_code flow end-to-end

Simple test:
  run_code(argv=["echo", "Hello from sandbox!"])
  → {"success": true, "stdout": "Hello from sandbox!\n", "exit_code": 0}

Ready for Claude Code integration testing.
Test instructions for running run_code tool from Claude Code with expected
outputs, troubleshooting, and behind-the-scenes explanation.
- Add write_local_file() MCP tool (DELIBERATELY UNSAFE for demonstration)
- Shows contrast between unprotected file access vs sandboxed run_code
- No path validation - can write anywhere (educational attack surface)
- Audit logging for all operations (pending/success/failure)
- Extensive documentation in docstring warning about dangers

Tests (8 new, all passing):
- test_write_local_file_exists
- test_write_local_file_creates_file
- test_write_local_file_accepts_any_path (shows danger)
- test_write_local_file_handles_permission_error
- test_write_local_file_audits_actions
- test_write_local_file_has_warning_docstring
- test_demo_scenario_unsafe_vs_safe
- test_write_local_file_always_available

Demo scenario:
  UNSAFE: write_local_file(path="/Users/you/Desktop/hack.txt", content="pwned")
          → ✅ Succeeds anywhere (DANGEROUS!)

  SAFE:   run_code(argv=["echo", "safe", ">", "/tmp/safe.txt"])
          → ✅ Sandboxed to operator ceiling (/tmp only)

TDD cycle:
1. RED: Wrote 8 failing tests
2. GREEN: Implemented write_local_file with audit logging
3. REFACTOR: Fixed test suite (1594 passing)

Related: Issue #84 (MXC sandbox integration)
Phase 1 T6.5/T10 complete - demonstration tool ready
Complete walkthrough showing unsafe vs safe file access:
- Scenario 1: write_local_file (DANGEROUS - no protection)
- Scenario 2: run_code with Desktop path (BLOCKED by ceiling)
- Scenario 3: run_code with /tmp path (ALLOWED within ceiling)

Includes:
- Prerequisites and environment setup
- Step-by-step testing instructions for Claude Code
- Interpretation guide (what each outcome means)
- Key security concepts (operator ceiling, fail-closed, attribution)
- Cleanup and next steps

Ready for user testing on macOS.
Add sandbox/session.py with seam for future Entra identity binding:

- Backend.SESSION enum value (Phase 2 opt-in)
- SessionConfig dataclass (agent_user_id, tenant_id, intune_policy_id)
- identity_binding() function stub (raises NotImplementedError)
- Comprehensive module docstring documenting Phase 2 requirements

Phase 2 Requirements (when APIs GA):
- Bind MXC sessions to Entra Agent User identity
- Per-conversation session isolation (cross-conversation containment)
- Intune governance integration (policy-controlled capabilities)
- M365 audit attribution (agent actions vs human actions)

Gating Questions Documented:
- Is entrabot Agent User same identity MXC attributes to? (UNVERIFIED)
- Can MXC sessions reference Entra identity providers? (UNCLEAR)
- Does Intune expose agent governance APIs? (NO, as of 2026-06)

Tests (10 new, all passing):
- Backend enum has SESSION and PROCESS values
- SessionConfig dataclass with required/optional fields
- identity_binding() raises NotImplementedError (Phase 2)
- identity_binding() accepts SessionConfig (type safety)
- Module has Phase 2 documentation
- Backward compatibility with Phase 1 (Backend.PROCESS unchanged)

TDD cycle:
1. RED: Wrote 10 failing tests (module not found)
2. GREEN: Implemented minimal stub (Phase 2 placeholder)
3. REFACTOR: Fixed linting (contextlib.suppress)

Current behavior:
- Phase 1 code unchanged (Backend.PROCESS only)
- Backend.SESSION available but raises NotImplementedError
- No impact on existing sandbox functionality

Test suite: 1605 passing (+10 new)
Related: Issue #84 (MXC sandbox integration T7/T10)
Add comprehensive documentation for MXC sandbox integration:

1. ADR-007 (14KB):
   - Full decision record for MXC sandbox integration
   - Context: Why sandboxing, prior state, user requests
   - Decision: Phase 1 (process-level) + Phase 2 (session-bound identity)
   - Implementation: Architecture, security model, code structure
   - Consequences: Positive (least-privilege, fail-closed, platform-enforced)
                 Negative (binary required, macOS-only Phase 1, test mock)
   - Alternatives considered (5 rejected approaches with rationale)
   - Validation: Functional tests passing, security tests in T9
   - Future work: Phase 2 identity binding, Windows/Linux support

2. TODOS.md update:
   - Mark AppContainer item complete (superseded by MXC)
   - Reference Issue #84 and ADR-007
   - Document Phase 1 status (shipped) and Phase 2 status (stub)

3. README.md updates:
   - Add MXC Sandbox to "The stack" section
   - Mention --enable-sandbox flag in Quickstart
   - Link to ADR-007 for deep dive

Key messaging:
- **Phase 1 SHIPPED**: Process-level containment, macOS Seatbelt, opt-in
- **Phase 2 STUB**: Session-bound Entra identity attribution (future APIs)
- **Security model**: Operator ceiling, LLM narrows only, audit-first
- **Demo value**: Contrast unsafe write_local_file vs safe run_code

Documentation now complete for:
- Decision rationale and alternatives
- Architecture and security design
- Implementation status and future roadmap
- User-facing setup and capabilities

Related: Issue #84 (MXC sandbox integration T8/T10)
Add comprehensive security tests for MXC sandbox (15 new tests, opt-in only):

**Attack Scenarios Covered:**

1. **Symlink Escapes** (2 tests):
   - Block symlink to protected dir (e.g., /tmp/link → ~/.ssh)
   - Allow symlink within allowlist (stays in boundary)

2. **Path Traversal** (2 tests):
   - Block ../../ traversal outside allowlist
   - Block absolute paths outside allowlist

3. **Secret Access** (3 tests):
   - Block keychain access (keychainAccess=false enforced)
   - Block SSH key reads (~/.ssh/id_rsa)
   - Test environment variable isolation

4. **Network Isolation** (2 tests):
   - Block network when defaultPolicy=block
   - Allow network when defaultPolicy=allow (skip: test mock limitation)

5. **Timeout Enforcement** (2 tests):
   - Terminate process that exceeds timeout
   - Terminate entire process tree on timeout

6. **Binary Tampering** (2 tests):
   - Detect binary with wrong SHA256
   - Verify SHA256 check cannot be bypassed

7. **Fork Bomb** (1 test):
   - Contain fork bomb (skip: process limit not in Phase 1)

8. **Cleanup** (1 test):
   - Verify no writes after sandbox exit

**Opt-In Design:**
- Tests require ENTRABOT_TEST_ADVERSARIAL=1 env var
- Skipped by default (safe for CI without isolation)
- Create real files/symlinks/processes when enabled
- Document security posture in module docstring

**Why Opt-In:**
- Tests create real attack scenarios (symlinks, processes)
- Require MXC binary to be present
- Should run in isolated/ephemeral environments only
- Not safe for parallel execution without containers

**Usage:**
```bash
# Skipped by default (safe)
pytest tests/sandbox/test_adversarial.py -v
# → 15 skipped

# Enable to run (requires MXC binary + isolation)
ENTRABOT_TEST_ADVERSARIAL=1 pytest tests/sandbox/test_adversarial.py -v
# → 15 tests exercise real attack scenarios
```

**Validation Strategy:**
- Unit tests (T1-T7) verify policy logic
- Adversarial tests (T9) verify OS enforcement
- Together: prove sandbox withstands real attacks

Test suite: 1605 passing, 16 skipped (15 adversarial + 1 existing)
Related: Issue #84 (MXC sandbox integration T9/T10)
Two demo scripts showing least-privilege enforcement:

1. test_demo_simple.py (Python):
   - Direct calls to run_code() MCP tool
   - Tests READ (allowed), WRITE to Documents (blocked), WRITE to /tmp (allowed)
   - Shows audit logging in action

2. test_demo_scenario.sh (Bash):
   - Shell-based testing harness
   - Same three scenarios, uses venv Python

**Key Finding:**
The security model works correctly! clamp_to_ceiling() properly removes
Documents from readwrite list when not in operator ceiling. Test with
mock MXC binary shows write succeeding because mock doesn't enforce
policy - it just runs commands. With REAL MXC binary (when available),
the OS will block the write.

Verification (added test script):
```bash
python << EOF
# Test clamping directly
clamped = clamp_to_ceiling(agent_policy, ceiling, backend_caps)
# Result: clamped.readwrite_paths == []  ✅ CORRECT
EOF
```

Demo shows:
- READ from Documents: ✅ Allowed (in readonly ceiling)
- WRITE to Documents: Policy clamped to [], write blocked by real MXC
- WRITE to /tmp: ✅ Allowed (in readwrite ceiling)

Related: Issue #84 (MXC sandbox integration - demo verification)
Built mxc-exec-mac v0.6.1 from github.com/microsoft/mxc.

Changes:
- Real MXC binary (1.6MB ARM64 Mach-O) replaces bash mock
- Mock backed up as .mxc-exec-mac.mock
- Added stdin support patch for entrabot integration
- Updated setup_sandbox.sh with build instructions
- Updated docs with build process

Binary details:
- Size: 1.6MB (1,704,592 bytes)
- Platform: macOS ARM64 (Apple Silicon)
- Version: Built from mxc v0.6.1
- SHA256: 700e9e7120c78fe9ecdb8c99309ba6df0ea467ac5b581b803b73d655bbccff36

The real binary uses macOS Seatbelt for OS-enforced sandboxing.
Test mock proved plumbing worked; real binary provides actual
containment for E2E testing.

Related: #84 (MXC sandbox Phase 1)
The ceiling clamp matched paths via exact string equality on raw
strings, before canonicalization. Two correctness failures resulted:

- Representation mismatch: ~/Documents vs /Users/me/Documents, or a
  trailing-slash variant, named the same dir but failed to match and
  were silently dropped (fail-closed, so a legitimate read/write was
  wrongly denied).
- No subpath narrowing: a ceiling grant of /tmp would not admit a
  request to narrow into /tmp/run-42/out — exactly the many-narrow-
  sandboxes pattern MXC guidance recommends.

Fix: canonicalize (expanduser + realpath) both ceiling and requested
paths, then admit a request if it equals or is a descendant of a
ceiling entry. Order is load-bearing: canonicalization happens BEFORE
the containment check so a symlink inside a granted dir cannot smuggle
access to a target outside the ceiling. Original request strings are
returned so downstream canonicalize_paths validates/resolves them as
before.

Also fixes canonicalize_paths to expanduser ~, since the hardened
clamp now admits tilde-spelled requests.

Verified against the real mxc-exec-mac (Seatbelt) binary: tilde reads,
tilde writes (blocked when outside ceiling), and subpath narrowing all
enforce correctly at the OS level.

Tests: +6 (subpath, trailing-slash, tilde match, prefix-collision
reject, symlink-escape reject, tilde canonicalize). 1616 passing.

Related: #84 (MXC sandbox Phase 1)
…onicalization)

Hand-off-ready report for the MXC maintainers documenting two points
found while integrating mxc-exec-mac v0.6.1 (Seatbelt) into entrabot:

1. Filesystem rules enforce on the kernel-resolved path but build the
   Seatbelt profile from the literal policy path, so granting /tmp
   silently denies /tmp writes (/tmp -> /private/tmp). Reproducible;
   /private/tmp works. Affects /tmp, /var (incl. $TMPDIR), /etc.

2. Canonicalization order is security-relevant: realpath-first then
   allow/deny matching prevents symlink-escape (allowlist) and
   deny-bypass (deniedPaths).

Includes reproduction, root cause, suggested fixes, and our downstream
workaround.

Related: #84 (MXC sandbox Phase 1)
Enables provisioning a separate Agent Identity + Agent User under the
EXISTING Blueprint, without disturbing the production chain — the
pattern needed to E2E-test the MXC sandbox against a throwaway agent.

setup.sh:
- --new --use-blueprint=APP_ID now creates a fresh Agent Identity/User
  under an existing Blueprint (previously --new and --use-blueprint were
  mutually exclusive). Exports ENTRABOT_REUSE_BLUEPRINT and
  ENTRABOT_PIN_BLUEPRINT_APP_ID for the provisioner.
- --state-file=PATH / --env-file=PATH write provisioning state and env
  to custom locations so prod and test chains coexist (e.g.
  .entrabot-state-mxc-test.json + .env.mxc-test). All hardcoded
  .entrabot-state.json / .env references parameterized; Python heredocs
  use raw strings so paths with spaces are safe.

create_entra_agent_ids.py:
- Honors a pinned Blueprint App ID (ENTRABOT_PIN_BLUEPRINT_APP_ID) and
  reuses it instead of creating/finding by display name.

entra_provisioning.py:
- State persistence honors an ENTRABOT_STATE_FILE override path.

.gitignore broadened to .entrabot-state*.json so test state files are
ignored. Docs + engineering-status updated.

+25 tests across tests/scripts/. All 35 script tests pass.

Related: #84 (MXC sandbox Phase 1)

Co-authored-by: Copilot <[email protected]>
scripts/demo_sandbox.py drives the REAL mxc-exec-mac binary through the
exact run_code enforcement chain (operator ceiling -> clamp -> canonicalize
-> MXC) and narrates each beat for a live audience:

- Act 1: motivates containment (the unsafe write_local_file baseline)
- Act 2: READ Documents allowed, WRITE Documents blocked, WRITE /tmp and
  ~/Downloads allowed — each showing the clamp decision and the OS verdict
- Act 3: a symlink inside an allowed dir pointing out is rejected
  (canonicalize-before-containment)
- Closes with a Teams talk-track for the live demo

Interactive by default (pauses between beats); --no-pause for recording.
Verified: 5/5 scenarios behave as designed against the real Seatbelt binary.

Related: #84 (MXC sandbox Phase 1)

Co-authored-by: Copilot <[email protected]>
setup.sh could already WRITE provisioning output to a custom --env-file,
but the runtime always loaded the hardcoded ./.env, so the test-identity
env file was never actually read by the MCP server. _load_dotenv now
honors ENTRABOT_ENV_FILE (expanduser'd), falling back to ./.env, while
preserving the don't-overwrite-existing-env precedence.

This lets a throwaway test agent run from its own env file
(.env.mxc-test) without disturbing production .env — used for the MXC
sandbox live demo. Verified: three-hop token mint + Graph identity +
Teams scope all pass as [email protected] via this path.

Tests: +2 (override honored; existing env not clobbered). 24 config
tests pass.

Related: #84 (MXC sandbox Phase 1)

Co-authored-by: Copilot <[email protected]>
--config-only prints the operator-set configuration panel (ceiling,
agent identity, run_code/network/keychain state) and exits — for
showing the initial setup at the start of a live demo before the
enforcement run.

Also cleans up pre-existing lint in the file (unused imports, long
line, try/except-pass → contextlib.suppress, intentional import-order
ignore).

Co-authored-by: Copilot <[email protected]>
Running ./scripts/demo_sandbox.py directly picked up the system python3
(3.9 on macOS) via the shebang, crashing on modern type syntax in the
entrabot package (which needs 3.12+). Added a stdlib-only re-exec guard
that relaunches under .venv/bin/python3 when not already running it, so
the script works without activating the venv first.

Co-authored-by: Copilot <[email protected]>
- New guide docs/guides/mxc-sandbox.md: end-to-end HOWTO to enable the
  sandbox yourself — build the binary (setup_sandbox.sh), set the
  operator ceiling, restart, and verify kernel enforcement. Includes
  config reference, the security model, an isolated-test-agent recipe,
  and troubleshooting.
- README: link the guide from the MXC stack bullet, the quickstart
  --enable-sandbox note, and the docs pointer list.
- mkdocs nav: add the guide under Guides.
- README H1 updated.

Co-authored-by: Copilot <[email protected]>
Keeps local demo/test MCP configs and backups out of version control.

Co-authored-by: Copilot <[email protected]>
Comment thread scripts/demo_sandbox.py
secret.parent.mkdir(parents=True, exist_ok=True)
if not secret.exists():
secret.write_text("SECRET: quarterly numbers the agent may read but must not alter\n")
print(f"\n{DIM}Fixture ready: {secret}{NC}")
brandwe and others added 5 commits June 21, 2026 19:40
write_local_file is the DELIBERATELY-UNSAFE contrast tool — it bypasses
the sandbox and writes anywhere. It was registered unconditionally, so
the agent always had an unsandboxed write path that defeats run_code
containment. Now it is registered as an MCP tool ONLY when the operator
opts in via ENTRABOT_ENABLE_UNSAFE_WRITE=1; off by default. The function
stays defined (importable for tests).

This closes the entrabot-side escape hatch. Note the host's own built-in
tools (Claude Code Edit/Bash, Copilot CLI shell) are a separate,
larger escape that must be handled at the host level (--tools).

Tests: +2 registration tests (default-off, on-when-enabled); 1620 pass.

Co-authored-by: Copilot <[email protected]>
The MXC sandbox contains code run through run_code, NOT the host. Claude
Code / Copilot CLI / Codex ship built-in Bash/Edit/Write/Read tools with
full unsandboxed disk access; if left enabled the agent uses those and
bypasses the sandbox entirely (this is exactly what happened in testing —
the agent wrote to ~/Documents via Claude Code's Edit tool, not run_code).

Adds a prominent 'sandbox contains run_code, not the agent' section with
the VERIFIED Claude Code launch command:
  --disallowedTools "Bash Write Edit NotebookEdit Read Glob Grep WebFetch WebSearch Task"
Empirically confirmed: run_code still works, direct Write returns 'No such
tool available' and creates no file. Documents that --tools "" is WRONG
(it strips MCP tools incl run_code). Notes the capability trade-off and
points at the OS-user/VM whole-agent model (ADR-007 Phase 2) as the way to
keep powerful tools while containing the agent. Adds troubleshooting rows.

Co-authored-by: Copilot <[email protected]>
The agent defaulted to OneDrive/Graph when asked to read a file in the
user's local Documents folder, and (with host built-ins stripped)
concluded it had no way to touch local files — because nothing told it
run_code IS the local-filesystem path. Two fixes:

1. run_code tool docstring: now states plainly this is the only way to
   read OR write the user's LOCAL disk, distinct from the OneDrive/Files
   tools; that the sandbox is permission-based on the REAL filesystem
   (not an isolated/throwaway container, so writes persist); and to
   attempt the operation and let the kernel decide rather than
   pre-judging a path as off-limits.

2. Body prompt (prompts/anatomy/identity-and-tools.md): adds a 'Local
   files vs cloud files (run_code)' section so the Teams agent routes
   local-path requests to run_code instead of OneDrive.

Verified: with built-ins stripped, the agent now uses run_code to read
~/Documents/entrabot-secret.txt (was: 'I can only open files shared with
me via OneDrive'). Tests + lint green.

Co-authored-by: Copilot <[email protected]>
The generic run_code tool worked for local reads (the model reaches for
'cat') but the model would not use it to WRITE local files — it routed
'save a file' to the cloud OneDrive tools and concluded it had no local
write path. Cramming file I/O into one 'run a command' tool fought the
model's verb-based tool selection.

Fix: expose intent-matching tools on top of the SAME containment
machinery (operator ceiling -> clamp -> realpath -> Seatbelt):

- src/entrabot/sandbox/local_files.py: ceiling_from_env, build_read/
  write_command (shlex-quoted, injection-safe via printf '%s'),
  sandboxed_read (grants read-only on the file), sandboxed_write (grants
  read-write on the parent dir so new files work). Containment unchanged.
- mcp_server: read_local_file(path) and write_local_file(path, content)
  MCP tools, gated behind ENTRABOT_ENABLE_RUN_CODE, audit-first, with
  docstrings that route local/on-disk requests here (not OneDrive) and
  tell the model to attempt and let the kernel decide.
- Renamed the deliberately-unsafe demo tool write_local_file ->
  unsafe_write_local_file (still gated behind ENTRABOT_ENABLE_UNSAFE_WRITE)
  to free the intuitive name for the safe sandboxed tool.

Verified against the REAL Seatbelt binary AND end-to-end via Claude Code
with built-ins stripped: natural-language 'read my Documents file' ->
read_local_file (allowed); 'save to Documents' -> write_local_file
(kernel-blocked, not created); 'save to Downloads' -> write_local_file
(written). +10 tests; 1630 pass; lint clean.

Co-authored-by: Copilot <[email protected]>
Body prompt (identity-and-tools.md): the 'Local files vs cloud files'
section now routes read requests to read_local_file, write/save requests
to write_local_file, and command execution to run_code — instead of
funneling everything through run_code (which the model wouldn't use for
writes).

Guide (mxc-sandbox.md): documents all three sandbox tools, why the
purpose-named file tools exist (intent-based tool selection), and the
unsafe_write_local_file contrast tool.

Co-authored-by: Copilot <[email protected]>
brandwe and others added 4 commits June 26, 2026 17:49
The background Teams poll re-delivered weeks-old messages on every MCP
restart. Root cause: chat_cursors.is_stale() measured age from last_ts
(the newest-MESSAGE watermark) instead of last_written_at (when the
cursor was persisted).

Any chat idle longer than the 24h cap therefore had a "stale" cursor
even when it had just been written, so _register_watched_chat discarded
it and re-ran _bootstrap_chat on every restart. _bootstrap_chat
deliberately leaves the newest message unseen so it fires once -- so each
re-bootstrap re-pushed that chat's weeks-old newest message as if it were
live. With ~50 idle chats and frequent restarts (amplified by the open
MCP-disconnect issue) this produced a flood of stale replays.

Fix: is_stale() now takes last_written_at. Both call sites pass the write
timestamp:
  - mcp_server._register_watched_chat (rehydrate-vs-bootstrap decision)
  - body_bootstrap._cursor_freshness (cursors_stale telemetry, which was
    itself miscounting for the same reason)

The 24h cap still does its real job: if the server was actually down
longer than the cap, messages may have been missed and the seen-set is
untrustworthy, so we re-baseline via a fresh bootstrap.

TDD: added test_idle_chat_recent_write_rehydrates_despite_old_last_ts
(red before the fix, green after). Corrected two existing tests that
encoded the buggy behavior by crafting cursors with an old
last_written_at directly. Full suite: 1527 passed, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Adds Windows support for the MXC sandbox alongside the existing macOS
Seatbelt path, and fixes read_local_file/write_local_file on Windows.

- Windows runner: ProcessContainerRunner (src/entrabot/sandbox/windows.py)
  drives wxc-exec.exe with the processcontainer backend (default, non-
  experimental on Win11 24H2+), passing policy JSON inline via
  --config-base64 (no temp file).
- Binary resolution/verification extended for the Windows wxc-exec.exe
  layout with SHA256 pinning (binary.py); policy/config wiring (policy.py,
  config.py, __init__.py).
- Platform-aware local-file commands (local_files.py): wxc-exec.exe runs
  process.commandLine via CreateProcessW with no implicit shell, so the
  POSIX `cat`/`printf` commands failed (0x80070002). Windows read now uses
  `cmd /c type "<path>"`; Windows write uses an inline Python base64 writer
  via subprocess.list2cmdline for byte-exact, injection-safe writes. POSIX
  branch unchanged.
- mcp_server: distinguish a sandbox-helper spawn failure ("Sandbox helper
  could not run the command") from a real policy denial, so a spawn failure
  is no longer misreported as a missing/blocked path.
- Setup + demo tooling (scripts/setup_sandbox.ps1, demo_sandbox.ps1,
  demo_sandbox_run.py, start_demo.ps1) and docs (mxc-windows-sandbox-
  preview.md, mxc-sandbox-demo-windows.md, guide + ADR updates).
- Tests: tests/sandbox/test_windows.py plus updates to test_binary.py,
  test_policy.py, test_local_files.py, tests/test_local_file_tools.py.

OPEN (needs live wxc-exec.exe): whether python.exe+stdlib load inside the
processcontainer for the write path; validate via scripts/demo_sandbox.ps1.
Read works regardless; fallback for write is a certutil -decode approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ponsive

Eager boot (_eager_init -> _init_auth) called the synchronous, blocking
three-hop acquire_agent_user_token and MSAL auth.authenticate() directly
on the asyncio loop. asyncio.create_task does not make a sync body
non-blocking, so the loop was frozen for ~60s during auth and the MCP
`initialize` handshake could not be serviced.

Claude Code tolerates a slow MCP server, but stdio/ACP engine hosts
(GitHub Copilot CLI) enforce a startup readiness deadline: the stalled
handshake surfaced as `MCP error -32001: Request timed out` and the
engine launch aborted with `launch_engine ... exit code 1`.

Wrap both blocking calls in asyncio.to_thread so auth runs on a worker
thread and the loop answers `initialize` immediately (~1.8s, was >60s).
Eager observation is preserved. Code fix only; .mcp.json and
scripts/mcp_config.py were never the problem.

Adds TestInitAuthDoesNotBlockEventLoop (asserts the loop stays
responsive while a slow blocking token call runs) and Learning #69.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Unrelated to the boot-auth fix; cleaning up lint/test debt found while
running the full suite.

- test_delegated.py: TestTokenCache.test_cache_location_uses_stable_user_cache_dir
  did mkdir(parents=True) without exist_ok, so a leftover .pytest-scratch
  dir from any interrupted run made it fail with FileExistsError. Add
  exist_ok=True to both mkdir calls.
- test_demo_simple.py: sort the stdlib import block, hoist `shutil` to the
  top, and noqa the one import that must follow sys.path setup (I001/E402).
- test_a365_setup_prereqs.py: wrap two over-length assert strings (E501).

ruff check . clean; full suite 1659 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants