diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..9c95195b
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,11 @@
+# Phase Gate Enforcement — Protected Files
+# ==========================================
+# Changes to enforcement infrastructure require admin review.
+# This mitigates the PR-self-modification attack vector where
+# a PR modifies phase-config.yml or workflow files to weaken gates.
+
+# All GitHub configuration (workflows, config, templates)
+.github/** @Deepfreezechill
+
+# Enforcement documentation
+docs/enforcement/** @Deepfreezechill
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..6d8f6252
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,51 @@
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 10
+ labels:
+ - dependencies
+ - security
+ reviewers:
+ - Deepfreezechill
+ commit-message:
+ prefix: "deps"
+ # Group minor/patch updates to reduce PR noise
+ groups:
+ production-deps:
+ patterns:
+ - "*"
+ exclude-patterns:
+ - "pytest*"
+ - "ruff"
+ - "mypy"
+ - "black"
+ - "flake8"
+ - "pytest-*"
+ update-types:
+ - minor
+ - patch
+ dev-deps:
+ patterns:
+ - "pytest*"
+ - "ruff"
+ - "mypy"
+ - "pytest-*"
+ update-types:
+ - minor
+ - patch
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ labels:
+ - ci
+ - dependencies
+ commit-message:
+ prefix: "ci"
diff --git a/.github/phase-config.yml b/.github/phase-config.yml
new file mode 100644
index 00000000..4c291555
--- /dev/null
+++ b/.github/phase-config.yml
@@ -0,0 +1,81 @@
+# Phase Gate Dependency Configuration
+# =====================================
+# This file defines the dependency graph between project phases.
+# Milestone names in GitHub MUST match the phase names here exactly.
+#
+# Enforcement workflow reads this file on every PR event.
+# Changes to this file require a PR (enforced by branch protection).
+#
+# Dependency Graph:
+#
+# P0 (Emergency Hardening)
+# / \
+# / \
+# v v
+# P1 (Foundation) P2 (Smart Sandbox)
+# | |
+# v |
+# P3 (store.py) |
+# | |
+# v |
+# P4 (tool+mcp) |
+# | |
+# v v
+# P5 (evolver+grounding) <--+
+# |
+# v
+# P6 (Production Readiness)
+# |
+# v
+# P7 (Enforcement & Launch)
+
+phases:
+ "Phase 0 — Emergency Hardening":
+ prerequisites: []
+ description: "Stop the bleeding — critical security, error handling, env hygiene"
+
+ "Phase 1 — Foundation Architecture":
+ prerequisites:
+ - "Phase 0 — Emergency Hardening"
+ description: "Hexagonal skeleton, DI container, type system, CI foundation"
+
+ "Phase 2 — Smart Sandbox":
+ prerequisites:
+ - "Phase 0 — Emergency Hardening"
+ description: "E2B integration, capability leases, 9-stage pre-execution pipeline"
+
+ "Phase 3 — Extract store.py":
+ prerequisites:
+ - "Phase 1 — Foundation Architecture"
+ description: "Decompose store.py god-class into 7 focused modules"
+
+ "Phase 4 — Extract tool_layer + mcp_server":
+ prerequisites:
+ - "Phase 3 — Extract store.py"
+ description: "Decompose tool_layer.py (6 modules) and mcp_server.py (5 modules)"
+
+ "Phase 5 — Extract evolver + grounding":
+ prerequisites:
+ - "Phase 2 — Smart Sandbox"
+ - "Phase 4 — Extract tool_layer + mcp_server"
+ description: "Decompose evolver.py (9 modules) and grounding_agent.py (8 modules)"
+
+ "Phase 6 — Production Readiness":
+ prerequisites:
+ - "Phase 5 — Extract evolver + grounding"
+ description: "Observability, SLOs, deployment architecture, DX polish"
+
+ "Phase 7 — Enforcement & Launch":
+ prerequisites:
+ - "Phase 6 — Production Readiness"
+ description: "Eight-eyes integration, SkillGuard, launch checklist"
+
+# Escape hatch configuration
+escape_hatch:
+ label: "emergency:bypass"
+ require_reason: true
+ reason_pattern: "## ⚠️ Bypass Reason"
+ min_reason_length: 30
+ # Only these GitHub usernames can invoke emergency bypass
+ allowed_actors:
+ - "Deepfreezechill"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..3eeac392
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,32 @@
+## 🎯 What This Solves
+Closes #
+
+**Phase:** P0 / P1 / P2 / P3 / P4 / P5 / P6 / P7
+**Track:** 🔴 Security / 🟡 Architecture / 🟢 Platform
+
+## 🔧 Changes Made
+-
+
+## 🧪 Testing Done
+- [ ] Unit tests pass
+- [ ] Integration points verified
+- [ ] Manual smoke test (if applicable)
+
+## 🔍 Review Focus Areas
+
+
+## ⚠️ Risk & Rollback
+- [ ] No breaking changes
+- [ ] Rollback plan:
+
+## 📝 ADR
+- [ ] No architecture decisions made
+- [ ] ADR written: `docs/adr/ADR-XXX.md`
+
+
diff --git a/.github/workflows/auto-close.yml b/.github/workflows/auto-close.yml
new file mode 100644
index 00000000..737a5f6f
--- /dev/null
+++ b/.github/workflows/auto-close.yml
@@ -0,0 +1,36 @@
+name: Auto-Close Issues on PR Merge
+on:
+ pull_request:
+ types: [closed]
+
+jobs:
+ auto-close:
+ if: github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
+ - name: Close linked issues and update project
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ const body = pr.body || '';
+ const title = pr.title || '';
+ const combined = body + ' ' + title;
+
+ // Extract issue references (Closes #123, Fixes #456, etc.)
+ const issueRefs = [...combined.matchAll(/(?:closes?|fixes?|resolves?)\s+#(\d+)/gi)];
+
+ for (const match of issueRefs) {
+ const issueNum = parseInt(match[1]);
+ try {
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issueNum,
+ state: 'closed'
+ });
+ console.log(`✅ Closed issue #${issueNum}`);
+ } catch (error) {
+ console.log(`❌ Failed to close #${issueNum}: ${error.message}`);
+ }
+ }
diff --git a/.github/workflows/bypass-audit.yml b/.github/workflows/bypass-audit.yml
new file mode 100644
index 00000000..f0dc8858
--- /dev/null
+++ b/.github/workflows/bypass-audit.yml
@@ -0,0 +1,122 @@
+# ============================================================================
+# Emergency Bypass Audit — Track and report all enforcement overrides
+# ============================================================================
+#
+# Fires when the `emergency:bypass` label is added or removed.
+# Creates a persistent audit record as a repo issue with the `audit` label.
+# Also removes the bypass label after merge to prevent re-use.
+
+name: Bypass Audit Trail
+
+on:
+ pull_request:
+ types: [closed, labeled]
+ branches: [main]
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ # Record when bypass label is applied
+ audit-bypass-applied:
+ if: >
+ github.event.action == 'labeled' &&
+ github.event.label.name == 'emergency:bypass'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Record bypass activation
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ const actor = context.actor;
+
+ // Extract bypass reason from PR body
+ const body = pr.body || '';
+ const reasonIdx = body.indexOf('## ⚠️ Bypass Reason');
+ let reason = '(No reason provided)';
+ if (reasonIdx !== -1) {
+ reason = body.slice(reasonIdx + '## ⚠️ Bypass Reason'.length).trim().split('\n')[0];
+ }
+
+ // Create audit issue
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `🔓 Bypass Activated: PR #${pr.number} by @${actor}`,
+ body: [
+ `## Emergency Bypass Audit Record`,
+ ``,
+ `| Field | Value |`,
+ `|-------|-------|`,
+ `| **PR** | #${pr.number} |`,
+ `| **Title** | ${pr.title} |`,
+ `| **Actor** | @${actor} |`,
+ `| **Timestamp** | ${new Date().toISOString()} |`,
+ `| **Reason** | ${reason} |`,
+ `| **PR Labels** | ${pr.labels.map(l => '`' + l.name + '`').join(', ')} |`,
+ ``,
+ `> This record was auto-generated by the bypass audit system.`,
+ `> Bypass records are permanent and cannot be deleted.`,
+ ].join('\n'),
+ labels: ['audit', 'emergency:bypass'],
+ });
+
+ core.info(`📝 Audit record created for bypass on PR #${pr.number}`);
+
+ # Clean up bypass label after merge + create completion record
+ audit-bypass-merged:
+ if: >
+ github.event.action == 'closed' &&
+ github.event.pull_request.merged == true &&
+ contains(github.event.pull_request.labels.*.name, 'emergency:bypass')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Record bypass merge and clean up
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+
+ // Remove the bypass label so it can't be re-used if PR is reopened
+ try {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ name: 'emergency:bypass',
+ });
+ core.info('Removed emergency:bypass label from merged PR');
+ } catch (err) {
+ core.warning(`Could not remove bypass label: ${err.message}`);
+ }
+
+ // Find the audit issue for this PR and add merge note
+ const auditIssues = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: 'audit,emergency:bypass',
+ state: 'open',
+ });
+
+ const auditIssue = auditIssues.data.find(i =>
+ i.title.includes(`PR #${pr.number}`)
+ );
+
+ if (auditIssue) {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: auditIssue.number,
+ body: [
+ `## ✅ Bypass Merge Complete`,
+ ``,
+ `PR #${pr.number} was merged at ${new Date().toISOString()}.`,
+ `Merge commit: ${pr.merge_commit_sha}`,
+ ``,
+ `This audit record will remain open for 30-day review.`,
+ ].join('\n'),
+ });
+ }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..e1110062
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,97 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ name: Lint & Format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.13"
+
+ - name: Install ruff
+ run: pip install ruff==0.11.12
+
+ - name: Ruff lint check
+ run: ruff check .
+
+ - name: Ruff format check
+ run: ruff format --check .
+
+ test:
+ name: Test & Coverage
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.13"
+
+ - name: Install dependencies
+ run: |
+ pip install -e ".[dev]"
+ pip install pytest-timeout==2.4.0 pytest-cov==6.2.1
+
+ - name: Run tests with coverage
+ run: |
+ python -m pytest tests/ -v \
+ --timeout=30 \
+ --cov=openspace \
+ --cov-report=term-missing \
+ --cov-report=xml \
+ --cov-fail-under=20
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@bbbca2dafaf7f97fd117779653c6fb65339ca750 # v4
+ if: always()
+ with:
+ name: coverage-report
+ path: coverage.xml
+
+ typecheck:
+ name: Type Check (mypy)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.13"
+
+ - name: Install mypy
+ run: pip install mypy==1.16.0
+
+ - name: Run mypy
+ run: mypy openspace/ --ignore-missing-imports
+
+ dependency-audit:
+ name: Dependency Audit (pip-audit)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.13"
+
+ - name: Install pip-audit
+ run: pip install "pip-audit>=2.7.0,<3"
+
+ - name: Audit dependencies
+ run: |
+ pip install -e ".[dev]" --quiet
+ pip-audit --strict --desc on
diff --git a/.github/workflows/phase-enforce.yml b/.github/workflows/phase-enforce.yml
new file mode 100644
index 00000000..930f5119
--- /dev/null
+++ b/.github/workflows/phase-enforce.yml
@@ -0,0 +1,439 @@
+# ============================================================================
+# Phase Gate Enforcement — Fail-Closed PR Merge Guard
+# ============================================================================
+#
+# PURPOSE: Block merges for PRs that reference issues in phases whose
+# prerequisites are not yet complete. If this Action crashes or
+# fails to report a status, the merge is BLOCKED (fail-closed).
+#
+# PATTERN LINEAGE:
+# - eight-eyes/circuit_breaker.py → retry + fail-closed design
+# - squad-audit/squad-label-enforce.yml → structured PR comments
+# - squad-audit/squad-promote.yml → prerequisite validation gates
+#
+# REQUIRED: Branch protection must require this check ("Phase Gate Enforcement")
+# ============================================================================
+
+name: Phase Gate Enforcement
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, labeled, unlabeled, edited]
+ branches: [main]
+
+permissions:
+ contents: read
+ issues: read
+ pull-requests: write
+
+# Concurrency: cancel in-flight runs for the same PR
+concurrency:
+ group: phase-enforce-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ enforce:
+ name: "Phase Gate Enforcement"
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout BASE branch (not PR — prevents self-modification attack)
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.base.sha }}
+
+ - name: Install js-yaml
+ run: npm install js-yaml@4 --no-save --silent 2>/dev/null
+
+ - name: Load phase configuration
+ id: config
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const yaml = require('js-yaml');
+ const configPath = '.github/phase-config.yml';
+
+ if (!fs.existsSync(configPath)) {
+ core.setFailed('FAIL-CLOSED: .github/phase-config.yml not found. Cannot enforce phase gates.');
+ return;
+ }
+
+ try {
+ const raw = fs.readFileSync(configPath, 'utf8');
+ const config = yaml.load(raw);
+
+ // Validate structure
+ if (!config.phases || typeof config.phases !== 'object') {
+ core.setFailed('FAIL-CLOSED: phase-config.yml missing "phases" key.');
+ return;
+ }
+
+ // Check for circular dependencies
+ const visited = new Set();
+ const visiting = new Set();
+ function detectCycle(phase) {
+ if (visiting.has(phase)) return true;
+ if (visited.has(phase)) return false;
+ visiting.add(phase);
+ const prereqs = config.phases[phase]?.prerequisites || [];
+ for (const p of prereqs) {
+ if (!config.phases[p]) {
+ core.setFailed(`FAIL-CLOSED: Phase "${phase}" has unknown prerequisite "${p}"`);
+ return true;
+ }
+ if (detectCycle(p)) return true;
+ }
+ visiting.delete(phase);
+ visited.add(phase);
+ return false;
+ }
+
+ for (const phase of Object.keys(config.phases)) {
+ if (detectCycle(phase)) {
+ core.setFailed(`FAIL-CLOSED: Circular dependency detected involving "${phase}"`);
+ return;
+ }
+ }
+
+ core.setOutput('config', JSON.stringify(config));
+ core.info('✅ Phase configuration loaded and validated');
+ } catch (err) {
+ core.setFailed(`FAIL-CLOSED: Failed to parse phase-config.yml: ${err.message}`);
+ }
+
+ - name: Enforce phase gates
+ if: steps.config.outputs.config
+ uses: actions/github-script@v7
+ env:
+ PHASE_CONFIG: ${{ steps.config.outputs.config }}
+ with:
+ script: |
+ // =================================================================
+ // ENFORCEMENT ENGINE — Fail-closed by design
+ // =================================================================
+ //
+ // If ANY step throws an unhandled exception, the Action fails,
+ // no status is reported, and the merge is blocked.
+ // This is intentional. We retry API calls, but logic errors = deny.
+ // =================================================================
+
+ const MAX_RETRIES = 3;
+ const RETRY_DELAYS = [1000, 3000, 5000]; // ms
+
+ // Retry wrapper (from eight-eyes circuit breaker pattern)
+ async function withRetry(fn, label) {
+ let lastErr;
+ for (let i = 0; i <= MAX_RETRIES; i++) {
+ try {
+ return await fn();
+ } catch (err) {
+ lastErr = err;
+ if (i < MAX_RETRIES) {
+ const delay = RETRY_DELAYS[i] || 5000;
+ core.warning(`[retry ${i + 1}/${MAX_RETRIES}] ${label}: ${err.message} — retrying in ${delay}ms`);
+ await new Promise(r => setTimeout(r, delay));
+ }
+ }
+ }
+ throw new Error(`${label} failed after ${MAX_RETRIES} retries: ${lastErr.message}`);
+ }
+
+ // ---- Load config (via env to avoid string interpolation attacks) ----
+ const config = JSON.parse(process.env.PHASE_CONFIG);
+ const phaseConfig = config.phases;
+ const escapeHatch = config.escape_hatch || {};
+ const pr = context.payload.pull_request;
+ const prBody = (pr.body || '') + ' ' + (pr.title || '');
+
+ // Audit log accumulator
+ const audit = {
+ pr_number: pr.number,
+ pr_title: pr.title,
+ timestamp: new Date().toISOString(),
+ run_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
+ checks: [],
+ verdict: null,
+ };
+
+ function addCheck(name, passed, detail) {
+ const icon = passed ? '✅' : '❌';
+ audit.checks.push({ name, passed, detail, icon });
+ core.info(`${icon} ${name}: ${detail}`);
+ }
+
+ // ---- LAYER 4: Escape hatch (check first — may short-circuit) ----
+ const prLabels = pr.labels.map(l => l.name);
+ const bypassLabel = escapeHatch.label || 'emergency:bypass';
+ const hasBypassLabel = prLabels.includes(bypassLabel);
+
+ if (hasBypassLabel) {
+ // KEY 1: Actor must be in allowlist
+ const allowedActors = escapeHatch.allowed_actors || [];
+ const actor = context.actor;
+ if (allowedActors.length > 0 && !allowedActors.includes(actor)) {
+ addCheck('Emergency bypass', false, `Actor @${actor} is not authorized to invoke bypass. Allowed: ${allowedActors.join(', ')}`);
+ // Fall through to normal enforcement (deny)
+ } else {
+ // KEY 2: Reason pattern must be present (not inside HTML comments)
+ const reasonPattern = escapeHatch.reason_pattern || '## ⚠️ Bypass Reason';
+ const minLength = escapeHatch.min_reason_length || 30;
+ // Strip HTML comments before searching to prevent template placeholder match
+ const bodyNoComments = (pr.body || '').replace(//g, '');
+ const reasonIdx = bodyNoComments.indexOf(reasonPattern);
+ if (reasonIdx === -1) {
+ addCheck('Emergency bypass', false, `Label \`${bypassLabel}\` present but no "${reasonPattern}" section in PR body (outside HTML comments). Both keys required.`);
+ // Don't short-circuit — fall through to fail
+ } else {
+ const reasonText = bodyNoComments.slice(reasonIdx + reasonPattern.length).trim().split('\n')[0];
+ if (!reasonText || reasonText.length < minLength) {
+ addCheck('Emergency bypass', false, `Bypass reason too short (${(reasonText || '').length}/${minLength} chars). Explain WHY this bypass is necessary.`);
+ } else {
+ addCheck('Emergency bypass', true, `OVERRIDE by @${actor}: "${reasonText.slice(0, 100)}"`);
+
+ // Short-circuit: bypass granted
+ audit.verdict = 'BYPASS';
+ audit.bypass_reason = reasonText;
+ audit.bypass_actor = actor;
+
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.warning(`⚠️ EMERGENCY BYPASS by @${actor}: ${reasonText.slice(0, 100)}`);
+ return; // Exit with success
+ }
+ }
+ }
+ }
+
+ // ---- LAYER 1: Issue linkage ----
+ const issuePattern = /(?:closes?|fixes?|resolves?)\s+#(\d+)/gi;
+ const issueMatches = [...prBody.matchAll(issuePattern)];
+ const linkedIssueNumbers = [...new Set(issueMatches.map(m => parseInt(m[1])))];
+
+ if (linkedIssueNumbers.length === 0) {
+ addCheck('Issue linkage', false,
+ 'No linked issues found. PR must contain "Closes #N", "Fixes #N", or "Resolves #N".');
+ audit.verdict = 'FAIL';
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.setFailed('Phase gate: No linked issues');
+ return;
+ }
+
+ addCheck('Issue linkage', true, `Linked issues: ${linkedIssueNumbers.map(n => '#' + n).join(', ')}`);
+
+ // ---- Fetch linked issues ----
+ const linkedIssues = [];
+ for (const num of linkedIssueNumbers) {
+ try {
+ const issue = await withRetry(() =>
+ github.rest.issues.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: num,
+ }),
+ `Fetch issue #${num}`
+ );
+ linkedIssues.push(issue.data);
+ } catch (err) {
+ addCheck('Issue fetch', false, `Failed to fetch #${num}: ${err.message}`);
+ }
+ }
+
+ if (linkedIssues.length === 0) {
+ addCheck('Issue fetch', false, 'Could not fetch any linked issues');
+ audit.verdict = 'FAIL';
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.setFailed('Phase gate: Failed to fetch linked issues');
+ return;
+ }
+
+ // ---- LAYER 2: Milestone check ----
+ const issuesWithMilestones = linkedIssues.filter(i => i.milestone);
+ const issuesWithoutMilestones = linkedIssues.filter(i => !i.milestone);
+
+ if (issuesWithoutMilestones.length > 0) {
+ const nums = issuesWithoutMilestones.map(i => '#' + i.number).join(', ');
+ addCheck('Milestone assignment', false,
+ `Issues without milestones: ${nums}. Every issue must be in a phase milestone.`);
+ audit.verdict = 'FAIL';
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.setFailed(`Phase gate: Issues ${nums} have no milestone`);
+ return;
+ }
+
+ // Validate milestone names match config
+ const milestoneNames = [...new Set(issuesWithMilestones.map(i => i.milestone.title))];
+ const unknownMilestones = milestoneNames.filter(m => !phaseConfig[m]);
+
+ if (unknownMilestones.length > 0) {
+ addCheck('Milestone validation', false,
+ `Unknown milestones: ${unknownMilestones.join(', ')}. Must match a phase in phase-config.yml.`);
+ audit.verdict = 'FAIL';
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.setFailed(`Phase gate: Unknown milestones: ${unknownMilestones.join(', ')}`);
+ return;
+ }
+
+ addCheck('Milestone assignment', true, `All issues in known phases: ${milestoneNames.join(', ')}`);
+
+ // ---- LAYER 3: Phase prerequisite check ----
+ const allMilestones = await withRetry(() =>
+ github.rest.issues.listMilestones({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'all',
+ per_page: 100,
+ }),
+ 'Fetch all milestones'
+ );
+
+ const milestoneMap = {};
+ for (const ms of allMilestones.data) {
+ milestoneMap[ms.title] = ms;
+ }
+
+ let allPrereqsMet = true;
+ const prereqDetails = [];
+
+ for (const phaseName of milestoneNames) {
+ const phase = phaseConfig[phaseName];
+ const prereqs = phase.prerequisites || [];
+
+ if (prereqs.length === 0) {
+ prereqDetails.push(`**${phaseName}**: No prerequisites (✅)`);
+ continue;
+ }
+
+ for (const prereqName of prereqs) {
+ const prereqMs = milestoneMap[prereqName];
+
+ if (!prereqMs) {
+ prereqDetails.push(`**${prereqName}**: Milestone not found (❌)`);
+ allPrereqsMet = false;
+ continue;
+ }
+
+ const total = prereqMs.open_issues + prereqMs.closed_issues;
+ if (total === 0) {
+ prereqDetails.push(`**${prereqName}**: Empty milestone — no tasks defined (❌)`);
+ allPrereqsMet = false;
+ continue;
+ }
+
+ if (prereqMs.open_issues > 0) {
+ const pct = Math.round((prereqMs.closed_issues / total) * 100);
+ prereqDetails.push(
+ `**${prereqName}**: ${prereqMs.open_issues} open of ${total} (${pct}% done) (❌)`
+ );
+ allPrereqsMet = false;
+ } else {
+ prereqDetails.push(`**${prereqName}**: ${total}/${total} complete (✅)`);
+ }
+ }
+ }
+
+ addCheck('Phase prerequisites', allPrereqsMet,
+ prereqDetails.join(' | '));
+
+ // ---- VERDICT ----
+ if (allPrereqsMet) {
+ audit.verdict = 'PASS';
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.info('✅ Phase gate: All prerequisites met. Merge allowed.');
+ } else {
+ audit.verdict = 'FAIL';
+ const auditComment = formatAuditComment(audit);
+ await postAuditComment(auditComment);
+ core.setFailed('Phase gate: Prerequisites not met. See PR comment for details.');
+ }
+
+ // ---- AUDIT HELPERS ----
+
+ function formatAuditComment(audit) {
+ const icon = audit.verdict === 'PASS' ? '✅' :
+ audit.verdict === 'BYPASS' ? '⚠️' : '🚫';
+ const color = audit.verdict === 'PASS' ? 'green' :
+ audit.verdict === 'BYPASS' ? 'orange' : 'red';
+
+ let body = `## 🔒 Phase Gate Enforcement — ${icon} ${audit.verdict}\n\n`;
+ body += `| Check | Result | Detail |\n|-------|--------|--------|\n`;
+
+ for (const check of audit.checks) {
+ const detail = check.detail.length > 120
+ ? check.detail.slice(0, 117) + '...'
+ : check.detail;
+ body += `| ${check.name} | ${check.icon} | ${detail} |\n`;
+ }
+
+ body += `\n**Verdict:** ${audit.verdict}`;
+ if (audit.bypass_reason) {
+ body += ` — Reason: "${audit.bypass_reason.slice(0, 200)}"`;
+ }
+ body += `\n**Timestamp:** ${audit.timestamp}`;
+ body += `\n**Run:** [View workflow run](${audit.run_url})`;
+
+ if (audit.verdict === 'FAIL') {
+ body += `\n\n---\n`;
+ body += `> **How to fix:** Ensure all prerequisite phases are complete, `;
+ body += `or add \`emergency:bypass\` label with a \`## Bypass Reason\` section in the PR body.\n`;
+ }
+
+ if (audit.verdict === 'BYPASS') {
+ body += `\n\n---\n`;
+ body += `> ⚠️ **This merge bypassed phase gate enforcement.** `;
+ body += `The bypass reason has been recorded for audit purposes.\n`;
+ }
+
+ return body;
+ }
+
+ async function postAuditComment(body) {
+ // Find existing enforcement comment and update it (don't spam)
+ const marker = '## 🔒 Phase Gate Enforcement';
+ try {
+ const comments = await withRetry(() =>
+ github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ per_page: 100,
+ }),
+ 'Fetch PR comments'
+ );
+
+ const existing = comments.data.find(c =>
+ c.user.login === 'github-actions[bot]' &&
+ c.body.startsWith(marker)
+ );
+
+ if (existing) {
+ await withRetry(() =>
+ github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body: body,
+ }),
+ 'Update audit comment'
+ );
+ } else {
+ await withRetry(() =>
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ body: body,
+ }),
+ 'Create audit comment'
+ );
+ }
+ } catch (err) {
+ // Audit comment failure does NOT block the enforcement decision.
+ // The status check result is what matters for merge blocking.
+ core.warning(`Failed to post audit comment: ${err.message}`);
+ }
+ }
diff --git a/.github/workflows/phase-gates.yml b/.github/workflows/phase-gates.yml
new file mode 100644
index 00000000..f042bb84
--- /dev/null
+++ b/.github/workflows/phase-gates.yml
@@ -0,0 +1,49 @@
+name: Phase Gate Monitor
+on:
+ issues:
+ types: [closed]
+ schedule:
+ - cron: '0 9 * * 1' # Every Monday at 9 AM UTC
+
+jobs:
+ check-gates:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check milestone completion
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const milestones = await github.rest.issues.listMilestones({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open'
+ });
+
+ for (const ms of milestones.data) {
+ const total = ms.open_issues + ms.closed_issues;
+ if (total === 0) continue;
+
+ const pct = Math.round((ms.closed_issues / total) * 100);
+
+ // Phase complete!
+ if (ms.open_issues === 0) {
+ // Check if we already created a completion issue
+ const existing = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: 'phase-complete',
+ state: 'all'
+ });
+ const alreadyDone = existing.data.some(i => i.title.includes(ms.title));
+ if (alreadyDone) continue;
+
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `🎉 ${ms.title} — COMPLETE (${total} tasks)`,
+ body: `## ✅ Phase Complete\n\nAll **${total}** tasks in **${ms.title}** are done.\n\n### Gate Checklist\n- [ ] All tests pass\n- [ ] Manual smoke test passed\n- [ ] No P0 bugs open\n- [ ] ADRs written for any plan deviations\n- [ ] Ready to begin next phase\n\n### Summary\nPhase closed automatically when all ${total} issues were resolved.`,
+ labels: ['phase-complete']
+ });
+ console.log(`🎉 ${ms.title} complete!`);
+ }
+ }
diff --git a/.github/workflows/progress-dashboard.yml b/.github/workflows/progress-dashboard.yml
new file mode 100644
index 00000000..a0f7767e
--- /dev/null
+++ b/.github/workflows/progress-dashboard.yml
@@ -0,0 +1,89 @@
+name: Progress Dashboard
+on:
+ schedule:
+ - cron: '0 8 * * 1,4' # Monday & Thursday at 8 AM UTC
+ workflow_dispatch:
+
+jobs:
+ dashboard:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate progress report
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const milestones = await github.rest.issues.listMilestones({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'all',
+ sort: 'created',
+ direction: 'asc'
+ });
+
+ let totalOpen = 0, totalClosed = 0;
+ let phaseLines = [];
+
+ for (const ms of milestones.data) {
+ const total = ms.open_issues + ms.closed_issues;
+ if (total === 0) continue;
+ const pct = Math.round((ms.closed_issues / total) * 100);
+ totalOpen += ms.open_issues;
+ totalClosed += ms.closed_issues;
+
+ const bar = '█'.repeat(Math.floor(pct / 5)) + '░'.repeat(20 - Math.floor(pct / 5));
+ const status = ms.open_issues === 0 ? '✅ DONE' : `⏳ ${ms.open_issues} left`;
+ phaseLines.push(`| ${ms.title} | ${bar} ${pct}% | ${ms.closed_issues}/${total} | ${status} |`);
+ }
+
+ const grandTotal = totalOpen + totalClosed;
+ const overallPct = grandTotal > 0 ? Math.round((totalClosed / grandTotal) * 100) : 0;
+ const overallBar = '█'.repeat(Math.floor(overallPct / 5)) + '░'.repeat(20 - Math.floor(overallPct / 5));
+
+ const report = `# 📊 OpenSpace Upgrade — Progress Dashboard
+
+> Auto-generated: ${new Date().toISOString().split('T')[0]} | [View Project Board](https://github.com/users/Deepfreezechill/projects/1)
+
+## Overall: ${overallPct}% Complete
+\`\`\`
+${overallBar} ${overallPct}% (${totalClosed}/${grandTotal} tasks)
+\`\`\`
+
+## Phase Breakdown
+| Phase | Progress | Done/Total | Status |
+|-------|----------|------------|--------|
+${phaseLines.join('\n')}
+
+## Velocity
+- **This week:** Check [closed issues](https://github.com/${context.repo.owner}/${context.repo.repo}/issues?q=is%3Aclosed+sort%3Aupdated-desc)
+- **Target:** ~12-15 tasks/week for 6-month completion
+
+---
+*Updated automatically Mon & Thu. Run manually: Actions → Progress Dashboard → Run workflow*`;
+
+ // Find or create the dashboard issue
+ const existing = await github.rest.issues.listForRepo({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: 'dashboard',
+ state: 'open'
+ });
+
+ if (existing.data.length > 0) {
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: existing.data[0].number,
+ body: report
+ });
+ console.log(`Updated dashboard issue #${existing.data[0].number}`);
+ } else {
+ const created = await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: '📊 Project Progress Dashboard',
+ body: report,
+ labels: ['dashboard']
+ });
+ // Pin it
+ console.log(`Created dashboard issue #${created.data.number}`);
+ }
diff --git a/.github/workflows/stale-check.yml b/.github/workflows/stale-check.yml
new file mode 100644
index 00000000..98aaafed
--- /dev/null
+++ b/.github/workflows/stale-check.yml
@@ -0,0 +1,52 @@
+name: Stale Issue Detection
+on:
+ schedule:
+ - cron: '0 10 * * 2,5' # Tuesday & Friday at 10 AM UTC
+
+jobs:
+ check-stale:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Flag stale in-progress issues
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fiveDaysAgo = new Date();
+ fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5);
+
+ // Get all open issues
+ const issues = await github.paginate(github.rest.issues.listForRepo, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ sort: 'updated',
+ direction: 'asc',
+ per_page: 100
+ });
+
+ // Filter to issues assigned (in progress) but not updated recently
+ const stale = issues.filter(i => {
+ if (!i.assignee) return false;
+ const updated = new Date(i.updated_at);
+ return updated < fiveDaysAgo;
+ });
+
+ for (const issue of stale.slice(0, 10)) {
+ const hasLabel = issue.labels.some(l => l.name === 'stale');
+ if (hasLabel) continue;
+
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ labels: ['stale']
+ });
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: `🕐 **Stale check:** This issue is assigned but hasn't been updated in 5+ days.\n\n- Still working on it? Post a quick status update.\n- Blocked? Add the \`blocked\` label and describe the blocker.\n- Moved on? Unassign yourself.\n\n*Remove the \`stale\` label when you respond. Auto-generated by stale-check workflow.*`
+ });
+ }
+ console.log(`Flagged ${Math.min(stale.length, 10)} stale issues`);
diff --git a/.gitignore b/.gitignore
index 77dd181f..c8ac8fd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,3 +73,8 @@ openspace/skills/*
node_modules/
# Frontend local dependency link
frontend/node_modules
+
+# Coverage
+.coverage
+htmlcov/
+coverage.xml
diff --git a/README.md b/README.md
index b4076896..ff2c2238 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+[](https://github.com/Deepfreezechill/openspace-upgrade/actions/workflows/ci.yml)
+
diff --git a/docs/adr/000-template.md b/docs/adr/000-template.md
new file mode 100644
index 00000000..1a5f6284
--- /dev/null
+++ b/docs/adr/000-template.md
@@ -0,0 +1,20 @@
+# ADR-000: Template
+
+**Status:** [Accepted | Superseded | Deprecated]
+**Date:** YYYY-MM-DD
+**Phase:** P0-P7
+
+## Context
+What is the situation? What forces are at play?
+
+## Decision
+What did we decide?
+
+## Alternatives Considered
+- **Option A:** ...
+- **Option B:** ...
+
+## Consequences
+- **Good:** ...
+- **Bad:** ...
+- **Trade-offs accepted:** ...
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 00000000..4c843d69
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,20 @@
+# Architecture Decision Records
+
+This directory contains Architecture Decision Records (ADRs) for the OpenSpace upgrade project.
+
+## What is an ADR?
+A short document capturing a significant architecture decision and its context.
+
+## Rules
+- Write an ADR when choosing between two viable approaches
+- Write an ADR when deviating from the original plan
+- 5 minutes max per ADR — if it takes longer, you're over-engineering it
+- Number sequentially (ADR-001, ADR-002, ...)
+
+## Template
+Copy `000-template.md` to create a new ADR.
+
+## Index
+| # | Title | Status | Date |
+|---|-------|--------|------|
+| 000 | Template | — | — |
diff --git a/docs/enforcement/ARCHITECTURE.md b/docs/enforcement/ARCHITECTURE.md
new file mode 100644
index 00000000..c42045eb
--- /dev/null
+++ b/docs/enforcement/ARCHITECTURE.md
@@ -0,0 +1,343 @@
+# 🔒 Phase Gate Enforcement Architecture
+
+> **Classification:** Security-Critical Infrastructure
+> **Status:** DRAFT — Pending /8eyes Security Audit
+> **Author:** /collab Security & Enforcement Lead
+> **Pattern Lineage:** eight-eyes/circuit_breaker → squad-audit/label-enforce → openspace/phase-gates
+
+---
+
+## Executive Summary
+
+A **fail-closed enforcement system** that makes it **technically impossible** to merge
+out-of-phase code into the openspace-upgrade repository without explicit, audited admin override.
+
+**Key guarantee:** If the enforcement system crashes, is misconfigured, or encounters any
+unexpected state — the merge is BLOCKED, not allowed.
+
+---
+
+## 1. Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ ENFORCEMENT TOPOLOGY │
+│ │
+│ Developer creates PR │
+│ │ │
+│ ▼ │
+│ ┌─────────────────┐ ┌──────────────────────────────┐ │
+│ │ PR Event Fires │────▶│ phase-enforce.yml (Action) │ │
+│ └─────────────────┘ │ │ │
+│ │ LAYER 1: Issue Linkage │ │
+│ │ ├─ PR body contains │ │
+│ │ │ "Closes #N" or "Fixes #N"│ │
+│ │ └─ At least one linked issue │ │
+│ │ │ │
+│ │ LAYER 2: Milestone Check │ │
+│ │ ├─ Linked issue has milestone│ │
+│ │ └─ Milestone = valid phase │ │
+│ │ │ │
+│ │ LAYER 3: Phase Gate Check │ │
+│ │ ├─ Load PHASE_DEPS graph │ │
+│ │ ├─ For each prerequisite: │ │
+│ │ │ └─ Is milestone 100%? │ │
+│ │ └─ ALL prereqs must be met │ │
+│ │ │ │
+│ │ LAYER 4: Escape Hatch Check │ │
+│ │ ├─ Has `emergency:bypass`? │ │
+│ │ ├─ Bypass reason in PR body? │ │
+│ │ └─ Log override to audit │ │
+│ │ │ │
+│ │ LAYER 5: Audit Emission │ │
+│ │ └─ PR comment with verdict │ │
+│ └──────────────┬───────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌──────────────────────────────┐ │
+│ │ GitHub Required Status Check │ │
+│ │ │ │
+│ │ ✅ status = "success" │ │
+│ │ → Merge button ENABLED │ │
+│ │ │ │
+│ │ ❌ status = "failure" │ │
+│ │ → Merge button DISABLED │ │
+│ │ │ │
+│ │ ⚫ status = (missing/pending)│ │
+│ │ → Merge button DISABLED │ ← FAIL-CLOSED
+│ │ (Action crashed or hung) │ │
+│ └──────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ BRANCH PROTECTION (Layer 0) │ │
+│ │ │ │
+│ │ main branch: │ │
+│ │ ├─ Require PR before merging (no direct push) │ │
+│ │ ├─ Require status check: "Phase Gate Enforcement" (STRICT) │ │
+│ │ ├─ Require conversation resolution │ │
+│ │ ├─ Do NOT allow bypassing (even for admins — see escape hatch) │ │
+│ │ └─ Restrict pushes: only github-actions[bot] │ │
+│ │ │ │
+│ │ WHY: Even admins can't push to main. The ONLY path to main is │ │
+│ │ through a PR that passes the enforcement check. │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+## 2. Phase Dependency Graph
+
+```
+ P0 (Foundation)
+ / \
+ / \
+ ▼ ▼
+ P1 (Core A) P2 (Core B)
+ │ │
+ ▼ │
+ P3 (Extensions) │
+ │ │
+ ▼ │
+ P4 (Integration) │
+ │ │
+ ▼ ▼
+ P5 (Convergence) ◀─┘
+ │
+ ▼
+ P6 (Hardening)
+ │
+ ▼
+ P7 (Release)
+```
+
+**Encoded as configuration:**
+
+```yaml
+# .github/phase-config.yml
+phases:
+ "Phase 0 — Emergency Hardening":
+ prerequisites: [] # No prerequisites — always open
+ "Phase 1 — Foundation Architecture":
+ prerequisites: ["Phase 0 — Emergency Hardening"]
+ "Phase 2 — Smart Sandbox":
+ prerequisites: ["Phase 0 — Emergency Hardening"]
+ "Phase 3 — Extract store.py":
+ prerequisites: ["Phase 1 — Foundation Architecture"]
+ "Phase 4 — Extract tool_layer + mcp_server":
+ prerequisites: ["Phase 3 — Extract store.py"]
+ "Phase 5 — Extract evolver + grounding":
+ prerequisites:
+ - "Phase 2 — Smart Sandbox"
+ - "Phase 4 — Extract tool_layer + mcp_server"
+ "Phase 6 — Production Readiness":
+ prerequisites: ["Phase 5 — Extract evolver + grounding"]
+ "Phase 7 — Enforcement & Launch":
+ prerequisites: ["Phase 6 — Production Readiness"]
+
+# Milestone names MUST match these phase names exactly.
+# This is the single source of truth for phase ordering.
+```
+
+## 3. Enforcement Decision Matrix
+
+| Condition | Result | Status Check | Audit |
+|-----------|--------|-------------|-------|
+| PR links issue(s) in completed-prereq phase | ✅ PASS | `success` | Green comment |
+| PR links issue(s) in Phase 0 (no prereqs) | ✅ PASS | `success` | Green comment |
+| PR has `emergency:bypass` label + reason | ⚠️ BYPASS | `success` | Orange comment with reason |
+| PR links no issues | ❌ FAIL | `failure` | Red comment: "Link an issue" |
+| PR links issue with no milestone | ❌ FAIL | `failure` | Red comment: "Add to milestone" |
+| PR links issue in milestone with unmet prereqs | ❌ FAIL | `failure` | Red comment: lists blockers |
+| PR has `emergency:bypass` but no reason | ❌ FAIL | `failure` | Red comment: "Provide bypass reason" |
+| Enforcement Action crashes | ⬛ MISSING | BLOCKED | No comment (investigate) |
+| GitHub API rate limited | ❌ FAIL | `failure` | Red comment: "Retry in N min" |
+
+**The last two rows are the fail-closed guarantee.** Missing status = blocked.
+
+## 4. Layer Details
+
+### Layer 0: Branch Protection (GitHub Settings)
+
+Configure via Settings → Branches → Branch protection rules → `main`:
+
+| Setting | Value | Rationale |
+|---------|-------|-----------|
+| Require a pull request before merging | ✅ | No direct pushes |
+| Require approvals | 0 (solo dev) | Solo dev doesn't need self-approval |
+| Dismiss stale pull request approvals | ✅ | If you add reviewers later |
+| Require status checks to pass | ✅ **CRITICAL** | This is the enforcement point |
+| Status check: `Phase Gate Enforcement` | ✅ Required | Must match workflow job name |
+| Require branches to be up to date | ✅ | Prevents stale merges |
+| Require conversation resolution | ✅ | Forces addressing review comments |
+| Do not allow bypassing the above settings | ✅ | Even admins must use escape hatch |
+| Restrict who can push to matching branches | github-actions[bot] | Belt and suspenders |
+
+> **CRITICAL:** "Do not allow bypassing" is what makes this fail-closed for admins too.
+> The escape hatch is through the `emergency:bypass` label, not through admin override
+> of branch protection. This way every bypass is audited.
+
+### Layer 1: PR → Issue Linkage
+
+```
+PR body or title must contain:
+ - "Closes #N" or "Fixes #N" or "Resolves #N"
+ - At least ONE valid issue reference
+
+Why not just "mentions #N"?
+ - "Closes" creates a formal link GitHub tracks
+ - Prevents gaming by mentioning random issues
+ - Auto-closes issue on merge (existing auto-close.yml behavior)
+```
+
+### Layer 2: Issue → Milestone Mapping
+
+```
+Every linked issue must belong to a milestone.
+The milestone name must match a key in phase-config.yml.
+
+Why milestone, not labels?
+ - Milestones have completion tracking (open/closed counts)
+ - Milestones are a single assignment (can't be in two phases)
+ - GitHub API provides milestone.open_issues natively
+ - Labels are used for other things (type:, priority:, etc.)
+```
+
+### Layer 3: Phase Gate Validation
+
+```python
+# Pseudocode for the core logic
+def check_phase_gate(issue_milestone, phase_config, all_milestones):
+ phase = phase_config[issue_milestone]
+
+ for prereq_name in phase.prerequisites:
+ prereq_milestone = find_milestone(prereq_name, all_milestones)
+
+ if prereq_milestone is None:
+ FAIL("Prerequisite milestone '{prereq_name}' not found")
+
+ if prereq_milestone.open_issues > 0:
+ FAIL(f"Phase '{prereq_name}' has {prereq_milestone.open_issues} open issues")
+
+ PASS("All prerequisite phases complete")
+```
+
+### Layer 4: Escape Hatch
+
+The escape hatch uses a **two-key mechanism** (inspired by nuclear launch protocols):
+
+1. **Key 1:** `emergency:bypass` label on the PR (admin-only label)
+2. **Key 2:** PR body contains `## Bypass Reason\n`
+
+Both are required. If only the label exists without a reason, the check still fails.
+
+```
+Emergency bypass is for:
+ ✅ Critical security patches
+ ✅ Production-down hotfixes
+ ✅ Phase gate enforcement bugs
+ ✅ External dependency deadlines
+
+Emergency bypass is NOT for:
+ ❌ "I'm almost done with this phase"
+ ❌ "This is a small change"
+ ❌ "I'll fix the issue later"
+```
+
+### Layer 5: Audit Trail
+
+Every enforcement decision produces a **structured PR comment**:
+
+```markdown
+## 🔒 Phase Gate Enforcement — [PASS|FAIL|BYPASS]
+
+| Check | Result | Detail |
+|-------|--------|--------|
+| Issue linkage | ✅/❌ | #42, #43 |
+| Milestone assigned | ✅/❌ | Phase 2 — Core B |
+| Phase prerequisites | ✅/❌ | Phase 0 ✅, Phase 1 ⏳ (3 open) |
+| Emergency bypass | ➖/⚠️ | N/A or "reason text" |
+
+**Verdict:** MERGE ALLOWED / MERGE BLOCKED
+**Timestamp:** 2025-01-15T10:30:00Z
+**Run:** [Actions link]
+```
+
+## 5. What CAN'T Be Enforced This Way?
+
+### Hard Limits of GitHub Actions Enforcement
+
+| Gap | Description | Mitigation |
+|-----|-------------|------------|
+| **Issue quality** | Can't verify the issue actually describes real work | Human review / triage process |
+| **Milestone accuracy** | Can't verify an issue is in the RIGHT milestone | Code review + milestone audits |
+| **Code correctness** | Phase gate ≠ code quality | Separate CI/CD pipeline (tests, lint) |
+| **Partial completion** | Issue marked "closed" but work is incomplete | PR review discipline |
+| **Cross-repo deps** | If phases span multiple repos | Extend to org-level workflows |
+| **Retroactive edits** | Someone could close prerequisite issues without doing work | Progress dashboard catches anomalies |
+| **GitHub outage** | If GitHub Actions is down, no status = no merge (GOOD — fail-closed) | Wait for GitHub to recover |
+| **Rate limits** | 1000 API calls/hour for Actions | Batch API calls; retry with backoff |
+| **Race conditions** | Two PRs pass gate simultaneously, one re-opens prereq issues | Re-run check on push to PR branch |
+
+### Branch Strategy Tradeoff Analysis
+
+| Strategy | Pros | Cons | Verdict |
+|----------|------|------|---------|
+| **Single main + status checks** | Simple, clear, one source of truth | All enforcement is in the Action | ✅ **Recommended** |
+| **Branch per phase** | Physical isolation, easy to reason about | Complex merge choreography, 8 branches, merge conflicts | ❌ Overkill for solo/small team |
+| **Hybrid (main + release branches)** | Release isolation | Unnecessary until Phase 7 | ❌ Premature complexity |
+
+**Recommendation: Single `main` branch with enforcement via required status checks.**
+
+For a solo dev or small team, branch-per-phase creates merge conflict hell across 8 long-lived
+branches. The enforcement Action provides equivalent protection with zero branch management overhead.
+
+## 6. Failure Mode Analysis
+
+Inspired by eight-eyes circuit breaker: every failure mode is enumerated and handled.
+
+| Failure | Category | System Behavior | Recovery |
+|---------|----------|-----------------|----------|
+| Action YAML syntax error | Crash | No status reported → BLOCKED | Fix YAML, re-push |
+| `actions/github-script` OOM | Crash | No status reported → BLOCKED | Optimize script |
+| GitHub API 500 | Transient | Retry 3x with backoff → FAIL if persists | Wait, re-run |
+| GitHub API 403 (rate limit) | Transient | FAIL with "rate limited" message | Wait for reset |
+| phase-config.yml missing | Config | FAIL with clear error message | Add config file |
+| phase-config.yml malformed | Config | FAIL with validation error | Fix YAML |
+| Milestone name mismatch | Config | FAIL: "milestone X not in config" | Align names |
+| PR references deleted issue | Edge case | FAIL: "issue #N not found" | Fix PR body |
+| Circular dependency in config | Config | FAIL: "circular dep detected" | Fix config |
+| New phase added, config not updated | Config | FAIL: "milestone X not in config" | Update config |
+
+**Key insight from eight-eyes:** The circuit breaker pattern (HEALTHY → RETRY → CIRCUIT_OPEN)
+maps to our GitHub Actions retry strategy. We retry API calls 3x with backoff before failing.
+But unlike eight-eyes, we don't need AWAITING_USER because the "user" (developer) is already
+present — they see the failed check and can re-run it.
+
+## 7. Security Considerations
+
+| Threat | Vector | Mitigation |
+|--------|--------|------------|
+| Modify enforcement workflow | Push to `.github/workflows/` | Branch protection prevents direct push; PR still needs to pass OLD enforcement |
+| Create bypass label without authorization | Label the PR | Make `emergency:bypass` a **protected label** (org-level, or enforce via label enforcement workflow) |
+| Close prerequisite issues to fake completion | Close issues without work | Progress dashboard shows velocity anomalies; issues without PRs are suspicious |
+| Fork + PR from fork | Different branch protection | Fork PRs still trigger the same required status check |
+| Workflow_dispatch bypass | Trigger Action manually | Enforcement is on `pull_request` trigger, not `workflow_dispatch` |
+| Delete and recreate main branch | Nuclear option | Repo admin only; audit log catches this |
+
+## 8. Implementation Checklist
+
+- [ ] Create `.github/phase-config.yml` with phase dependency graph
+- [ ] Create `.github/workflows/phase-enforce.yml` (the enforcement Action)
+- [ ] Configure branch protection on `main` per Layer 0 settings
+- [ ] Create `emergency:bypass` label (restricted to admins)
+- [ ] Create `phase-enforce-audit` label for tracking audit comments
+- [ ] Test with a PR that SHOULD be blocked (Phase 1 issue, Phase 0 incomplete)
+- [ ] Test with a PR that SHOULD pass (Phase 0 issue, no prereqs)
+- [ ] Test the escape hatch (add label + reason → should pass)
+- [ ] Test crash behavior (syntax error in phase-config.yml → should block)
+- [ ] Document for contributors in README
+
+---
+
+*This document is the single source of truth for the enforcement architecture.
+Changes to the enforcement system MUST update this document first.*
diff --git a/docs/enforcement/configure-branch-protection.sh b/docs/enforcement/configure-branch-protection.sh
new file mode 100644
index 00000000..1966fab8
--- /dev/null
+++ b/docs/enforcement/configure-branch-protection.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# ============================================================================
+# configure-branch-protection.sh
+# ============================================================================
+#
+# Configures branch protection rules for the openspace-upgrade repo.
+# Run this ONCE after pushing the enforcement workflow.
+#
+# Prerequisites:
+# - GitHub CLI (gh) installed and authenticated
+# - Admin access to the repository
+# - phase-enforce.yml must have run at least once (for status check to exist)
+#
+# Usage:
+# chmod +x configure-branch-protection.sh
+# ./configure-branch-protection.sh
+#
+# Example:
+# ./configure-branch-protection.sh Deepfreezechill openspace-upgrade
+# ============================================================================
+
+set -euo pipefail
+
+OWNER="${1:?Usage: $0 }"
+REPO="${2:?Usage: $0 }"
+
+echo "🔒 Configuring branch protection for ${OWNER}/${REPO}:main"
+echo ""
+
+# Step 1: Create the emergency:bypass label if it doesn't exist
+echo "📋 Creating emergency:bypass label..."
+gh label create "emergency:bypass" \
+ --repo "${OWNER}/${REPO}" \
+ --description "Emergency phase gate bypass — requires audit reason" \
+ --color "FF0000" \
+ 2>/dev/null || echo " (label already exists)"
+
+# Step 2: Create audit label
+echo "📋 Creating audit label..."
+gh label create "audit" \
+ --repo "${OWNER}/${REPO}" \
+ --description "Enforcement audit trail record" \
+ --color "FFA500" \
+ 2>/dev/null || echo " (label already exists)"
+
+# Step 3: Configure branch protection
+# Note: gh api is used because `gh branch-protection` doesn't support all options
+echo ""
+echo "🛡️ Configuring branch protection on 'main'..."
+echo ""
+echo " Required settings (configure manually in GitHub Settings → Branches):"
+echo ""
+echo " ┌─────────────────────────────────────────────────────────────────┐"
+echo " │ Branch protection rule: main │"
+echo " │ │"
+echo " │ [✅] Require a pull request before merging │"
+echo " │ Required approving reviews: 0 (solo dev) │"
+echo " │ [✅] Dismiss stale pull request approvals when new │"
+echo " │ commits are pushed │"
+echo " │ │"
+echo " │ [✅] Require status checks to pass before merging │"
+echo " │ [✅] Require branches to be up to date before merging │"
+echo " │ Status checks that are required: │"
+echo " │ → Phase Gate Enforcement │"
+echo " │ │"
+echo " │ [✅] Require conversation resolution before merging │"
+echo " │ │"
+echo " │ [✅] Do not allow bypassing the above settings │"
+echo " │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ │"
+echo " │ THIS IS THE CRITICAL SETTING FOR FAIL-CLOSED │"
+echo " │ │"
+echo " │ [✅] Restrict who can push to matching branches │"
+echo " │ → github-actions[bot] (for automated merges only) │"
+echo " │ │"
+echo " └─────────────────────────────────────────────────────────────────┘"
+echo ""
+
+# Step 4: API-based configuration (what we can automate)
+echo "🔧 Applying API-based branch protection..."
+
+# Note: The GitHub REST API for branch protection requires the status check name
+# to have been reported at least once. Push the workflow and run it on a test PR first.
+gh api -X PUT "repos/${OWNER}/${REPO}/branches/main/protection" \
+ --input - <<'EOF'
+{
+ "required_status_checks": {
+ "strict": true,
+ "contexts": ["Phase Gate Enforcement"]
+ },
+ "enforce_admins": true,
+ "required_pull_request_reviews": {
+ "dismiss_stale_reviews": true,
+ "require_code_owner_reviews": false,
+ "required_approving_review_count": 0
+ },
+ "restrictions": null,
+ "required_conversation_resolution": true
+}
+EOF
+
+echo ""
+echo "✅ Branch protection configured!"
+echo ""
+echo "⚠️ MANUAL STEP REQUIRED:"
+echo " Go to Settings → Branches → main → Edit"
+echo " Check: 'Do not allow bypassing the above settings'"
+echo " (This setting cannot be configured via API)"
+echo ""
+echo "🧪 TEST THE SETUP:"
+echo " 1. Create a test PR that links to a Phase 1 issue (Phase 0 incomplete)"
+echo " 2. Verify the merge button is DISABLED"
+echo " 3. Add 'emergency:bypass' label + '## Bypass Reason' section"
+echo " 4. Verify the merge button is ENABLED"
+echo " 5. Remove the label, verify DISABLED again"
+echo " 6. Close the test PR"
diff --git a/docs/sdk-api-spec.md b/docs/sdk-api-spec.md
new file mode 100644
index 00000000..10db8b99
--- /dev/null
+++ b/docs/sdk-api-spec.md
@@ -0,0 +1,365 @@
+# SkillGuard SDK — REST API Specification
+
+> **Phase:** 1 (design only — implementation in Phase 6)
+> **Status:** Draft
+> **Version:** 0.1.0
+
+## Overview
+
+The SkillGuard SDK API provides programmatic access to the SkillGuard skill
+execution engine. All endpoints live under the `/api/v2/` prefix and use
+JSON request/response bodies. The API is **async-first**: long-running
+operations return a `task_id` for polling.
+
+---
+
+## Base URL
+
+```
+https://:/api/v2
+```
+
+---
+
+## Authentication
+
+| Mechanism | Header | Notes |
+|-----------|--------|-------|
+| Bearer token | `Authorization: Bearer ` | Required for all endpoints |
+| Rate limiting | Per-IP + per-identity sliding window | Configurable via env vars |
+
+Token requirements:
+- Minimum 32 characters
+- Set via `OPENSPACE_MCP_BEARER_TOKEN` environment variable
+- Constant-time comparison (HMAC-based)
+- Fail-closed: missing/weak token → 401
+
+---
+
+## Common Response Envelope
+
+All responses use a standard envelope:
+
+```json
+{
+ "ok": true,
+ "data": { ... },
+ "error": null,
+ "request_id": "uuid-v4"
+}
+```
+
+Error responses:
+
+```json
+{
+ "ok": false,
+ "data": null,
+ "error": {
+ "code": "SKILL_NOT_FOUND",
+ "message": "Skill 'foo' not found in registry",
+ "details": {}
+ },
+ "request_id": "uuid-v4"
+}
+```
+
+---
+
+## Endpoints
+
+### Tasks
+
+#### `POST /api/v2/tasks`
+
+Execute a task through the SkillGuard pipeline.
+
+**Request:**
+```json
+{
+ "task": "string (required)",
+ "workspace_dir": "string | null",
+ "max_iterations": "integer | null",
+ "skill_dirs": ["string"] | null,
+ "search_scope": "all | local | cloud"
+}
+```
+
+**Response (202 Accepted):**
+```json
+{
+ "ok": true,
+ "data": {
+ "task_id": "uuid-v4",
+ "status": "queued",
+ "poll_url": "/api/v2/tasks/{task_id}"
+ }
+}
+```
+
+#### `GET /api/v2/tasks/{task_id}`
+
+Poll task execution status and results.
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "task_id": "uuid-v4",
+ "status": "running | completed | failed | cancelled",
+ "result": {
+ "success": true,
+ "output": "string",
+ "tools_used": [{"tool_name": "string", "arguments": {}, "success": true}],
+ "skill_used": "string | null",
+ "evolved_skills": ["string"],
+ "duration_ms": 1234
+ },
+ "error": null
+ }
+}
+```
+
+#### `DELETE /api/v2/tasks/{task_id}`
+
+Cancel a running task.
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {"task_id": "uuid-v4", "status": "cancelled"}
+}
+```
+
+---
+
+### Skills
+
+#### `GET /api/v2/skills`
+
+List skills in the registry.
+
+**Query params:** `?active_only=true&limit=50&offset=0`
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "skills": [
+ {
+ "id": "string",
+ "name": "string",
+ "version": "string",
+ "active": true,
+ "created_at": "ISO-8601"
+ }
+ ],
+ "total": 42,
+ "limit": 50,
+ "offset": 0
+ }
+}
+```
+
+#### `GET /api/v2/skills/{skill_id}`
+
+Get detailed skill information.
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "id": "string",
+ "name": "string",
+ "version": "string",
+ "active": true,
+ "created_at": "ISO-8601",
+ "manifest": { "...SkillManifest fields..." },
+ "lineage": ["parent_id_1", "parent_id_2"]
+ }
+}
+```
+
+#### `GET /api/v2/skills/search`
+
+Search skills across local and cloud registries.
+
+**Query params:** `?q=query&source=all|local|cloud&limit=20&auto_import=true`
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "results": [
+ {
+ "id": "string",
+ "name": "string",
+ "description": "string",
+ "source": "local | cloud",
+ "score": 0.95,
+ "imported": false
+ }
+ ],
+ "total": 15
+ }
+}
+```
+
+#### `POST /api/v2/skills/{skill_id}/fix`
+
+Trigger fix-evolution on a broken skill.
+
+**Request:**
+```json
+{
+ "direction": "string (required)"
+}
+```
+
+**Response (202 Accepted):**
+```json
+{
+ "ok": true,
+ "data": {
+ "evolution_id": "uuid-v4",
+ "status": "queued",
+ "poll_url": "/api/v2/evolutions/{evolution_id}"
+ }
+}
+```
+
+#### `POST /api/v2/skills/{skill_id}/upload`
+
+Upload a skill to cloud.
+
+**Request:**
+```json
+{
+ "visibility": "public | private",
+ "tags": ["string"],
+ "change_summary": "string | null"
+}
+```
+
+**Response (201 Created):**
+```json
+{
+ "ok": true,
+ "data": {
+ "cloud_id": "string",
+ "url": "string",
+ "visibility": "public"
+ }
+}
+```
+
+---
+
+### Evolutions
+
+#### `GET /api/v2/evolutions/{evolution_id}`
+
+Poll evolution status.
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "evolution_id": "uuid-v4",
+ "skill_id": "string",
+ "status": "running | completed | failed",
+ "result": {
+ "improved": true,
+ "changes_summary": "string",
+ "new_version": "string"
+ }
+ }
+}
+```
+
+---
+
+### System
+
+#### `GET /api/v2/health`
+
+Health check.
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "status": "healthy",
+ "version": "0.1.0",
+ "initialized": true,
+ "backends": ["shell", "mcp", "gui"]
+ }
+}
+```
+
+#### `GET /api/v2/config`
+
+Get current configuration (non-sensitive fields only).
+
+> **Security:** This endpoint MUST NOT expose bearer tokens, API keys,
+> secret values, internal file paths, or database credentials. Only
+> operational settings are returned.
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "data": {
+ "model": "string",
+ "max_iterations": 10,
+ "search_scope": "all",
+ "sandbox_enabled": true
+ }
+}
+```
+
+---
+
+## Error Codes
+
+| Code | HTTP | Description |
+|------|------|-------------|
+| `AUTH_REQUIRED` | 401 | Missing or invalid bearer token |
+| `RATE_LIMITED` | 429 | Request rate limit exceeded |
+| `TASK_NOT_FOUND` | 404 | Task ID does not exist |
+| `SKILL_NOT_FOUND` | 404 | Skill ID not in registry |
+| `TASK_FAILED` | 500 | Task execution failed |
+| `NOT_INITIALIZED` | 503 | SkillGuard not initialized |
+| `VALIDATION_ERROR` | 422 | Invalid request body |
+| `EVOLUTION_NOT_FOUND` | 404 | Evolution ID does not exist |
+| `INVALID_STATE` | 409 | Operation invalid for current state (e.g., cancel completed task) |
+
+---
+
+## Rate Limiting
+
+- **Per-token:** Configurable via `OPENSPACE_RATE_LIMIT_PER_TOKEN` (default: 60/min)
+- **Per-IP:** Configurable via `OPENSPACE_RATE_LIMIT_PER_IP` (default: 120/min)
+- **Window:** Configurable via `OPENSPACE_RATE_LIMIT_WINDOW` (default: 60s)
+
+Rate limit headers on every response:
+```
+X-RateLimit-Limit: 60
+X-RateLimit-Remaining: 42
+X-RateLimit-Reset: 1680000000
+```
+
+---
+
+## Versioning
+
+- API version in URL path (`/api/v2/`)
+- Breaking changes require version bump
+- v1 (dashboard API) remains available for backward compatibility
diff --git a/docs/sdk-public-surface.md b/docs/sdk-public-surface.md
new file mode 100644
index 00000000..56bcc6da
--- /dev/null
+++ b/docs/sdk-public-surface.md
@@ -0,0 +1,236 @@
+# SkillGuard SDK — Public API Surface
+
+> **Phase:** 1 (design only — implementation in Phase 6)
+> **Status:** Draft
+> **Version:** 0.1.0
+
+## Overview
+
+This document defines which SkillGuard operations are SDK-accessible, the
+authentication model, rate limits, and data types exposed to SDK consumers.
+
+---
+
+## SDK-Accessible Operations
+
+### Tier 1 — Core Operations (launch priority)
+
+| Operation | Method | Endpoint | Description |
+|-----------|--------|----------|-------------|
+| Execute task | POST | `/api/v2/tasks` | Run task through full pipeline |
+| Poll task | GET | `/api/v2/tasks/{id}` | Get execution status/result |
+| Cancel task | DELETE | `/api/v2/tasks/{id}` | Cancel running task |
+| Search skills | GET | `/api/v2/skills/search` | Query local + cloud skills |
+| List skills | GET | `/api/v2/skills` | List registered skills |
+| Get skill | GET | `/api/v2/skills/{id}` | Skill details + manifest |
+| Health check | GET | `/api/v2/health` | System health + version |
+
+### Tier 2 — Management Operations
+
+| Operation | Method | Endpoint | Description |
+|-----------|--------|----------|-------------|
+| Fix skill | POST | `/api/v2/skills/{id}/fix` | Trigger fix-evolution |
+| Upload skill | POST | `/api/v2/skills/{id}/upload` | Publish to cloud |
+| Poll evolution | GET | `/api/v2/evolutions/{id}` | Evolution status |
+| Get config | GET | `/api/v2/config` | Non-sensitive config |
+
+### Not SDK-Accessible (internal only)
+
+| Operation | Reason |
+|-----------|--------|
+| `initialize()` | Server lifecycle — managed by host process |
+| `cleanup()` | Server lifecycle — managed by host process |
+| Direct LLM calls | Security boundary — must go through task pipeline |
+| Sandbox control | Security boundary — managed by policy engine |
+| Secret access | Security boundary — never exposed via API |
+| Recording management | Internal telemetry — not for consumers |
+
+---
+
+## Authentication Model
+
+### Bearer Token Authentication
+
+```
+Authorization: Bearer
+```
+
+**Token lifecycle:**
+1. Admin generates token (min 32 chars) and sets `OPENSPACE_MCP_BEARER_TOKEN`
+2. SDK client includes token in every request
+3. Server validates via constant-time HMAC comparison
+4. Invalid/missing token → 401 immediately (fail-closed)
+
+**Security properties:**
+- Constant-time comparison prevents timing attacks
+- Minimum length enforced at startup
+- No token = server refuses all requests
+- Tokens are never logged or included in error responses
+
+### Future Auth Enhancements (Phase 5)
+
+| Feature | Phase | Notes |
+|---------|-------|-------|
+| API key rotation | 5 | Zero-downtime key rotation |
+| OAuth2 / OIDC | 5 | For multi-tenant deployments |
+| Scoped permissions | 5 | Read-only vs execute tokens |
+| mTLS | 5 | Certificate-based for infra |
+
+---
+
+## Rate Limits
+
+| Scope | Default | Env Var |
+|-------|---------|---------|
+| Per-token | 60 req/min | `OPENSPACE_RATE_LIMIT_PER_TOKEN` |
+| Per-IP | 120 req/min | `OPENSPACE_RATE_LIMIT_PER_IP` |
+| Window | 60 seconds | `OPENSPACE_RATE_LIMIT_WINDOW` |
+
+**Behavior on limit:**
+- HTTP 429 with `Retry-After` header
+- Sliding window (not fixed buckets)
+- Per-identity and per-IP limits enforced independently
+
+---
+
+## SDK Data Types
+
+These types form the SDK's data model. All are immutable dataclasses
+serialized as JSON.
+
+### Request Types
+
+```python
+@dataclass(frozen=True)
+class TaskRequest:
+ task: str
+ workspace_dir: str | None = None
+ max_iterations: int | None = None
+ skill_dirs: list[str] | None = None
+ search_scope: str = "all" # "all" | "local" | "cloud"
+
+@dataclass(frozen=True)
+class SkillSearchRequest:
+ query: str
+ source: str = "all" # "all" | "local" | "cloud"
+ limit: int = 20
+ auto_import: bool = True
+
+@dataclass(frozen=True)
+class SkillFixRequest:
+ direction: str
+
+@dataclass(frozen=True)
+class SkillUploadRequest:
+ visibility: str = "public" # "public" | "private"
+ tags: list[str] | None = None
+ change_summary: str | None = None
+```
+
+### Response Types
+
+```python
+@dataclass(frozen=True)
+class ToolUsageRecord:
+ tool_name: str
+ arguments: dict[str, Any] = field(default_factory=dict)
+ success: bool = True
+
+@dataclass(frozen=True)
+class TaskResult:
+ task_id: str
+ status: str # "queued" | "running" | "completed" | "failed" | "cancelled"
+ success: bool | None = None
+ output: str | None = None
+ tools_used: list[ToolUsageRecord] | None = None
+ skill_used: str | None = None
+ evolved_skills: list[str] | None = None
+ duration_ms: int | None = None
+ error: str | None = None
+
+@dataclass(frozen=True)
+class SkillInfo:
+ id: str
+ name: str
+ version: str
+ active: bool
+ created_at: str # ISO-8601
+
+@dataclass(frozen=True)
+class SkillDetail:
+ """Extended skill view for GET /skills/{id}."""
+ id: str
+ name: str
+ version: str
+ active: bool
+ created_at: str # ISO-8601
+ manifest: dict[str, Any] = field(default_factory=dict)
+ lineage: list[str] = field(default_factory=list)
+
+@dataclass(frozen=True)
+class SkillSearchResult:
+ id: str
+ name: str
+ description: str
+ source: str # "local" | "cloud"
+ score: float
+ imported: bool = False
+
+@dataclass(frozen=True)
+class HealthStatus:
+ status: str # "healthy" | "degraded" | "unhealthy"
+ version: str
+ initialized: bool
+ backends: list[str]
+```
+
+### Domain Types (re-exported via SDK)
+
+| Type | Module | Description |
+|------|--------|-------------|
+| `SkillIdentity` | `openspace.domain.types` | Skill ID + version |
+| `SkillManifest` | `openspace.domain.types` | Full skill metadata |
+| `ToolDescriptor` | `openspace.domain.types` | Tool name + schema |
+| `ToolCallResult` | `openspace.domain.types` | Tool execution result |
+| `SandboxPolicy` | `openspace.domain.types` | Security policy config |
+
+---
+
+## SDK Client Interface (Python)
+
+> Implementation in Phase 6 (`openspace-sdk` package)
+
+```python
+class SkillGuardClient:
+ """Async Python client for the SkillGuard API."""
+
+ def __init__(self, base_url: str, token: str) -> None: ...
+
+ # Tier 1 — Core
+ async def execute(self, request: TaskRequest) -> TaskResult: ...
+ async def poll_task(self, task_id: str) -> TaskResult: ...
+ async def cancel_task(self, task_id: str) -> TaskResult: ...
+ async def search_skills(self, request: SkillSearchRequest) -> list[SkillSearchResult]: ...
+ async def list_skills(self, *, active_only: bool = False) -> list[SkillInfo]: ...
+ async def get_skill(self, skill_id: str) -> SkillDetail: ...
+ async def health(self) -> HealthStatus: ...
+
+ # Tier 2 — Management
+ async def fix_skill(self, skill_id: str, request: SkillFixRequest) -> str: ...
+ async def upload_skill(self, skill_id: str, request: SkillUploadRequest) -> dict: ...
+ async def poll_evolution(self, evolution_id: str) -> dict: ...
+ async def get_config(self) -> dict: ...
+
+ # Context manager
+ async def __aenter__(self) -> "SkillGuardClient": ...
+ async def __aexit__(self, *exc: Any) -> None: ...
+```
+
+---
+
+## Backward Compatibility
+
+- `/api/v1/` (dashboard API) remains unchanged
+- `/api/v2/` is the SDK surface — breaking changes require version bump
+- MCP tool interface continues to work alongside REST API
+- Both APIs share the same SkillGuard instance
diff --git a/openspace/.env.example b/openspace/.env.example
index d0328013..1cef0bc6 100644
--- a/openspace/.env.example
+++ b/openspace/.env.example
@@ -40,14 +40,34 @@ OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx
# EMBEDDING_API_KEY=
# EMBEDDING_MODEL= "openai/text-embedding-3-small"
-# ---- E2B Sandbox (Optional) ----
-# Required only if sandbox mode is enabled in security config.
-# E2B_API_KEY=
+# ---- E2B Sandbox (Required for MCP stdio servers) ----
+# E2B sandbox is ENFORCED by default for all stdio-based MCP servers.
+# You MUST provide an API key for E2B to execute skills securely.
+# Get your API key from https://e2b.dev/
+E2B_API_KEY=
+
+# ---- Sandbox Configuration ----
+# Sandbox is mandatory. To opt out (development only, NOT recommended):
+# OPENSPACE_ALLOW_UNSANDBOXED=1
# ---- Local Server (Optional) ----
# Override the default local server URL (default: http://127.0.0.1:5000)
# Useful for remote VM integration (e.g., OSWorld).
# LOCAL_SERVER_URL=http://127.0.0.1:5000
+# ---- MCP Server Authentication (REQUIRED for HTTP transports) ----
+# A shared-secret bearer token that ALL HTTP requests to the MCP server
+# must provide via "Authorization: Bearer " header.
+# REQUIRED when using --transport sse or --transport streamable-http.
+# Not needed for --transport stdio (local process IPC).
+# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
+# OPENSPACE_MCP_BEARER_TOKEN=
+
+# ---- Rate Limiting (Optional, HTTP transports only) ----
+# Sliding-window rate limiter. Requests exceeding limits get 429.
+# OPENSPACE_RATE_LIMIT_PER_TOKEN=60 # max requests per token per window
+# OPENSPACE_RATE_LIMIT_PER_IP=30 # max requests per IP per window
+# OPENSPACE_RATE_LIMIT_WINDOW=60 # window size in seconds
+
# ---- Debug (Optional) ----
# OPENSPACE_DEBUG=true
\ No newline at end of file
diff --git a/openspace/app/__init__.py b/openspace/app/__init__.py
new file mode 100644
index 00000000..c0276bb8
--- /dev/null
+++ b/openspace/app/__init__.py
@@ -0,0 +1,7 @@
+"""Application layer — composition root and service wiring.
+
+The :class:`AppContainer` holds references to all domain services,
+wired through Protocol interfaces from :mod:`openspace.domain.ports`.
+No module in the codebase should construct services directly — they
+should receive them from the container.
+"""
diff --git a/openspace/app/container.py b/openspace/app/container.py
new file mode 100644
index 00000000..69fb0429
--- /dev/null
+++ b/openspace/app/container.py
@@ -0,0 +1,187 @@
+"""AppContainer — composition root for SkillGuard services.
+
+The container holds **Optional** references to all domain services,
+typed against Protocol interfaces. Services are wired by
+:func:`build_container` (production) or :func:`build_test_container`
+(testing with mocks).
+
+Lifecycle::
+
+ container = await build_container(config)
+ await container.startup()
+ ...
+ await container.shutdown()
+
+Direct construction is also supported for testing::
+
+ container = AppContainer(llm=mock_llm, skill_store=mock_store)
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+from openspace.domain.ports import (
+ AgentExecutorPort,
+ AnalysisPort,
+ AuthPort,
+ CapabilityLeaseResolverPort,
+ CloudSkillPort,
+ LLMClientPort,
+ PolicyEnginePort,
+ SandboxPort,
+ SecretBrokerPort,
+ SkillEvolutionPort,
+ SkillStorePort,
+ TelemetryPort,
+ ToolBackendPort,
+)
+
+
+# ── Lifecycle callback type ───────────────────────────────────────────
+
+LifecycleHook = Any # Callable[[], Awaitable[None]] — avoid complex generic
+
+
+@dataclass
+class AppContainer:
+ """Composition root — holds all domain service references.
+
+ Every field is ``Optional`` so the container can be partially wired
+ (e.g. CLI mode doesn't need ``sandbox``). Services are typed
+ against Protocol interfaces, never concrete classes.
+
+ Attributes are grouped by domain concern:
+
+ **Core services** — always expected in production:
+ llm, agent_executor, telemetry
+
+ **Skill engine** — enabled when skill features are active:
+ skill_store, skill_evolution, analysis, cloud_skill
+
+ **Security / sandbox** — Phase 2+ features:
+ sandbox, policy_engine, auth, secret_broker, capability_lease_resolver
+
+ **Tool layer**:
+ tool_backend
+ """
+
+ # ── Core services ─────────────────────────────────────────────────
+ llm: Optional[LLMClientPort] = None
+ agent_executor: Optional[AgentExecutorPort] = None
+ telemetry: Optional[TelemetryPort] = None
+
+ # ── Skill engine ──────────────────────────────────────────────────
+ skill_store: Optional[SkillStorePort] = None
+ skill_evolution: Optional[SkillEvolutionPort] = None
+ analysis: Optional[AnalysisPort] = None
+ cloud_skill: Optional[CloudSkillPort] = None
+
+ # ── Security / sandbox ────────────────────────────────────────────
+ sandbox: Optional[SandboxPort] = None
+ policy_engine: Optional[PolicyEnginePort] = None
+ auth: Optional[AuthPort] = None
+ secret_broker: Optional[SecretBrokerPort] = None
+ capability_lease_resolver: Optional[CapabilityLeaseResolverPort] = None
+
+ # ── Tool layer ────────────────────────────────────────────────────
+ tool_backend: Optional[ToolBackendPort] = None
+
+ # ── Lifecycle hooks ───────────────────────────────────────────────
+ _startup_hooks: List[LifecycleHook] = field(
+ default_factory=list, repr=False
+ )
+ _shutdown_hooks: List[LifecycleHook] = field(
+ default_factory=list, repr=False
+ )
+ _started: bool = field(default=False, repr=False)
+
+ # ── Lifecycle management ──────────────────────────────────────────
+
+ def register_startup_hook(self, hook: LifecycleHook) -> None:
+ """Register an async callable to run during :meth:`startup`."""
+ self._startup_hooks.append(hook)
+
+ def register_shutdown_hook(self, hook: LifecycleHook) -> None:
+ """Register an async callable to run during :meth:`shutdown`."""
+ self._shutdown_hooks.append(hook)
+
+ async def startup(self) -> None:
+ """Run all registered startup hooks in order.
+
+ On partial failure, marks the container as started so that
+ :meth:`shutdown` can clean up resources from hooks that
+ succeeded. Re-raises the original exception after marking.
+
+ Raises :class:`RuntimeError` if called when already started.
+ """
+ if self._started:
+ raise RuntimeError("AppContainer already started")
+ try:
+ for hook in self._startup_hooks:
+ await hook()
+ except Exception:
+ # Mark as started so shutdown() can clean up partial resources
+ self._started = True
+ raise
+ self._started = True
+
+ async def shutdown(self) -> None:
+ """Run all registered shutdown hooks in reverse order.
+
+ Collects exceptions from hooks and raises the first one after
+ all hooks have been attempted (best-effort cleanup).
+ Safe to call on un-started containers (no-op).
+ """
+ if not self._started:
+ return # idempotent — safe to call on un-started container
+ errors: List[Exception] = []
+ for hook in reversed(self._shutdown_hooks):
+ try:
+ await hook()
+ except Exception as exc: # noqa: BLE001 — best-effort cleanup
+ errors.append(exc)
+ self._started = False
+ if errors:
+ raise errors[0]
+
+ @property
+ def is_started(self) -> bool:
+ """Whether :meth:`startup` has been called."""
+ return self._started
+
+ # ── Convenience accessors ─────────────────────────────────────────
+
+ # Service slot names (for validation in require())
+ _SERVICE_SLOTS = frozenset({
+ "llm", "agent_executor", "telemetry",
+ "skill_store", "skill_evolution", "analysis", "cloud_skill",
+ "sandbox", "policy_engine", "auth", "secret_broker",
+ "capability_lease_resolver", "tool_backend",
+ })
+
+ def require(self, service_name: str) -> Any:
+ """Get a service by name, raising if it is ``None``.
+
+ Example::
+
+ llm = container.require("llm")
+ # equivalent to: assert container.llm is not None
+
+ Raises:
+ AttributeError: If *service_name* is not a known service slot.
+ RuntimeError: If the service is known but not wired (``None``).
+ """
+ if service_name not in self._SERVICE_SLOTS:
+ raise AttributeError(
+ f"Unknown service '{service_name}'. "
+ f"Known services: {sorted(self._SERVICE_SLOTS)}"
+ )
+ value = getattr(self, service_name)
+ if value is None:
+ raise RuntimeError(
+ f"Required service '{service_name}' is not wired in AppContainer"
+ )
+ return value
diff --git a/openspace/app/factory.py b/openspace/app/factory.py
new file mode 100644
index 00000000..1f067f95
--- /dev/null
+++ b/openspace/app/factory.py
@@ -0,0 +1,197 @@
+"""Container factory — wires real or mock implementations.
+
+Production::
+
+ from openspace.app.factory import build_container
+
+ container = await build_container(config)
+ await container.startup()
+
+Testing::
+
+ from openspace.app.factory import build_test_container
+
+ container = build_test_container()
+ assert container.llm is not None # pre-wired mock
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from openspace.app.container import AppContainer
+from openspace.domain.ports import (
+ AgentExecutorPort,
+ AnalysisPort,
+ AuthPort,
+ CapabilityLeaseResolverPort,
+ CloudSkillPort,
+ LLMClientPort,
+ PolicyEnginePort,
+ SandboxPort,
+ SecretBrokerPort,
+ SkillEvolutionPort,
+ SkillStorePort,
+ TelemetryPort,
+ ToolBackendPort,
+)
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Production factory
+# ══════════════════════════════════════════════════════════════════════
+
+
+async def build_container(
+ config: Any = None,
+ *,
+ llm: Optional[LLMClientPort] = None,
+ skill_store: Optional[SkillStorePort] = None,
+ telemetry: Optional[TelemetryPort] = None,
+ agent_executor: Optional[AgentExecutorPort] = None,
+ sandbox: Optional[SandboxPort] = None,
+ policy_engine: Optional[PolicyEnginePort] = None,
+ auth: Optional[AuthPort] = None,
+ secret_broker: Optional[SecretBrokerPort] = None,
+ capability_lease_resolver: Optional[CapabilityLeaseResolverPort] = None,
+ tool_backend: Optional[ToolBackendPort] = None,
+ skill_evolution: Optional[SkillEvolutionPort] = None,
+ analysis: Optional[AnalysisPort] = None,
+ cloud_skill: Optional[CloudSkillPort] = None,
+) -> AppContainer:
+ """Build an :class:`AppContainer` with real implementations.
+
+ Currently accepts explicit service overrides. In Phase 4, this
+ will read ``config`` to auto-construct concrete implementations
+ from :mod:`openspace.tool_layer.OpenSpaceConfig`.
+
+ Parameters
+ ----------
+ config:
+ Application configuration (reserved for Phase 4 auto-wiring).
+ **kwargs:
+ Explicit service instances to wire. Any service not provided
+ will remain ``None`` on the container.
+
+ Returns
+ -------
+ AppContainer
+ A wired (but not yet started) container. Call
+ :meth:`~AppContainer.startup` to run lifecycle hooks.
+ """
+ container = AppContainer(
+ llm=llm,
+ agent_executor=agent_executor,
+ telemetry=telemetry,
+ skill_store=skill_store,
+ skill_evolution=skill_evolution,
+ analysis=analysis,
+ cloud_skill=cloud_skill,
+ sandbox=sandbox,
+ policy_engine=policy_engine,
+ auth=auth,
+ secret_broker=secret_broker,
+ capability_lease_resolver=capability_lease_resolver,
+ tool_backend=tool_backend,
+ )
+
+ # Register shutdown hooks for services that need cleanup
+ if sandbox is not None:
+ container.register_shutdown_hook(sandbox.stop)
+ if telemetry is not None:
+ container.register_shutdown_hook(lambda: _wrap_sync(telemetry.shutdown))
+
+ return container
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Test factory
+# ══════════════════════════════════════════════════════════════════════
+
+
+class _StubLLM:
+ """Minimal LLM stub for testing — satisfies LLMClientPort."""
+
+ async def complete(
+ self, messages: Any, *, tools: Any = None, execute_tools: bool = True, **kw: Any
+ ) -> dict:
+ return {"role": "assistant", "content": "stub response"}
+
+ def estimate_tokens(self, text: str) -> int:
+ return max(1, len(text) // 4)
+
+
+class _StubSkillStore:
+ """Minimal skill store stub for testing — satisfies SkillStorePort."""
+
+ def __init__(self) -> None:
+ self._records: dict = {}
+
+ async def save_record(self, record: Any) -> None:
+ self._records[getattr(record, "skill_id", "unknown")] = record
+
+ def load_record(self, skill_id: str) -> Any:
+ return self._records.get(skill_id)
+
+ def load_all(self, *, active_only: bool = False) -> dict:
+ return dict(self._records)
+
+ def load_active(self) -> dict:
+ return dict(self._records)
+
+ async def delete_record(self, skill_id: str) -> bool:
+ return self._records.pop(skill_id, None) is not None
+
+ def count(self, *, active_only: bool = False) -> int:
+ return len(self._records)
+
+
+class _StubTelemetry:
+ """Minimal telemetry stub for testing — satisfies TelemetryPort."""
+
+ def __init__(self) -> None:
+ self.events: list = []
+
+ def capture(self, event_name: str, properties: Any = None) -> None:
+ self.events.append((event_name, properties))
+
+ def flush(self) -> None:
+ pass
+
+ def shutdown(self) -> None:
+ pass
+
+
+def build_test_container(
+ *,
+ llm: Optional[LLMClientPort] = None,
+ skill_store: Optional[SkillStorePort] = None,
+ telemetry: Optional[TelemetryPort] = None,
+ **overrides: Any,
+) -> AppContainer:
+ """Build an :class:`AppContainer` pre-wired with stubs for testing.
+
+ Provides sensible defaults for ``llm``, ``skill_store``, and
+ ``telemetry``. Pass explicit mocks to override any service.
+
+ Returns
+ -------
+ AppContainer
+ A container ready for unit/integration tests (not started).
+ """
+ return AppContainer(
+ llm=llm or _StubLLM(), # type: ignore[arg-type]
+ skill_store=skill_store or _StubSkillStore(), # type: ignore[arg-type]
+ telemetry=telemetry or _StubTelemetry(), # type: ignore[arg-type]
+ **overrides,
+ )
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Helpers
+# ══════════════════════════════════════════════════════════════════════
+
+
+async def _wrap_sync(fn: Any) -> None:
+ """Wrap a synchronous callable as an async no-arg coroutine."""
+ fn()
diff --git a/openspace/auth/__init__.py b/openspace/auth/__init__.py
new file mode 100644
index 00000000..804bef4a
--- /dev/null
+++ b/openspace/auth/__init__.py
@@ -0,0 +1 @@
+"""OpenSpace authentication module."""
diff --git a/openspace/auth/bearer.py b/openspace/auth/bearer.py
new file mode 100644
index 00000000..4016e56f
--- /dev/null
+++ b/openspace/auth/bearer.py
@@ -0,0 +1,112 @@
+"""Shared-secret bearer token authentication for OpenSpace servers.
+
+Provides ASGI middleware that validates HTTP requests against a
+shared-secret bearer token read from the environment.
+
+Design decisions:
+ - Fail-closed: missing or invalid tokens → 401, never fallback.
+ - Constant-time comparison via hmac.compare_digest (timing-safe).
+ - Minimum token length enforced (32 chars) to prevent weak secrets.
+ - Only applies to HTTP/WebSocket scopes; ASGI lifespan passes through.
+
+Usage:
+ Set OPENSPACE_MCP_BEARER_TOKEN in your environment before starting
+ any HTTP transport (SSE, streamable-http).
+"""
+
+from __future__ import annotations
+
+import hmac
+import json
+import logging
+import os
+from typing import Any, Callable
+
+logger = logging.getLogger("openspace.auth")
+
+BEARER_TOKEN_ENV = "OPENSPACE_MCP_BEARER_TOKEN"
+MIN_TOKEN_LENGTH = 32
+
+
+def get_bearer_token() -> str | None:
+ """Read the shared-secret bearer token from environment."""
+ return os.environ.get(BEARER_TOKEN_ENV)
+
+
+def validate_token_strength(token: str) -> tuple[bool, str]:
+ """Check that a token meets minimum security requirements.
+
+ Returns (is_valid, reason).
+ """
+ if len(token) < MIN_TOKEN_LENGTH:
+ return False, (
+ f"Token too short ({len(token)} chars). "
+ f"Minimum is {MIN_TOKEN_LENGTH} characters."
+ )
+ return True, "OK"
+
+
+class BearerTokenMiddleware:
+ """ASGI middleware: reject HTTP requests without a valid bearer token.
+
+ Wraps any ASGI application. Non-HTTP scopes (e.g. lifespan) are
+ passed through without authentication.
+ """
+
+ def __init__(self, app: Any, token: str) -> None:
+ self.app = app
+ self._token = token
+
+ async def __call__(
+ self,
+ scope: dict,
+ receive: Callable,
+ send: Callable,
+ ) -> None:
+ # Only authenticate HTTP and WebSocket requests
+ if scope["type"] not in ("http", "websocket"):
+ await self.app(scope, receive, send)
+ return
+
+ headers = dict(scope.get("headers", []))
+ auth_value = headers.get(b"authorization", b"").decode("latin-1")
+
+ if not auth_value.startswith("Bearer "):
+ logger.warning(
+ "Rejected request: missing bearer token (path=%s)",
+ scope.get("path", "?"),
+ )
+ await self._send_401(send, "Missing bearer token")
+ return
+
+ provided = auth_value[7:] # strip "Bearer "
+ if not hmac.compare_digest(provided, self._token):
+ logger.warning(
+ "Rejected request: invalid bearer token (path=%s)",
+ scope.get("path", "?"),
+ )
+ await self._send_401(send, "Invalid bearer token")
+ return
+
+ await self.app(scope, receive, send)
+
+ @staticmethod
+ async def _send_401(send: Callable, detail: str) -> None:
+ """Send a 401 Unauthorized JSON response."""
+ body = json.dumps(
+ {"error": "unauthorized", "detail": detail},
+ ensure_ascii=False,
+ ).encode("utf-8")
+
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 401,
+ "headers": [
+ [b"content-type", b"application/json"],
+ [b"www-authenticate", b'Bearer realm="openspace-mcp"'],
+ [b"content-length", str(len(body)).encode()],
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": body})
diff --git a/openspace/auth/provider.py b/openspace/auth/provider.py
new file mode 100644
index 00000000..164617d6
--- /dev/null
+++ b/openspace/auth/provider.py
@@ -0,0 +1,751 @@
+"""Advanced MCP authentication and authorization — EPIC 2.5.
+
+Provides:
+- HMAC-signed token creation and validation with claims (scopes, trust tier,
+ subject, expiry, audience) — no external JWT dependency required.
+- Per-tool authorization enforcing scope and trust-tier requirements.
+- Token lifecycle: creation, validation, expiry, revocation.
+- AuthContext: request-scoped identity that flows through the MCP pipeline.
+
+Design decisions:
+- Fail-closed at every layer: invalid/missing → deny.
+- HMAC-SHA256 signatures (timing-safe via hmac.compare_digest).
+- Deny-before-allow: tool authorization checks blocklist before allowlist.
+- Trust tier ceiling: a token's tier caps the tools it can invoke.
+- Audience binding: tokens are bound to a specific service to prevent
+ cross-service replay attacks.
+- No external crypto dependencies: uses stdlib hashlib + hmac + secrets.
+
+Security requirements:
+- **Each service MUST use a unique signing secret.** HMAC is symmetric —
+ any party that knows the secret can mint arbitrary tokens. Sharing a
+ secret between services allows cross-service token forgery. If
+ multi-service deployment is needed with a shared trust root, migrate
+ to asymmetric signing (RSA/Ed25519) with per-service key pairs.
+
+Issues:
+- #51: AuthPort concrete implementation
+- #104: Token scoping and claims model
+- #105: Per-tool authorization
+- #106: Trust-tier gating
+- #107: Token lifecycle (revoke, expire, rotate)
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import math
+import secrets
+import threading
+import time
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional
+
+from openspace.sandbox.leases import TrustTier
+
+
+# ---------------------------------------------------------------------------
+# #104 — Token Claims Model
+# ---------------------------------------------------------------------------
+
+
+class TokenScope(str, Enum):
+ """Scopes that can be granted to a token."""
+
+ TOOL_EXECUTE = "tool:execute"
+ TOOL_SEARCH = "tool:search"
+ TOOL_ADMIN = "tool:admin"
+ SECRET_READ = "secret:read"
+ SECRET_WRITE = "secret:write"
+ LEASE_ACQUIRE = "lease:acquire"
+ LEASE_ADMIN = "lease:admin"
+
+
+# Default scopes per trust tier (deny-before-allow escalation)
+TIER_DEFAULT_SCOPES: dict[TrustTier, frozenset[TokenScope]] = {
+ TrustTier.T0_UNTRUSTED: frozenset(),
+ TrustTier.T1_BASIC: frozenset({TokenScope.TOOL_SEARCH}),
+ TrustTier.T2_STANDARD: frozenset({
+ TokenScope.TOOL_SEARCH, TokenScope.TOOL_EXECUTE, TokenScope.LEASE_ACQUIRE,
+ }),
+ TrustTier.T3_ELEVATED: frozenset({
+ TokenScope.TOOL_SEARCH, TokenScope.TOOL_EXECUTE,
+ TokenScope.LEASE_ACQUIRE, TokenScope.SECRET_READ,
+ }),
+ TrustTier.T4_FULL: frozenset({
+ TokenScope.TOOL_SEARCH, TokenScope.TOOL_EXECUTE, TokenScope.TOOL_ADMIN,
+ TokenScope.LEASE_ACQUIRE, TokenScope.LEASE_ADMIN,
+ TokenScope.SECRET_READ, TokenScope.SECRET_WRITE,
+ }),
+}
+
+
+@dataclass(frozen=True)
+class AuthClaims:
+ """Immutable claims extracted from a validated token.
+
+ These flow through the request pipeline as the caller's identity.
+ """
+
+ subject: str
+ trust_tier: TrustTier
+ scopes: frozenset[TokenScope]
+ issued_at: float
+ expires_at: float
+ token_id: str
+ audience: str = "" # service binding — prevents cross-service replay
+
+ @property
+ def is_expired(self) -> bool:
+ return time.time() > self.expires_at
+
+ @property
+ def remaining_seconds(self) -> float:
+ return max(0.0, self.expires_at - time.time())
+
+ def has_scope(self, scope: TokenScope) -> bool:
+ return scope in self.scopes
+
+ def has_any_scope(self, *scopes: TokenScope) -> bool:
+ return bool(self.scopes & frozenset(scopes))
+
+
+# ---------------------------------------------------------------------------
+# Exceptions
+# ---------------------------------------------------------------------------
+
+
+class AuthError(Exception):
+ """Base for all authentication/authorization errors."""
+
+
+class TokenInvalidError(AuthError):
+ """Token is malformed, tampered, or has an invalid signature."""
+
+
+class TokenExpiredError(AuthError):
+ """Token has passed its expiry time."""
+
+
+class TokenRevokedError(AuthError):
+ """Token has been explicitly revoked."""
+
+
+class InsufficientScopeError(AuthError):
+ """Caller lacks the required scope for this operation."""
+
+
+class InsufficientTierError(AuthError):
+ """Caller's trust tier is below the required level."""
+
+
+class ToolNotAuthorizedError(AuthError):
+ """Caller is not authorized to invoke this specific tool."""
+
+
+# ---------------------------------------------------------------------------
+# #104 — Token Creation and Validation (HMAC-SHA256)
+# ---------------------------------------------------------------------------
+
+# Token format: base64(json_payload).base64(hmac_signature)
+_TOKEN_SEPARATOR = "."
+_MIN_SECRET_LENGTH = 32
+_MAX_TTL_SECONDS = 86_400 # 24 hours — prevents long-lived token abuse
+
+
+def _compute_signature(payload_b64: str, secret: bytes) -> str:
+ """Compute HMAC-SHA256 signature over the base64-encoded payload."""
+ sig = hmac.new(secret, payload_b64.encode("utf-8"), hashlib.sha256).digest()
+ return base64.urlsafe_b64encode(sig).rstrip(b"=").decode("ascii")
+
+
+def create_token(
+ *,
+ secret: str,
+ subject: str,
+ trust_tier: TrustTier = TrustTier.T1_BASIC,
+ scopes: frozenset[TokenScope] | None = None,
+ ttl_seconds: int = 3600,
+ token_id: str | None = None,
+ audience: str = "",
+) -> str:
+ """Create an HMAC-signed token with claims.
+
+ Args:
+ secret: Server signing secret (min 32 chars).
+ subject: Identity of the token holder (e.g., service name, user ID).
+ trust_tier: Maximum trust tier this token can operate at.
+ scopes: Explicit scopes; defaults to tier's default scopes.
+ ttl_seconds: Token lifetime in seconds (default 1 hour).
+ token_id: Optional unique token ID; auto-generated if not provided.
+ audience: Service identifier for token binding. When set, the
+ validator must pass the same audience to reject cross-service
+ replay attacks.
+
+ Returns:
+ Signed token string (base64-payload.base64-signature).
+
+ Raises:
+ ValueError: If secret is too short or ttl_seconds is invalid.
+ """
+ if len(secret) < _MIN_SECRET_LENGTH:
+ raise ValueError(
+ f"Signing secret must be at least {_MIN_SECRET_LENGTH} characters"
+ )
+ if ttl_seconds < 1:
+ raise ValueError("ttl_seconds must be positive")
+ if ttl_seconds > _MAX_TTL_SECONDS:
+ raise ValueError(
+ f"ttl_seconds must not exceed {_MAX_TTL_SECONDS} "
+ f"({_MAX_TTL_SECONDS // 3600}h)"
+ )
+
+ now = time.time()
+ # Validate scopes against tier ceiling — prevent privilege inversion
+ tier_allowed = TIER_DEFAULT_SCOPES.get(trust_tier, frozenset())
+ if scopes is not None:
+ excess = scopes - tier_allowed
+ if excess:
+ excess_names = ", ".join(
+ s.value for s in sorted(excess, key=lambda s: s.value)
+ )
+ raise ValueError(
+ f"Scopes [{excess_names}] exceed tier "
+ f"{trust_tier.value} ceiling"
+ )
+ effective_scopes = scopes
+ else:
+ effective_scopes = tier_allowed
+
+ payload = {
+ "sub": subject,
+ "tier": trust_tier.value,
+ "scopes": sorted(s.value for s in effective_scopes),
+ "iat": now,
+ "exp": now + ttl_seconds,
+ "jti": token_id or secrets.token_urlsafe(16),
+ "aud": audience,
+ }
+
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+ payload_b64 = base64.urlsafe_b64encode(payload_json.encode("utf-8")).rstrip(
+ b"="
+ ).decode("ascii")
+
+ signature = _compute_signature(payload_b64, secret.encode("utf-8"))
+ return f"{payload_b64}{_TOKEN_SEPARATOR}{signature}"
+
+
+def validate_token(
+ token: str, *, secret: str, expected_audience: str = ""
+) -> AuthClaims:
+ """Validate an HMAC-signed token and extract claims.
+
+ Args:
+ token: The token string to validate.
+ secret: The server signing secret.
+ expected_audience: If non-empty, the token's ``aud`` claim must
+ match exactly. This prevents cross-service token replay.
+
+ Returns:
+ AuthClaims with validated identity information.
+
+ Raises:
+ TokenInvalidError: If token is malformed, signature is invalid,
+ or audience does not match.
+ TokenExpiredError: If token has expired.
+ """
+ if _TOKEN_SEPARATOR not in token:
+ raise TokenInvalidError("Malformed token: missing separator")
+
+ parts = token.split(_TOKEN_SEPARATOR)
+ if len(parts) != 2:
+ raise TokenInvalidError("Malformed token: expected 2 parts")
+
+ payload_b64, provided_sig = parts
+
+ # Verify signature (timing-safe)
+ expected_sig = _compute_signature(payload_b64, secret.encode("utf-8"))
+ if not hmac.compare_digest(provided_sig, expected_sig):
+ raise TokenInvalidError("Invalid token signature")
+
+ # Decode payload
+ try:
+ padding = 4 - (len(payload_b64) % 4)
+ if padding != 4:
+ payload_b64 += "=" * padding
+ payload_json = base64.urlsafe_b64decode(payload_b64).decode("utf-8")
+ payload = json.loads(payload_json)
+ except Exception as exc:
+ raise TokenInvalidError("Failed to decode token payload") from exc
+
+ # Extract and validate fields
+ try:
+ subject = str(payload["sub"])
+ tier = TrustTier(payload["tier"])
+ scope_values = payload.get("scopes", [])
+ scopes = frozenset(TokenScope(s) for s in scope_values)
+ issued_at = float(payload["iat"])
+ expires_at = float(payload["exp"])
+ token_id = str(payload["jti"])
+ audience = str(payload.get("aud", ""))
+ except (KeyError, ValueError) as exc:
+ raise TokenInvalidError("Invalid token claims") from exc
+
+ # Reject non-finite timestamps (NaN/Infinity bypass expiry checks)
+ if not math.isfinite(issued_at) or not math.isfinite(expires_at):
+ raise TokenInvalidError("Token timestamps must be finite")
+ if expires_at <= issued_at:
+ raise TokenInvalidError("Token expiry must be after issuance")
+
+ # Audience enforcement — prevents cross-service token replay
+ if expected_audience and audience != expected_audience:
+ raise TokenInvalidError("Token audience mismatch")
+
+ # Check expiry
+ if time.time() > expires_at:
+ raise TokenExpiredError("Token has expired")
+
+ return AuthClaims(
+ subject=subject,
+ trust_tier=tier,
+ scopes=scopes,
+ issued_at=issued_at,
+ expires_at=expires_at,
+ token_id=token_id,
+ audience=audience,
+ )
+
+
+# ---------------------------------------------------------------------------
+# #105 — Per-Tool Authorization
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class ToolPolicy:
+ """Authorization policy for a single MCP tool.
+
+ Defines what scopes and minimum trust tier are required to invoke a tool.
+ """
+
+ tool_name: str
+ required_scopes: frozenset[TokenScope] = frozenset({TokenScope.TOOL_EXECUTE})
+ min_trust_tier: TrustTier = TrustTier.T1_BASIC
+ blocked_subjects: frozenset[str] = frozenset()
+ allowed_subjects: frozenset[str] = frozenset()
+
+
+# Canonical tier ordering — single source of truth
+_TIER_ORDER: list[TrustTier] = [
+ TrustTier.T0_UNTRUSTED,
+ TrustTier.T1_BASIC,
+ TrustTier.T2_STANDARD,
+ TrustTier.T3_ELEVATED,
+ TrustTier.T4_FULL,
+]
+
+
+# Default policies for built-in MCP tools
+DEFAULT_TOOL_POLICIES: dict[str, ToolPolicy] = {
+ "execute_task": ToolPolicy(
+ tool_name="execute_task",
+ required_scopes=frozenset({TokenScope.TOOL_EXECUTE}),
+ min_trust_tier=TrustTier.T2_STANDARD,
+ ),
+ "search_skills": ToolPolicy(
+ tool_name="search_skills",
+ required_scopes=frozenset({TokenScope.TOOL_SEARCH}),
+ min_trust_tier=TrustTier.T1_BASIC,
+ ),
+ "fix_skill": ToolPolicy(
+ tool_name="fix_skill",
+ required_scopes=frozenset({TokenScope.TOOL_EXECUTE, TokenScope.TOOL_ADMIN}),
+ min_trust_tier=TrustTier.T3_ELEVATED,
+ ),
+ "upload_skill": ToolPolicy(
+ tool_name="upload_skill",
+ required_scopes=frozenset({TokenScope.TOOL_ADMIN}),
+ min_trust_tier=TrustTier.T3_ELEVATED,
+ ),
+}
+
+
+def authorize_tool(claims: AuthClaims, policy: ToolPolicy) -> None:
+ """Check whether the caller is authorized to invoke a tool.
+
+ Enforcement order: blocked → tier → scopes → allowed.
+
+ Args:
+ claims: The caller's validated auth claims.
+ policy: The authorization policy for the target tool.
+
+ Raises:
+ ToolNotAuthorizedError: If the caller is blocked by subject.
+ InsufficientTierError: If the caller's trust tier is too low.
+ InsufficientScopeError: If the caller lacks required scopes.
+ """
+ # 1. Deny-before-allow: blocked subjects
+ if policy.blocked_subjects and claims.subject in policy.blocked_subjects:
+ raise ToolNotAuthorizedError(
+ f"Subject '{claims.subject}' is blocked from tool '{policy.tool_name}'"
+ )
+
+ # 2. Trust tier check
+ caller_level = _TIER_ORDER.index(claims.trust_tier)
+ required_level = _TIER_ORDER.index(policy.min_trust_tier)
+
+ if caller_level < required_level:
+ raise InsufficientTierError(
+ f"Tool '{policy.tool_name}' requires tier {policy.min_trust_tier.value}, "
+ f"caller has {claims.trust_tier.value}"
+ )
+
+ # 3. Scope check
+ missing_scopes = policy.required_scopes - claims.scopes
+ if missing_scopes:
+ missing_names = ", ".join(s.value for s in sorted(missing_scopes, key=lambda s: s.value))
+ raise InsufficientScopeError(
+ f"Tool '{policy.tool_name}' requires scopes [{missing_names}], "
+ f"caller is missing them"
+ )
+
+ # 4. Allowed subjects (if set, only listed subjects may proceed)
+ if policy.allowed_subjects and claims.subject not in policy.allowed_subjects:
+ raise ToolNotAuthorizedError(
+ f"Subject '{claims.subject}' is not in the allowed list "
+ f"for tool '{policy.tool_name}'"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #106 — Trust-Tier Gating
+# ---------------------------------------------------------------------------
+
+
+def check_tier_ceiling(
+ claims: AuthClaims,
+ requested_tier: TrustTier,
+) -> None:
+ """Enforce that a caller cannot request a tier above their own.
+
+ This is a **tier-only** check. For full lease authorization
+ (tier + scope), use :func:`authorize_lease` instead.
+
+ Raises:
+ InsufficientTierError: If requested tier exceeds the caller's tier.
+ """
+ _tier_order = _TIER_ORDER
+ caller_level = _tier_order.index(claims.trust_tier)
+ requested_level = _tier_order.index(requested_tier)
+
+ if requested_level > caller_level:
+ raise InsufficientTierError(
+ f"Cannot request tier {requested_tier.value}: "
+ f"caller's ceiling is {claims.trust_tier.value}"
+ )
+
+
+def authorize_lease(
+ claims: AuthClaims,
+ requested_tier: TrustTier,
+ *,
+ admin: bool = False,
+) -> None:
+ """Full lease authorization: tier ceiling + scope enforcement.
+
+ Called before capability lease acquisition. Checks both that the
+ caller's tier is sufficient AND that the caller has the appropriate
+ lease scope.
+
+ Args:
+ claims: The caller's validated auth claims.
+ requested_tier: The trust tier being requested for the lease.
+ admin: If True, requires LEASE_ADMIN scope; otherwise LEASE_ACQUIRE.
+
+ Raises:
+ InsufficientTierError: If requested tier exceeds the caller's tier.
+ InsufficientScopeError: If the caller lacks the required lease scope.
+ """
+ check_tier_ceiling(claims, requested_tier)
+
+ required_scope = TokenScope.LEASE_ADMIN if admin else TokenScope.LEASE_ACQUIRE
+ if required_scope not in claims.scopes:
+ raise InsufficientScopeError(
+ f"Lease {'admin' if admin else 'acquisition'} requires "
+ f"scope {required_scope.value}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #107 — Token Lifecycle (Revocation Registry)
+# ---------------------------------------------------------------------------
+
+
+import logging as _logging
+
+_registry_logger = _logging.getLogger("openspace.auth.registry")
+
+
+class RegistryFullError(AuthError):
+ """Revocation registry has hit its hard ceiling."""
+
+
+class TokenRegistry:
+ """Thread-safe token lifecycle management.
+
+ Tracks issued and revoked tokens with automatic expiry cleanup.
+ Only evicts **expired** entries — unexpired revoked tokens are
+ never discarded, preventing revocation-bypass via FIFO flooding.
+
+ A hard ceiling (HARD_MAX) prevents unbounded memory growth.
+ If the registry is full of unexpired entries and cannot GC enough
+ room, ``revoke()`` raises ``RegistryFullError`` (fail-closed).
+ """
+
+ MAX_REVOKED = 10_000
+ HARD_MAX = 100_000 # absolute ceiling — prevents OOM
+ _GC_INTERVAL = 100 # run lazy GC on reads every N calls
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._revoked: set[str] = set()
+ # (token_id, expires_at_epoch) — expires_at lets us GC safely
+ self._revoked_order: list[tuple[str, float]] = []
+ self._ops_since_gc: int = 0
+
+ def revoke(self, token_id: str, expires_at: float | None = None) -> None:
+ """Revoke a token by its ID.
+
+ Args:
+ token_id: Unique token identifier.
+ expires_at: Epoch when the token expires. Revocation entries
+ for expired tokens are eligible for garbage-collection.
+ If *None*, the entry is kept indefinitely (safe default).
+
+ Raises:
+ RegistryFullError: If the registry has hit HARD_MAX and
+ garbage-collection cannot reclaim space.
+ """
+ with self._lock:
+ if token_id in self._revoked:
+ return
+
+ # Always GC expired entries before adding
+ self._gc_expired_locked()
+ self._ops_since_gc = 0
+
+ # Hard ceiling: fail-closed rather than silently dropping entries
+ if len(self._revoked_order) >= self.HARD_MAX:
+ _registry_logger.critical(
+ "Revocation registry at hard ceiling (%d entries). "
+ "New revocation rejected — investigate token lifecycle.",
+ self.HARD_MAX,
+ )
+ raise RegistryFullError(
+ f"Revocation registry full ({self.HARD_MAX} entries)"
+ )
+
+ # Warn at 2× soft limit
+ if len(self._revoked_order) > 2 * self.MAX_REVOKED:
+ _registry_logger.warning(
+ "Revocation registry at %d entries (soft limit: %d). "
+ "Consider reviewing token TTLs.",
+ len(self._revoked_order),
+ self.MAX_REVOKED,
+ )
+
+ self._revoked.add(token_id)
+ exp = expires_at if expires_at is not None else float("inf")
+ self._revoked_order.append((token_id, exp))
+
+ def is_revoked(self, token_id: str) -> bool:
+ """Check if a token ID has been revoked."""
+ with self._lock:
+ self._ops_since_gc += 1
+ # Lazy GC on reads to clean up expired entries below the cap
+ if self._ops_since_gc >= self._GC_INTERVAL:
+ self._gc_expired_locked()
+ self._ops_since_gc = 0
+ return token_id in self._revoked
+
+ def revoked_count(self) -> int:
+ """Number of currently tracked revoked tokens."""
+ with self._lock:
+ return len(self._revoked)
+
+ def _gc_expired_locked(self) -> None:
+ """Remove entries whose tokens have expired. Must hold _lock.
+
+ Only removes entries where ``expires_at < now``. Unexpired
+ revoked tokens are **never** evicted — this prevents the
+ FIFO-flooding attack where an adversary revokes 10K dummy
+ tokens to resurrect a stolen, still-valid revoked token.
+ """
+ now = time.time()
+ surviving: list[tuple[str, float]] = []
+ for tid, exp in self._revoked_order:
+ if exp < now:
+ self._revoked.discard(tid)
+ else:
+ surviving.append((tid, exp))
+ self._revoked_order = surviving
+
+
+# ---------------------------------------------------------------------------
+# #51 — AuthProvider (concrete AuthPort implementation)
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class AuthProvider:
+ """Concrete implementation of AuthPort with HMAC-signed tokens.
+
+ Integrates token validation, revocation checking, and per-tool
+ authorization into a single service.
+
+ Args:
+ signing_secret: Server secret for HMAC-SHA256 token signing.
+ tool_policies: Per-tool authorization policies.
+ registry: Token lifecycle registry (optional, created if not provided).
+ """
+
+ signing_secret: str = field(repr=False)
+ audience: str = "" # service identifier for token binding
+ tool_policies: dict[str, ToolPolicy] = field(
+ default_factory=lambda: dict(DEFAULT_TOOL_POLICIES)
+ )
+ registry: TokenRegistry = field(default_factory=TokenRegistry)
+ _initialized: bool = field(default=False, repr=False, init=False)
+
+ def __post_init__(self) -> None:
+ if len(self.signing_secret) < _MIN_SECRET_LENGTH:
+ raise ValueError(
+ f"signing_secret must be at least {_MIN_SECRET_LENGTH} characters"
+ )
+ object.__setattr__(self, "_initialized", True)
+
+ def __setattr__(self, name: str, value: object) -> None:
+ if name == "signing_secret" and getattr(self, "_initialized", False):
+ raise AttributeError("signing_secret is immutable after init")
+ super().__setattr__(name, value)
+
+ def create_token(
+ self,
+ *,
+ subject: str,
+ trust_tier: TrustTier = TrustTier.T1_BASIC,
+ scopes: frozenset[TokenScope] | None = None,
+ ttl_seconds: int = 3600,
+ token_id: str | None = None,
+ ) -> str:
+ """Create a signed token for the given subject."""
+ return create_token(
+ secret=self.signing_secret,
+ subject=subject,
+ trust_tier=trust_tier,
+ scopes=scopes,
+ ttl_seconds=ttl_seconds,
+ token_id=token_id,
+ audience=self.audience,
+ )
+
+ async def authenticate(self, token: str) -> bool:
+ """AuthPort.authenticate — validate token and check revocation."""
+ try:
+ claims = self.validate_and_check(token)
+ return claims is not None
+ except AuthError:
+ return False
+
+ async def validate_token(self, token: str) -> tuple[bool, str]:
+ """AuthPort.validate_token — returns (valid, subject_or_error)."""
+ try:
+ claims = self.validate_and_check(token)
+ return True, claims.subject
+ except AuthError as exc:
+ return False, str(exc)
+
+ def validate_and_check(self, token: str) -> AuthClaims:
+ """Full validation: signature → expiry → audience → revocation.
+
+ Returns validated AuthClaims or raises AuthError subclass.
+ """
+ claims = validate_token(
+ token, secret=self.signing_secret,
+ expected_audience=self.audience,
+ )
+
+ if self.registry.is_revoked(claims.token_id):
+ raise TokenRevokedError(
+ f"Token '{claims.token_id}' has been revoked"
+ )
+
+ return claims
+
+ def authorize(self, token: str, tool_name: str) -> AuthClaims:
+ """Validate token AND authorize for a specific tool.
+
+ This is the primary entry point for MCP tool dispatch.
+
+ Returns:
+ AuthClaims if authorized.
+
+ Raises:
+ AuthError subclass if authentication or authorization fails.
+ """
+ claims = self.validate_and_check(token)
+
+ policy = self.tool_policies.get(tool_name)
+ if policy is None:
+ # Unknown tools require admin scope by default
+ policy = ToolPolicy(
+ tool_name=tool_name,
+ required_scopes=frozenset({TokenScope.TOOL_ADMIN}),
+ min_trust_tier=TrustTier.T3_ELEVATED,
+ )
+
+ authorize_tool(claims, policy)
+ return claims
+
+ def revoke_token(self, token_id: str, *, expires_at: float) -> None:
+ """Revoke a token by its ID.
+
+ Args:
+ token_id: Unique token identifier to revoke.
+ expires_at: Epoch when the token expires. **Required** so the
+ registry can garbage-collect the entry after expiry.
+ Obtain from ``AuthClaims.expires_at`` or the token payload.
+ """
+ self.registry.revoke(token_id, expires_at=expires_at)
+
+ def revoke(self, token: str) -> None:
+ """Revoke a token by validating it and extracting expiry.
+
+ This is the preferred high-level API: pass the full token string,
+ and the provider extracts token_id + expires_at automatically.
+ Audience is enforced — a provider can only revoke tokens minted
+ for its own service. Use ``revoke_token()`` for cross-service
+ admin revocation.
+
+ Args:
+ token: The full signed token string to revoke.
+
+ Raises:
+ AuthError: If the token cannot be validated (signature/format/audience).
+ """
+ claims = validate_token(
+ token, secret=self.signing_secret,
+ expected_audience=self.audience,
+ )
+ self.registry.revoke(claims.token_id, expires_at=claims.expires_at)
+
+ def check_tier_ceiling(
+ self, claims: AuthClaims, requested_tier: TrustTier
+ ) -> None:
+ """Enforce tier ceiling for capability lease requests."""
+ check_tier_ceiling(claims, requested_tier)
diff --git a/openspace/auth/rate_limit.py b/openspace/auth/rate_limit.py
new file mode 100644
index 00000000..15d09a60
--- /dev/null
+++ b/openspace/auth/rate_limit.py
@@ -0,0 +1,252 @@
+"""Sliding-window rate limiter for OpenSpace servers.
+
+Implements per-IP and per-token rate limiting as ASGI middleware.
+Uses an in-memory sliding-window log algorithm — no external dependencies.
+
+Design decisions:
+ - Sliding window (not fixed window) for smoother rate enforcement.
+ - Per-IP buckets keyed on direct client address (scope["client"]).
+ X-Forwarded-For is NOT trusted by default — only explicit trusted
+ proxies are honored (prevents IP spoofing bypass).
+ - Per-identity buckets keyed on IP:token composite (not raw token)
+ so that shared-secret auth doesn't create a single global bucket.
+ - MUST be placed AFTER BearerTokenMiddleware so only authenticated
+ requests create rate-limit state (prevents memory DoS via fake tokens).
+ - Max bucket count enforced to prevent memory exhaustion from unique keys.
+ - Configurable via environment variables with sensible defaults.
+ - Returns 429 with Retry-After header on limit breach.
+ - Thread-safe via asyncio.Lock (single-process model).
+
+Environment variables:
+ OPENSPACE_RATE_LIMIT_PER_TOKEN — requests per window per identity (default: 60)
+ OPENSPACE_RATE_LIMIT_PER_IP — requests per window per IP (default: 30)
+ OPENSPACE_RATE_LIMIT_WINDOW — window size in seconds (default: 60)
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import time
+from collections import defaultdict
+from typing import Any, Callable
+
+logger = logging.getLogger("openspace.auth")
+
+# Environment variable names
+RATE_LIMIT_PER_TOKEN_ENV = "OPENSPACE_RATE_LIMIT_PER_TOKEN"
+RATE_LIMIT_PER_IP_ENV = "OPENSPACE_RATE_LIMIT_PER_IP"
+RATE_LIMIT_WINDOW_ENV = "OPENSPACE_RATE_LIMIT_WINDOW"
+
+# Defaults
+DEFAULT_PER_TOKEN = 60
+DEFAULT_PER_IP = 30
+DEFAULT_WINDOW = 60 # seconds
+MAX_BUCKETS = 10_000 # hard cap to prevent memory exhaustion
+
+
+def _env_int(name: str, default: int) -> int:
+ raw = os.environ.get(name)
+ if raw is None:
+ return default
+ try:
+ val = int(raw)
+ return val if val > 0 else default
+ except ValueError:
+ return default
+
+
+class SlidingWindowCounter:
+ """In-memory sliding-window rate counter.
+
+ Each key (IP or identity) gets a list of timestamps. On each check,
+ expired entries are pruned and the count is compared to the limit.
+ Hard-capped at MAX_BUCKETS to prevent memory exhaustion.
+ """
+
+ def __init__(self, limit: int, window: float, max_buckets: int = MAX_BUCKETS) -> None:
+ self.limit = limit
+ self.window = window
+ self._max_buckets = max_buckets
+ self._buckets: dict[str, list[float]] = defaultdict(list)
+ self._lock = asyncio.Lock()
+ self._last_cleanup = time.monotonic()
+ self._cleanup_interval = max(window * 2, 120.0)
+
+ async def is_allowed(self, key: str) -> tuple[bool, int, float]:
+ """Check if a request is allowed for the given key.
+
+ Returns (allowed, remaining, retry_after).
+ - allowed: True if under limit
+ - remaining: requests left in window
+ - retry_after: seconds until oldest entry expires (0 if allowed)
+ """
+ now = time.monotonic()
+ cutoff = now - self.window
+
+ async with self._lock:
+ # Periodic cleanup of stale keys
+ if now - self._last_cleanup > self._cleanup_interval:
+ self._cleanup(cutoff)
+ self._last_cleanup = now
+
+ # If this is a NEW key and we're at capacity, try harder:
+ # force-clean stale buckets before rejecting. This prevents
+ # expired entries from blocking legitimate new clients.
+ if key not in self._buckets and len(self._buckets) >= self._max_buckets:
+ self._cleanup(cutoff)
+ self._last_cleanup = now
+
+ # Still at capacity after cleanup? Reject the new key.
+ # Never evict active buckets — that would reset their quota
+ # and let attackers bypass rate limiting via key churn.
+ if key not in self._buckets and len(self._buckets) >= self._max_buckets:
+ logger.warning(
+ "Rate limiter at max capacity (%d buckets), "
+ "rejecting new key",
+ self._max_buckets,
+ )
+ return False, 0, float(self.window)
+
+ bucket = self._buckets[key]
+
+ # Prune expired timestamps
+ while bucket and bucket[0] <= cutoff:
+ bucket.pop(0)
+
+ if len(bucket) >= self.limit:
+ retry_after = bucket[0] + self.window - now
+ return False, 0, max(retry_after, 0.1)
+
+ bucket.append(now)
+ remaining = self.limit - len(bucket)
+
+ return True, remaining, 0.0
+
+ def _cleanup(self, cutoff: float) -> None:
+ """Remove keys with no recent activity."""
+ stale = [k for k, v in self._buckets.items() if not v or v[-1] <= cutoff]
+ for k in stale:
+ del self._buckets[k]
+
+
+class RateLimitMiddleware:
+ """ASGI middleware: per-IP and per-identity sliding-window rate limiting.
+
+ MUST be placed AFTER BearerTokenMiddleware in the middleware chain.
+ This ensures only authenticated requests create rate-limit state,
+ preventing memory DoS via fake tokens from unauthenticated floods.
+ """
+
+ def __init__(self, app: Any) -> None:
+ self.app = app
+ self._per_identity = _env_int(RATE_LIMIT_PER_TOKEN_ENV, DEFAULT_PER_TOKEN)
+ per_ip = _env_int(RATE_LIMIT_PER_IP_ENV, DEFAULT_PER_IP)
+ window = _env_int(RATE_LIMIT_WINDOW_ENV, DEFAULT_WINDOW)
+
+ self._identity_limiter = SlidingWindowCounter(self._per_identity, window)
+ self._ip_limiter = SlidingWindowCounter(per_ip, window)
+ self._window = window
+
+ logger.info(
+ "Rate limiter: %d req/identity, %d req/IP, %ds window",
+ self._per_identity, per_ip, window,
+ )
+
+ async def __call__(
+ self, scope: dict, receive: Callable, send: Callable,
+ ) -> None:
+ if scope["type"] not in ("http",):
+ await self.app(scope, receive, send)
+ return
+
+ client_ip = self._extract_ip(scope)
+ token = self._extract_token(scope)
+
+ # Check IP limit first
+ ip_ok, ip_remaining, ip_retry = await self._ip_limiter.is_allowed(client_ip)
+ if not ip_ok:
+ logger.warning("Rate limit exceeded for IP %s", client_ip)
+ await self._send_429(send, ip_retry, "IP rate limit exceeded")
+ return
+
+ # Check identity limit (IP:token composite key)
+ # Using composite key ensures shared-secret auth doesn't create
+ # a single global bucket — each IP gets its own token quota.
+ id_remaining = self._per_identity
+ if token:
+ identity_key = f"{client_ip}:{token[:8]}"
+ id_ok, id_remaining, id_retry = await self._identity_limiter.is_allowed(
+ identity_key
+ )
+ if not id_ok:
+ logger.warning("Rate limit exceeded for identity (IP=%s)", client_ip)
+ await self._send_429(send, id_retry, "Rate limit exceeded")
+ return
+
+ # Determine governing limit for response headers
+ if token:
+ governing_remaining = min(ip_remaining, id_remaining)
+ governing_limit = min(self._ip_limiter.limit, self._identity_limiter.limit)
+ else:
+ governing_remaining = ip_remaining
+ governing_limit = self._ip_limiter.limit
+
+ async def rate_limit_send(message: dict) -> None:
+ if message["type"] == "http.response.start":
+ headers = list(message.get("headers", []))
+ headers.extend([
+ [b"x-ratelimit-remaining", str(governing_remaining).encode()],
+ [b"x-ratelimit-limit", str(governing_limit).encode()],
+ [b"x-ratelimit-window", str(self._window).encode()],
+ ])
+ message = {**message, "headers": headers}
+ await send(message)
+
+ await self.app(scope, receive, rate_limit_send)
+
+ @staticmethod
+ def _extract_ip(scope: dict) -> str:
+ """Extract client IP from ASGI scope.
+
+ Only uses the direct peer address (scope["client"]).
+ X-Forwarded-For is NOT trusted — an attacker can trivially
+ spoof it to bypass IP-based rate limiting. If deployed behind
+ a reverse proxy, configure the proxy to set the real client IP
+ in scope["client"] (e.g., uvicorn --proxy-headers with
+ --forwarded-allow-ips).
+ """
+ client = scope.get("client")
+ if client:
+ return client[0]
+ return "unknown"
+
+ @staticmethod
+ def _extract_token(scope: dict) -> str | None:
+ """Extract bearer token from Authorization header (if present)."""
+ headers = dict(scope.get("headers", []))
+ auth = headers.get(b"authorization", b"").decode("latin-1")
+ if auth.startswith("Bearer "):
+ return auth[7:]
+ return None
+
+ @staticmethod
+ async def _send_429(send: Callable, retry_after: float, detail: str) -> None:
+ retry_int = max(1, int(retry_after + 0.5))
+ body = json.dumps(
+ {"error": "rate_limited", "detail": detail, "retry_after": retry_int},
+ ensure_ascii=False,
+ ).encode("utf-8")
+
+ await send({
+ "type": "http.response.start",
+ "status": 429,
+ "headers": [
+ [b"content-type", b"application/json"],
+ [b"retry-after", str(retry_int).encode()],
+ [b"content-length", str(len(body)).encode()],
+ ],
+ })
+ await send({"type": "http.response.body", "body": body})
diff --git a/openspace/config/README.md b/openspace/config/README.md
index 74327ff4..a68d4ac0 100644
--- a/openspace/config/README.md
+++ b/openspace/config/README.md
@@ -99,7 +99,7 @@ Layered system — later files override earlier ones:
|---------|-----------|-------------|
| `shell` | `mode`, `timeout`, `conda_env`, `working_dir` | `"local"` (default) or `"server"`, command timeout (default: `60`s) |
| `gui` | `mode`, `timeout`, `driver_type`, `screenshot_on_error` | Local/server mode, automation driver (default: `pyautogui`) |
-| `mcp` | `timeout`, `sandbox`, `eager_sessions` | Request timeout (`30`s), E2B sandbox, lazy/eager server init |
+| `mcp` | `timeout`, `sandbox`, `eager_sessions` | Request timeout (`30`s), E2B sandbox (**enforced by default**), lazy/eager server init |
| `tool_search` | `search_mode`, `max_tools`, `enable_llm_filter` | `"hybrid"` (semantic + LLM), max tools to return (`40`), embedding cache |
| `tool_quality` | `enabled`, `enable_persistence`, `evolve_interval` | Quality tracking, self-evolution every N calls (default: `5`) |
| `skills` | `enabled`, `skill_dirs`, `max_select` | Directories to scan, max skills injected per task (default: `2`) |
@@ -110,6 +110,26 @@ Layered system — later files override earlier ones:
|-------|-------------|---------|
| `allow_shell_commands` | Enable shell execution | `true` |
| `blocked_commands` | Platform-specific blacklists (common/linux/darwin/windows) | `rm -rf`, `shutdown`, `dd`, etc. |
-| `sandbox_enabled` | Enable sandboxing for all operations | `false` |
+| `sandbox_enabled` | Enable sandboxing for all operations | **`true`** |
| Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global |
+### E2B Sandbox Configuration
+
+Sandbox execution is **enforced by default** for all stdio-based MCP servers. This prevents
+untrusted skill code from executing directly on the host.
+
+| Setting | Source | Description |
+|---------|--------|-------------|
+| `E2B_API_KEY` | **Environment variable** (required) | API key from [e2b.dev](https://e2b.dev). Never passed via config. |
+| `mcp.sandbox` | `config_grounding.json` | Default: `true`. Controls sandbox enforcement for MCP backend. |
+| `sandbox_enabled` | `config_security.json` | Default: `true`. Global and per-backend sandbox policy. |
+| `sandbox_template_id` | Config only | E2B sandbox template (default: `"base"`). |
+| `timeout` | Config only | Sandbox command timeout in seconds (default: `600`). |
+| `OPENSPACE_ALLOW_UNSANDBOXED` | Environment variable | Set to `1` to allow unsandboxed execution (dev only, **NOT recommended**). |
+
+**Fail-closed behavior:**
+- Missing `E2B_API_KEY` → startup fails (no silent fallback)
+- Missing E2B SDK → startup fails with install instructions
+- Invalid config → startup fails (no default-config fallback)
+- Sandbox start failure → execution aborted (no downgrade to direct execution)
+
diff --git a/openspace/config/config_grounding.json b/openspace/config/config_grounding.json
index 79508a10..a78bb69a 100644
--- a/openspace/config/config_grounding.json
+++ b/openspace/config/config_grounding.json
@@ -14,7 +14,7 @@
"timeout": 30,
"max_retries": 3,
"retry_interval": 2.0,
- "sandbox": false,
+ "sandbox": true,
"auto_initialize": true,
"eager_sessions": false,
"sse_read_timeout": 300.0,
diff --git a/openspace/config/config_security.json b/openspace/config/config_security.json
index c64f75e8..26c361b3 100644
--- a/openspace/config/config_security.json
+++ b/openspace/config/config_security.json
@@ -10,7 +10,7 @@
"darwin": ["diskutil", "dd", "pfctl", "launchctl", "killall"],
"windows": ["del", "format", "rd", "rmdir", "/s", "/q", "taskkill", "/f"]
},
- "sandbox_enabled": false
+ "sandbox_enabled": true
},
"backend": {
"shell": {
@@ -54,10 +54,10 @@
"wmic"
]
},
- "sandbox_enabled": false
+ "sandbox_enabled": true
},
"mcp": {
- "sandbox_enabled": false
+ "sandbox_enabled": true
},
"web": {
"allow_network_access": true,
diff --git a/openspace/config/grounding.py b/openspace/config/grounding.py
index 853bbfb3..d655d6bd 100644
--- a/openspace/config/grounding.py
+++ b/openspace/config/grounding.py
@@ -105,7 +105,7 @@ class WebConfig(BackendConfig):
class MCPConfig(BackendConfig):
"""MCP backend configuration"""
- sandbox: bool = Field(False, description="Whether to enable sandbox")
+ sandbox: bool = Field(True, description="Whether to enable sandbox (enforced by default for security)")
auto_initialize: bool = Field(True, description="Whether to auto initialize")
eager_sessions: bool = Field(False, description="Whether to eagerly create sessions for all servers on initialization")
retry_interval: float = Field(2.0, ge=0.1, le=60.0, description="Wait time between retries in seconds")
@@ -221,6 +221,14 @@ class SkillConfig(BaseModel):
2, ge=1, le=20,
description="Maximum number of skills to inject per task"
)
+ auto_import_enabled: bool = Field(
+ False,
+ description=(
+ "Allow automatic import of cloud skills. "
+ "Disabled by default — importing untrusted code is a "
+ "supply-chain risk until a trust-tier system is in place."
+ ),
+ )
class GroundingConfig(BaseModel):
diff --git a/openspace/config/loader.py b/openspace/config/loader.py
index cd414168..4a1719d8 100644
--- a/openspace/config/loader.py
+++ b/openspace/config/loader.py
@@ -33,12 +33,24 @@ def _deep_merge_dict(base: dict, update: dict) -> dict:
result[key] = value
return result
-def _load_json_file(path: Path) -> Dict[str, Any]:
+def _load_json_file(path: Path, *, critical: bool = False) -> Dict[str, Any]:
"""Load single JSON configuration file.
- This function wraps the generic load_json_file and adds global configuration specific error handling and logging.
+ This function wraps the generic load_json_file and adds global
+ configuration specific error handling and logging.
+
+ Args:
+ path: Path to JSON file.
+ critical: If True, raise on parse errors instead of returning {}.
+ Use for security-critical config files where silent
+ fallback to empty dict could weaken security posture.
"""
if not path.exists():
+ if critical:
+ raise FileNotFoundError(
+ f"Critical configuration file missing: {path}. "
+ "Cannot start with potentially insecure defaults."
+ )
logger.debug(f"Configuration file does not exist, skipping: {path}")
return {}
@@ -47,18 +59,33 @@ def _load_json_file(path: Path) -> Dict[str, Any]:
logger.info(f"Loaded configuration file: {path}")
return data
except Exception as e:
+ if critical:
+ raise RuntimeError(
+ f"Failed to parse critical configuration file {path}: {e}. "
+ "Refusing to start with potentially insecure defaults."
+ ) from e
logger.warning(f"Failed to load configuration file {path}: {e}")
return {}
-def _load_multiple_files(paths: Iterable[Path]) -> Dict[str, Any]:
- """Load configuration from multiple files"""
+def _load_multiple_files(paths: Iterable[Path], critical_files: frozenset[str] = frozenset()) -> Dict[str, Any]:
+ """Load configuration from multiple files.
+
+ Args:
+ paths: Config file paths to load and merge.
+ critical_files: Filenames (not full paths) that must not fail silently.
+ """
merged = {}
for path in paths:
- data = _load_json_file(path)
+ is_critical = path.name in critical_files
+ data = _load_json_file(path, critical=is_critical)
if data:
merged = _deep_merge_dict(merged, data)
return merged
+# Security config must not fail silently — malformed security config
+# could silently disable sandbox enforcement.
+_CRITICAL_CONFIG_FILES = frozenset({CONFIG_SECURITY})
+
def load_config(*config_paths: Union[str, Path]) -> GroundingConfig:
"""
Load configuration files
@@ -76,7 +103,9 @@ def load_config(*config_paths: Union[str, Path]) -> GroundingConfig:
]
# Load and merge configuration
- raw_data = _load_multiple_files(paths)
+ # Security config is marked critical — parse errors raise instead of
+ # silently falling back to defaults (which could disable sandbox).
+ raw_data = _load_multiple_files(paths, critical_files=_CRITICAL_CONFIG_FILES)
# Load MCP configuration (separate processing)
# Check if mcpServers already provided in merged custom configs
@@ -98,11 +127,17 @@ def load_config(*config_paths: Union[str, Path]) -> GroundingConfig:
logger.debug(f"Loaded MCP servers from default config_mcp.json ({len(raw_data['mcp']['servers'])} servers)")
# Validate and create configuration object
+ # Fail-closed: invalid config raises instead of silently falling back
+ # to defaults (which could disable sandbox enforcement)
try:
_config = GroundingConfig.model_validate(raw_data)
except Exception as e:
- logger.error(f"Validation failed, using default configuration: {e}")
- _config = GroundingConfig()
+ logger.error(f"Configuration validation failed: {e}")
+ raise RuntimeError(
+ f"GroundingConfig validation failed — refusing to start with "
+ f"potentially insecure defaults. Fix the config or remove it "
+ f"to use secure defaults. Error: {e}"
+ ) from e
# Adjust log level according to configuration
if _config.debug:
diff --git a/openspace/domain/__init__.py b/openspace/domain/__init__.py
new file mode 100644
index 00000000..6f1aa603
--- /dev/null
+++ b/openspace/domain/__init__.py
@@ -0,0 +1,7 @@
+"""Domain layer — ports (Protocol interfaces) and core value types.
+
+This package defines the contract boundary between the application core
+and its infrastructure adapters. Nothing in ``openspace.domain`` should
+import from adapter packages (``cloud``, ``grounding.backends``, ``llm``,
+``recording``, ``local_server``, etc.).
+"""
diff --git a/openspace/domain/enums.py b/openspace/domain/enums.py
new file mode 100644
index 00000000..d519ea9a
--- /dev/null
+++ b/openspace/domain/enums.py
@@ -0,0 +1,112 @@
+"""Consolidated domain enumerations.
+
+Re-exports existing enums from their current locations for backward
+compatibility, and adds new enums that were previously magic strings.
+Existing code can continue importing from the original modules; new code
+should prefer ``openspace.domain.enums``.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+
+# ── Re-exports from existing locations ────────────────────────────────
+from openspace.skill_engine.types import (
+ EvolutionType,
+ SkillCategory,
+ SkillOrigin,
+ SkillVisibility,
+)
+from openspace.grounding.core.types import (
+ BackendType,
+ SessionStatus,
+ ToolStatus,
+)
+from openspace.grounding.core.exceptions import ErrorCode as GroundingErrorCode
+
+
+# ── New enums (were magic strings) ────────────────────────────────────
+
+class TaskStatus(str, Enum):
+ """Execution task lifecycle status."""
+
+ PENDING = "pending"
+ RUNNING = "running"
+ SUCCESS = "success"
+ FAILED = "failed"
+ ERROR = "error"
+ TIMEOUT = "timeout"
+
+
+class SearchMode(str, Enum):
+ """Skill / tool search strategies."""
+
+ SEMANTIC = "semantic"
+ KEYWORD = "keyword"
+ HYBRID = "hybrid"
+
+
+class SearchScope(str, Enum):
+ """Where to search for skills."""
+
+ LOCAL = "local"
+ CLOUD = "cloud"
+ ALL = "all"
+
+
+class TrustTier(str, Enum):
+ """Capability trust level for sandbox decisions."""
+
+ UNTRUSTED = "untrusted"
+ BASIC = "basic"
+ STANDARD = "standard"
+ PRIVILEGED = "privileged"
+
+
+class SkillStatus(str, Enum):
+ """Lifecycle status of a skill record."""
+
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ DEPRECATED = "deprecated"
+
+
+class PatchType(str, Enum):
+ """How a skill patch is applied."""
+
+ AUTO = "auto"
+ FULL = "full"
+ DIFF = "diff"
+ PATCH = "patch"
+
+
+class MCPErrorCode(str, Enum):
+ """Error codes surfaced through the MCP server layer."""
+
+ EXECUTION_ERROR = "EXECUTION_ERROR"
+ VALIDATION_ERROR = "VALIDATION_ERROR"
+ SKILL_NOT_FOUND = "SKILL_NOT_FOUND"
+ PERMISSION_DENIED = "PERMISSION_DENIED"
+ INTERNAL_ERROR = "INTERNAL_ERROR"
+ TIMEOUT_ERROR = "TIMEOUT_ERROR"
+
+
+__all__ = [
+ # Re-exported
+ "BackendType",
+ "EvolutionType",
+ "GroundingErrorCode",
+ "SessionStatus",
+ "SkillCategory",
+ "SkillOrigin",
+ "SkillVisibility",
+ "ToolStatus",
+ # New
+ "MCPErrorCode",
+ "PatchType",
+ "SearchMode",
+ "SearchScope",
+ "SkillStatus",
+ "TaskStatus",
+ "TrustTier",
+]
diff --git a/openspace/domain/exceptions.py b/openspace/domain/exceptions.py
new file mode 100644
index 00000000..b9c8c5d9
--- /dev/null
+++ b/openspace/domain/exceptions.py
@@ -0,0 +1,266 @@
+"""Domain exception hierarchy.
+
+All domain-specific exceptions inherit from :class:`OpenSpaceError`.
+Each subclass maps to a stable ``error_code`` that the MCP layer can
+surface to callers without leaking internals.
+
+Usage::
+
+ from openspace.domain.exceptions import ValidationError, NotFoundError
+
+ raise ValidationError("skill_dirs must be a list")
+ raise NotFoundError("skill", skill_id="abc-123")
+
+The centralized :func:`map_to_mcp_error_code` converts any exception
+into the appropriate MCP error code string for ``safe_error_response()``.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+
+# Generic fallback for client_message when no safe_message is provided.
+_GENERIC_CLIENT_MESSAGE = "An internal error occurred"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Base exception
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class OpenSpaceError(Exception):
+ """Root exception for all domain errors.
+
+ Attributes:
+ message: Human-readable description (server-side only, may contain internals).
+ error_code: Stable string code for MCP/API responses.
+ retryable: Whether the caller may retry the operation.
+ context: Extra key-value pairs for **server-side** logging / diagnostics.
+ safe_message: Optional sanitized message for client-facing use.
+ """
+
+ error_code: str = "INTERNAL_ERROR"
+ retryable: bool = False
+
+ def __init__(
+ self,
+ message: str = "",
+ *,
+ retryable: Optional[bool] = None,
+ safe_message: Optional[str] = None,
+ **context: Any,
+ ) -> None:
+ super().__init__(message)
+ self.message = message
+ if retryable is not None:
+ self.retryable = retryable
+ self.safe_message = safe_message
+ self.context: Dict[str, Any] = context
+
+ @property
+ def client_message(self) -> str:
+ """Message safe for client-facing responses.
+
+ Returns ``safe_message`` if explicitly set, otherwise a generic
+ fallback. **Never** returns the raw ``message`` to prevent
+ accidental disclosure of internal details.
+ """
+ return self.safe_message or _GENERIC_CLIENT_MESSAGE
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Serialize for **server-side** structured logging.
+
+ .. warning::
+ This output may contain sensitive data from ``message`` and
+ ``context``. Do NOT send it to clients. Use
+ :meth:`to_safe_dict` for client-facing serialization.
+ """
+ return {
+ "error_code": self.error_code,
+ "message": self.message,
+ "retryable": self.retryable,
+ "context": self.context,
+ }
+
+ def to_safe_dict(self) -> Dict[str, Any]:
+ """Serialize for client-facing responses (redacted)."""
+ return {
+ "error_code": self.error_code,
+ "message": self.client_message,
+ "retryable": self.retryable,
+ }
+
+ def __str__(self) -> str:
+ return f"[{self.error_code}] {self.message}"
+
+ def __repr__(self) -> str:
+ ctx = f", context={self.context}" if self.context else ""
+ return f"{type(self).__name__}({self.message!r}{ctx})"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Concrete domain exceptions
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class ValidationError(OpenSpaceError):
+ """Bad input from the caller (missing / invalid args)."""
+
+ error_code = "VALIDATION_ERROR"
+
+
+class NotFoundError(OpenSpaceError):
+ """Requested resource does not exist.
+
+ Usage::
+
+ raise NotFoundError("skill", skill_id="abc-123")
+ raise NotFoundError("session", session_name="default")
+ """
+
+ error_code = "SKILL_NOT_FOUND"
+
+ def __init__(self, resource_type: str = "resource", **context: Any) -> None:
+ resource_id = context.get("skill_id") or context.get("session_name") or ""
+ msg = f"{resource_type} not found"
+ if resource_id:
+ msg += f": {resource_id}"
+ super().__init__(msg, **context)
+ self.resource_type = resource_type
+
+
+class PermissionDeniedError(OpenSpaceError):
+ """Authentication or authorization failure."""
+
+ error_code = "PERMISSION_DENIED"
+
+
+class OperationTimeoutError(OpenSpaceError):
+ """Operation exceeded its time limit.
+
+ Named ``OperationTimeoutError`` to avoid shadowing the builtin
+ ``TimeoutError``.
+ """
+
+ error_code = "TIMEOUT_ERROR"
+ retryable = True
+
+
+class ExecutionError(OpenSpaceError):
+ """Task execution failed at runtime."""
+
+ error_code = "EXECUTION_ERROR"
+
+
+class DependencyError(OpenSpaceError):
+ """Required external dependency is missing or broken."""
+
+ error_code = "EXECUTION_ERROR"
+
+ def __init__(self, message: str = "", *, dependency: str = "", **context: Any) -> None:
+ super().__init__(message, dependency=dependency, **context)
+ self.dependency = dependency
+
+
+class ConfigurationError(OpenSpaceError):
+ """Invalid or missing configuration."""
+
+ error_code = "VALIDATION_ERROR"
+
+
+class ExternalServiceError(OpenSpaceError):
+ """Upstream / third-party service failure.
+
+ ``retryable`` is derived from ``status_code`` when present:
+ 5xx and 429 are retryable; 4xx (except 429) are not.
+ Can be overridden explicitly.
+ """
+
+ error_code = "EXECUTION_ERROR"
+
+ def __init__(
+ self,
+ message: str = "",
+ *,
+ service: str = "",
+ status_code: Optional[int] = None,
+ retryable: Optional[bool] = None,
+ **context: Any,
+ ) -> None:
+ # Derive retryable from status_code if not explicitly set
+ if retryable is None and status_code is not None:
+ if status_code == 429 or 500 <= status_code < 600:
+ retryable = True
+ elif 400 <= status_code < 500:
+ retryable = False
+ else:
+ retryable = True # Unknown codes: optimistic retry
+ elif retryable is None:
+ retryable = True # Default for unknown upstream failures
+ super().__init__(message, retryable=retryable, service=service, **context)
+ self.service = service
+ self.status_code = status_code
+
+
+class SandboxError(OpenSpaceError):
+ """Sandbox creation, execution, or teardown failure."""
+
+ error_code = "EXECUTION_ERROR"
+
+
+class EvolutionError(OpenSpaceError):
+ """Skill evolution failed."""
+
+ error_code = "EXECUTION_ERROR"
+
+
+class InternalError(OpenSpaceError):
+ """Catch-all for unexpected server errors."""
+
+ error_code = "INTERNAL_ERROR"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Centralized error code mapping
+# ═══════════════════════════════════════════════════════════════════════
+
+# Built-in exceptions that map to specific MCP codes.
+_BUILTIN_TO_CODE: list[tuple[type, str]] = [
+ (TimeoutError, "TIMEOUT_ERROR"), # builtin TimeoutError
+ (PermissionError, "PERMISSION_DENIED"), # builtin PermissionError
+ (FileNotFoundError, "SKILL_NOT_FOUND"), # builtin FileNotFoundError
+ (ValueError, "VALIDATION_ERROR"), # builtin ValueError
+]
+
+
+def map_to_mcp_error_code(exc: BaseException) -> str:
+ """Convert any exception to the appropriate MCP error code.
+
+ Handles:
+ 1. :class:`OpenSpaceError` subclasses — returns their ``error_code``
+ 2. Built-in exceptions (TimeoutError, PermissionError, etc.)
+ 3. Unknown exceptions — returns ``INTERNAL_ERROR``
+ """
+ if isinstance(exc, OpenSpaceError):
+ return exc.error_code
+ for exc_type, code in _BUILTIN_TO_CODE:
+ if isinstance(exc, exc_type):
+ return code
+ return "INTERNAL_ERROR"
+
+
+__all__ = [
+ "ConfigurationError",
+ "DependencyError",
+ "EvolutionError",
+ "ExecutionError",
+ "ExternalServiceError",
+ "InternalError",
+ "NotFoundError",
+ "OpenSpaceError",
+ "OperationTimeoutError",
+ "PermissionDeniedError",
+ "SandboxError",
+ "ValidationError",
+ "map_to_mcp_error_code",
+]
diff --git a/openspace/domain/logging.py b/openspace/domain/logging.py
new file mode 100644
index 00000000..dbebe103
--- /dev/null
+++ b/openspace/domain/logging.py
@@ -0,0 +1,310 @@
+"""Structured logging with context propagation.
+
+Wraps :mod:`structlog` over the existing stdlib ``logging`` infrastructure
+so that every log event carries structured key-value pairs (``task_id``,
+``correlation_id``, ``session_id``, etc.) without changing existing call
+sites.
+
+Usage — new code::
+
+ from openspace.domain.logging import get_logger, bind_context
+
+ log = get_logger(__name__)
+
+ # Bind context for the current async task / request
+ bind_context(task_id="t-42", correlation_id="abc123")
+ log.info("task_started", workspace="/tmp") # structured event
+
+Usage — existing code (zero changes needed)::
+
+ # stdlib loggers continue to work; structlog processors
+ # will format their output through the shared formatter.
+ import logging
+ logger = logging.getLogger("openspace.mcp_server")
+ logger.info("old-style message") # still works, gets structured formatting
+
+Context propagation uses :mod:`contextvars` so it is safe across
+``asyncio`` task boundaries.
+"""
+
+from __future__ import annotations
+
+import contextvars
+import logging
+import re
+import sys
+from typing import Any, Dict, Optional
+
+import structlog
+
+
+# ── Context variables (async-safe) ────────────────────────────────────
+
+_task_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
+ "task_id", default=""
+)
+_correlation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
+ "correlation_id", default=""
+)
+_session_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
+ "session_id", default=""
+)
+_request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
+ "request_id", default=""
+)
+
+_ALL_CONTEXT_VARS: Dict[str, contextvars.ContextVar[str]] = {
+ "task_id": _task_id_var,
+ "correlation_id": _correlation_id_var,
+ "session_id": _session_id_var,
+ "request_id": _request_id_var,
+}
+
+
+# ── Public context helpers ────────────────────────────────────────────
+
+
+def bind_context(**kwargs: str) -> None:
+ """Bind context variables for the current async task / request.
+
+ Example::
+
+ bind_context(task_id="t-42", correlation_id="abc123")
+ """
+ for key, value in kwargs.items():
+ var = _ALL_CONTEXT_VARS.get(key)
+ if var is not None:
+ var.set(value)
+
+
+def clear_context() -> None:
+ """Reset all context variables to their defaults."""
+ for var in _ALL_CONTEXT_VARS.values():
+ var.set("")
+
+
+def get_context() -> Dict[str, str]:
+ """Return a snapshot of all non-empty context variables."""
+ return {
+ key: var.get()
+ for key, var in _ALL_CONTEXT_VARS.items()
+ if var.get()
+ }
+
+
+# ── Sensitive data redaction ──────────────────────────────────────────
+
+_SENSITIVE_KEYS = frozenset({
+ "api_key",
+ "token",
+ "bearer_token",
+ "password",
+ "secret",
+ "authorization",
+ "credentials",
+ "private_key",
+ "access_token",
+ "refresh_token",
+ "client_secret",
+ "secret_key",
+ "auth_token",
+})
+
+# Suffixes / prefixes that indicate a key holds sensitive data.
+# More precise than substring matching to avoid false positives like
+# "token_count" or "basket_id".
+_SENSITIVE_SUFFIXES = ("_key", "_token", "_secret", "_password", "_credential")
+_SENSITIVE_PREFIXES = ("secret_", "password_", "auth_")
+
+_MAX_VALUE_LENGTH = 1000
+
+# camelCase → snake_case normalizer so "apiKey" matches "api_key"
+_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])([A-Z])")
+
+
+def _normalize_key(key: str) -> str:
+ """Normalize a key to lower snake_case for sensitive matching."""
+ return _CAMEL_BOUNDARY.sub(r"_\1", key).lower()
+
+
+def _is_sensitive_key(key: str) -> bool:
+ """Check if a key name indicates sensitive data.
+
+ Handles snake_case, camelCase, and PascalCase variants by normalizing
+ to lower snake_case before matching (e.g. ``apiKey`` → ``api_key``).
+ """
+ normalized = _normalize_key(key)
+ return (
+ normalized in _SENSITIVE_KEYS
+ or normalized.endswith(_SENSITIVE_SUFFIXES)
+ or normalized.startswith(_SENSITIVE_PREFIXES)
+ )
+
+
+_MAX_REDACT_DEPTH = 10
+
+
+def _redact_value(value: Any, parent_sensitive: bool = False, _depth: int = 0) -> Any:
+ """Recursively redact sensitive values in nested structures.
+
+ Stops at ``_MAX_REDACT_DEPTH`` to prevent ``RecursionError`` on
+ deeply nested or self-referential payloads.
+ """
+ if parent_sensitive:
+ return "***REDACTED***"
+ if _depth >= _MAX_REDACT_DEPTH:
+ return value
+ if isinstance(value, dict):
+ return {
+ k: _redact_value(v, _is_sensitive_key(k), _depth + 1)
+ for k, v in value.items()
+ }
+ if isinstance(value, (list, tuple)):
+ return type(value)(_redact_value(v, _depth=_depth + 1) for v in value)
+ return value
+
+
+def _redact_sensitive(
+ logger: Any, method_name: str, event_dict: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Structlog processor: redact sensitive keys and truncate long values.
+
+ Handles nested dicts/lists recursively to prevent leakage via
+ structured payloads in JSON logging mode.
+ """
+ for key in list(event_dict.keys()):
+ if _is_sensitive_key(key):
+ event_dict[key] = "***REDACTED***"
+ elif isinstance(event_dict[key], (dict, list, tuple)):
+ event_dict[key] = _redact_value(event_dict[key])
+ elif isinstance(event_dict[key], str) and len(event_dict[key]) > _MAX_VALUE_LENGTH:
+ event_dict[key] = event_dict[key][:_MAX_VALUE_LENGTH] + "...[truncated]"
+ return event_dict
+
+
+# ── Inject contextvars into every log event ───────────────────────────
+
+
+def _inject_context_vars(
+ logger: Any, method_name: str, event_dict: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Structlog processor: merge contextvars into the event dict."""
+ ctx = get_context()
+ for key, value in ctx.items():
+ if key not in event_dict:
+ event_dict[key] = value
+ return event_dict
+
+
+# ── Configuration ─────────────────────────────────────────────────────
+
+_configured = False
+
+
+def configure_logging(
+ *,
+ level: int = logging.INFO,
+ json_output: bool = False,
+ colors: bool = True,
+) -> None:
+ """Configure structlog + stdlib logging.
+
+ Call once at application startup. Safe to call multiple times
+ (subsequent calls are no-ops unless the module is reset).
+
+ Args:
+ level: stdlib log level (default INFO).
+ json_output: If True, emit JSON lines to stdout (for production).
+ If False, emit human-readable colored output.
+ colors: Enable colored console output (ignored if json_output=True).
+ """
+ global _configured
+ if _configured:
+ return
+ _configured = True
+
+ # Shared structlog processors (run for both structlog and stdlib loggers)
+ shared_processors: list[Any] = [
+ structlog.contextvars.merge_contextvars,
+ _inject_context_vars,
+ _redact_sensitive,
+ structlog.stdlib.add_log_level,
+ structlog.stdlib.add_logger_name,
+ structlog.processors.TimeStamper(fmt="iso"),
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.UnicodeDecoder(),
+ ]
+
+ if json_output:
+ # Production: JSON lines
+ renderer = structlog.processors.JSONRenderer()
+ else:
+ # Development: human-readable
+ renderer = structlog.dev.ConsoleRenderer(colors=colors)
+
+ # Configure structlog
+ structlog.configure(
+ processors=[
+ *shared_processors,
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
+ ],
+ logger_factory=structlog.stdlib.LoggerFactory(),
+ wrapper_class=structlog.stdlib.BoundLogger,
+ cache_logger_on_first_use=True,
+ )
+
+ # Configure stdlib root logger with structlog formatter
+ formatter = structlog.stdlib.ProcessorFormatter(
+ processors=[
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+ renderer,
+ ],
+ # Apply shared processors (redaction, context injection) to stdlib
+ # log records that bypass structlog's pipeline.
+ foreign_pre_chain=shared_processors,
+ )
+
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(formatter)
+
+ root = logging.getLogger()
+ # Remove existing handlers to avoid duplicate output
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(level)
+
+ # Suppress noisy third-party loggers
+ for noisy in ("httpx", "httpcore", "urllib3", "litellm", "openai"):
+ logging.getLogger(noisy).setLevel(logging.WARNING)
+
+
+def reset_logging() -> None:
+ """Reset logging configuration (for testing)."""
+ global _configured
+ _configured = False
+ structlog.reset_defaults()
+
+
+# ── Public logger factory ─────────────────────────────────────────────
+
+
+def get_logger(name: Optional[str] = None) -> structlog.stdlib.BoundLogger:
+ """Get a structured logger.
+
+ Auto-configures on first call if not yet configured.
+ Compatible with stdlib logging — all events flow through
+ the same formatter pipeline.
+ """
+ if not _configured:
+ configure_logging()
+ return structlog.get_logger(name or "openspace")
+
+
+__all__ = [
+ "bind_context",
+ "clear_context",
+ "configure_logging",
+ "get_context",
+ "get_logger",
+ "reset_logging",
+]
diff --git a/openspace/domain/ports.py b/openspace/domain/ports.py
new file mode 100644
index 00000000..4e48a949
--- /dev/null
+++ b/openspace/domain/ports.py
@@ -0,0 +1,415 @@
+"""Port protocols — structural typing contracts for all domain boundaries.
+
+Each ``Protocol`` here defines the *minimal* interface that the domain
+layer requires from an adapter. Concrete implementations live in
+infrastructure packages (``llm``, ``cloud``, ``grounding``, etc.).
+
+Usage::
+
+ from openspace.domain.ports import SkillStorePort
+
+ def some_service(store: SkillStorePort) -> None:
+ record = store.load_record("skill-42")
+ ...
+
+All methods use domain types (``openspace.domain.types``) at the
+boundary, **not** adapter-specific types.
+"""
+
+from __future__ import annotations
+
+from typing import (
+ Any,
+ Dict,
+ List,
+ Optional,
+ Protocol,
+ Sequence,
+ runtime_checkable,
+)
+
+from openspace.domain.types import (
+ CapabilityLease,
+ EvolutionRequest,
+ EvolutionResult,
+ ExecutionAnalysisSnapshot,
+ SandboxPolicy,
+ SkillManifest,
+ SkillSearchResult,
+ TaskRequest,
+ TaskResult,
+ ToolCallResult,
+ ToolDescriptor,
+)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 1. SkillStorePort — persistence for skill records & analyses
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class SkillStorePort(Protocol):
+ """Port for skill record persistence (Issue #42).
+
+ Note: The concrete ``SkillStore`` uses ``SkillRecord`` internally.
+ An adapter (Phase 1.3) will map between ``SkillManifest`` and
+ ``SkillRecord`` at the boundary.
+ """
+
+ async def save_record(self, record: SkillManifest) -> None: ...
+
+ def load_record(self, skill_id: str) -> Optional[SkillManifest]: ...
+
+ def load_all(self, *, active_only: bool = False) -> Dict[str, SkillManifest]: ...
+
+ def load_active(self) -> Dict[str, SkillManifest]: ...
+
+ async def delete_record(self, skill_id: str) -> bool: ...
+
+ def count(self, *, active_only: bool = False) -> int: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 2. LLMClientPort — language model completion
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class LLMClientPort(Protocol):
+ """Port for LLM completions (Issue #43)."""
+
+ async def complete(
+ self,
+ messages: List[Dict[str, Any]] | str,
+ *,
+ tools: Optional[List[Any]] = None,
+ execute_tools: bool = True,
+ **kwargs: Any,
+ ) -> Dict[str, Any]: ...
+
+ def estimate_tokens(self, text: str) -> int:
+ """Estimate token count for a text string.
+
+ Default implementation uses a simple heuristic.
+ """
+ return max(1, len(text) // 4)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 3. CloudSkillPort — remote skill marketplace
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class CloudSkillPort(Protocol):
+ """Port for cloud skill operations (Issue #44)."""
+
+ async def search_skills(
+ self, query: str, *, limit: int = 20
+ ) -> List[SkillSearchResult]: ...
+
+ async def import_skill(
+ self, skill_id: str, target_dir: str
+ ) -> Dict[str, Any]: ...
+
+ async def publish_skill(
+ self, skill_dir: str, *, visibility: str = "private"
+ ) -> Dict[str, Any]: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 4. SandboxPort — isolated code execution
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class SandboxPort(Protocol):
+ """Port for sandboxed execution environments (Issue #45)."""
+
+ async def start(self) -> bool: ...
+
+ async def stop(self) -> None: ...
+
+ async def execute_safe(self, command: str, **kwargs: Any) -> Any: ...
+
+ @property
+ def is_active(self) -> bool: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 5. SkillEvolutionPort — skill evolution engine
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class SkillEvolutionPort(Protocol):
+ """Port for skill evolution (Issue #46)."""
+
+ async def evolve(self, request: EvolutionRequest) -> Optional[EvolutionResult]: ...
+
+ async def process_analysis(
+ self, analysis: ExecutionAnalysisSnapshot
+ ) -> List[EvolutionResult]: ...
+
+ async def wait_background(self) -> None: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 6. AgentExecutorPort — task execution orchestration
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class AgentExecutorPort(Protocol):
+ """Port for high-level task execution (Issue #47)."""
+
+ async def execute(self, request: TaskRequest) -> TaskResult: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 7. AnalysisPort — post-execution analysis
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class AnalysisPort(Protocol):
+ """Port for execution analysis (Issue #48)."""
+
+ async def analyze_execution(
+ self,
+ task_id: str,
+ recording_dir: str,
+ execution_result: Dict[str, Any],
+ *,
+ available_tools: Optional[List[Any]] = None,
+ ) -> Optional[ExecutionAnalysisSnapshot]: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 8. ToolBackendPort — tool discovery and invocation
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class ToolBackendPort(Protocol):
+ """Port for tool backends (Issue #49).
+
+ Note: Concrete ``Provider`` subclasses use a different call signature.
+ An adapter (Phase 1.3) will normalize the interface at the boundary.
+ """
+
+ async def list_tools(
+ self, *, session_name: Optional[str] = None
+ ) -> List[ToolDescriptor]: ...
+
+ async def call_tool(
+ self,
+ tool_name: str,
+ arguments: Optional[Dict[str, Any]] = None,
+ *,
+ session_name: Optional[str] = None,
+ ) -> ToolCallResult: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 9. PolicyEnginePort — security policy evaluation
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class PolicyEnginePort(Protocol):
+ """Port for security policy decisions (Issue #50).
+
+ Uses ``str`` for ``backend_type`` to avoid coupling the domain layer
+ to infrastructure enums. Adapters convert to ``BackendType`` enum.
+ """
+
+ async def check_command_allowed(
+ self, backend_type: str, command: str
+ ) -> bool: ...
+
+ async def check_domain_allowed(
+ self, backend_type: str, domain: str
+ ) -> bool: ...
+
+ def get_policy(self, backend_type: str) -> SandboxPolicy: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 10. AuthPort — authentication
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class AuthPort(Protocol):
+ """Port for authentication (Issue #51)."""
+
+ async def authenticate(self, token: str) -> bool: ...
+
+ async def validate_token(self, token: str) -> tuple[bool, str]: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 11. SecretBrokerPort — secure secret management (Phase 2)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class SecretBrokerPort(Protocol):
+ """Port for secret brokering (Issue #52).
+
+ Implementation deferred to Phase 2. Interface defined here so
+ downstream code can type-hint against it now.
+ """
+
+ async def get_secret(self, key: str, *, scope: str = "task") -> Optional[str]: ...
+
+ async def revoke(self, key: str) -> bool: ...
+
+ def list_available(self, *, scope: str = "task") -> List[str]: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 12. TelemetryPort — event capture & metrics
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class TelemetryPort(Protocol):
+ """Port for telemetry (Issue #53).
+
+ Note: The concrete ``Telemetry`` class accepts ``BaseTelemetryEvent``
+ objects. An adapter (Phase 1.3) will map the ``(event_name, properties)``
+ signature to the event-object API.
+ """
+
+ def capture(self, event_name: str, properties: Dict[str, Any]) -> None: ...
+
+ def flush(self) -> None: ...
+
+ def shutdown(self) -> None: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 13. CapabilityLeaseResolverPort — lease management (Phase 2)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class CapabilityLeaseResolverPort(Protocol):
+ """Port for capability lease resolution (Issue #54).
+
+ Implementation deferred to Phase 2. Interface defined here so
+ sandbox and security code can type-hint against it now.
+ """
+
+ async def acquire(
+ self,
+ capability: str,
+ *,
+ trust_tier: str = "T1",
+ ttl_seconds: int = 300,
+ ) -> Optional[CapabilityLease]: ...
+
+ async def release(self, lease_id: str) -> bool: ...
+
+ async def validate(self, lease_id: str) -> bool: ...
+
+ async def list_active(self, *, granted_to: Optional[str] = None) -> List[CapabilityLease]: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Phase 2 — Filesystem Broker (EPIC 2.2)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class FilesystemBrokerPort(Protocol):
+ """Port for policy-enforced filesystem access (EPIC 2.2).
+
+ Provides jailed path resolution, read/write enforcement,
+ deny-list checking, and TOCTOU-safe file operations.
+ """
+
+ def resolve(self, path: str) -> "Path": ...
+
+ def check_read(self, path: str) -> "Path": ...
+
+ def check_write(self, path: str, size_bytes: int = 0) -> "Path": ...
+
+ def open_read(self, path: str) -> int: ...
+
+ def open_write(self, path: str, size_bytes: int = 0) -> int: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Phase 2 — Network Proxy (EPIC 2.3)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class NetworkProxyPort(Protocol):
+ """Port for policy-enforced outbound network access (EPIC 2.3).
+
+ Provides domain allow/deny enforcement, port filtering,
+ concurrent connection tracking, and proxy lifecycle management.
+ """
+
+ def check_request(self, domain: str, port: int) -> None: ...
+
+ async def connect(self, domain: str, port: int) -> str: ...
+
+ async def disconnect(self, connection_id: str) -> None: ...
+
+ async def list_connections(self) -> list: ...
+
+ async def shutdown(self) -> int: ...
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Phase 2 — Process Broker (EPIC 2.4)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+@runtime_checkable
+class ProcessBrokerPort(Protocol):
+ """Port for policy-enforced process execution (EPIC 2.4).
+
+ Provides command allow/deny enforcement, shell control,
+ process tracking with concurrency limits, execution time bounds,
+ and dangerous syscall (link/symlink) restriction.
+ """
+
+ def check_command(self, command: str, args: list[str]) -> None: ...
+
+ def check_shell(self, shell_command: str) -> None: ...
+
+ def track_process(self, pid: int, command: str) -> None: ...
+
+ def release_process(self, pid: int) -> None: ...
+
+ @property
+ def active_count(self) -> int: ...
+
+ def check_syscall(self, syscall: str, *args: str) -> None: ...
+
+
+__all__ = [
+ "AgentExecutorPort",
+ "AnalysisPort",
+ "AuthPort",
+ "CapabilityLeaseResolverPort",
+ "CloudSkillPort",
+ "FilesystemBrokerPort",
+ "LLMClientPort",
+ "NetworkProxyPort",
+ "PolicyEnginePort",
+ "ProcessBrokerPort",
+ "SandboxPort",
+ "SecretBrokerPort",
+ "SkillEvolutionPort",
+ "SkillStorePort",
+ "TelemetryPort",
+ "ToolBackendPort",
+]
diff --git a/openspace/domain/types.py b/openspace/domain/types.py
new file mode 100644
index 00000000..17e96d2d
--- /dev/null
+++ b/openspace/domain/types.py
@@ -0,0 +1,392 @@
+"""Frozen domain value objects and data-transfer types.
+
+All types here are **immutable** (``frozen=True``) so they can be safely
+shared across async boundaries, cached, and hashed. Mutations produce
+new instances via ``dataclasses.replace()``.
+
+Existing mutable dataclasses in ``openspace.skill_engine.types`` remain
+for backward compatibility. New code should prefer these frozen variants.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, Dict, FrozenSet, List, Optional, Tuple
+
+
+def _deep_freeze(value: Any) -> Any:
+ """Recursively convert mutable containers to immutable equivalents.
+
+ - dict → tuple of (key, frozen_value) pairs
+ - list → tuple of frozen values
+ - set → frozenset of frozen values
+ - scalar / already-frozen → returned as-is
+ """
+ if isinstance(value, dict):
+ return tuple((k, _deep_freeze(v)) for k, v in value.items())
+ if isinstance(value, (list, tuple)):
+ return tuple(_deep_freeze(v) for v in value)
+ if isinstance(value, set):
+ return frozenset(_deep_freeze(v) for v in value)
+ return value
+
+
+# ─── Task Execution Types ─────────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class TaskRequest:
+ """Immutable request to execute a task."""
+
+ task: str
+ task_id: str = ""
+ workspace_dir: str = ""
+ max_iterations: Optional[int] = None
+ search_scope: str = "all"
+ skill_dirs: Tuple[str, ...] = ()
+ context: Tuple[Tuple[str, Any], ...] = ()
+
+ @property
+ def context_dict(self) -> Dict[str, Any]:
+ return dict(self.context)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "TaskRequest":
+ ctx = data.get("context") or {}
+ return cls(
+ task=data["task"],
+ task_id=data.get("task_id", ""),
+ workspace_dir=data.get("workspace_dir", ""),
+ max_iterations=data.get("max_iterations"),
+ search_scope=data.get("search_scope", "all"),
+ skill_dirs=tuple(data.get("skill_dirs") or []),
+ context=_deep_freeze(ctx) if isinstance(ctx, dict) else (),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class ToolExecution:
+ """Record of a single tool call within a task."""
+
+ tool_name: str
+ arguments: Tuple[Tuple[str, Any], ...] = ()
+ status: str = "success"
+ duration_ms: float = 0.0
+ error: Optional[str] = None
+
+
+@dataclass(frozen=True, slots=True)
+class TaskResult:
+ """Immutable result of a task execution."""
+
+ task_id: str
+ status: str # "success" | "error" | "timeout"
+ response: str = ""
+ error: Optional[str] = None
+ execution_time: float = 0.0
+ iterations: int = 0
+ skills_used: Tuple[str, ...] = ()
+ evolved_skills: Tuple[str, ...] = ()
+ tool_executions: Tuple[ToolExecution, ...] = ()
+ warnings: Tuple[str, ...] = ()
+
+ @property
+ def ok(self) -> bool:
+ return self.status == "success"
+
+ def to_dict(self) -> Dict[str, Any]:
+ d: Dict[str, Any] = {
+ "task_id": self.task_id,
+ "status": self.status,
+ "response": self.response,
+ "execution_time": self.execution_time,
+ "iterations": self.iterations,
+ "skills_used": list(self.skills_used),
+ }
+ if self.error:
+ d["error"] = self.error
+ if self.evolved_skills:
+ d["evolved_skills"] = list(self.evolved_skills)
+ if self.tool_executions:
+ d["tool_executions"] = [
+ {
+ "tool_name": te.tool_name,
+ "arguments": dict(te.arguments),
+ "status": te.status,
+ "duration_ms": te.duration_ms,
+ **({"error": te.error} if te.error else {}),
+ }
+ for te in self.tool_executions
+ ]
+ if self.warnings:
+ d["warnings"] = list(self.warnings)
+ return d
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "TaskResult":
+ tool_execs = tuple(
+ ToolExecution(
+ tool_name=te["tool_name"],
+ arguments=_deep_freeze(te.get("arguments", {}))
+ if isinstance(te.get("arguments"), dict)
+ else (),
+ status=te.get("status", "success"),
+ duration_ms=te.get("duration_ms", 0.0),
+ error=te.get("error"),
+ )
+ for te in (data.get("tool_executions") or [])
+ )
+ return cls(
+ task_id=data.get("task_id", ""),
+ status=data.get("status", "error"),
+ response=data.get("response", ""),
+ error=data.get("error"),
+ execution_time=data.get("execution_time", 0.0),
+ iterations=data.get("iterations", 0),
+ skills_used=tuple(data.get("skills_used") or []),
+ evolved_skills=tuple(data.get("evolved_skills") or []),
+ tool_executions=tool_execs,
+ warnings=tuple(data.get("warnings") or []),
+ )
+
+
+# ─── Skill Identity & Metadata ────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class SkillIdentity:
+ """Lightweight, hashable skill reference."""
+
+ skill_id: str
+ name: str
+ description: str = ""
+ category: str = "workflow"
+ source: str = "local"
+
+ def __hash__(self) -> int:
+ return hash(self.skill_id)
+
+
+@dataclass(frozen=True, slots=True)
+class SkillManifest:
+ """Full skill metadata — immutable snapshot."""
+
+ skill_id: str
+ name: str
+ description: str
+ path: str = ""
+ is_active: bool = True
+ category: str = "workflow"
+ visibility: str = "private"
+ creator_id: str = ""
+ tags: Tuple[str, ...] = ()
+ tool_dependencies: Tuple[str, ...] = ()
+ critical_tools: Tuple[str, ...] = ()
+
+ # Lineage
+ origin: str = "imported"
+ generation: int = 0
+ parent_skill_ids: Tuple[str, ...] = ()
+ source_task_id: str = ""
+ change_summary: str = ""
+
+ # Counters (snapshot at freeze-time)
+ total_selections: int = 0
+ total_applied: int = 0
+ total_completions: int = 0
+ total_fallbacks: int = 0
+
+ # Timestamps
+ first_seen: Optional[datetime] = None
+ last_updated: Optional[datetime] = None
+
+ @property
+ def effective_rate(self) -> float:
+ if self.total_selections == 0:
+ return 0.0
+ return self.total_applied / self.total_selections
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "skill_id": self.skill_id,
+ "name": self.name,
+ "description": self.description,
+ "path": self.path,
+ "is_active": self.is_active,
+ "category": self.category,
+ "visibility": self.visibility,
+ "origin": self.origin,
+ "generation": self.generation,
+ "tags": list(self.tags),
+ "total_selections": self.total_selections,
+ "total_applied": self.total_applied,
+ "first_seen": self.first_seen.isoformat() if self.first_seen else None,
+ "last_updated": self.last_updated.isoformat() if self.last_updated else None,
+ }
+
+
+# ─── Evolution Types ──────────────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class EvolutionRequest:
+ """Immutable request to evolve one or more skills."""
+
+ evolution_type: str # "fix" | "derived" | "captured"
+ trigger: str # "analysis" | "tool_degradation" | "metric_monitor"
+ target_skill_ids: Tuple[str, ...] = ()
+ source_task_id: str = ""
+ direction: str = ""
+ category: Optional[str] = None
+ tool_issue_summary: str = ""
+ metric_summary: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class EvolutionResult:
+ """Immutable outcome of an evolution attempt."""
+
+ success: bool
+ evolved_skill_id: Optional[str] = None
+ evolved_skill_name: Optional[str] = None
+ parent_skill_ids: Tuple[str, ...] = ()
+ evolution_type: str = ""
+ change_summary: str = ""
+ error: Optional[str] = None
+
+
+# ─── Analysis Types ───────────────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class SkillJudgmentSnapshot:
+ """Immutable judgment of a skill's performance on a task."""
+
+ skill_id: str
+ skill_applied: bool = False
+ note: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class EvolutionSuggestionSnapshot:
+ """Immutable suggestion for evolution from an analysis."""
+
+ evolution_type: str
+ target_skill_ids: Tuple[str, ...] = ()
+ category: Optional[str] = None
+ direction: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class ExecutionAnalysisSnapshot:
+ """Immutable snapshot of a post-execution analysis."""
+
+ task_id: str
+ timestamp: datetime
+ task_completed: bool = False
+ execution_note: str = ""
+ tool_issues: Tuple[str, ...] = ()
+ skill_judgments: Tuple[SkillJudgmentSnapshot, ...] = ()
+ evolution_suggestions: Tuple[EvolutionSuggestionSnapshot, ...] = ()
+ analyzed_by: str = ""
+ analyzed_at: Optional[datetime] = None
+
+
+# ─── Search Types ─────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchResult:
+ """A single skill search hit."""
+
+ skill_id: str
+ name: str
+ description: str
+ score: float = 0.0
+ source: str = "local"
+ body: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchResponse:
+ """Collection of search results."""
+
+ query: str
+ results: Tuple[SkillSearchResult, ...] = ()
+ total_count: int = 0
+ search_mode: str = "hybrid"
+
+
+# ─── Sandbox / Security Types ─────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class SandboxPolicy:
+ """Immutable sandbox policy snapshot."""
+
+ sandbox_enabled: bool = True
+ trust_tier: str = "untrusted"
+ allowed_commands: FrozenSet[str] = frozenset()
+ blocked_commands: FrozenSet[str] = frozenset()
+ allowed_domains: FrozenSet[str] = frozenset()
+ blocked_domains: FrozenSet[str] = frozenset()
+ max_execution_time_s: int = 300
+ max_memory_mb: int = 512
+
+
+@dataclass(frozen=True, slots=True)
+class CapabilityLease:
+ """A time-bounded, revocable capability grant (Phase 2)."""
+
+ lease_id: str
+ capability: str
+ granted_to: str
+ trust_tier: str = "basic"
+ expires_at: Optional[datetime] = None
+ revoked: bool = False
+
+
+# ─── Tool Types ───────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class ToolDescriptor:
+ """Lightweight tool identity for protocol boundaries."""
+
+ name: str
+ description: str = ""
+ backend_type: str = "not_set"
+ parameters_schema: Tuple[Tuple[str, Any], ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class ToolCallResult:
+ """Immutable result of a tool invocation."""
+
+ status: str # "success" | "error"
+ content: str = ""
+ error: Optional[str] = None
+ execution_time_ms: float = 0.0
+ metadata: Tuple[Tuple[str, Any], ...] = ()
+
+
+__all__ = [
+ "CapabilityLease",
+ "EvolutionRequest",
+ "EvolutionResult",
+ "EvolutionSuggestionSnapshot",
+ "ExecutionAnalysisSnapshot",
+ "SandboxPolicy",
+ "SkillIdentity",
+ "SkillJudgmentSnapshot",
+ "SkillManifest",
+ "SkillSearchResponse",
+ "SkillSearchResult",
+ "TaskRequest",
+ "TaskResult",
+ "ToolCallResult",
+ "ToolDescriptor",
+ "ToolExecution",
+ "_deep_freeze",
+]
diff --git a/openspace/errors.py b/openspace/errors.py
new file mode 100644
index 00000000..d02b958a
--- /dev/null
+++ b/openspace/errors.py
@@ -0,0 +1,155 @@
+"""Structured error handling for MCP responses.
+
+All MCP tool responses MUST use these helpers so that internal details
+(tracebacks, file paths, line numbers, module names) are NEVER leaked
+to the client. Full diagnostics are logged server-side with a
+correlation ID that operators can use to match client errors to logs.
+
+Error codes
+-----------
+EXECUTION_ERROR — Task execution failed (execute_task runtime errors)
+VALIDATION_ERROR — Bad input from the caller (missing / invalid args)
+SKILL_NOT_FOUND — Requested skill directory or record doesn't exist
+PERMISSION_DENIED — Auth / authz failure
+INTERNAL_ERROR — Catch-all for unexpected server errors
+TIMEOUT_ERROR — Operation exceeded time limit
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import uuid
+from typing import Any
+
+logger = logging.getLogger("openspace.mcp_server")
+
+# ── Error codes ──────────────────────────────────────────────────────
+EXECUTION_ERROR = "EXECUTION_ERROR"
+VALIDATION_ERROR = "VALIDATION_ERROR"
+SKILL_NOT_FOUND = "SKILL_NOT_FOUND"
+PERMISSION_DENIED = "PERMISSION_DENIED"
+INTERNAL_ERROR = "INTERNAL_ERROR"
+TIMEOUT_ERROR = "TIMEOUT_ERROR"
+
+# Patterns that must never appear in client-facing messages
+_TRACEBACK_PATTERNS = re.compile(
+ r"Traceback \(most recent call last\)"
+ r"|File \".+\", line \d+"
+ r"|^\s+raise\s"
+ r"|^\s+at\s+[\w.]+\("
+ r"|openspace[/\\.]"
+ r"|\.py:\d+"
+ r"|\.py\b",
+ re.MULTILINE,
+)
+
+# Generic fallback — never expose internal exception class names
+_GENERIC_ERROR = "An internal error occurred"
+
+
+def _generate_correlation_id() -> str:
+ """Short correlation ID for matching client errors to server logs."""
+ return uuid.uuid4().hex[:12]
+
+
+def sanitize_error(exc: BaseException) -> str:
+ """Extract a safe, human-readable message from an exception.
+
+ Strips file paths, line numbers, module names, and stack traces.
+ Returns a generic message if the raw string contains internal details.
+ Never returns exception class names (type(exc).__name__).
+ """
+ raw = str(exc)
+ if not raw or _TRACEBACK_PATTERNS.search(raw):
+ return _GENERIC_ERROR
+ # Windows paths — including spaces and quoted paths
+ sanitized = re.sub(r"[A-Za-z]:\\[^\s\"']*(?:\s[^\s\\\"']+)*\.?\w*", "", raw)
+ # UNC paths (\\server\share\...)
+ sanitized = re.sub(r"\\\\[^\s\"']+", "", sanitized)
+ # Unix-style absolute paths
+ sanitized = re.sub(r"/(?:[\w.-]+/)+[\w.-]*", "", sanitized)
+ # Dotted module names (e.g. openspace.cloud.auth.TokenResolver)
+ sanitized = re.sub(r"\b\w+(?:\.\w+){2,}\b", "", sanitized)
+ # Standalone line-number references
+ sanitized = re.sub(r"\bline \d+\b", "", sanitized)
+ # If everything got redacted to placeholders, return generic
+ cleaned = re.sub(r"<(?:path|module|location)>", "", sanitized).strip()
+ if not cleaned:
+ return _GENERIC_ERROR
+ # Truncate to a reasonable length
+ if len(sanitized) > 300:
+ sanitized = sanitized[:297] + "..."
+ return sanitized
+
+
+def safe_error_response(
+ error_code: str,
+ message: str,
+ *,
+ correlation_id: str | None = None,
+) -> str:
+ """Build a structured JSON error response for MCP tool results.
+
+ Returns a JSON string with:
+ - isError: true
+ - error_code: one of the module-level constants
+ - message: human-readable description (no internals)
+ - correlation_id: opaque ID to match server-side logs
+ """
+ cid = correlation_id or _generate_correlation_id()
+ payload: dict[str, Any] = {
+ "isError": True,
+ "error_code": error_code,
+ "message": message,
+ "correlation_id": cid,
+ }
+ return json.dumps(payload, ensure_ascii=False)
+
+
+def handle_mcp_exception(
+ exc: BaseException,
+ *,
+ tool_name: str,
+ error_code: str = INTERNAL_ERROR,
+) -> str:
+ """One-liner for MCP except blocks: log full traceback, return safe JSON.
+
+ For :class:`~openspace.domain.exceptions.OpenSpaceError` instances,
+ uses ``client_message`` (never the raw message) and ``error_code``
+ from the exception. For all other exceptions, sanitizes via
+ :func:`sanitize_error`.
+
+ Usage::
+
+ except Exception as e:
+ return handle_mcp_exception(e, tool_name="execute_task",
+ error_code=EXECUTION_ERROR)
+ """
+ cid = _generate_correlation_id()
+ logger.error(
+ "%s failed [%s]: %s",
+ tool_name,
+ cid,
+ exc,
+ exc_info=True,
+ )
+
+ # Prefer domain exception's safe client_message when available
+ try:
+ from openspace.domain.exceptions import OpenSpaceError as _OSE
+ from openspace.domain.exceptions import map_to_mcp_error_code
+
+ if isinstance(exc, _OSE):
+ safe_msg = exc.client_message
+ error_code = exc.error_code
+ return safe_error_response(error_code, safe_msg, correlation_id=cid)
+
+ # Use centralized mapping for builtin exceptions too
+ error_code = map_to_mcp_error_code(exc)
+ except ImportError:
+ pass
+
+ safe_msg = sanitize_error(exc)
+ return safe_error_response(error_code, safe_msg, correlation_id=cid)
diff --git a/openspace/grounding/backends/mcp/client.py b/openspace/grounding/backends/mcp/client.py
index a6833e20..e49b41ee 100644
--- a/openspace/grounding/backends/mcp/client.py
+++ b/openspace/grounding/backends/mcp/client.py
@@ -29,7 +29,7 @@ class MCPClient:
def __init__(
self,
config: str | dict[str, Any] | None = None,
- sandbox: bool = False,
+ sandbox: bool = True,
sandbox_options: SandboxOptions | None = None,
timeout: float = 30.0,
sse_read_timeout: float = 300.0,
@@ -94,7 +94,7 @@ def _get_mcp_servers(self) -> dict[str, Any]:
def from_dict(
cls,
config: dict[str, Any],
- sandbox: bool = False,
+ sandbox: bool = True,
sandbox_options: SandboxOptions | None = None,
timeout: float = 30.0,
sse_read_timeout: float = 300.0,
@@ -118,7 +118,7 @@ def from_dict(
@classmethod
def from_config_file(
- cls, filepath: str, sandbox: bool = False, sandbox_options: SandboxOptions | None = None,
+ cls, filepath: str, sandbox: bool = True, sandbox_options: SandboxOptions | None = None,
timeout: float = 30.0, sse_read_timeout: float = 300.0,
max_retries: int = 3, retry_interval: float = 2.0,
) -> "MCPClient":
diff --git a/openspace/grounding/backends/mcp/config.py b/openspace/grounding/backends/mcp/config.py
index 8af3645c..a8213ea8 100644
--- a/openspace/grounding/backends/mcp/config.py
+++ b/openspace/grounding/backends/mcp/config.py
@@ -2,8 +2,12 @@
Configuration loader for MCP session.
This module provides functionality to load MCP configuration from JSON files.
+
+Security: Sandbox is enforced by default for all stdio-based MCP servers.
+Unsandboxed execution requires explicit OPENSPACE_ALLOW_UNSANDBOXED=1 env var.
"""
+import os
from typing import Any, Optional
from openspace.grounding.core.types import SandboxOptions
@@ -26,10 +30,39 @@
E2BSandbox = None
E2B_AVAILABLE = False
+
+# Trusted sandbox config keys that may be sourced from config/env
+_TRUSTED_SANDBOX_KEYS = frozenset({
+ "timeout", "sse_read_timeout", "supergateway_command", "port",
+ "sandbox_template_id",
+})
+
+
+def _build_trusted_sandbox_options(
+ caller_options: SandboxOptions | None,
+ default_timeout: float,
+ default_sse_timeout: float,
+) -> dict[str, Any]:
+ """Build sandbox options from trusted sources only.
+
+ Strips caller-supplied api_key (must come from env E2B_API_KEY).
+ Only allows known config keys through; ignores anything else.
+ """
+ base: dict[str, Any] = {
+ "timeout": default_timeout,
+ "sse_read_timeout": default_sse_timeout,
+ }
+ if caller_options:
+ for key in _TRUSTED_SANDBOX_KEYS:
+ if key in caller_options:
+ base[key] = caller_options[key]
+ return base
+
+
async def create_connector_from_config(
server_config: dict[str, Any],
server_name: str = "unknown",
- sandbox: bool = False,
+ sandbox: bool = True,
sandbox_options: SandboxOptions | None = None,
timeout: float = 30.0,
sse_read_timeout: float = 300.0,
@@ -40,10 +73,15 @@ async def create_connector_from_config(
) -> MCPBaseConnector:
"""Create a connector based on server configuration.
+ For stdio-based servers, sandbox is ENFORCED. Unsandboxed stdio execution
+ is denied by default. Set OPENSPACE_ALLOW_UNSANDBOXED=1 to explicitly
+ opt out (development/testing only — NOT recommended for production).
+
Args:
server_config: The server configuration section
server_name: Name of the MCP server (for display purposes)
sandbox: Whether to use sandboxed execution mode for running MCP servers.
+ Defaults to True (enforced).
sandbox_options: Optional sandbox configuration options.
timeout: Timeout for operations in seconds (default: 30.0)
sse_read_timeout: SSE read timeout in seconds (default: 300.0)
@@ -56,13 +94,40 @@ async def create_connector_from_config(
A configured connector instance
Raises:
- RuntimeError: If dependencies are not installed and user declines installation
+ RuntimeError: If sandbox is required but not available, or if
+ dependencies are not installed and user declines installation
"""
# Get original command and args from config
original_command = get_config_value(server_config, "command")
original_args = get_config_value(server_config, "args", [])
+ # --- Sandbox enforcement BEFORE any host-side operations ---
+ # Reject unsandboxed stdio early, before ensure_dependencies runs
+ # npm/pip install on the host. This prevents a malicious server config
+ # from triggering host-side package installs before being denied.
+ if is_stdio_server(server_config) and not sandbox:
+ allow_unsandboxed = os.environ.get("OPENSPACE_ALLOW_UNSANDBOXED", "").strip()
+ if allow_unsandboxed != "1":
+ raise RuntimeError(
+ f"Unsandboxed stdio execution denied for server '{server_name}'. "
+ "Sandbox is required for all stdio-based MCP servers. "
+ "Set OPENSPACE_ALLOW_UNSANDBOXED=1 to override (NOT recommended)."
+ )
+ import logging
+ logging.getLogger(__name__).warning(
+ "SECURITY: Running server '%s' WITHOUT sandbox (OPENSPACE_ALLOW_UNSANDBOXED=1). "
+ "This is NOT recommended for production use.",
+ server_name,
+ )
+
+ if is_stdio_server(server_config) and sandbox and not E2B_AVAILABLE:
+ raise ImportError(
+ "E2B sandbox support not available. Please install e2b-code-interpreter: "
+ "'pip install e2b-code-interpreter'"
+ )
+
+ # --- Host-side operations (only after sandbox enforcement passes) ---
# Check and install dependencies if needed (only for stdio servers)
if is_stdio_server(server_config) and check_dependencies:
# Use provided installer or get global instance
@@ -73,7 +138,7 @@ async def create_connector_from_config(
# Ensure dependencies are installed (using original command/args)
await installer.ensure_dependencies(server_name, original_command, original_args)
- # Stdio connector (command-based)
+ # Stdio connector — unsandboxed (only reachable with explicit opt-out)
if is_stdio_server(server_config) and not sandbox:
return StdioConnector(
command=get_config_value(server_config, "command"),
@@ -81,19 +146,15 @@ async def create_connector_from_config(
env=get_config_value(server_config, "env", None),
)
- # Sandboxed connector
- elif is_stdio_server(server_config) and sandbox:
- if not E2B_AVAILABLE:
- raise ImportError(
- "E2B sandbox support not available. Please install e2b-code-interpreter: "
- "'pip install e2b-code-interpreter'"
- )
-
- # Create E2B sandbox instance
- _sandbox_options = sandbox_options or {}
+ # Sandboxed connector (E2B_AVAILABLE already verified above)
+ elif is_stdio_server(server_config):
+ # Build sandbox options from trusted config/env only (never user input)
+ _sandbox_options = _build_trusted_sandbox_options(
+ sandbox_options, timeout, sse_read_timeout
+ )
e2b_sandbox = E2BSandbox(_sandbox_options)
- # Extract timeout values from sandbox_options or use defaults
+ # Extract timeout values from trusted options
connector_timeout = _sandbox_options.get("timeout", timeout)
connector_sse_timeout = _sandbox_options.get("sse_read_timeout", sse_read_timeout)
diff --git a/openspace/grounding/backends/mcp/provider.py b/openspace/grounding/backends/mcp/provider.py
index db4cadbc..33fd5570 100644
--- a/openspace/grounding/backends/mcp/provider.py
+++ b/openspace/grounding/backends/mcp/provider.py
@@ -39,7 +39,7 @@ def __init__(self, config: Dict | None = None, installer: Optional[MCPInstallerM
super().__init__(BackendType.MCP, config)
# Extract MCP-specific configuration
- sandbox = get_config_value(config, "sandbox", False)
+ sandbox = get_config_value(config, "sandbox", True)
timeout = get_config_value(config, "timeout", 30)
sse_read_timeout = get_config_value(config, "sse_read_timeout", 300.0)
max_retries = get_config_value(config, "max_retries", 3)
diff --git a/openspace/grounding/backends/mcp/transport/connectors/sandbox.py b/openspace/grounding/backends/mcp/transport/connectors/sandbox.py
index a5b6f4c5..20a329ef 100644
--- a/openspace/grounding/backends/mcp/transport/connectors/sandbox.py
+++ b/openspace/grounding/backends/mcp/transport/connectors/sandbox.py
@@ -16,6 +16,7 @@
from openspace.grounding.backends.mcp.transport.task_managers import SseConnectionManager
from openspace.grounding.core.security import BaseSandbox
from openspace.grounding.backends.mcp.transport.connectors.base import MCPBaseConnector
+from openspace.security.env_filter import get_safe_env, ENV_ALLOWLIST
logger = Logger.get_logger(__name__)
@@ -54,7 +55,12 @@ def __init__(
# Store user command configuration
self.user_command = command
self.user_args = args or []
- self.user_env = env or {}
+ # Filter env vars through the security allowlist so that host
+ # secrets (API keys, tokens, DB URLs) never reach the sandbox.
+ raw_env = env or {}
+ self.user_env = {
+ k: v for k, v in raw_env.items() if k in ENV_ALLOWLIST
+ }
self.port = port
# Create a placeholder connection manager (will be set up in connect())
diff --git a/openspace/grounding/core/quality/manager.py b/openspace/grounding/core/quality/manager.py
index 197087ef..fa3e83bc 100644
--- a/openspace/grounding/core/quality/manager.py
+++ b/openspace/grounding/core/quality/manager.py
@@ -299,16 +299,8 @@ async def evaluate_description(
"""
Evaluate tool description quality using LLM.
"""
- try:
- from gdpval_bench.token_tracker import set_call_source, reset_call_source
- _src_tok = set_call_source("quality")
- except ImportError:
- _src_tok = None
-
if not self._llm_client:
logger.debug("LLM client not available for description evaluation")
- if _src_tok is not None:
- reset_call_source(_src_tok)
return None
record = self.get_record(tool)
@@ -473,9 +465,6 @@ def safe_float(value, default=0.5, min_val=0.0, max_val=1.0):
except Exception as e:
logger.error(f"Description evaluation failed for {tool.name}: {e}")
return None
- finally:
- if _src_tok is not None:
- reset_call_source(_src_tok)
# Quality-Aware Ranking
def adjust_ranking(
diff --git a/openspace/grounding/core/security/e2b_sandbox.py b/openspace/grounding/core/security/e2b_sandbox.py
index a3a3ffed..c7a92783 100644
--- a/openspace/grounding/core/security/e2b_sandbox.py
+++ b/openspace/grounding/core/security/e2b_sandbox.py
@@ -57,12 +57,12 @@ def __init__(self, options: SandboxOptions):
"'pip install e2b-code-interpreter'."
)
- # Get API key from options or environment
- self.api_key = options.get("api_key") or os.environ.get("E2B_API_KEY")
+ # API key MUST come from environment only (never caller-supplied options)
+ self.api_key = os.environ.get("E2B_API_KEY")
if not self.api_key:
raise ValueError(
- "E2B API key is required. Provide it via 'options.api_key'"
- " or the E2B_API_KEY environment variable."
+ "E2B API key is required. Set the E2B_API_KEY environment variable. "
+ "Caller-supplied API keys are not accepted for security."
)
# Get sandbox configuration
diff --git a/openspace/llm/client.py b/openspace/llm/client.py
index 19a16649..99d956d3 100644
--- a/openspace/llm/client.py
+++ b/openspace/llm/client.py
@@ -201,12 +201,6 @@ async def _summarize_tool_result(
timeout: float = 120.0
) -> str:
"""Use LLM to summarize large tool results."""
- try:
- from gdpval_bench.token_tracker import set_call_source, reset_call_source
- _src_tok = set_call_source("summarizer")
- except ImportError:
- _src_tok = None
-
try:
logger.info(f"Summarizing tool result from '{tool_name}': {len(content):,} chars")
@@ -252,9 +246,6 @@ async def _summarize_tool_result(
except Exception as e:
logger.warning(f"Summarization failed for '{tool_name}': {e}")
return None
- finally:
- if _src_tok is not None:
- reset_call_source(_src_tok)
async def _tool_result_to_message_async(
diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py
index 7e3428ff..a2a396a4 100644
--- a/openspace/mcp_server.py
+++ b/openspace/mcp_server.py
@@ -22,7 +22,6 @@
import logging
import os
import sys
-import traceback
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -198,6 +197,20 @@ def _get_store():
return _standalone_store
+def _is_auto_import_enabled() -> bool:
+ """Check whether cloud auto-import is enabled in SkillConfig.
+
+ Returns ``False`` (safe default) if the config is unavailable or the
+ flag is not explicitly set to ``True``. This ensures that untrusted
+ cloud skills are never imported without an explicit opt-in.
+ """
+ if _openspace_instance and _openspace_instance.is_initialized():
+ gc = getattr(_openspace_instance, "_grounding_config", None)
+ if gc and gc.skills:
+ return gc.skills.auto_import_enabled
+ return False
+
+
def _get_cloud_client():
"""Get a OpenSpaceClient instance (raises CloudError if not configured)."""
from openspace.cloud.auth import get_openspace_auth
@@ -321,7 +334,14 @@ async def _cloud_search_and_import(task: str, limit: int = 8) -> List[Dict[str,
that stage 2 has a larger pool to choose from. The two BM25 passes
are NOT redundant — stage 1 filters thousands of cloud candidates down
to a manageable import set; stage 2 makes the final task-specific choice.
+
+ Returns an empty list immediately when ``auto_import_enabled`` is
+ ``False`` (the default) — untrusted cloud code is never imported
+ without an explicit opt-in.
"""
+ if not _is_auto_import_enabled():
+ logger.debug("Cloud auto-import is disabled (auto_import_enabled=False)")
+ return []
try:
from openspace.cloud.search import (
SkillSearchEngine, build_cloud_candidates,
@@ -381,7 +401,12 @@ async def _cloud_search_and_import(task: str, limit: int = 8) -> List[Dict[str,
async def _do_import_cloud_skill(skill_id: str, target_dir: Optional[str] = None) -> Dict[str, Any]:
- """Download a cloud skill and register it locally."""
+ """Download a cloud skill and register it locally.
+
+ Refuses to proceed when ``auto_import_enabled`` is ``False``.
+ """
+ if not _is_auto_import_enabled():
+ return {"status": "blocked", "reason": "auto_import_enabled is False"}
client = _get_cloud_client()
if target_dir:
@@ -476,8 +501,10 @@ def _json_ok(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
-def _json_error(error: Any, **extra) -> str:
- return json.dumps({"error": str(error), **extra}, ensure_ascii=False)
+def _json_error(error_msg: str, *, error_code: str = "VALIDATION_ERROR") -> str:
+ """Return a structured error for validation / not-found cases."""
+ from openspace.errors import safe_error_response
+ return safe_error_response(error_code, error_msg)
# MCP Tools (4 tools)
@@ -556,8 +583,8 @@ async def execute_task(
return _json_ok(formatted)
except Exception as e:
- logger.error(f"execute_task failed: {e}", exc_info=True)
- return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
+ from openspace.errors import handle_mcp_exception, EXECUTION_ERROR
+ return handle_mcp_exception(e, tool_name="execute_task", error_code=EXECUTION_ERROR)
@mcp.tool()
@@ -621,7 +648,7 @@ async def search_skills(
_AUTO_IMPORT_MAX = 3
import_summary: List[Dict[str, Any]] = []
- if auto_import:
+ if auto_import and _is_auto_import_enabled():
cloud_results = [
r for r in results
if r.get("source") == "cloud"
@@ -642,11 +669,12 @@ async def search_skills(
cr["auto_imported"] = True
cr["local_path"] = imp_result.get("local_path", "")
except Exception as imp_err:
- logger.warning(f"auto_import failed for {cr['skill_id']}: {imp_err}")
+ logger.warning(f"auto_import failed for {cr['skill_id']}: {imp_err}",
+ exc_info=True)
import_summary.append({
"skill_id": cr["skill_id"],
"import_status": "error",
- "error": str(imp_err),
+ "error": "Cloud skill import failed",
})
output: Dict[str, Any] = {"results": results, "count": len(results)}
@@ -655,8 +683,8 @@ async def search_skills(
return _json_ok(output)
except Exception as e:
- logger.error(f"search_skills failed: {e}", exc_info=True)
- return _json_error(e)
+ from openspace.errors import handle_mcp_exception, EXECUTION_ERROR
+ return handle_mcp_exception(e, tool_name="search_skills", error_code=EXECUTION_ERROR)
@mcp.tool()
@@ -698,19 +726,19 @@ async def fix_skill(
skill_path = Path(skill_dir)
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
- return _json_error(f"SKILL.md not found in {skill_dir}")
+ return _json_error("SKILL.md not found in the specified skill directory", error_code="SKILL_NOT_FOUND")
openspace = await _get_openspace()
registry = openspace._skill_registry
if not registry:
- return _json_error("SkillRegistry not initialized")
+ return _json_error("SkillRegistry not initialized", error_code="INTERNAL_ERROR")
if not openspace._skill_evolver:
- return _json_error("Skill evolution is not enabled")
+ return _json_error("Skill evolution is not enabled", error_code="INTERNAL_ERROR")
# Step 1: Register the skill (idempotent)
meta = registry.register_skill_dir(skill_path)
if not meta:
- return _json_error(f"Failed to register skill from {skill_dir}")
+ return _json_error("Failed to register skill from the specified directory", error_code="SKILL_NOT_FOUND")
store = _get_store()
await store.sync_from_registry([meta])
@@ -718,12 +746,12 @@ async def fix_skill(
# Step 2: Load record + content
rec = store.load_record(meta.skill_id)
if not rec:
- return _json_error(f"Failed to load skill record for {meta.skill_id}")
+ return _json_error("Failed to load skill record", error_code="SKILL_NOT_FOUND")
evolver = openspace._skill_evolver
content = evolver._load_skill_content(rec)
if not content:
- return _json_error(f"Cannot load content for skill: {meta.skill_id}")
+ return _json_error("Cannot load content for the specified skill", error_code="SKILL_NOT_FOUND")
# Step 3: Run FIX evolution
recent = store.load_analyses(skill_id=meta.skill_id, limit=5)
@@ -773,8 +801,8 @@ async def fix_skill(
})
except Exception as e:
- logger.error(f"fix_skill failed: {e}", exc_info=True)
- return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
+ from openspace.errors import handle_mcp_exception, EXECUTION_ERROR
+ return handle_mcp_exception(e, tool_name="fix_skill", error_code=EXECUTION_ERROR)
@mcp.tool()
@@ -818,7 +846,7 @@ async def upload_skill(
try:
skill_path = Path(skill_dir)
if not (skill_path / "SKILL.md").exists():
- return _json_error(f"SKILL.md not found in {skill_dir}")
+ return _json_error("SKILL.md not found in the specified skill directory", error_code="SKILL_NOT_FOUND")
# Read pre-saved metadata (written by execute_task/fix_skill)
meta = _read_upload_meta(skill_path)
@@ -844,22 +872,90 @@ async def upload_skill(
return _json_ok(result)
except Exception as e:
- logger.error(f"upload_skill failed: {e}", exc_info=True)
- return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
+ from openspace.errors import handle_mcp_exception, EXECUTION_ERROR
+ return handle_mcp_exception(e, tool_name="upload_skill", error_code=EXECUTION_ERROR)
def run_mcp_server() -> None:
- """Console-script entry point for ``openspace-mcp``."""
+ """Console-script entry point for ``openspace-mcp``.
+
+ For HTTP transports (SSE, streamable-http), bearer token auth is
+ REQUIRED. Set OPENSPACE_MCP_BEARER_TOKEN in the environment.
+ The server refuses to start without it (fail-closed).
+
+ For stdio transport, auth is not applicable (local process IPC).
+ """
import argparse
+ import uvicorn
+
+ from openspace.auth.bearer import (
+ BEARER_TOKEN_ENV,
+ BearerTokenMiddleware,
+ get_bearer_token,
+ validate_token_strength,
+ )
+ from openspace.auth.rate_limit import RateLimitMiddleware
+
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
- parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
+ parser.add_argument(
+ "--transport",
+ choices=["stdio", "sse", "streamable-http"],
+ default="stdio",
+ )
parser.add_argument("--port", type=int, default=8080)
+ parser.add_argument("--host", type=str, default="127.0.0.1")
args = parser.parse_args()
+ if args.transport == "stdio":
+ mcp.run(transport="stdio")
+ return
+
+ # --- HTTP transports: enforce bearer token auth (fail-closed) ---
+ token = get_bearer_token()
+ if not token:
+ logger.critical(
+ "FAIL-CLOSED: %s not set. Refusing to start %s transport "
+ "without authentication. Set the environment variable or "
+ "use --transport stdio for local-only access.",
+ BEARER_TOKEN_ENV,
+ args.transport,
+ )
+ sys.exit(1)
+
+ token_ok, reason = validate_token_strength(token)
+ if not token_ok:
+ logger.critical("FAIL-CLOSED: %s — %s", BEARER_TOKEN_ENV, reason)
+ sys.exit(1)
+
if args.transport == "sse":
- mcp.run(transport="sse", sse_params={"port": args.port})
+ starlette_app = mcp.sse_app()
else:
- mcp.run(transport="stdio")
+ starlette_app = mcp.streamable_http_app()
+
+ # Middleware chain: request → BearerAuth → RateLimit → MCP app
+ # Auth is outermost: unauthenticated floods are rejected immediately
+ # (cheap hmac check) before any rate-limit state is created.
+ # This prevents memory DoS via fake tokens from unauthenticated requests.
+ rate_limited_app = RateLimitMiddleware(starlette_app)
+ protected_app = BearerTokenMiddleware(rate_limited_app, token)
+
+ logger.info(
+ "Starting MCP server with bearer auth + rate limiting on %s:%d (%s transport)",
+ args.host,
+ args.port,
+ args.transport,
+ )
+
+ config = uvicorn.Config(
+ protected_app,
+ host=args.host,
+ port=args.port,
+ log_level="info",
+ )
+ server = uvicorn.Server(config)
+ import anyio
+
+ anyio.run(server.serve)
if __name__ == "__main__":
diff --git a/openspace/sandbox/__init__.py b/openspace/sandbox/__init__.py
new file mode 100644
index 00000000..2852e0a1
--- /dev/null
+++ b/openspace/sandbox/__init__.py
@@ -0,0 +1 @@
+"""Sandbox subsystem — capability leases and resource brokering."""
diff --git a/openspace/sandbox/fs_broker.py b/openspace/sandbox/fs_broker.py
new file mode 100644
index 00000000..71f25898
--- /dev/null
+++ b/openspace/sandbox/fs_broker.py
@@ -0,0 +1,681 @@
+"""Filesystem broker — jailed, policy-enforced file access.
+
+EPIC 2.2 — Filesystem Broker
+
+Issues:
+- #89: Virtual path resolution (skills://current/** → jailed real path)
+- #90: Read/write/deny enforcement with max bytes + type checks
+- #91: Chroot-style jailing; prevent symlink/traversal escapes
+- #92: TOCTOU protection using O_NOFOLLOW, openat(), dir-FD pinning
+"""
+
+from __future__ import annotations
+
+import errno
+import fnmatch
+import os
+import platform
+import stat
+from dataclasses import dataclass, field
+from pathlib import Path, PurePosixPath
+from typing import IO, List, Optional, Union
+
+from openspace.sandbox.leases import REQUIRED_DENIED_PATHS, FilesystemCapability
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+_IS_WINDOWS = platform.system() == "Windows"
+
+# O_NOFOLLOW prevents open() from following symlinks (POSIX only)
+_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
+
+VIRTUAL_SCHEME = "skills://"
+
+
+# ---------------------------------------------------------------------------
+# #89 — Virtual Path Resolution
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class JailConfig:
+ """Filesystem jail configuration derived from a lease's FilesystemCapability."""
+
+ jail_root: Path
+ read_patterns: tuple[str, ...] = ()
+ write_patterns: tuple[str, ...] = ()
+ denied_patterns: tuple[str, ...] = ()
+ max_file_size_bytes: int = 10 * 1024 * 1024 # 10 MB
+ temp_dir_only: bool = True
+
+ @classmethod
+ def from_capability(cls, capability: FilesystemCapability, jail_root: Path) -> JailConfig:
+ """Build a JailConfig from a FilesystemCapability and a jail root."""
+ return cls(
+ jail_root=jail_root,
+ read_patterns=tuple(capability.read_paths),
+ write_patterns=tuple(capability.write_paths),
+ denied_patterns=tuple(capability.denied_paths),
+ max_file_size_bytes=capability.max_file_size_mb * 1024 * 1024,
+ temp_dir_only=capability.temp_dir_only,
+ )
+
+
+class PathEscapeError(PermissionError):
+ """Raised when a resolved path escapes the jail root."""
+
+
+class DeniedPathError(PermissionError):
+ """Raised when a path matches a deny pattern."""
+
+
+class FileSizeLimitError(PermissionError):
+ """Raised when a write would exceed the file size limit."""
+
+
+class WriteNotAllowedError(PermissionError):
+ """Raised when writing is not allowed for a path."""
+
+
+class ReadNotAllowedError(PermissionError):
+ """Raised when reading is not allowed for a path."""
+
+
+def resolve_virtual_path(virtual_path: str, jail_root: Path) -> Path:
+ """Resolve a ``skills://`` virtual path to a real jailed path.
+
+ ``skills://current/foo.txt`` → ``/foo.txt``
+
+ Raises ``ValueError`` for invalid virtual paths or traversal attempts.
+ """
+ if not virtual_path.startswith(VIRTUAL_SCHEME):
+ raise ValueError(f"Not a virtual path: {virtual_path!r} (must start with {VIRTUAL_SCHEME!r})")
+
+ remainder = virtual_path[len(VIRTUAL_SCHEME) :]
+
+ # Strip the namespace prefix (e.g., "current/")
+ if "/" in remainder:
+ _namespace, _, relative = remainder.partition("/")
+ else:
+ relative = ""
+
+ if not relative:
+ return jail_root
+
+ # Canonicalise: reject any ".." components before joining to jail
+ clean = PurePosixPath(relative)
+ if ".." in clean.parts:
+ raise ValueError(f"Traversal in virtual path: {virtual_path!r}")
+
+ return jail_root / clean
+
+
+# ---------------------------------------------------------------------------
+# #91 — Chroot-style Jailing
+# ---------------------------------------------------------------------------
+
+
+def _resolve_no_symlinks(path: Path, jail_root: Path) -> Path:
+ """Resolve *path* component-by-component, rejecting symlinks that escape.
+
+ On each component we call ``Path.resolve()`` and verify we're
+ still inside *jail_root*. This prevents ``../`` traversal **and**
+ symlink-based escapes.
+ """
+ jail_resolved = jail_root.resolve()
+
+ # Resolve the full path — this follows symlinks
+ try:
+ resolved = path.resolve()
+ except OSError as exc:
+ raise PathEscapeError(f"Cannot resolve path: {exc}") from exc
+
+ # On Windows, case-insensitive comparison
+ if _IS_WINDOWS:
+ jail_str = str(jail_resolved).lower()
+ resolved_str = str(resolved).lower()
+ else:
+ jail_str = str(jail_resolved)
+ resolved_str = str(resolved)
+
+ # Must be jail_root itself or a child
+ if resolved_str != jail_str and not resolved_str.startswith(jail_str + os.sep):
+ raise PathEscapeError(
+ f"Path escapes jail: resolved to {resolved}, jail root is {jail_resolved}"
+ )
+
+ return resolved
+
+
+def ensure_jailed(path: Union[str, Path], jail_root: Path) -> Path:
+ """Ensure *path* resolves within *jail_root*.
+
+ Raises ``PathEscapeError`` if the path escapes.
+ Raises ``ValueError`` if path contains null bytes.
+ """
+ path_str = str(path)
+ if "\x00" in path_str:
+ raise ValueError(f"Null byte in path: {path_str!r}")
+ return _resolve_no_symlinks(Path(path), jail_root)
+
+
+# ---------------------------------------------------------------------------
+# #90 — Read/Write/Deny Enforcement
+# ---------------------------------------------------------------------------
+
+
+def _is_temp_path(path_str: str, path_obj: Path) -> bool:
+ """Check if *path* is under a recognised temp directory using ancestry, not substring.
+
+ Always normalises ``..`` components via ``Path.resolve(strict=False)``
+ to prevent traversal bypasses like ``/tmp/../etc/shadow``.
+ """
+ import tempfile
+
+ temp_roots = [Path(tempfile.gettempdir())]
+ if not _IS_WINDOWS:
+ temp_roots.extend([Path("/tmp"), Path("/temp")])
+ else:
+ for var in ("TEMP", "TMP"):
+ val = os.environ.get(var)
+ if val:
+ temp_roots.append(Path(val))
+
+ try:
+ resolved = path_obj.resolve(strict=False)
+ except OSError:
+ try:
+ resolved = Path(path_str).resolve(strict=False)
+ except OSError:
+ return False
+
+ for temp_root in temp_roots:
+ try:
+ resolved.relative_to(temp_root.resolve())
+ return True
+ except (ValueError, OSError):
+ continue
+ return False
+
+
+def _matches_any(path_str: str, patterns: tuple[str, ...], *, jail_root: Optional[Path] = None) -> bool:
+ """Check if *path_str* matches any of the glob *patterns*.
+
+ When *jail_root* is provided, also matches against the jail-relative path.
+
+ .. note::
+ Python's ``fnmatch`` treats ``*`` as matching across path separators,
+ unlike shell glob. A pattern like ``output/*`` will match
+ ``output/deep/nested/file.txt``. This is intentional — allow/deny
+ patterns are **recursive** by default.
+
+ Patterns may use ``~`` (tilde) notation which is expanded via
+ ``os.path.expanduser`` before matching.
+
+ Matching is against the **full path** and **jail-relative path** only.
+ Basename-only matching is intentionally excluded to prevent
+ over-permissive allowlists (e.g., ``allowed.txt`` matching any
+ same-named file in any subdirectory).
+ """
+ if not patterns:
+ return False
+
+ candidates: list[str] = [path_str]
+
+ if jail_root is not None:
+ try:
+ rel = os.path.relpath(path_str, jail_root)
+ candidates.append(rel)
+ candidates.append(rel.replace("\\", "/"))
+ except ValueError:
+ pass
+
+ for pattern in patterns:
+ expanded = os.path.expanduser(pattern)
+ for candidate in candidates:
+ if fnmatch.fnmatch(candidate, expanded):
+ return True
+ if expanded != pattern and fnmatch.fnmatch(candidate, pattern):
+ return True
+ return False
+
+
+def check_denied(path: Union[str, Path], config: JailConfig) -> None:
+ """Raise ``DeniedPathError`` if *path* matches any deny pattern.
+
+ .. note::
+ This is a **pattern-only** check. For full jail enforcement
+ (containment + deny + allowlist), use :class:`FilesystemBroker`.
+ """
+ path_str = str(path)
+ if _matches_any(path_str, config.denied_patterns, jail_root=config.jail_root):
+ raise DeniedPathError(f"Path is denied by policy: {path_str}")
+
+
+def check_read(path: Union[str, Path], config: JailConfig) -> None:
+ """Raise if reading *path* is not allowed by **pattern rules**.
+
+ Rules:
+ 1. Path must not be denied.
+ 2. If read_patterns is non-empty, path must match at least one.
+
+ .. note::
+ This is a **pattern-only** check. For full jail enforcement
+ (containment + deny + allowlist), use :class:`FilesystemBroker`.
+ """
+ check_denied(path, config)
+
+ path_str = str(path)
+ if config.read_patterns and not _matches_any(path_str, config.read_patterns, jail_root=config.jail_root):
+ raise ReadNotAllowedError(f"Path not in read allowlist: {path_str}")
+
+
+def check_write(
+ path: Union[str, Path],
+ config: JailConfig,
+ size_bytes: int = 0,
+) -> None:
+ """Raise if writing *path* is not allowed by **pattern rules**.
+
+ Rules:
+ 1. Path must not be denied.
+ 2. If temp_dir_only, path must be under a temp directory.
+ 3. If write_patterns is non-empty, path must match at least one.
+ 4. size_bytes must not exceed max_file_size_bytes.
+
+ .. note::
+ This is a **pattern-only** check. For full jail enforcement
+ (containment + deny + allowlist), use :class:`FilesystemBroker`.
+ """
+ check_denied(path, config)
+
+ path_str = str(path)
+ path_obj = Path(path)
+
+ if config.temp_dir_only:
+ if not _is_temp_path(path_str, path_obj):
+ raise WriteNotAllowedError(f"Writes restricted to temp directories: {path_str}")
+
+ if config.write_patterns and not _matches_any(path_str, config.write_patterns, jail_root=config.jail_root):
+ raise WriteNotAllowedError(f"Path not in write allowlist: {path_str}")
+
+ if size_bytes > config.max_file_size_bytes:
+ raise FileSizeLimitError(
+ f"Write of {size_bytes} bytes exceeds limit of {config.max_file_size_bytes} bytes"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #92 — TOCTOU-safe File Operations
+# ---------------------------------------------------------------------------
+
+
+def _ensure_regular_file(fd: int, path: Path) -> None:
+ """Reject non-regular files (directories, FIFOs, devices, sockets).
+
+ A directory fd could be exploited via ``openat()`` / ``dir_fd`` to escape
+ the jail. Only regular files are permitted.
+ """
+ try:
+ mode = os.fstat(fd).st_mode
+ except OSError:
+ os.close(fd)
+ raise
+ if not stat.S_ISREG(mode):
+ os.close(fd)
+ raise PathEscapeError(
+ f"Not a regular file (mode={oct(mode)}): {path}"
+ )
+
+
+def safe_open_read(path: Union[str, Path], jail_root: Path) -> int:
+ """Open a file for reading with TOCTOU protection.
+
+ Returns a raw file descriptor. Caller MUST close it via ``os.close(fd)``.
+
+ On POSIX: uses ``O_NOFOLLOW`` to reject symlinks at the final component.
+ On Windows: relies on ``ensure_jailed`` pre-check (no O_NOFOLLOW).
+ Rejects non-regular files (directories, FIFOs, devices) to prevent
+ ``openat``-based jail escapes.
+ """
+ resolved = ensure_jailed(path, jail_root)
+
+ flags = os.O_RDONLY | _O_NOFOLLOW
+ try:
+ fd = os.open(str(resolved), flags)
+ except OSError as exc:
+ if exc.errno == errno.ELOOP:
+ raise PathEscapeError(f"Symlink detected at final component: {resolved}") from exc
+ raise
+
+ _ensure_regular_file(fd, resolved)
+ _verify_fd_path(fd, resolved, jail_root)
+ return fd
+
+
+def safe_open_write(
+ path: Union[str, Path],
+ jail_root: Path,
+ *,
+ create: bool = True,
+ max_size_bytes: int = 0,
+) -> int:
+ """Open a file for writing with TOCTOU protection.
+
+ Returns a raw file descriptor. Caller MUST close it via ``os.close(fd)``.
+ """
+ resolved = ensure_jailed(path, jail_root)
+
+ flags = os.O_WRONLY | _O_NOFOLLOW
+ if create:
+ flags |= os.O_CREAT
+ mode = 0o644
+
+ try:
+ fd = os.open(str(resolved), flags, mode)
+ except OSError as exc:
+ if exc.errno == errno.ELOOP:
+ raise PathEscapeError(f"Symlink detected at final component: {resolved}") from exc
+ raise
+
+ # Post-open type check: reject directories/FIFOs/devices
+ _ensure_regular_file(fd, resolved)
+
+ # Post-open size check
+ if max_size_bytes > 0:
+ try:
+ st = os.fstat(fd)
+ if st.st_size > max_size_bytes:
+ os.close(fd)
+ raise FileSizeLimitError(
+ f"Existing file {resolved} is {st.st_size} bytes, exceeds {max_size_bytes}"
+ )
+ except FileSizeLimitError:
+ raise
+ except OSError:
+ os.close(fd)
+ raise
+
+ _verify_fd_path(fd, resolved, jail_root, unlink_on_escape=True)
+ return fd
+
+
+def bounded_write(fd: int, data: bytes, max_size_bytes: int) -> int:
+ """Write *data* to *fd*, enforcing cumulative size limit.
+
+ Checks ``max(file_size, current_offset) + len(data)`` against
+ *max_size_bytes* to prevent sparse-file / seek-based bypasses.
+ Raises ``FileSizeLimitError`` if the write would exceed the cap.
+ Returns the number of bytes written.
+
+ .. warning::
+ The check-then-write is **not atomic**. Concurrent writers to the
+ same fd can exceed the cap. This is acceptable because each sandbox
+ runs a single skill process; multi-writer scenarios are out of scope.
+ EPIC 2.9 (Runtime Quotas) adds OS-level ``rlimit`` enforcement as
+ a hard backstop.
+ """
+ if max_size_bytes > 0:
+ try:
+ current_size = os.fstat(fd).st_size
+ except OSError:
+ current_size = 0
+ try:
+ current_offset = os.lseek(fd, 0, os.SEEK_CUR)
+ except OSError:
+ current_offset = current_size
+ effective_pos = max(current_size, current_offset)
+ if effective_pos + len(data) > max_size_bytes:
+ raise FileSizeLimitError(
+ f"Write of {len(data)} bytes at offset {effective_pos} would exceed "
+ f"limit of {max_size_bytes} bytes"
+ )
+ return os.write(fd, data)
+
+
+def _verify_fd_path(
+ fd: int,
+ expected: Path,
+ jail_root: Path,
+ *,
+ unlink_on_escape: bool = False,
+) -> None:
+ """Post-open verification: ensure the fd actually points inside the jail.
+
+ - On Linux: reads ``/proc/self/fd/{fd}`` to verify actual path.
+ - On all platforms: checks ``st_dev`` matches the jail root's device
+ to detect hard-link escapes across filesystems.
+ - On Windows/macOS without /proc: relies on st_dev check only.
+
+ If *unlink_on_escape* is True and a jail escape is detected, the file
+ is unlinked before raising, limiting damage from O_CREAT races.
+
+ **Limitation**: Same-device hard links created by a compromised process
+ that already has write access both inside and outside the jail can
+ bypass path-based checks. This is mitigated by the sandbox process
+ broker (EPIC 2.4) restricting link/symlink syscalls.
+ """
+ # Device check: file must be on the same filesystem as the jail
+ try:
+ fd_stat = os.fstat(fd)
+ jail_stat = os.stat(str(jail_root.resolve()))
+ if fd_stat.st_dev != jail_stat.st_dev:
+ if unlink_on_escape:
+ _safe_unlink(expected)
+ os.close(fd)
+ raise PathEscapeError(
+ f"File device ({fd_stat.st_dev}) differs from jail device ({jail_stat.st_dev})"
+ )
+ except PathEscapeError:
+ raise
+ except OSError:
+ pass # Best-effort
+
+ if _IS_WINDOWS:
+ return
+
+ proc_link = f"/proc/self/fd/{fd}"
+ try:
+ actual = Path(os.readlink(proc_link)).resolve()
+ except OSError:
+ return
+
+ jail_resolved = jail_root.resolve()
+ actual_str = str(actual)
+ jail_str = str(jail_resolved)
+
+ if actual_str != jail_str and not actual_str.startswith(jail_str + "/"):
+ if unlink_on_escape:
+ _safe_unlink(expected)
+ os.close(fd)
+ raise PathEscapeError(
+ f"Post-open verification failed: fd points to {actual}, outside jail {jail_resolved}"
+ )
+
+ return actual
+
+
+def _safe_unlink(path: Path) -> None:
+ """Best-effort removal of a file created during a TOCTOU race."""
+ try:
+ os.unlink(str(path))
+ except OSError:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# High-level Broker
+# ---------------------------------------------------------------------------
+
+
+class FilesystemBroker:
+ """Policy-enforced filesystem broker with jailing.
+
+ Combines virtual path resolution, jail enforcement, deny-list
+ checking, read/write policy, file-size limits, and TOCTOU-safe
+ operations into a single entry point.
+ """
+
+ def __init__(self, config: JailConfig) -> None:
+ self._config = config
+ self._jail_root = config.jail_root.resolve()
+
+ @property
+ def jail_root(self) -> Path:
+ return self._jail_root
+
+ @property
+ def config(self) -> JailConfig:
+ return self._config
+
+ def resolve(self, path: str) -> Path:
+ """Resolve a path (virtual or real) to a jailed real path.
+
+ Virtual paths (``skills://current/...``) are resolved first,
+ then jail enforcement is applied.
+ """
+ if path.startswith(VIRTUAL_SCHEME):
+ real_path = resolve_virtual_path(path, self._jail_root)
+ else:
+ real_path = Path(path)
+ return ensure_jailed(real_path, self._jail_root)
+
+ def check_read(self, path: str) -> Path:
+ """Validate and return the jailed path for reading.
+
+ Raises on escape, denied, or not-in-allowlist.
+ """
+ resolved = self.resolve(path)
+ check_read(resolved, self._config)
+ return resolved
+
+ def check_write(self, path: str, size_bytes: int = 0) -> Path:
+ """Validate and return the jailed path for writing.
+
+ Raises on escape, denied, not-in-allowlist, or size exceeded.
+ """
+ resolved = self.resolve(path)
+ check_write(resolved, self._config, size_bytes=size_bytes)
+ return resolved
+
+ def open_read(self, path: str) -> int:
+ """TOCTOU-safe open for reading. Returns raw fd.
+
+ Performs deny-list re-check after safe_open using the actual resolved
+ path from /proc/self/fd (Linux) to close the check-then-open race window.
+ """
+ resolved = self.check_read(path)
+ fd = safe_open_read(resolved, self._jail_root)
+ self._post_open_policy_check(fd, resolved, is_write=False)
+ return fd
+
+ def open_write(self, path: str, size_bytes: int = 0) -> int:
+ """TOCTOU-safe open for writing. Returns raw fd.
+
+ Performs deny-list re-check after safe_open using the actual resolved
+ path from /proc/self/fd (Linux) to close the check-then-open race window.
+
+ .. important::
+ Callers MUST use :func:`bounded_write` instead of ``os.write``
+ to enforce cumulative file-size limits. Direct ``os.write``
+ bypasses size cap enforcement.
+ """
+ resolved = self.check_write(path, size_bytes=size_bytes)
+ fd = safe_open_write(
+ resolved,
+ self._jail_root,
+ max_size_bytes=self._config.max_file_size_bytes,
+ )
+ self._post_open_policy_check(fd, resolved, is_write=True)
+ return fd
+
+ def _post_open_policy_check(self, fd: int, expected: Path, *, is_write: bool = False) -> None:
+ """Re-check the full policy against the actual fd path (Linux /proc, macOS F_GETPATH).
+
+ If the actual path differs from expected, re-runs deny, allowlist, and
+ temp_dir checks to close the check-then-open TOCTOU window.
+ """
+ actual_path = self._resolve_fd_actual_path(fd)
+ if actual_path is None:
+ return
+
+ actual_str = str(actual_path)
+ if actual_str == str(expected.resolve()):
+ return # No race — path unchanged
+
+ # Full policy re-check on the actual path
+ # 1. Jail containment
+ jail_str = str(self._jail_root)
+ if actual_str != jail_str and not actual_str.startswith(jail_str + os.sep):
+ os.close(fd)
+ raise PathEscapeError(
+ f"Post-open: actual path {actual_path} is outside jail {self._jail_root}"
+ )
+
+ # 2. Deny patterns
+ if _matches_any(actual_str, self._config.denied_patterns, jail_root=self._jail_root):
+ os.close(fd)
+ raise DeniedPathError(
+ f"Post-open deny check: actual path {actual_path} matches deny pattern"
+ )
+
+ # 3. Read/write allowlist
+ if is_write:
+ if self._config.write_patterns and not _matches_any(
+ actual_str, self._config.write_patterns, jail_root=self._jail_root
+ ):
+ os.close(fd)
+ raise WriteNotAllowedError(
+ f"Post-open: actual path {actual_path} not in write allowlist"
+ )
+ if self._config.temp_dir_only:
+ if not _is_temp_path(actual_str, actual_path):
+ os.close(fd)
+ raise WriteNotAllowedError(
+ f"Post-open: actual path {actual_path} not in temp directory"
+ )
+ else:
+ if self._config.read_patterns and not _matches_any(
+ actual_str, self._config.read_patterns, jail_root=self._jail_root
+ ):
+ os.close(fd)
+ raise ReadNotAllowedError(
+ f"Post-open: actual path {actual_path} not in read allowlist"
+ )
+
+ @staticmethod
+ def _resolve_fd_actual_path(fd: int) -> Optional[Path]:
+ """Resolve the actual filesystem path of an open fd.
+
+ - Linux: ``/proc/self/fd/{fd}``
+ - macOS: ``fcntl.F_GETPATH``
+ - Windows/other: returns None (no reliable mechanism)
+ """
+ if _IS_WINDOWS:
+ return None
+
+ # Try /proc/self/fd first (Linux)
+ proc_link = f"/proc/self/fd/{fd}"
+ try:
+ return Path(os.readlink(proc_link)).resolve()
+ except OSError:
+ pass
+
+ # Try fcntl F_GETPATH (macOS)
+ try:
+ import fcntl
+ F_GETPATH = 50 # macOS-specific
+ result = fcntl.fcntl(fd, F_GETPATH, b"\0" * 1024)
+ if isinstance(result, bytes):
+ path_bytes = result.split(b"\0", 1)[0]
+ else:
+ path_bytes = b""
+ if not path_bytes:
+ return None # Empty result — cannot verify
+ return Path(path_bytes.decode()).resolve()
+ except (ImportError, OSError, AttributeError, UnicodeDecodeError):
+ pass
+
+ return None
diff --git a/openspace/sandbox/leases.py b/openspace/sandbox/leases.py
new file mode 100644
index 00000000..ebe9e553
--- /dev/null
+++ b/openspace/sandbox/leases.py
@@ -0,0 +1,474 @@
+"""Capability lease schema, validation, and resolution.
+
+EPIC 2.1 — Capability Lease System
+
+Issues:
+- #84: YAML schema definition (LeaseSchema Pydantic model)
+- #85: Parser + validator (parse_lease, validate_lease)
+- #86: Default tier templates (TIER_DEFAULTS)
+- #87: Lease resolver implementing CapabilityLeaseResolverPort
+"""
+
+from __future__ import annotations
+
+import asyncio
+import uuid
+from datetime import datetime, timedelta, timezone
+from enum import Enum
+from typing import Any, Dict, FrozenSet, List, Optional
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+from openspace.domain.types import CapabilityLease, SandboxPolicy
+
+# ---------------------------------------------------------------------------
+# #84 — Capability Lease YAML Schema
+# ---------------------------------------------------------------------------
+
+
+class TrustTier(str, Enum):
+ """Trust tiers from most restrictive to most permissive."""
+
+ T0_UNTRUSTED = "T0"
+ T1_BASIC = "T1"
+ T2_STANDARD = "T2"
+ T3_ELEVATED = "T3"
+ T4_FULL = "T4"
+
+
+REQUIRED_DENIED_PATHS = frozenset({"/etc/shadow", "/etc/passwd", "~/.ssh/*", "**/.env"})
+REQUIRED_BLOCKED_DOMAINS = frozenset({
+ "169.254.169.254",
+ "metadata.google.internal",
+ "metadata.internal",
+ "100.100.100.200",
+ "fd00:ec2::254",
+})
+REQUIRED_BLOCKED_COMMANDS = frozenset({"rm", "rmdir", "mkfs", "dd", "shutdown", "reboot", "kill", "pkill"})
+
+
+class FilesystemCapability(BaseModel):
+ """Filesystem access capabilities."""
+
+ read_paths: list[str] = Field(default_factory=list, description="Glob patterns for readable paths")
+ write_paths: list[str] = Field(default_factory=list, description="Glob patterns for writable paths")
+ denied_paths: list[str] = Field(
+ default_factory=lambda: ["/etc/shadow", "/etc/passwd", "~/.ssh/*", "**/.env"],
+ description="Glob patterns always denied",
+ )
+ max_file_size_mb: int = Field(default=10, ge=1, le=1024)
+ temp_dir_only: bool = Field(default=True, description="Restrict writes to temp directories")
+
+ @field_validator("denied_paths")
+ @classmethod
+ def _deny_list_not_empty(cls, v: list[str]) -> list[str]:
+ if not v:
+ raise ValueError("denied_paths cannot be empty — security invariant")
+ # Ensure required entries are always present
+ merged = list(v)
+ for required in REQUIRED_DENIED_PATHS:
+ if required not in merged:
+ merged.append(required)
+ return merged
+
+
+class NetworkCapability(BaseModel):
+ """Network access capabilities."""
+
+ allowed_domains: list[str] = Field(default_factory=list, description="Allowed outbound domains")
+ blocked_domains: list[str] = Field(
+ default_factory=lambda: [
+ "169.254.169.254",
+ "metadata.google.internal",
+ "metadata.internal",
+ "100.100.100.200",
+ "fd00:ec2::254",
+ ],
+ description="Always-blocked domains (cloud metadata, etc.)",
+ )
+ max_connections: int = Field(default=5, ge=0, le=100)
+ allowed_ports: list[int] = Field(default_factory=lambda: [80, 443], description="Allowed outbound ports")
+ outbound_enabled: bool = Field(default=False)
+
+ @field_validator("blocked_domains")
+ @classmethod
+ def _block_list_not_empty(cls, v: list[str]) -> list[str]:
+ if not v:
+ raise ValueError("blocked_domains cannot be empty — cloud metadata must always be blocked")
+ # Ensure required entries are always present
+ merged = list(v)
+ for required in REQUIRED_BLOCKED_DOMAINS:
+ if required not in merged:
+ merged.append(required)
+ return merged
+
+
+class ProcessCapability(BaseModel):
+ """Process execution capabilities."""
+
+ allowed_commands: list[str] = Field(default_factory=list, description="Allowed command basenames")
+ blocked_commands: list[str] = Field(
+ default_factory=lambda: ["rm", "rmdir", "mkfs", "dd", "shutdown", "reboot", "kill", "pkill"],
+ description="Always-blocked commands",
+ )
+ max_processes: int = Field(default=3, ge=0, le=50)
+ max_execution_time_s: int = Field(default=300, ge=1, le=3600)
+ allow_shell: bool = Field(default=False, description="Allow shell invocation (bash/sh/cmd)")
+
+ @field_validator("blocked_commands")
+ @classmethod
+ def _blocked_cmds_not_empty(cls, v: list[str]) -> list[str]:
+ if not v:
+ raise ValueError("blocked_commands cannot be empty — safety invariant")
+ merged = list(v)
+ for required in REQUIRED_BLOCKED_COMMANDS:
+ if required not in merged:
+ merged.append(required)
+ return merged
+
+
+class ResourceCapability(BaseModel):
+ """Resource usage limits."""
+
+ max_memory_mb: int = Field(default=512, ge=64, le=8192)
+ max_cpu_percent: int = Field(default=50, ge=1, le=100)
+ max_disk_mb: int = Field(default=100, ge=1, le=10240)
+ max_output_size_mb: int = Field(default=5, ge=1, le=100)
+
+
+class SecretCapability(BaseModel):
+ """Secret access capabilities."""
+
+ allowed_scopes: list[str] = Field(
+ default_factory=lambda: ["task"],
+ description="Scopes this lease can access (task, session, global)",
+ )
+ allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = unrestricted)")
+ max_secrets: int = Field(default=0, ge=0, le=50, description="Max secrets accessible (0 = none)")
+
+
+class LeaseSchema(BaseModel):
+ """Complete capability lease definition.
+
+ This is the Pydantic model that maps to a YAML lease file.
+ Skills declare their required capabilities via this schema.
+ """
+
+ name: str = Field(description="Human-readable lease name")
+ trust_tier: TrustTier = Field(default=TrustTier.T1_BASIC)
+ ttl_seconds: int = Field(default=300, ge=10, le=3600, description="Lease duration")
+ filesystem: FilesystemCapability = Field(default_factory=FilesystemCapability)
+ network: NetworkCapability = Field(default_factory=NetworkCapability)
+ process: ProcessCapability = Field(default_factory=ProcessCapability)
+ resources: ResourceCapability = Field(default_factory=ResourceCapability)
+ secrets: SecretCapability = Field(default_factory=SecretCapability)
+
+ @model_validator(mode="after")
+ def _validate_tier_consistency(self) -> "LeaseSchema":
+ """Enforce per-tier upper bounds on capability domains."""
+ tier = self.trust_tier
+
+ if tier == TrustTier.T0_UNTRUSTED:
+ # T0: strictest — sandbox-only isolation
+ if self.network.outbound_enabled:
+ raise ValueError("T0 (untrusted) cannot have outbound network enabled")
+ if self.network.allowed_domains:
+ raise ValueError(
+ "T0 (untrusted) cannot have allowed_domains when outbound is disabled"
+ )
+ if self.process.allow_shell:
+ raise ValueError("T0 (untrusted) cannot allow shell execution")
+ if self.process.max_processes > 1:
+ raise ValueError("T0 (untrusted) cannot spawn more than 1 process")
+ if self.secrets.max_secrets > 0:
+ raise ValueError("T0 (untrusted) cannot access secrets")
+ if not self.filesystem.temp_dir_only:
+ raise ValueError("T0 (untrusted) must restrict writes to temp directories")
+ if self.filesystem.write_paths:
+ raise ValueError("T0 (untrusted) cannot have explicit write paths")
+ if self.resources.max_memory_mb > 256:
+ raise ValueError("T0 (untrusted) cannot exceed 256MB memory")
+
+ elif tier == TrustTier.T1_BASIC:
+ # T1: read-only + limited tools — no network, no shell
+ if self.network.outbound_enabled:
+ raise ValueError("T1 (basic) cannot have outbound network enabled")
+ if self.network.allowed_domains:
+ raise ValueError(
+ "T1 (basic) cannot have allowed_domains when outbound is disabled"
+ )
+ if self.process.allow_shell:
+ raise ValueError("T1 (basic) cannot allow shell execution")
+ if self.secrets.max_secrets > 0:
+ raise ValueError("T1 (basic) cannot access secrets")
+
+ elif tier == TrustTier.T2_STANDARD:
+ # T2: read-write + network — no shell
+ if self.process.allow_shell:
+ raise ValueError("T2 (standard) cannot allow shell execution")
+
+ return self
+
+
+# ---------------------------------------------------------------------------
+# #85 — Parser + Validator
+# ---------------------------------------------------------------------------
+
+
+def parse_lease(data: Dict[str, Any]) -> LeaseSchema:
+ """Parse and validate a lease definition from a dict (e.g., YAML-loaded).
+
+ Raises ``pydantic.ValidationError`` on invalid input.
+ """
+ return LeaseSchema.model_validate(data)
+
+
+def validate_lease(data: Dict[str, Any]) -> list[str]:
+ """Validate a lease definition and return a list of error messages.
+
+ Returns an empty list if the lease is valid.
+ """
+ try:
+ parse_lease(data)
+ return []
+ except Exception as exc:
+ # Extract individual error messages from Pydantic ValidationError
+ if hasattr(exc, "errors"):
+ return [f"{'.'.join(str(l) for l in e['loc'])}: {e['msg']}" for e in exc.errors()]
+ return [str(exc)]
+
+
+# ---------------------------------------------------------------------------
+# #86 — Default Tier Templates (T0–T4)
+# ---------------------------------------------------------------------------
+
+TIER_DEFAULTS: Dict[TrustTier, LeaseSchema] = {
+ TrustTier.T0_UNTRUSTED: LeaseSchema(
+ name="T0 — Untrusted (sandbox-only)",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ ttl_seconds=60,
+ filesystem=FilesystemCapability(read_paths=[], write_paths=[], temp_dir_only=True, max_file_size_mb=1),
+ network=NetworkCapability(outbound_enabled=False, max_connections=0, allowed_domains=[]),
+ process=ProcessCapability(
+ allowed_commands=["echo", "cat", "head", "tail"],
+ max_processes=1,
+ max_execution_time_s=30,
+ allow_shell=False,
+ ),
+ resources=ResourceCapability(max_memory_mb=128, max_cpu_percent=10, max_disk_mb=10, max_output_size_mb=1),
+ secrets=SecretCapability(max_secrets=0, allowed_scopes=[], allowed_keys=[]),
+ ),
+ TrustTier.T1_BASIC: LeaseSchema(
+ name="T1 — Basic (read-only + limited tools)",
+ trust_tier=TrustTier.T1_BASIC,
+ ttl_seconds=300,
+ filesystem=FilesystemCapability(read_paths=["workspace/**"], write_paths=[], temp_dir_only=True),
+ network=NetworkCapability(outbound_enabled=False, max_connections=0),
+ process=ProcessCapability(
+ allowed_commands=["echo", "cat", "head", "tail", "grep", "find", "ls", "wc"],
+ max_processes=2,
+ max_execution_time_s=120,
+ allow_shell=False,
+ ),
+ resources=ResourceCapability(max_memory_mb=256, max_cpu_percent=25, max_disk_mb=50),
+ secrets=SecretCapability(max_secrets=0, allowed_scopes=["task"]),
+ ),
+ TrustTier.T2_STANDARD: LeaseSchema(
+ name="T2 — Standard (read-write + network)",
+ trust_tier=TrustTier.T2_STANDARD,
+ ttl_seconds=600,
+ filesystem=FilesystemCapability(
+ read_paths=["workspace/**", "/tmp/**"],
+ write_paths=["workspace/**"],
+ temp_dir_only=False,
+ max_file_size_mb=10,
+ ),
+ network=NetworkCapability(
+ outbound_enabled=True,
+ max_connections=5,
+ allowed_domains=["*.githubusercontent.com", "pypi.org", "registry.npmjs.org"],
+ ),
+ process=ProcessCapability(
+ allowed_commands=["echo", "cat", "head", "tail", "grep", "find", "ls", "wc", "python", "pip", "node", "npm"],
+ max_processes=5,
+ max_execution_time_s=300,
+ allow_shell=False,
+ ),
+ resources=ResourceCapability(max_memory_mb=512, max_cpu_percent=50, max_disk_mb=100),
+ secrets=SecretCapability(max_secrets=3, allowed_scopes=["task"]),
+ ),
+ TrustTier.T3_ELEVATED: LeaseSchema(
+ name="T3 — Elevated (shell + broad access)",
+ trust_tier=TrustTier.T3_ELEVATED,
+ ttl_seconds=1200,
+ filesystem=FilesystemCapability(
+ read_paths=["**"],
+ write_paths=["workspace/**", "/tmp/**"],
+ temp_dir_only=False,
+ max_file_size_mb=50,
+ ),
+ network=NetworkCapability(outbound_enabled=True, max_connections=20, allowed_domains=["*"]),
+ process=ProcessCapability(
+ allowed_commands=["*"],
+ max_processes=10,
+ max_execution_time_s=600,
+ allow_shell=True,
+ ),
+ resources=ResourceCapability(max_memory_mb=2048, max_cpu_percent=75, max_disk_mb=500),
+ secrets=SecretCapability(max_secrets=10, allowed_scopes=["task", "session"]),
+ ),
+ TrustTier.T4_FULL: LeaseSchema(
+ name="T4 — Full trust (unrestricted)",
+ trust_tier=TrustTier.T4_FULL,
+ ttl_seconds=3600,
+ filesystem=FilesystemCapability(
+ read_paths=["**"],
+ write_paths=["**"],
+ temp_dir_only=False,
+ max_file_size_mb=1024,
+ ),
+ network=NetworkCapability(outbound_enabled=True, max_connections=100, allowed_domains=["*"]),
+ process=ProcessCapability(
+ allowed_commands=["*"],
+ max_processes=50,
+ max_execution_time_s=3600,
+ allow_shell=True,
+ ),
+ resources=ResourceCapability(max_memory_mb=8192, max_cpu_percent=100, max_disk_mb=10240, max_output_size_mb=100),
+ secrets=SecretCapability(max_secrets=50, allowed_scopes=["task", "session", "global"]),
+ ),
+}
+
+
+def get_tier_default(tier: TrustTier) -> LeaseSchema:
+ """Return a deep copy of the default lease template for a trust tier."""
+ return TIER_DEFAULTS[tier].model_copy(deep=True)
+
+
+# ---------------------------------------------------------------------------
+# #87 — Lease Resolver (implements CapabilityLeaseResolverPort)
+# ---------------------------------------------------------------------------
+
+
+class InMemoryLeaseResolver:
+ """In-memory implementation of CapabilityLeaseResolverPort.
+
+ Uses asyncio.Lock for safe concurrent access. Production deployments
+ may swap this for a database-backed resolver.
+ """
+
+ def __init__(self) -> None:
+ self._leases: Dict[str, CapabilityLease] = {}
+ self._lock = asyncio.Lock()
+
+ _MIN_TTL = 10
+ _MAX_TTL = 3600
+
+ async def acquire(
+ self,
+ capability: str,
+ *,
+ trust_tier: str = "T1",
+ ttl_seconds: int = 300,
+ ) -> Optional[CapabilityLease]:
+ """Create and store a new capability lease.
+
+ Raises ``ValueError`` if *ttl_seconds* is outside 10–3600
+ or *trust_tier* is not a recognised ``TrustTier`` value.
+
+ **Authorization note:** This resolver is a bookkeeping layer —
+ it records that a lease was granted but does not decide *who*
+ may request *which* tier. Tier authorization is enforced by the
+ pre-execution pipeline (EPIC 2.10) and MCP auth layer (EPIC 2.5),
+ which determine the maximum tier a caller may request before
+ invoking ``acquire()``.
+ """
+ # Validate inputs at the resolver boundary
+ valid_tiers = {t.value for t in TrustTier}
+ if trust_tier not in valid_tiers:
+ raise ValueError(f"Invalid trust_tier '{trust_tier}'; must be one of {sorted(valid_tiers)}")
+ if not (self._MIN_TTL <= ttl_seconds <= self._MAX_TTL):
+ raise ValueError(f"ttl_seconds must be {self._MIN_TTL}-{self._MAX_TTL}, got {ttl_seconds}")
+ async with self._lock:
+ lease_id = f"lease-{uuid.uuid4().hex[:12]}"
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
+ lease = CapabilityLease(
+ lease_id=lease_id,
+ capability=capability,
+ granted_to="current_task",
+ trust_tier=trust_tier,
+ expires_at=expires_at,
+ revoked=False,
+ )
+ self._leases[lease_id] = lease
+ return lease
+
+ async def release(self, lease_id: str) -> bool:
+ """Revoke a lease by ID."""
+ async with self._lock:
+ if lease_id not in self._leases:
+ return False
+ old = self._leases[lease_id]
+ # CapabilityLease is frozen — replace with revoked copy
+ self._leases[lease_id] = CapabilityLease(
+ lease_id=old.lease_id,
+ capability=old.capability,
+ granted_to=old.granted_to,
+ trust_tier=old.trust_tier,
+ expires_at=old.expires_at,
+ revoked=True,
+ )
+ return True
+
+ async def validate(self, lease_id: str) -> bool:
+ """Check if a lease is active (not revoked, not expired)."""
+ async with self._lock:
+ lease = self._leases.get(lease_id)
+ if lease is None or lease.revoked:
+ return False
+ if lease.expires_at and lease.expires_at < datetime.now(timezone.utc):
+ return False
+ return True
+
+ async def list_active(self, *, granted_to: Optional[str] = None) -> List[CapabilityLease]:
+ """Return all non-revoked, non-expired leases."""
+ async with self._lock:
+ now = datetime.now(timezone.utc)
+ active: List[CapabilityLease] = []
+ for lease in self._leases.values():
+ if lease.revoked:
+ continue
+ if lease.expires_at and lease.expires_at < now:
+ continue
+ if granted_to and lease.granted_to != granted_to:
+ continue
+ active.append(lease)
+ return active
+
+
+# ---------------------------------------------------------------------------
+# Lease → SandboxPolicy conversion
+# ---------------------------------------------------------------------------
+
+
+def lease_to_sandbox_policy(lease_schema: LeaseSchema) -> SandboxPolicy:
+ """Convert a LeaseSchema into a SandboxPolicy for runtime enforcement.
+
+ Maps: trust_tier, allowed/blocked commands, allowed/blocked domains,
+ max_execution_time_s, max_memory_mb.
+
+ Deferred to Phase 2 broker EPICs (2.2–2.6): filesystem paths,
+ temp_dir_only, max_file_size, outbound_enabled, allowed_ports,
+ max_connections, allow_shell, max_processes, secret scopes.
+ Those fields will be enforced by the respective broker layers.
+ """
+ return SandboxPolicy(
+ sandbox_enabled=True,
+ trust_tier=lease_schema.trust_tier.value,
+ allowed_commands=frozenset(lease_schema.process.allowed_commands),
+ blocked_commands=frozenset(lease_schema.process.blocked_commands),
+ allowed_domains=frozenset(lease_schema.network.allowed_domains),
+ blocked_domains=frozenset(lease_schema.network.blocked_domains),
+ max_execution_time_s=lease_schema.process.max_execution_time_s,
+ max_memory_mb=lease_schema.resources.max_memory_mb,
+ )
diff --git a/openspace/sandbox/net_proxy.py b/openspace/sandbox/net_proxy.py
new file mode 100644
index 00000000..3ba82002
--- /dev/null
+++ b/openspace/sandbox/net_proxy.py
@@ -0,0 +1,438 @@
+"""Network proxy — policy-enforced outbound network access control.
+
+EPIC 2.3 — Network Proxy
+
+Issues:
+- #95: Domain-based allow/deny enforcement with glob matching
+- #96: Concurrent connection tracking and limits
+- #97: Port-based filtering and validation
+- #98: Outbound enable/disable enforcement + proxy lifecycle
+"""
+
+from __future__ import annotations
+
+import asyncio
+import fnmatch
+import ipaddress
+import time
+from dataclasses import dataclass, field
+from typing import Optional
+
+from openspace.sandbox.leases import NetworkCapability
+
+
+# ---------------------------------------------------------------------------
+# Known DNS rebinding services that resolve to arbitrary IPs.
+# These must be blocked to prevent metadata endpoint access via aliases
+# like 169.254.169.254.nip.io → 169.254.169.254.
+# ---------------------------------------------------------------------------
+
+_DNS_REBINDING_PATTERNS: tuple[str, ...] = (
+ "*.nip.io",
+ "nip.io",
+ "*.sslip.io",
+ "sslip.io",
+ "*.xip.io",
+ "xip.io",
+ "*.traefik.me",
+ "traefik.me",
+ "*.localtest.me",
+ "localtest.me",
+)
+
+# IPv4-mapped IPv6 equivalents of common metadata endpoints
+_IPV6_METADATA_ALIASES: tuple[str, ...] = (
+ "::ffff:169.254.169.254",
+ "::ffff:a9fe:a9fe",
+ "::ffff:100.100.100.200",
+ "::ffff:6464:64c8",
+ "[::ffff:169.254.169.254]",
+ "[::ffff:a9fe:a9fe]",
+)
+
+# IP networks that must be blocked to prevent SSRF to local services.
+# Loopback, link-local, and unspecified addresses allow reaching localhost,
+# cloud-internal endpoints, and adjacent machines on the same network segment.
+_BLOCKED_IP_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
+ ipaddress.IPv4Network("127.0.0.0/8"), # IPv4 loopback
+ ipaddress.IPv6Network("::1/128"), # IPv6 loopback
+ ipaddress.IPv4Network("0.0.0.0/8"), # "this host" — often aliases localhost
+ ipaddress.IPv6Network("::/128"), # IPv6 unspecified
+ ipaddress.IPv4Network("169.254.0.0/16"), # IPv4 link-local (already caught by metadata)
+ ipaddress.IPv6Network("fe80::/10"), # IPv6 link-local
+ ipaddress.IPv6Network("fc00::/7"), # IPv6 unique-local (ULA, private)
+)
+
+
+# ---------------------------------------------------------------------------
+# Exceptions
+# ---------------------------------------------------------------------------
+
+
+class NetworkPolicyError(Exception):
+ """Base for all network proxy policy violations."""
+
+
+class OutboundDisabledError(NetworkPolicyError):
+ """Raised when outbound networking is disabled for this lease."""
+
+
+class DomainDeniedError(NetworkPolicyError):
+ """Raised when the target domain is on the block list."""
+
+
+class DomainNotAllowedError(NetworkPolicyError):
+ """Raised when the target domain is not on the allow list."""
+
+
+class PortNotAllowedError(NetworkPolicyError):
+ """Raised when the target port is not in the allowed set."""
+
+
+class ConnectionLimitError(NetworkPolicyError):
+ """Raised when the concurrent connection limit is reached."""
+
+
+class ConnectionNotFoundError(NetworkPolicyError):
+ """Raised when attempting to release a non-existent connection."""
+
+
+# ---------------------------------------------------------------------------
+# #95 — Domain-Based Allow/Deny Enforcement
+# ---------------------------------------------------------------------------
+
+
+def _is_blocked_ip(host: str) -> bool:
+ """Return True if *host* is an IP in a blocked network (loopback, link-local, etc.)."""
+ cleaned = host.strip("[]")
+ try:
+ addr = ipaddress.ip_address(cleaned)
+ except ValueError:
+ return False
+
+ # Collapse IPv4-mapped IPv6 to plain IPv4 for network checks
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
+ addr = addr.ipv4_mapped
+
+ return any(addr in net for net in _BLOCKED_IP_NETWORKS)
+
+
+def _normalize_ip(host: str) -> Optional[str]:
+ """Normalize an IP address to its canonical IPv4 form if possible.
+
+ Handles IPv4-mapped IPv6 (``::ffff:A.B.C.D``), bracket-wrapped
+ literals (``[::1]``), and plain IPv4/IPv6 strings.
+ Returns the canonical string form, or None if *host* is not an IP.
+ """
+ cleaned = host.strip("[]")
+ try:
+ addr = ipaddress.ip_address(cleaned)
+ except ValueError:
+ return None
+
+ # Collapse IPv4-mapped IPv6 to plain IPv4
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
+ return str(addr.ipv4_mapped)
+ return str(addr)
+
+
+def _matches_domain(domain: str, pattern: str) -> bool:
+ """Check if *domain* matches a domain *pattern*.
+
+ Supports:
+ - Exact match: ``example.com`` matches ``example.com``
+ - Wildcard subdomain: ``*.example.com`` matches ``sub.example.com``
+ - IP addresses: normalized before comparison (IPv4-mapped IPv6 collapsed)
+ - Universal wildcard: ``*`` matches everything
+
+ Case-insensitive matching (domains are case-insensitive per RFC 4343).
+ """
+ domain_lower = domain.lower().strip(".")
+ pattern_lower = pattern.lower().strip(".")
+
+ # Normalize IPs: ::ffff:169.254.169.254 → 169.254.169.254
+ domain_ip = _normalize_ip(domain_lower)
+ pattern_ip = _normalize_ip(pattern_lower)
+
+ if domain_ip and pattern_ip:
+ return domain_ip == pattern_ip
+ if domain_ip:
+ return fnmatch.fnmatch(domain_ip, pattern_lower)
+ if pattern_ip:
+ return fnmatch.fnmatch(domain_lower, pattern_ip)
+
+ return fnmatch.fnmatch(domain_lower, pattern_lower)
+
+
+def check_domain_blocked(domain: str, blocked_domains: list[str]) -> None:
+ """Raise ``DomainDeniedError`` if *domain* matches any blocked pattern.
+
+ Block list is checked **first** (deny-before-allow), and includes
+ cloud metadata endpoints by default. Also checks against known DNS
+ rebinding services (nip.io, sslip.io, etc.), IPv6 metadata aliases,
+ and loopback/link-local/ULA IP ranges.
+ """
+ # Block loopback, link-local, ULA IPs before pattern matching
+ if _is_blocked_ip(domain):
+ raise DomainDeniedError(
+ f"Domain '{domain}' is a blocked IP address (loopback/link-local/ULA)"
+ )
+
+ # Build extended block list: explicit + rebinding + IPv6 aliases
+ extended = list(blocked_domains) + list(_DNS_REBINDING_PATTERNS) + list(_IPV6_METADATA_ALIASES)
+
+ for pattern in extended:
+ if _matches_domain(domain, pattern):
+ raise DomainDeniedError(
+ f"Domain '{domain}' is blocked by pattern '{pattern}'"
+ )
+
+
+def check_domain_allowed(domain: str, allowed_domains: list[str]) -> None:
+ """Raise ``DomainNotAllowedError`` if *domain* is not in the allow list.
+
+ An empty allow list means **no domains are permitted** (deny-by-default).
+ A list containing ``"*"`` permits all domains.
+ """
+ if not allowed_domains:
+ raise DomainNotAllowedError(
+ f"Domain '{domain}' not allowed: allow list is empty"
+ )
+
+ for pattern in allowed_domains:
+ if _matches_domain(domain, pattern):
+ return # Allowed
+
+ raise DomainNotAllowedError(
+ f"Domain '{domain}' does not match any allowed pattern"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #97 — Port-Based Filtering
+# ---------------------------------------------------------------------------
+
+
+def check_port_allowed(port: int, allowed_ports: list[int]) -> None:
+ """Raise ``PortNotAllowedError`` if *port* is not in the allowed set.
+
+ An empty allowed_ports list means **no ports are permitted**.
+ Port 0 is never allowed (reserved).
+ """
+ if port <= 0 or port > 65535:
+ raise PortNotAllowedError(f"Invalid port number: {port}")
+ if not allowed_ports:
+ raise PortNotAllowedError(
+ f"Port {port} not allowed: allowed ports list is empty"
+ )
+ if port not in allowed_ports:
+ raise PortNotAllowedError(
+ f"Port {port} not in allowed ports: {allowed_ports}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #96 — Connection Tracking
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class ConnectionRecord:
+ """Metadata for an active outbound connection."""
+
+ connection_id: str
+ domain: str
+ port: int
+ opened_at: float = field(default_factory=time.monotonic)
+
+
+class ConnectionTracker:
+ """Thread-safe concurrent connection tracker with configurable limit.
+
+ Uses ``asyncio.Lock`` for coroutine safety. All public methods are async.
+ """
+
+ def __init__(self, max_connections: int) -> None:
+ if max_connections < 0:
+ raise ValueError(f"max_connections must be >= 0, got {max_connections}")
+ self._max = max_connections
+ self._active: dict[str, ConnectionRecord] = {}
+ self._lock = asyncio.Lock()
+ self._counter = 0
+
+ @property
+ def max_connections(self) -> int:
+ return self._max
+
+ @property
+ def active_count(self) -> int:
+ return len(self._active)
+
+ async def acquire(self, domain: str, port: int) -> str:
+ """Register a new connection. Returns a unique connection ID.
+
+ Raises ``ConnectionLimitError`` if the limit would be exceeded.
+ """
+ async with self._lock:
+ if len(self._active) >= self._max:
+ raise ConnectionLimitError(
+ f"Connection limit reached ({self._max}). "
+ f"Active: {len(self._active)}"
+ )
+ self._counter += 1
+ conn_id = f"conn-{self._counter}"
+ self._active[conn_id] = ConnectionRecord(
+ connection_id=conn_id,
+ domain=domain,
+ port=port,
+ )
+ return conn_id
+
+ async def release(self, connection_id: str) -> None:
+ """Release a connection by ID.
+
+ Raises ``ConnectionNotFoundError`` if the ID is not active.
+ """
+ async with self._lock:
+ if connection_id not in self._active:
+ raise ConnectionNotFoundError(
+ f"Connection '{connection_id}' not found in active set"
+ )
+ del self._active[connection_id]
+
+ async def list_active(self) -> list[ConnectionRecord]:
+ """Return a snapshot of all active connections."""
+ async with self._lock:
+ return list(self._active.values())
+
+ async def release_all(self) -> int:
+ """Release all active connections. Returns the count released."""
+ async with self._lock:
+ count = len(self._active)
+ self._active.clear()
+ return count
+
+
+# ---------------------------------------------------------------------------
+# #98 — Network Proxy Configuration + Lifecycle
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class NetworkProxyConfig:
+ """Immutable network proxy configuration derived from a NetworkCapability.
+
+ Constructed from a :class:`NetworkCapability` lease grant. The proxy
+ enforces all policy constraints: outbound enable/disable, domain
+ allow/deny, port filtering, and connection limits.
+ """
+
+ outbound_enabled: bool
+ allowed_domains: tuple[str, ...]
+ blocked_domains: tuple[str, ...]
+ allowed_ports: tuple[int, ...]
+ max_connections: int
+
+ @classmethod
+ def from_capability(cls, cap: NetworkCapability) -> "NetworkProxyConfig":
+ """Create a proxy config from a NetworkCapability.
+
+ If outbound is disabled, allowed_domains is forced empty to prevent
+ the deferred EPIC 2.1 R5 finding (non-empty allowed_domains with
+ outbound_enabled=False leaking into policy).
+ """
+ allowed = cap.allowed_domains if cap.outbound_enabled else []
+ return cls(
+ outbound_enabled=cap.outbound_enabled,
+ allowed_domains=tuple(allowed),
+ blocked_domains=tuple(cap.blocked_domains),
+ allowed_ports=tuple(cap.allowed_ports),
+ max_connections=cap.max_connections,
+ )
+
+
+class NetworkProxy:
+ """Policy-enforced network proxy for sandboxed skill execution.
+
+ Combines outbound gating, domain allow/deny, port filtering, and
+ concurrent connection tracking into a single entry point.
+
+ Usage::
+
+ proxy = NetworkProxy(config)
+ conn_id = await proxy.connect("pypi.org", 443)
+ # ... use connection ...
+ await proxy.disconnect(conn_id)
+ await proxy.shutdown()
+ """
+
+ def __init__(self, config: NetworkProxyConfig) -> None:
+ self._config = config
+ self._tracker = ConnectionTracker(config.max_connections)
+ self._shutdown = False
+ self._lifecycle_lock = asyncio.Lock()
+
+ @property
+ def config(self) -> NetworkProxyConfig:
+ return self._config
+
+ @property
+ def active_connections(self) -> int:
+ return self._tracker.active_count
+
+ def check_request(self, domain: str, port: int) -> None:
+ """Validate an outbound request against the full policy stack.
+
+ Checks are applied in order (fail-fast):
+ 1. Outbound enabled?
+ 2. Domain not blocked? (deny-before-allow)
+ 3. Domain allowed?
+ 4. Port allowed?
+
+ Does NOT acquire a connection slot — use :meth:`connect` for that.
+ """
+ if self._shutdown:
+ raise NetworkPolicyError("Proxy has been shut down")
+
+ if not self._config.outbound_enabled:
+ raise OutboundDisabledError(
+ "Outbound networking is disabled for this sandbox"
+ )
+
+ check_domain_blocked(domain, list(self._config.blocked_domains))
+ check_domain_allowed(domain, list(self._config.allowed_domains))
+ check_port_allowed(port, list(self._config.allowed_ports))
+
+ async def connect(self, domain: str, port: int) -> str:
+ """Validate policy and acquire a connection slot atomically.
+
+ The policy check and slot acquisition are performed under a shared
+ lifecycle lock to prevent races with :meth:`shutdown`.
+
+ Returns a connection ID for later release via :meth:`disconnect`.
+ Raises on policy violation, connection limit, or post-shutdown.
+ """
+ async with self._lifecycle_lock:
+ self.check_request(domain, port)
+ return await self._tracker.acquire(domain, port)
+
+ async def disconnect(self, connection_id: str) -> None:
+ """Release a connection slot."""
+ await self._tracker.release(connection_id)
+
+ async def list_connections(self) -> list[ConnectionRecord]:
+ """Return a snapshot of all active connections."""
+ return await self._tracker.list_active()
+
+ async def shutdown(self) -> int:
+ """Shut down the proxy, releasing all connections.
+
+ Acquires the lifecycle lock to prevent races with in-flight
+ :meth:`connect` calls. After shutdown, all further requests
+ are rejected.
+
+ Returns the number of connections released.
+ """
+ async with self._lifecycle_lock:
+ self._shutdown = True
+ return await self._tracker.release_all()
diff --git a/openspace/sandbox/process_broker.py b/openspace/sandbox/process_broker.py
new file mode 100644
index 00000000..d51a42c2
--- /dev/null
+++ b/openspace/sandbox/process_broker.py
@@ -0,0 +1,676 @@
+"""Process broker — policy-enforced process execution control.
+
+EPIC 2.4 — Process Broker
+
+Issues:
+- #99: Command allow/deny enforcement with blocked-command safety invariant
+- #100: Shell invocation control (bash/sh/cmd/powershell restriction)
+- #101: Process tracking with concurrency limits and execution time bounds
+- #102: Dangerous syscall restriction (link, symlink, hardlink, mount)
+- #103: ProcessBrokerPort integration and config-from-capability pattern
+"""
+
+from __future__ import annotations
+
+import fnmatch
+import os
+import time
+import threading
+from dataclasses import dataclass, field
+from pathlib import PurePosixPath, PureWindowsPath
+from typing import Optional
+
+from openspace.sandbox.leases import ProcessCapability, REQUIRED_BLOCKED_COMMANDS
+
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+# Shells that must be blocked when allow_shell=False
+_SHELL_BINARIES: frozenset[str] = frozenset({
+ "sh", "bash", "dash", "zsh", "fish", "csh", "tcsh", "ksh",
+ "ash", "rbash", "rksh",
+ "cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
+})
+
+# Commands that can invoke shells indirectly (shell wrappers).
+# When allow_shell=False, these must be checked for shell args.
+_SHELL_WRAPPERS: frozenset[str] = frozenset({
+ "env", "busybox", "xargs", "script", "nohup", "strace", "ltrace",
+ "nice", "ionice", "taskset", "timeout", "chrt", "setsid",
+ "sudo", "su", "doas", "runuser",
+ "time", "stdbuf", "chpst", "softlimit", "watch", "exec",
+ "find", "parallel", # -exec / ::: can invoke arbitrary commands
+ "flock", # flock /tmp/lock
+})
+
+# Syscalls that can create hard-link / symlink escapes from filesystem jails.
+# EPIC 2.2 deferred this to 2.4: "same-device hard links created by a
+# compromised process [...] can bypass path-based checks. This is mitigated
+# by the sandbox process broker (EPIC 2.4) restricting link/symlink syscalls."
+_DANGEROUS_SYSCALLS: frozenset[str] = frozenset({
+ "link", "linkat",
+ "symlink", "symlinkat",
+ "rename", "renameat", "renameat2",
+ "mount", "umount", "umount2",
+ "pivot_root", "chroot",
+ "mknod", "mknodat",
+})
+
+# Commands that create links (user-space equivalents of dangerous syscalls)
+_LINK_COMMANDS: frozenset[str] = frozenset({
+ "ln", "link", "mklink",
+ "mount", "umount", "fusermount",
+ "mknod",
+})
+
+# Wrapper flags that implicitly spawn a shell (must be checked when allow_shell=False)
+_SHELL_INVOKING_FLAGS: dict[str, frozenset[str]] = {
+ "sudo": frozenset({"-s", "--shell", "-i", "--login"}),
+ "su": frozenset({"-", "-l", "--login", "-s", "--shell", "-c", "--command"}),
+ "doas": frozenset({"-s"}),
+ "runuser": frozenset({"-l", "--login", "-s", "--shell", "-c", "--command"}),
+}
+
+
+# ---------------------------------------------------------------------------
+# Exceptions
+# ---------------------------------------------------------------------------
+
+
+class ProcessPolicyError(Exception):
+ """Base for all process broker policy violations."""
+
+
+class CommandBlockedError(ProcessPolicyError):
+ """Raised when a command is on the block list."""
+
+
+class CommandNotAllowedError(ProcessPolicyError):
+ """Raised when a command is not on the allow list."""
+
+
+class ShellNotAllowedError(ProcessPolicyError):
+ """Raised when shell invocation is disabled."""
+
+
+class ProcessLimitError(ProcessPolicyError):
+ """Raised when process concurrency limit is reached."""
+
+
+class SyscallBlockedError(ProcessPolicyError):
+ """Raised when a dangerous syscall is attempted."""
+
+
+class ExecutionTimeoutError(ProcessPolicyError):
+ """Raised when a process exceeds its execution time limit."""
+
+
+# ---------------------------------------------------------------------------
+# #99 — Command Allow/Deny Enforcement
+# ---------------------------------------------------------------------------
+
+
+def _extract_arg_tokens(args: list[str]) -> list[str]:
+ """Extract all command-like tokens from an argument list.
+
+ Handles:
+ - Plain args: ``["bash"]`` → ``["bash"]``
+ - Flag values: ``["-Sbash"]`` → ``["bash"]`` (strip single-char flag)
+ - Long flag values: ``["--split-string=bash -c id"]`` → ``["bash", "-c", "id"]``
+ - Env-var assignments: ``["FOO=bar"]`` → skipped (not commands)
+ - Space-concatenated: ``["sh -c id"]`` → ``["sh", "-c", "id"]``
+ - Sentinel ``--``: subsequent args treated as positional (no skipping)
+ """
+ tokens: list[str] = []
+ past_sentinel = False
+ for arg in args:
+ if arg == "--" and not past_sentinel:
+ past_sentinel = True
+ continue
+
+ if past_sentinel:
+ # After --, everything is positional (no flag/env-var skipping)
+ tokens.extend(arg.split())
+ elif arg.startswith("--") and "=" in arg:
+ # --flag=value → extract value, split on spaces
+ _, _, value = arg.partition("=")
+ tokens.extend(value.split())
+ elif arg.startswith("-") and len(arg) > 2 and not arg.startswith("--"):
+ # -Svalue → extract value after single-char flag (e.g., -Sbash)
+ value = arg[2:]
+ tokens.extend(value.split())
+ elif arg.startswith("-"):
+ # Plain flag like -c, --verbose → skip
+ continue
+ elif "=" in arg and not arg.startswith("/") and not arg.startswith("."):
+ # ENV_VAR=value → skip entirely (not a command invocation)
+ continue
+ else:
+ # Plain arg — split on spaces for concatenated commands
+ tokens.extend(arg.split())
+
+ return tokens
+
+
+def _extract_basename(command: str) -> str:
+ """Extract the command basename from a full path or bare command.
+
+ Handles both Unix and Windows paths, strips quotes, and normalizes
+ .exe extensions. Returns lowercase for case-insensitive matching.
+ """
+ # Strip surrounding quotes (single or double)
+ cleaned = command.strip().strip("'\"")
+
+ # Handle Windows paths first (backslash), then Unix
+ if "\\" in cleaned:
+ name = PureWindowsPath(cleaned).name
+ if name:
+ return name.lower()
+ if "/" in cleaned:
+ name = PurePosixPath(cleaned).name
+ if name:
+ return name.lower()
+ return cleaned.lower()
+
+
+def _strip_exe(basename: str) -> str:
+ """Strip all trailing .exe extensions for cross-platform command matching."""
+ while basename.endswith(".exe"):
+ basename = basename[:-4]
+ return basename
+
+
+def check_command_blocked(command: str, blocked_commands: list[str]) -> None:
+ """Raise ``CommandBlockedError`` if *command* matches any blocked pattern.
+
+ Always checks against REQUIRED_BLOCKED_COMMANDS regardless of the
+ provided list. Matching is case-insensitive, supports fnmatch globs,
+ extracts basenames from full paths, and strips .exe extensions for
+ cross-platform consistency (rm.exe → rm).
+ """
+ basename = _extract_basename(command)
+ basename_no_exe = _strip_exe(basename)
+
+ # Merge explicit + required blocks
+ all_blocked = set(b.lower() for b in blocked_commands) | {
+ r.lower() for r in REQUIRED_BLOCKED_COMMANDS
+ }
+
+ for pattern in all_blocked:
+ pattern_no_exe = _strip_exe(pattern)
+ # Match both with and without .exe
+ if (
+ fnmatch.fnmatch(basename, pattern)
+ or fnmatch.fnmatch(basename_no_exe, pattern)
+ or fnmatch.fnmatch(basename, pattern_no_exe)
+ or fnmatch.fnmatch(basename_no_exe, pattern_no_exe)
+ ):
+ raise CommandBlockedError(
+ f"Command '{command}' (basename '{basename}') is blocked "
+ f"by pattern '{pattern}'"
+ )
+
+
+def check_command_allowed(
+ command: str, allowed_commands: list[str]
+) -> None:
+ """Raise ``CommandNotAllowedError`` if *command* is not in the allow list.
+
+ An empty allow list means **all non-blocked commands** are permitted
+ (open policy). A non-empty list enforces an allowlist (closed policy).
+ Matching is case-insensitive and supports fnmatch globs.
+ """
+ if not allowed_commands:
+ return # Empty allowlist = open policy (block list still applies)
+
+ basename = _extract_basename(command)
+ basename_no_exe = _strip_exe(basename)
+
+ for pattern in allowed_commands:
+ pat = pattern.lower()
+ if fnmatch.fnmatch(basename, pat) or fnmatch.fnmatch(basename_no_exe, pat):
+ return # Allowed
+
+ raise CommandNotAllowedError(
+ f"Command '{command}' (basename '{basename}') not in allowed list"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #100 — Shell Invocation Control
+# ---------------------------------------------------------------------------
+
+
+def check_shell_allowed(
+ command: str, allow_shell: bool, args: list[str] | None = None
+) -> None:
+ """Raise ``ShellNotAllowedError`` if shell invocation is disabled.
+
+ Detects shell binaries by basename matching against ``_SHELL_BINARIES``.
+ Also detects shell wrappers (env, busybox, sudo, etc.) that invoke
+ shells via their arguments.
+ """
+ if allow_shell:
+ return # Shells permitted
+
+ basename = _extract_basename(command)
+ basename_no_exe = _strip_exe(basename)
+
+ if basename in _SHELL_BINARIES or basename_no_exe in _SHELL_BINARIES:
+ raise ShellNotAllowedError(
+ f"Shell invocation via '{command}' is not allowed "
+ f"(allow_shell=False)"
+ )
+
+ # Check shell wrappers: env bash, busybox sh, sudo bash, etc.
+ if basename in _SHELL_WRAPPERS or basename_no_exe in _SHELL_WRAPPERS:
+ if args:
+ for token in _extract_arg_tokens(args):
+ token_basename = _extract_basename(token)
+ token_no_exe = _strip_exe(token_basename)
+ if token_basename in _SHELL_BINARIES or token_no_exe in _SHELL_BINARIES:
+ raise ShellNotAllowedError(
+ f"Shell invocation via wrapper '{command}' with "
+ f"shell '{token}' is not allowed (allow_shell=False)"
+ )
+
+
+def check_shell_command(
+ shell_command: str, allow_shell: bool
+) -> None:
+ """Raise ``ShellNotAllowedError`` if shell command execution is disabled.
+
+ This checks the content of a shell command string (e.g., passed to
+ ``subprocess.run(cmd, shell=True)``). When ``allow_shell`` is False,
+ all shell commands are rejected.
+ """
+ if allow_shell:
+ return
+
+ raise ShellNotAllowedError(
+ f"Shell command execution is not allowed (allow_shell=False): "
+ f"'{shell_command[:100]}'"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #102 — Dangerous Syscall Restriction
+# ---------------------------------------------------------------------------
+
+
+def check_syscall_allowed(syscall: str, *args: str) -> None:
+ """Raise ``SyscallBlockedError`` if *syscall* is dangerous.
+
+ Blocks link/symlink/rename/mount/chroot/mknod syscalls that can
+ create jail escapes (deferred from EPIC 2.2 filesystem broker).
+ """
+ if syscall.lower() in _DANGEROUS_SYSCALLS:
+ raise SyscallBlockedError(
+ f"Syscall '{syscall}' is blocked (jail escape risk). "
+ f"Args: {args[:5]}"
+ )
+
+
+def check_link_command(command: str) -> None:
+ """Raise ``SyscallBlockedError`` if command creates links.
+
+ Blocks user-space commands that create hard/symbolic links,
+ which can bypass filesystem jail path-based checks.
+ Strips .exe extension for cross-platform matching.
+ """
+ basename = _extract_basename(command)
+ basename_no_exe = _strip_exe(basename)
+
+ if basename in _LINK_COMMANDS or basename_no_exe in _LINK_COMMANDS:
+ raise SyscallBlockedError(
+ f"Command '{command}' (basename '{basename}') creates links "
+ f"and is blocked to prevent jail escapes"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #101 — Process Tracking
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class ProcessRecord:
+ """Record of a tracked process."""
+
+ pid: int
+ command: str
+ start_time: float = field(default_factory=time.monotonic)
+ terminated: bool = False
+
+
+@dataclass
+class ProcessTracker:
+ """Thread-safe process tracker with concurrency and time limits.
+
+ Enforces:
+ - Maximum concurrent processes (``max_processes``)
+ - Maximum execution time per process (``max_execution_time_s``)
+ """
+
+ max_processes: int
+ max_execution_time_s: int
+ _processes: dict[int, ProcessRecord] = field(default_factory=dict)
+ _lock: threading.Lock = field(default_factory=threading.Lock)
+
+ def track(self, pid: int, command: str) -> None:
+ """Register a new process. Raises ``ProcessLimitError`` if at limit."""
+ with self._lock:
+ # Clean up terminated processes
+ self._cleanup_locked()
+
+ active = sum(1 for p in self._processes.values() if not p.terminated)
+ if active >= self.max_processes:
+ raise ProcessLimitError(
+ f"Process limit reached ({active}/{self.max_processes}). "
+ f"Cannot track pid={pid} command='{command}'"
+ )
+
+ if pid in self._processes and not self._processes[pid].terminated:
+ raise ProcessLimitError(
+ f"Process pid={pid} is already tracked and active"
+ )
+
+ self._processes[pid] = ProcessRecord(pid=pid, command=command)
+
+ def release(self, pid: int) -> None:
+ """Mark a process as terminated."""
+ with self._lock:
+ if pid in self._processes:
+ self._processes[pid].terminated = True
+
+ def check_timeout(self, pid: int) -> None:
+ """Raise ``ExecutionTimeoutError`` if process has exceeded time limit."""
+ with self._lock:
+ record = self._processes.get(pid)
+ if record is None:
+ return
+
+ elapsed = time.monotonic() - record.start_time
+ if elapsed > self.max_execution_time_s:
+ record.terminated = True
+ raise ExecutionTimeoutError(
+ f"Process pid={pid} command='{record.command}' exceeded "
+ f"time limit ({elapsed:.1f}s > {self.max_execution_time_s}s)"
+ )
+
+ def check_all_timeouts(self) -> list[int]:
+ """Check all active processes for timeouts. Returns list of timed-out PIDs."""
+ timed_out: list[int] = []
+ with self._lock:
+ now = time.monotonic()
+ for pid, record in self._processes.items():
+ if record.terminated:
+ continue
+ elapsed = now - record.start_time
+ if elapsed > self.max_execution_time_s:
+ record.terminated = True
+ timed_out.append(pid)
+ return timed_out
+
+ @property
+ def active_count(self) -> int:
+ """Number of active (non-terminated) processes."""
+ with self._lock:
+ self._cleanup_locked()
+ return sum(1 for p in self._processes.values() if not p.terminated)
+
+ def list_processes(self) -> list[ProcessRecord]:
+ """Return snapshot of all tracked processes (defensive copies)."""
+ with self._lock:
+ return [
+ ProcessRecord(
+ pid=p.pid,
+ command=p.command,
+ start_time=p.start_time,
+ terminated=p.terminated,
+ )
+ for p in self._processes.values()
+ ]
+
+ def _cleanup_locked(self) -> None:
+ """Remove terminated processes older than 60 seconds (must hold lock)."""
+ now = time.monotonic()
+ stale = [
+ pid
+ for pid, rec in self._processes.items()
+ if rec.terminated and (now - rec.start_time) > 60.0
+ ]
+ for pid in stale:
+ del self._processes[pid]
+
+
+# ---------------------------------------------------------------------------
+# Config + Broker (combines all enforcement)
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class ProcessBrokerConfig:
+ """Immutable configuration derived from a ``ProcessCapability``."""
+
+ allowed_commands: tuple[str, ...]
+ blocked_commands: tuple[str, ...]
+ max_processes: int
+ max_execution_time_s: int
+ allow_shell: bool
+
+ @classmethod
+ def from_capability(cls, cap: ProcessCapability) -> "ProcessBrokerConfig":
+ """Create config from a ProcessCapability, enforcing safety invariants."""
+ # Merge link commands into blocked set
+ merged_blocked = set(cap.blocked_commands) | _LINK_COMMANDS | REQUIRED_BLOCKED_COMMANDS
+ return cls(
+ allowed_commands=tuple(cap.allowed_commands),
+ blocked_commands=tuple(sorted(merged_blocked)),
+ max_processes=cap.max_processes,
+ max_execution_time_s=cap.max_execution_time_s,
+ allow_shell=cap.allow_shell,
+ )
+
+
+class ProcessBroker:
+ """Policy-enforced process execution broker.
+
+ Combines command allow/deny, shell control, process tracking,
+ execution time limits, and dangerous syscall/command blocking.
+
+ **Usage pattern** (follows FilesystemBroker / NetworkProxy)::
+
+ config = ProcessBrokerConfig.from_capability(lease.process)
+ broker = ProcessBroker(config)
+
+ # Before exec:
+ broker.check_command("python", ["script.py"])
+ broker.track_process(pid, "python")
+
+ # After completion:
+ broker.release_process(pid)
+ """
+
+ def __init__(self, config: ProcessBrokerConfig) -> None:
+ self._config = config
+ self._tracker = ProcessTracker(
+ max_processes=config.max_processes,
+ max_execution_time_s=config.max_execution_time_s,
+ )
+
+ @property
+ def config(self) -> ProcessBrokerConfig:
+ return self._config
+
+ def check_command(self, command: str, args: list[str] | None = None) -> None:
+ """Full command validation: blocked → link-check → shell-check → allowlist.
+
+ Inspects both *command* and *args* to catch shell wrapper bypasses
+ (e.g., ``env bash -c "..."``). Uses ``_extract_arg_tokens`` to
+ parse flag values, env-var values, and concatenated args.
+ Raises appropriate policy errors if any check fails.
+ """
+ basename = _extract_basename(command)
+ basename_no_exe = _strip_exe(basename)
+ is_wrapper = basename in _SHELL_WRAPPERS or basename_no_exe in _SHELL_WRAPPERS
+
+ # 1. Block list (deny-before-allow, includes REQUIRED_BLOCKED_COMMANDS)
+ check_command_blocked(command, list(self._config.blocked_commands))
+
+ # 2. For wrappers: check arg tokens for blocked commands (e.g., env rm)
+ # Non-wrappers don't get arg scanning to avoid false positives
+ # (e.g., `git checkout rm` should not block on `rm` in args)
+ if is_wrapper and args:
+ for token in _extract_arg_tokens(args):
+ check_command_blocked(token, list(self._config.blocked_commands))
+ check_link_command(token)
+
+ # 3. Link/symlink command check (EPIC 2.2 deferred hardening)
+ check_link_command(command)
+
+ # 4. Shell binary check (with args inspection for wrappers)
+ check_shell_allowed(command, self._config.allow_shell, args)
+
+ # 4b. Shell-invoking wrapper flags (sudo -s, su -, doas -s)
+ if not self._config.allow_shell and is_wrapper and args:
+ shell_flags = _SHELL_INVOKING_FLAGS.get(basename_no_exe)
+ if shell_flags:
+ for arg in args:
+ stripped = arg.strip().strip("'\"")
+ if stripped in shell_flags:
+ raise ShellNotAllowedError(
+ f"Wrapper '{basename}' with flag '{stripped}' "
+ f"invokes a shell but allow_shell=False"
+ )
+ # Combined short flags: -si means -s + -i
+ if (
+ stripped.startswith("-")
+ and not stripped.startswith("--")
+ and len(stripped) > 2
+ ):
+ for flag in shell_flags:
+ if len(flag) == 2 and flag[1] in stripped[1:]:
+ raise ShellNotAllowedError(
+ f"Wrapper '{basename}' with combined flag "
+ f"'{stripped}' contains shell flag '{flag}' "
+ f"but allow_shell=False"
+ )
+
+ # 5. Allow list — for wrappers, also check the wrapped command
+ check_command_allowed(command, list(self._config.allowed_commands))
+ if is_wrapper and args and self._config.allowed_commands:
+ allowed = list(self._config.allowed_commands)
+
+ def _is_skippable(basename: str) -> bool:
+ """Return True if token should be skipped in allowlist scan."""
+ if not basename:
+ return True
+ if basename.isdigit():
+ return True
+ if basename in _SHELL_WRAPPERS:
+ return True
+ if all(c in ".{}+;:" for c in basename):
+ return True
+ # Glob patterns (find -name *.py) are not commands
+ if any(c in basename for c in "*?["):
+ return True
+ return False
+
+ # A) Scan raw args for first plain positional command
+ found_plain = False
+ for raw_arg in args:
+ token = raw_arg.strip().strip("'\"")
+ if not token or token.startswith("-"):
+ continue # flag
+ # Env-var assignment (KEY=value) — not a command
+ if "=" in token and not token.startswith("/") and not token.startswith("."):
+ continue
+ token_basename = _extract_basename(token)
+ if _is_skippable(token_basename):
+ continue
+ check_command_allowed(token, allowed)
+ found_plain = True
+ break # Only check the first actual command
+
+ # B) Scan flag-embedded values (--split-string=curl, -Scurl)
+ # Only runs if pass A didn't find any plain positional command.
+ if not found_plain:
+ for token in _extract_arg_tokens(args):
+ token_basename = _extract_basename(token)
+ if _is_skippable(token_basename):
+ continue
+ check_command_allowed(token, allowed)
+ break
+
+ # C) Multi-command wrappers: check commands after -exec/-execdir
+ # Catches: find . -exec echo ; -exec curl evil ;
+ _EXEC_FLAGS = frozenset({"-exec", "-execdir"})
+ _EXEC_TERMINATORS = frozenset({";", "+", "{}"})
+ in_exec = False
+ exec_checked_first = False
+ for raw_arg in args:
+ token = raw_arg.strip().strip("'\"")
+ if token in _EXEC_FLAGS:
+ in_exec = True
+ exec_checked_first = False
+ continue
+ if in_exec:
+ if token in _EXEC_TERMINATORS:
+ continue
+ if not exec_checked_first:
+ # First token after -exec is the command
+ token_basename = _extract_basename(token)
+ if not _is_skippable(token_basename):
+ check_command_allowed(token, allowed)
+ exec_checked_first = True
+
+ def check_shell(self, shell_command: str) -> None:
+ """Validate a shell command string (``shell=True`` invocation)."""
+ check_shell_command(shell_command, self._config.allow_shell)
+
+ def check_syscall(self, syscall: str, *args: str) -> None:
+ """Validate a syscall name against the dangerous syscall list."""
+ check_syscall_allowed(syscall, *args)
+
+ def track_process(self, pid: int, command: str) -> None:
+ """Register a process for tracking. Raises if at concurrency limit."""
+ self._tracker.track(pid, command)
+
+ def check_and_track(
+ self, pid: int, command: str, args: list[str] | None = None
+ ) -> None:
+ """Atomic command validation + process reservation.
+
+ Combines ``check_command()`` and ``track_process()`` to prevent
+ TOCTOU races where multiple workers validate concurrently and
+ then all attempt to track, exceeding ``max_processes``.
+ """
+ # Validate command first (stateless checks — can fail fast)
+ self.check_command(command, args)
+
+ # Reserve process slot atomically
+ self._tracker.track(pid, command)
+
+ def release_process(self, pid: int) -> None:
+ """Mark a tracked process as terminated."""
+ self._tracker.release(pid)
+
+ def check_timeout(self, pid: int) -> None:
+ """Check if a tracked process has exceeded its time limit."""
+ self._tracker.check_timeout(pid)
+
+ def check_all_timeouts(self) -> list[int]:
+ """Check all tracked processes for timeouts."""
+ return self._tracker.check_all_timeouts()
+
+ @property
+ def active_count(self) -> int:
+ """Number of currently active tracked processes."""
+ return self._tracker.active_count
+
+ def list_processes(self) -> list[ProcessRecord]:
+ """Return snapshot of tracked processes."""
+ return self._tracker.list_processes()
diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
new file mode 100644
index 00000000..46796502
--- /dev/null
+++ b/openspace/secret/__init__.py
@@ -0,0 +1 @@
+"""OpenSpace secret management module."""
diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
new file mode 100644
index 00000000..db739a16
--- /dev/null
+++ b/openspace/secret/broker.py
@@ -0,0 +1,665 @@
+"""Concrete SecretBrokerPort implementation — EPIC 2.6.
+
+Provides:
+- Scoped secret storage (task, session, global) with lease-based access control.
+- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
+- Thread-safe operations with bounded storage per scope.
+- Integration with SecretCapability from lease system and auth token scopes.
+
+Design decisions:
+- Fail-closed: missing capability or insufficient scope → deny.
+- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
+- Secrets are stored in-memory only — no persistence across restarts.
+- Scope hierarchy: task < session < global (each is independent namespace).
+- Revocation is immediate and irreversible within a session.
+
+Security requirements:
+- Callers MUST present a valid SecretCapability from their lease.
+- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
+- T0/T1 tiers cannot access secrets (enforced by lease validation).
+- Secret values are encrypted at rest in memory to resist heap inspection.
+
+Issues:
+- #52: SecretBrokerPort concrete implementation
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import os
+import secrets
+import threading
+import time
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional
+
+from openspace.sandbox.leases import SecretCapability
+
+
+# ---------------------------------------------------------------------------
+# Secret Scope Model
+# ---------------------------------------------------------------------------
+
+
+class SecretScope(str, Enum):
+ """Hierarchical secret scopes matching lease capability model."""
+
+ TASK = "task"
+ SESSION = "session"
+ GLOBAL = "global"
+
+
+# Scope hierarchy for validation (higher index = broader access)
+_SCOPE_ORDER: list[SecretScope] = [
+ SecretScope.TASK,
+ SecretScope.SESSION,
+ SecretScope.GLOBAL,
+]
+
+
+# ---------------------------------------------------------------------------
+# Exceptions
+# ---------------------------------------------------------------------------
+
+
+class SecretBrokerError(Exception):
+ """Base for all secret broker errors."""
+
+
+class SecretAccessDenied(SecretBrokerError):
+ """Caller lacks permission to access the requested secret."""
+
+
+class SecretNotFoundError(SecretBrokerError):
+ """Requested secret does not exist."""
+
+
+class SecretStoreFull(SecretBrokerError):
+ """Secret store has reached its capacity limit."""
+
+
+class SecretKeyInvalid(SecretBrokerError):
+ """Secret key is malformed or invalid."""
+
+
+class SecretValueTooLarge(SecretBrokerError):
+ """Secret value exceeds maximum allowed size."""
+
+
+# ---------------------------------------------------------------------------
+# At-Rest Encryption (no external crypto dependencies)
+# ---------------------------------------------------------------------------
+
+
+class _SecretEncryptor:
+ """XOR-based at-rest encryption using HMAC-derived key stream.
+
+ NOT a general-purpose cipher — this provides defense-in-depth against
+ heap inspection only. The real security boundary is access control
+ via capabilities and token scopes.
+ """
+
+ def __init__(self, master_key: bytes) -> None:
+ self._master_key = master_key
+
+ def encrypt(self, plaintext: str) -> bytes:
+ """Encrypt a secret value, returning nonce + ciphertext + HMAC tag.
+
+ Layout: nonce (16) || ciphertext (N) || hmac_tag (32)
+ Integrity is verified on decrypt (encrypt-then-MAC).
+ """
+ nonce = os.urandom(16)
+ plaintext_bytes = plaintext.encode("utf-8")
+ key_stream = self._derive_stream(nonce, len(plaintext_bytes))
+ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
+ # Encrypt-then-MAC: HMAC over nonce + ciphertext
+ tag = hmac.new(
+ self._master_key, nonce + ciphertext, hashlib.sha256,
+ ).digest()
+ return nonce + ciphertext + tag
+
+ _TAG_LEN = 32 # HMAC-SHA256 output length
+
+ def decrypt(self, data: bytes) -> str:
+ """Decrypt nonce + ciphertext + HMAC tag back to plaintext.
+
+ Raises SecretBrokerError if data is corrupt or tampered with.
+ """
+ # Minimum: 16 (nonce) + 0 (ciphertext can be empty) + 32 (tag)
+ if len(data) < 16 + self._TAG_LEN:
+ raise SecretBrokerError("Corrupt encrypted data")
+ nonce = data[:16]
+ ciphertext = data[16:-self._TAG_LEN]
+ stored_tag = data[-self._TAG_LEN:]
+ # Verify integrity before decryption
+ expected_tag = hmac.new(
+ self._master_key, nonce + ciphertext, hashlib.sha256,
+ ).digest()
+ if not hmac.compare_digest(stored_tag, expected_tag):
+ raise SecretBrokerError("Encrypted data integrity check failed")
+ key_stream = self._derive_stream(nonce, len(ciphertext))
+ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
+ return plaintext_bytes.decode("utf-8")
+
+ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
+ """Derive a key stream of given length from nonce + master key."""
+ stream = b""
+ counter = 0
+ while len(stream) < length:
+ block = hmac.new(
+ self._master_key,
+ nonce + counter.to_bytes(4, "big"),
+ hashlib.sha256,
+ ).digest()
+ stream += block
+ counter += 1
+ return stream[:length]
+
+
+# ---------------------------------------------------------------------------
+# Secret Entry
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class SecretEntry:
+ """Metadata and encrypted value for a stored secret."""
+
+ key: str
+ scope: SecretScope
+ encrypted_value: bytes
+ owner: str # subject that created the secret
+ created_at: float
+ expires_at: float | None = None # None = no expiry
+
+
+# ---------------------------------------------------------------------------
+# Secret Store (scoped, encrypted, bounded)
+# ---------------------------------------------------------------------------
+
+_MAX_KEY_LENGTH = 256
+_KEY_PATTERN_CHARS = frozenset(
+ "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789"
+ "-_./:"
+)
+
+
+def _validate_key(key: str) -> None:
+ """Validate a secret key name."""
+ if not key:
+ raise SecretKeyInvalid("Secret key cannot be empty")
+ if len(key) > _MAX_KEY_LENGTH:
+ raise SecretKeyInvalid(
+ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
+ )
+ invalid = set(key) - _KEY_PATTERN_CHARS
+ if invalid:
+ raise SecretKeyInvalid(
+ f"Secret key contains invalid characters: {sorted(invalid)}"
+ )
+
+
+class SecretStore:
+ """Thread-safe, encrypted, scoped secret storage.
+
+ Secrets are organized by scope (task/session/global) and encrypted
+ at rest using HMAC-derived key streams. Each scope has an
+ independent namespace and capacity limit.
+ """
+
+ MAX_SECRETS_PER_SCOPE = 1000
+ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
+
+ def __init__(self, encryption_key: bytes | None = None) -> None:
+ self._lock = threading.Lock()
+ self._encryptor = _SecretEncryptor(
+ encryption_key or secrets.token_bytes(32)
+ )
+ # scope -> key -> SecretEntry
+ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
+ scope: {} for scope in SecretScope
+ }
+
+ def put(
+ self,
+ key: str,
+ value: str,
+ *,
+ scope: SecretScope,
+ owner: str,
+ expires_at: float | None = None,
+ ) -> None:
+ """Store or update a secret.
+
+ Args:
+ key: Secret key name.
+ value: Plaintext secret value (encrypted before storage).
+ scope: Secret scope namespace.
+ owner: Subject identity of the caller.
+ expires_at: Optional epoch expiry.
+
+ Raises:
+ SecretKeyInvalid: If key is malformed.
+ SecretStoreFull: If the scope has reached capacity.
+ ValueError: If value exceeds maximum length.
+ """
+ _validate_key(key)
+ value_bytes_len = len(value.encode("utf-8"))
+ if value_bytes_len > self.MAX_VALUE_LENGTH:
+ raise SecretValueTooLarge(
+ f"Secret value ({value_bytes_len} bytes) exceeds "
+ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
+ )
+
+ encrypted = self._encryptor.encrypt(value)
+ entry = SecretEntry(
+ key=key,
+ scope=scope,
+ encrypted_value=encrypted,
+ owner=owner,
+ created_at=time.time(),
+ expires_at=expires_at,
+ )
+
+ with self._lock:
+ scope_store = self._store[scope]
+ if key not in scope_store:
+ # Purge expired entries before capacity check
+ now = time.time()
+ expired_keys = [
+ k for k, e in scope_store.items()
+ if e.expires_at is not None and e.expires_at <= now
+ ]
+ for k in expired_keys:
+ del scope_store[k]
+ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
+ raise SecretStoreFull(
+ f"Scope '{scope.value}' is full "
+ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
+ )
+ scope_store[key] = entry
+
+ def put_checked(
+ self,
+ key: str,
+ value: str,
+ *,
+ scope: SecretScope,
+ owner: str,
+ expires_at: float | None = None,
+ cap_limit: int,
+ ) -> None:
+ """Atomic put with capability-level count check.
+
+ Same as put(), but additionally enforces a per-capability secret
+ count limit *inside* the lock, eliminating TOCTOU races between
+ count() and put() at the broker layer.
+
+ Raises:
+ SecretAccessDenied: If cap_limit would be exceeded for new keys.
+ SecretStoreFull: If scope hard limit is reached.
+ """
+ _validate_key(key)
+ value_bytes_len = len(value.encode("utf-8"))
+ if value_bytes_len > self.MAX_VALUE_LENGTH:
+ raise SecretValueTooLarge(
+ f"Secret value ({value_bytes_len} bytes) exceeds "
+ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
+ )
+
+ encrypted = self._encryptor.encrypt(value)
+ entry = SecretEntry(
+ key=key,
+ scope=scope,
+ encrypted_value=encrypted,
+ owner=owner,
+ created_at=time.time(),
+ expires_at=expires_at,
+ )
+
+ with self._lock:
+ scope_store = self._store[scope]
+ now = time.time()
+ # Purge expired entries first to avoid ghost fullness
+ expired_keys = [
+ k for k, e in scope_store.items()
+ if e.expires_at is not None and e.expires_at <= now
+ ]
+ for k in expired_keys:
+ del scope_store[k]
+ # After purge, expired keys are gone — is_new is accurate
+ is_new = key not in scope_store
+ if is_new:
+ # Capability limit: count only secrets owned by this caller
+ owner_count = sum(
+ 1 for e in scope_store.values()
+ if e.owner == owner
+ )
+ if owner_count >= cap_limit:
+ raise SecretAccessDenied(
+ f"Would exceed max_secrets limit ({cap_limit}) "
+ f"for scope '{scope.value}'"
+ )
+ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
+ raise SecretStoreFull(
+ f"Scope '{scope.value}' is full "
+ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
+ )
+ else:
+ # Overwrite: only the owner can update their own secret
+ existing = scope_store[key]
+ if existing.owner != owner:
+ raise SecretAccessDenied(
+ f"Cannot overwrite secret owned by '{existing.owner}'"
+ )
+ scope_store[key] = entry
+
+ def get(self, key: str, *, scope: SecretScope) -> str | None:
+ """Retrieve and decrypt a secret value.
+
+ Returns None if the key does not exist or has expired.
+ Expired entries are lazily removed.
+ """
+ with self._lock:
+ entry = self._store[scope].get(key)
+ if entry is None:
+ return None
+ # Lazy expiry — consistent >= boundary (expired at exact expiry time)
+ if entry.expires_at is not None and time.time() >= entry.expires_at:
+ del self._store[scope][key]
+ return None
+ return self._encryptor.decrypt(entry.encrypted_value)
+
+ def get_entry(self, key: str, *, scope: SecretScope) -> SecretEntry | None:
+ """Retrieve a secret entry (metadata) without decrypting.
+
+ Returns None if not found or expired. Used by broker layer
+ for owner-based access control.
+ """
+ with self._lock:
+ entry = self._store[scope].get(key)
+ if entry is None:
+ return None
+ if entry.expires_at is not None and time.time() >= entry.expires_at:
+ del self._store[scope][key]
+ return None
+ return entry
+
+ def get_owned(
+ self, key: str, *, scope: SecretScope, owner: str,
+ ) -> str | None:
+ """Retrieve and decrypt a secret only if owned by the given owner.
+
+ Returns None if not found, expired, or owned by someone else.
+ """
+ with self._lock:
+ entry = self._store[scope].get(key)
+ if entry is None:
+ return None
+ if entry.expires_at is not None and time.time() >= entry.expires_at:
+ del self._store[scope][key]
+ return None
+ if entry.owner != owner:
+ return None
+ return self._encryptor.decrypt(entry.encrypted_value)
+
+ def delete(self, key: str, *, scope: SecretScope) -> bool:
+ """Delete a secret. Returns True if it existed."""
+ with self._lock:
+ return self._store[scope].pop(key, None) is not None
+
+ def delete_owned(
+ self, key: str, *, scope: SecretScope, owner: str,
+ ) -> bool:
+ """Delete a secret only if owned by the given owner."""
+ with self._lock:
+ entry = self._store[scope].get(key)
+ if entry is None:
+ return False
+ if entry.owner != owner:
+ return False
+ del self._store[scope][key]
+ return True
+
+ def list_keys(self, *, scope: SecretScope) -> list[str]:
+ """List all non-expired secret keys in a scope."""
+ now = time.time()
+ with self._lock:
+ result = []
+ expired = []
+ for k, entry in self._store[scope].items():
+ if entry.expires_at is not None and now >= entry.expires_at:
+ expired.append(k)
+ else:
+ result.append(k)
+ # Lazy cleanup
+ for k in expired:
+ del self._store[scope][k]
+ return sorted(result)
+
+ def list_keys_owned(self, *, scope: SecretScope, owner: str) -> list[str]:
+ """List non-expired secret keys in a scope owned by the given owner."""
+ now = time.time()
+ with self._lock:
+ result = []
+ expired = []
+ for k, entry in self._store[scope].items():
+ if entry.expires_at is not None and now >= entry.expires_at:
+ expired.append(k)
+ elif entry.owner == owner:
+ result.append(k)
+ for k in expired:
+ del self._store[scope][k]
+ return sorted(result)
+
+ def count(self, *, scope: SecretScope) -> int:
+ """Number of non-expired secrets in a scope."""
+ return len(self.list_keys(scope=scope))
+
+ def clear_scope(self, scope: SecretScope) -> int:
+ """Remove all secrets in a scope. Returns count removed."""
+ with self._lock:
+ count = len(self._store[scope])
+ self._store[scope].clear()
+ return count
+
+
+# ---------------------------------------------------------------------------
+# #52 — SecretBroker (concrete SecretBrokerPort implementation)
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class SecretBroker:
+ """Concrete implementation of SecretBrokerPort.
+
+ Enforces lease-based access control via SecretCapability and
+ integrates with the scoped SecretStore for encrypted storage.
+
+ Access control layers:
+ 1. Capability check: caller's SecretCapability from lease
+ 2. Scope check: requested scope must be in capability's allowed_scopes
+ 3. Key check: if allowed_keys is non-empty, key must be listed
+ 4. Count check: caller cannot exceed max_secrets from capability
+ 5. Value encryption: all values encrypted at rest
+
+ Security: ``owner`` is bound at construction by the container from the
+ authenticated session identity. It MUST NOT be caller-supplied — this
+ prevents quota bypass via owner rotation.
+
+ Args:
+ store: The backing secret store (shared across brokers).
+ default_capability: Fallback capability if none provided per-call.
+ owner: Authenticated identity bound by the container (not caller-supplied).
+ """
+
+ store: SecretStore = field(default_factory=SecretStore)
+ default_capability: SecretCapability = field(
+ default_factory=SecretCapability
+ )
+ owner: str = "system"
+
+ def _resolve_scope(self, scope: str) -> SecretScope:
+ """Parse and validate a scope string."""
+ try:
+ return SecretScope(scope)
+ except ValueError:
+ raise SecretAccessDenied(
+ f"Invalid scope '{scope}'. "
+ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
+ )
+
+ def _check_capability(
+ self,
+ capability: SecretCapability,
+ *,
+ scope: str,
+ key: str | None = None,
+ writing: bool = False,
+ ) -> SecretScope:
+ """Validate access against a SecretCapability.
+
+ Returns the validated SecretScope.
+
+ Raises:
+ SecretAccessDenied: If capability doesn't allow the operation.
+ """
+ # 1. Max secrets check (0 = no access at all)
+ if capability.max_secrets <= 0:
+ raise SecretAccessDenied("Capability grants no secret access")
+
+ # 2. Scope check
+ resolved_scope = self._resolve_scope(scope)
+ if scope not in capability.allowed_scopes:
+ raise SecretAccessDenied(
+ f"Scope '{scope}' not in allowed scopes: "
+ f"{capability.allowed_scopes}"
+ )
+
+ # 3. Key check — empty allowed_keys = unrestricted (all tiers use
+ # empty by default; non-empty means explicit whitelist)
+ if key is not None and capability.allowed_keys:
+ if key not in capability.allowed_keys:
+ raise SecretAccessDenied(
+ f"Key '{key}' not in allowed keys"
+ )
+
+ return resolved_scope
+
+ # --- SecretBrokerPort interface ---
+
+ async def get_secret(
+ self,
+ key: str,
+ *,
+ scope: str = "task",
+ capability: SecretCapability | None = None,
+ ) -> Optional[str]:
+ """Retrieve a secret value.
+
+ Args:
+ key: Secret key to retrieve.
+ scope: Secret scope namespace.
+ capability: Caller's lease capability (uses default if None).
+
+ Returns:
+ Decrypted secret value, or None if not found.
+
+ Raises:
+ SecretAccessDenied: If capability doesn't permit access.
+ SecretKeyInvalid: If key is malformed.
+ """
+ _validate_key(key)
+ cap = capability or self.default_capability
+ resolved = self._check_capability(cap, scope=scope, key=key)
+ return self.store.get_owned(key, scope=resolved, owner=self.owner)
+
+ async def put_secret(
+ self,
+ key: str,
+ value: str,
+ *,
+ scope: str = "task",
+ capability: SecretCapability | None = None,
+ expires_at: float | None = None,
+ ) -> None:
+ """Store a secret value.
+
+ Args:
+ key: Secret key name.
+ value: Plaintext value to encrypt and store.
+ scope: Secret scope namespace.
+ capability: Caller's lease capability.
+ expires_at: Optional epoch expiry for the secret.
+
+ Raises:
+ SecretAccessDenied: If capability doesn't permit write.
+ SecretKeyInvalid: If key is malformed.
+ SecretStoreFull: If scope is at capacity.
+ """
+ _validate_key(key)
+ cap = capability or self.default_capability
+ resolved = self._check_capability(
+ cap, scope=scope, key=key, writing=True,
+ )
+
+ # Atomic put with capability count check inside the lock
+ # owner is bound at construction — not caller-supplied
+ self.store.put_checked(
+ key, value, scope=resolved, owner=self.owner,
+ expires_at=expires_at, cap_limit=cap.max_secrets,
+ )
+
+ async def revoke(
+ self,
+ key: str,
+ *,
+ scope: str = "task",
+ capability: SecretCapability | None = None,
+ ) -> bool:
+ """Revoke (delete) a secret.
+
+ Args:
+ key: Secret key to revoke.
+ scope: Secret scope namespace.
+ capability: Caller's lease capability.
+
+ Returns:
+ True if the secret existed and was deleted.
+ """
+ _validate_key(key)
+ cap = capability or self.default_capability
+ resolved = self._check_capability(cap, scope=scope, key=key)
+ return self.store.delete_owned(key, scope=resolved, owner=self.owner)
+
+ def list_available(
+ self,
+ *,
+ scope: str = "task",
+ capability: SecretCapability | None = None,
+ ) -> list[str]:
+ """List available secret keys in a scope.
+
+ Args:
+ scope: Secret scope namespace.
+ capability: Caller's lease capability.
+
+ Returns:
+ Sorted list of accessible key names.
+ """
+ cap = capability or self.default_capability
+ resolved = self._check_capability(cap, scope=scope)
+
+ all_keys = self.store.list_keys_owned(scope=resolved, owner=self.owner)
+
+ # Filter to allowed_keys if set
+ if cap.allowed_keys:
+ allowed = set(cap.allowed_keys)
+ return [k for k in all_keys if k in allowed]
+
+ return all_keys
diff --git a/openspace/security/__init__.py b/openspace/security/__init__.py
new file mode 100644
index 00000000..d7798f5b
--- /dev/null
+++ b/openspace/security/__init__.py
@@ -0,0 +1,59 @@
+"""OpenSpace Security — AST-based code safety scanning.
+
+Public API
+----------
+check_code_safety(source)
+ Pre-execution gate. Returns ``(is_safe, findings)``.
+
+scan_code / scan_file
+ Lower-level scanning functions.
+"""
+
+from __future__ import annotations
+
+from typing import List, Tuple
+
+from openspace.utils.logging import Logger
+from .ast_scanner import Finding, Severity, scan_code, scan_file # noqa: F401
+
+logger = Logger.get_logger(__name__)
+
+__all__ = [
+ "check_code_safety",
+ "scan_code",
+ "scan_file",
+ "Finding",
+ "Severity",
+]
+
+
+def check_code_safety(source: str) -> Tuple[bool, List[Finding]]:
+ """Scan *source* for dangerous APIs and decide whether execution is safe.
+
+ Returns ``(is_safe, findings)`` where *is_safe* is ``False`` when any
+ **CRITICAL** finding is present (code must be rejected).
+
+ **HIGH** findings are logged as warnings but do **not** block execution.
+ **MEDIUM** findings are informational only.
+ """
+ findings = scan_code(source)
+
+ for f in findings:
+ if f.severity == Severity.CRITICAL:
+ logger.warning(
+ "CRITICAL security finding — code REJECTED: [%s] %s (line %d)",
+ f.pattern_name, f.description, f.line,
+ )
+ elif f.severity == Severity.HIGH:
+ logger.warning(
+ "HIGH security finding: [%s] %s (line %d)",
+ f.pattern_name, f.description, f.line,
+ )
+ else:
+ logger.info(
+ "MEDIUM security finding: [%s] %s (line %d)",
+ f.pattern_name, f.description, f.line,
+ )
+
+ has_critical = any(f.severity == Severity.CRITICAL for f in findings)
+ return (not has_critical, findings)
diff --git a/openspace/security/ast_scanner.py b/openspace/security/ast_scanner.py
new file mode 100644
index 00000000..94acb8dc
--- /dev/null
+++ b/openspace/security/ast_scanner.py
@@ -0,0 +1,412 @@
+"""AST-based scanner for dangerous API usage in skill code.
+
+Walks a Python AST tree to detect calls to dangerous built-ins,
+OS-level APIs, subprocess invocations, dynamic imports, raw socket
+access, ctypes FFI, environment variable reads, sensitive file opens,
+and attribute-injection patterns.
+
+Patterns are loaded from ``blocklist.yml`` (shipped) and optionally
+extended with user-supplied blocklist files.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence
+
+from openspace.utils.logging import Logger
+
+logger = Logger.get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Data types
+# ---------------------------------------------------------------------------
+
+class Severity(str, Enum):
+ CRITICAL = "CRITICAL"
+ HIGH = "HIGH"
+ MEDIUM = "MEDIUM"
+
+
+@dataclass(frozen=True)
+class Finding:
+ """A single dangerous-API detection result."""
+ line: int
+ col: int
+ severity: Severity
+ pattern_name: str
+ description: str
+
+
+@dataclass
+class BlocklistPattern:
+ """One entry from the blocklist configuration."""
+ name: str
+ description: str
+ severity: Severity
+ ast_type: str # "Call", "Attribute", "Import"
+ targets: List[str]
+
+
+# ---------------------------------------------------------------------------
+# Blocklist loader (pure-stdlib, no PyYAML)
+# ---------------------------------------------------------------------------
+
+_BLOCKLIST_DIR = Path(__file__).resolve().parent
+
+
+def _parse_blocklist_yaml(text: str) -> List[Dict[str, Any]]:
+ """Minimal parser for the specific blocklist YAML structure.
+
+ Handles a top-level ``patterns:`` key containing a list of mappings
+ with scalar values and simple ``targets:`` sub-lists.
+
+ Bounds: max 500 patterns, max 100 targets per pattern.
+ """
+ MAX_PATTERNS = 500
+ MAX_TARGETS = 100
+
+ patterns: List[Dict[str, Any]] = []
+ current: Dict[str, Any] | None = None
+ in_targets = False
+ targets_list: List[str] = []
+
+ # Track indent of the pattern-level list dash (typically 2)
+ pattern_indent: int | None = None
+
+ for raw_line in text.splitlines():
+ stripped = raw_line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+
+ # Top-level key (e.g. "patterns:")
+ if stripped == "patterns:":
+ continue
+
+ indent = len(raw_line) - len(raw_line.lstrip())
+
+ # Sub-list item under targets (deeper indent)
+ if in_targets and stripped.startswith("- ") and (
+ pattern_indent is not None and indent > pattern_indent
+ ):
+ if len(targets_list) < MAX_TARGETS:
+ targets_list.append(stripped[2:].strip().strip("'\""))
+ continue
+
+ # New list item under patterns
+ if stripped.startswith("- "):
+ if pattern_indent is None:
+ pattern_indent = indent
+ if indent <= pattern_indent:
+ # Flush previous
+ if current is not None:
+ if in_targets:
+ current["targets"] = targets_list
+ patterns.append(current)
+ if len(patterns) >= MAX_PATTERNS:
+ break
+ current = {}
+ in_targets = False
+ targets_list = []
+ # Parse inline key on same line as dash
+ rest = stripped[2:].strip()
+ if ":" in rest:
+ k, v = rest.split(":", 1)
+ current[k.strip()] = v.strip()
+ continue
+
+ # Key inside a pattern mapping
+ if current is not None and ":" in stripped:
+ k, v = stripped.split(":", 1)
+ k = k.strip()
+ v = v.strip()
+ if k == "targets" and not v:
+ in_targets = True
+ targets_list = []
+ else:
+ in_targets = False
+ current[k] = v.strip("'\"")
+
+ # Flush last
+ if current is not None and len(patterns) < MAX_PATTERNS:
+ if in_targets:
+ current["targets"] = targets_list
+ patterns.append(current)
+
+ return patterns
+
+
+def load_blocklist(
+ extra_paths: Sequence[str | Path] | None = None,
+) -> List[BlocklistPattern]:
+ """Load dangerous-API patterns from the default blocklist and any extras.
+
+ FAIL-CLOSED: if the default blocklist cannot be loaded, raises
+ ``RuntimeError`` to prevent the scanner from running with no rules.
+ """
+ default_path = _BLOCKLIST_DIR / "blocklist.yml"
+ extra = [Path(p) for p in extra_paths] if extra_paths else []
+
+ results: List[BlocklistPattern] = []
+ seen_names: set[str] = set()
+
+ for idx, path in enumerate([default_path, *extra]):
+ is_default = idx == 0
+ if not path.exists():
+ if is_default:
+ raise RuntimeError(
+ f"Default blocklist not found: {path}. "
+ "Cannot scan without rules — refusing to run."
+ )
+ logger.warning("Blocklist file not found: %s", path)
+ continue
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ if is_default:
+ raise RuntimeError(
+ f"Cannot read default blocklist {path}: {exc}"
+ ) from exc
+ logger.warning("Cannot read blocklist %s: %s", path, exc)
+ continue
+
+ for entry in _parse_blocklist_yaml(text):
+ name = entry.get("name", "")
+ if not name or name in seen_names:
+ continue
+ seen_names.add(name)
+ try:
+ results.append(BlocklistPattern(
+ name=name,
+ description=entry.get("description", ""),
+ severity=Severity(entry.get("severity", "HIGH").upper()),
+ ast_type=entry.get("ast_type", "Call"),
+ targets=entry.get("targets", []),
+ ))
+ except (ValueError, KeyError) as exc:
+ logger.warning("Skipping malformed blocklist entry %r: %s", name, exc)
+
+ if not results:
+ raise RuntimeError(
+ "Blocklist loaded but produced zero patterns. "
+ "Cannot scan without rules — refusing to run."
+ )
+
+ return results
+
+
+# ---------------------------------------------------------------------------
+# AST Visitor
+# ---------------------------------------------------------------------------
+
+_SENSITIVE_PATH_RE = re.compile(r"^/(proc|etc|sys|dev)/")
+
+
+class DangerousAPIVisitor(ast.NodeVisitor):
+ """Walk a Python AST and collect :class:`Finding` objects for dangerous APIs."""
+
+ def __init__(self, patterns: List[BlocklistPattern] | None = None) -> None:
+ self.findings: List[Finding] = []
+ self._patterns = patterns or load_blocklist()
+
+ # Pre-index patterns by ast_type for O(1) lookup
+ self._call_targets: Dict[str, BlocklistPattern] = {}
+ self._attr_targets: Dict[str, BlocklistPattern] = {}
+ self._import_targets: Dict[str, BlocklistPattern] = {}
+
+ for pat in self._patterns:
+ bucket = {
+ "Call": self._call_targets,
+ "Attribute": self._attr_targets,
+ "Import": self._import_targets,
+ }.get(pat.ast_type)
+ if bucket is not None:
+ for t in pat.targets:
+ bucket[t] = pat
+
+ # -- helpers -------------------------------------------------------------
+
+ def _add(self, node: ast.AST, pat: BlocklistPattern, extra: str = "") -> None:
+ desc = pat.description
+ if extra:
+ desc = f"{desc} ({extra})"
+ self.findings.append(Finding(
+ line=getattr(node, "lineno", 0),
+ col=getattr(node, "col_offset", 0),
+ severity=pat.severity,
+ pattern_name=pat.name,
+ description=desc,
+ ))
+
+ @staticmethod
+ def _resolve_call_name(node: ast.Call) -> str | None:
+ """Return the dotted name of a Call node's function, or None."""
+ func = node.func
+ if isinstance(func, ast.Name):
+ return func.id
+ if isinstance(func, ast.Attribute):
+ parts: list[str] = [func.attr]
+ val = func.value
+ while isinstance(val, ast.Attribute):
+ parts.append(val.attr)
+ val = val.value
+ if isinstance(val, ast.Name):
+ parts.append(val.id)
+ return ".".join(reversed(parts))
+ return None
+
+ @staticmethod
+ def _resolve_attr_name(node: ast.Attribute) -> str | None:
+ """Return the dotted name of an Attribute node."""
+ parts: list[str] = [node.attr]
+ val = node.value
+ while isinstance(val, ast.Attribute):
+ parts.append(val.attr)
+ val = val.value
+ if isinstance(val, ast.Name):
+ parts.append(val.id)
+ return ".".join(reversed(parts))
+ return None
+
+ # -- visitors ------------------------------------------------------------
+
+ # Patterns that require argument inspection — skip in generic match
+ _CONTEXT_SENSITIVE = frozenset({"compile", "open"})
+
+ def visit_Call(self, node: ast.Call) -> None:
+ name = self._resolve_call_name(node)
+ if name:
+ # Direct call-target match (e.g. "eval", "os.system")
+ # Skip context-sensitive patterns that need argument checks
+ if name in self._call_targets and name not in self._CONTEXT_SENSITIVE:
+ self._add(node, self._call_targets[name], extra=name)
+
+ # Wildcard match (e.g. "subprocess.*" matches "subprocess.run")
+ prefix = name.split(".")[0]
+ wildcard_key = f"{prefix}.*"
+ if wildcard_key in self._call_targets and wildcard_key != name:
+ self._add(node, self._call_targets[wildcard_key], extra=name)
+
+ # compile() with 'exec' mode — positional or keyword
+ if name == "compile":
+ mode_val = None
+ if len(node.args) >= 3:
+ mode_arg = node.args[2]
+ if isinstance(mode_arg, ast.Constant):
+ mode_val = mode_arg.value
+ for kw in node.keywords:
+ if kw.arg == "mode" and isinstance(kw.value, ast.Constant):
+ mode_val = kw.value.value
+ if mode_val == "exec":
+ pat = self._call_targets.get("compile")
+ if pat:
+ self._add(node, pat, extra="compile(..., 'exec')")
+
+ # open() with sensitive paths — positional or keyword
+ if name == "open":
+ path_val = None
+ if node.args:
+ first_arg = node.args[0]
+ if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
+ path_val = first_arg.value
+ for kw in node.keywords:
+ if kw.arg == "file" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
+ path_val = kw.value.value
+ if path_val and _SENSITIVE_PATH_RE.match(path_val):
+ pat = self._call_targets.get("open")
+ if pat:
+ self._add(node, pat, extra=f"open('{path_val}')")
+
+ # getattr/setattr on modules (attribute injection)
+ if name in ("getattr", "setattr") and len(node.args) >= 2:
+ first_arg = node.args[0]
+ if isinstance(first_arg, ast.Name):
+ pat = self._call_targets.get(name)
+ if pat:
+ self._add(node, pat, extra=f"{name}() on '{first_arg.id}'")
+
+ self.generic_visit(node)
+
+ def visit_Attribute(self, node: ast.Attribute) -> None:
+ full_name = self._resolve_attr_name(node)
+ if full_name:
+ if full_name in self._attr_targets:
+ self._add(node, self._attr_targets[full_name], extra=full_name)
+
+ # Wildcard attribute match
+ prefix = full_name.split(".")[0]
+ wildcard_key = f"{prefix}.*"
+ if wildcard_key in self._attr_targets:
+ self._add(node, self._attr_targets[wildcard_key], extra=full_name)
+
+ self.generic_visit(node)
+
+ def visit_Import(self, node: ast.Import) -> None:
+ for alias in node.names:
+ if alias.name in self._import_targets:
+ self._add(node, self._import_targets[alias.name], extra=alias.name)
+ # Wildcard: "subprocess" matches "subprocess.*" import target
+ prefix = alias.name.split(".")[0]
+ wildcard = f"{prefix}.*"
+ if wildcard in self._import_targets:
+ self._add(node, self._import_targets[wildcard], extra=alias.name)
+ self.generic_visit(node)
+
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
+ module = node.module or ""
+ if module in self._import_targets:
+ self._add(node, self._import_targets[module], extra=module)
+ prefix = module.split(".")[0] if module else ""
+ wildcard = f"{prefix}.*"
+ if prefix and wildcard in self._import_targets:
+ self._add(node, self._import_targets[wildcard], extra=module)
+ self.generic_visit(node)
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def scan_code(
+ source_code: str,
+ extra_blocklists: Sequence[str | Path] | None = None,
+) -> List[Finding]:
+ """Parse *source_code* and return a list of dangerous-API findings."""
+ try:
+ tree = ast.parse(source_code)
+ except SyntaxError as exc:
+ return [Finding(
+ line=exc.lineno or 0,
+ col=exc.offset or 0,
+ severity=Severity.HIGH,
+ pattern_name="syntax_error",
+ description=f"Could not parse source: {exc.msg}",
+ )]
+
+ patterns = load_blocklist(extra_paths=extra_blocklists)
+ visitor = DangerousAPIVisitor(patterns=patterns)
+ visitor.visit(tree)
+ return visitor.findings
+
+
+def scan_file(
+ file_path: str | Path,
+ extra_blocklists: Sequence[str | Path] | None = None,
+) -> List[Finding]:
+ """Read a Python file and scan it for dangerous APIs."""
+ path = Path(file_path)
+ try:
+ source = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ return [Finding(
+ line=0,
+ col=0,
+ severity=Severity.HIGH,
+ pattern_name="file_read_error",
+ description=f"Cannot read file: {exc}",
+ )]
+ return scan_code(source, extra_blocklists=extra_blocklists)
diff --git a/openspace/security/blocklist.yml b/openspace/security/blocklist.yml
new file mode 100644
index 00000000..62a5f19d
--- /dev/null
+++ b/openspace/security/blocklist.yml
@@ -0,0 +1,177 @@
+# SkillGuard — Dangerous API Blocklist
+#
+# Each pattern defines an AST node type to match and a list of targets.
+# ast_type: Call — function/method calls
+# ast_type: Attribute — attribute access (without calling)
+# ast_type: Import — import / from-import statements
+#
+# severity: CRITICAL — code is rejected outright
+# severity: HIGH — warning logged, allowed for now
+# severity: MEDIUM — informational
+
+patterns:
+ - name: eval
+ description: "eval() can execute arbitrary code"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - eval
+
+ - name: exec
+ description: "exec() can execute arbitrary code"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - exec
+
+ - name: os_system
+ description: "os.system() executes shell commands"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - os.system
+
+ - name: os_popen
+ description: "os.popen() executes shell commands"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - os.popen
+
+ - name: subprocess
+ description: "subprocess module enables arbitrary command execution"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - subprocess.*
+
+ - name: subprocess_import
+ description: "Importing subprocess enables arbitrary command execution"
+ severity: HIGH
+ ast_type: Import
+ targets:
+ - subprocess.*
+
+ - name: dynamic_import
+ description: "__import__() allows dynamic, uncontrolled module loading"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - __import__
+
+ - name: socket
+ description: "Raw socket access can exfiltrate data or open back-doors"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - socket.*
+
+ - name: socket_import
+ description: "Importing socket enables raw network access"
+ severity: HIGH
+ ast_type: Import
+ targets:
+ - socket.*
+
+ - name: ctypes
+ description: "ctypes FFI can bypass Python's safety guarantees"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - ctypes.*
+
+ - name: ctypes_import
+ description: "Importing ctypes enables C FFI"
+ severity: HIGH
+ ast_type: Import
+ targets:
+ - ctypes.*
+
+ - name: env_access
+ description: "Environment variable access may leak secrets"
+ severity: HIGH
+ ast_type: Attribute
+ targets:
+ - os.environ
+
+ - name: env_getenv
+ description: "os.getenv() may leak secret environment variables"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - os.getenv
+
+ - name: env_environ_get
+ description: "os.environ.get() may leak secret environment variables"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - os.environ.get
+
+ - name: sensitive_file_open
+ description: "Opening /proc or /etc files may leak system information"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - open
+
+ - name: compile_exec
+ description: "compile() with 'exec' mode enables arbitrary code execution"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - compile
+
+ - name: getattr_injection
+ description: "getattr() on modules can access private/dangerous attributes"
+ severity: MEDIUM
+ ast_type: Call
+ targets:
+ - getattr
+
+ - name: setattr_injection
+ description: "setattr() on modules can inject malicious attributes"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - setattr
+
+ - name: importlib
+ description: "importlib.import_module() bypasses static import analysis"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - importlib.import_module
+ - importlib.*
+
+ - name: importlib_import
+ description: "Importing importlib enables dynamic module loading"
+ severity: HIGH
+ ast_type: Import
+ targets:
+ - importlib.*
+
+ - name: pickle
+ description: "pickle deserialization can execute arbitrary code"
+ severity: CRITICAL
+ ast_type: Call
+ targets:
+ - pickle.loads
+ - pickle.load
+ - pickle.Unpickler
+
+ - name: pickle_import
+ description: "Importing pickle enables arbitrary code execution via deserialization"
+ severity: HIGH
+ ast_type: Import
+ targets:
+ - pickle.*
+
+ - name: globals_access
+ description: "globals()/locals()/vars() enable dynamic name resolution bypasses"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - globals
+ - locals
+ - vars
diff --git a/openspace/security/env_filter.py b/openspace/security/env_filter.py
new file mode 100644
index 00000000..23688cb8
--- /dev/null
+++ b/openspace/security/env_filter.py
@@ -0,0 +1,65 @@
+"""Environment variable filtering for sandbox execution.
+
+Provides an explicit allowlist of environment variables that are safe to
+pass into an E2B sandbox. Everything else — API keys, tokens, database
+URLs, credentials — is stripped so that skill code running in the sandbox
+cannot exfiltrate host secrets.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Dict, FrozenSet
+
+__all__ = ["get_safe_env", "is_sensitive_key", "ENV_ALLOWLIST"]
+
+# ---------------------------------------------------------------------------
+# Allowlist — the ONLY env vars that may reach the sandbox
+# ---------------------------------------------------------------------------
+
+ENV_ALLOWLIST: FrozenSet[str] = frozenset({
+ "OPENSPACE_LOG_LEVEL",
+ "PATH",
+ "HOME",
+ "LANG",
+})
+
+# ---------------------------------------------------------------------------
+# Sensitive-key heuristic
+# ---------------------------------------------------------------------------
+
+_SENSITIVE_FRAGMENTS = re.compile(
+ r"TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH|PRIVATE"
+ r"|DATABASE_URL|DB_URL|CONNECTION_STRING"
+ r"|API_KEY|ACCESS_KEY|SIGNING"
+ r"|REDIS_URL|MONGO_URL|POSTGRES_URL|MYSQL_URL"
+ r"|CELERY_BROKER|SQLALCHEMY"
+ r"|DSN|SENTRY_DSN",
+ re.IGNORECASE,
+)
+
+# Some allowlisted keys contain sensitive fragments.
+# They are explicitly permitted and must not be flagged.
+_SENSITIVE_ALLOWLIST: FrozenSet[str] = frozenset()
+
+
+def is_sensitive_key(key: str) -> bool:
+ """Return ``True`` if *key* looks like it holds a secret.
+
+ Uses a regex heuristic that matches common naming conventions for
+ tokens, passwords, API keys, database URLs, etc.
+ """
+ if key in _SENSITIVE_ALLOWLIST:
+ return False
+ return bool(_SENSITIVE_FRAGMENTS.search(key))
+
+
+def get_safe_env() -> Dict[str, str]:
+ """Return a dict containing **only** allowlisted env vars.
+
+ Any variable whose name is not in :data:`ENV_ALLOWLIST` is dropped.
+ This is the environment dict that should be forwarded to the E2B
+ sandbox — nothing else.
+ """
+ return {k: v for k, v in os.environ.items() if k in ENV_ALLOWLIST}
diff --git a/openspace/skill_engine/analyzer.py b/openspace/skill_engine/analyzer.py
index fa5b74fa..2931dbc4 100644
--- a/openspace/skill_engine/analyzer.py
+++ b/openspace/skill_engine/analyzer.py
@@ -183,12 +183,6 @@ async def analyze_execution(
logger.debug(f"Analysis already exists for task {task_id}, skipping")
return existing
- try:
- from gdpval_bench.token_tracker import set_call_source, reset_call_source
- _src_tok = set_call_source("analyzer")
- except ImportError:
- _src_tok = None
-
try:
# 1. Load recording artifacts
context = self._load_recording_context(rec_path, execution_result)
@@ -232,9 +226,6 @@ async def analyze_execution(
except Exception as e:
logger.error(f"Execution analysis failed for task {task_id}: {e}")
return None
- finally:
- if _src_tok is not None:
- reset_call_source(_src_tok)
async def get_evolution_candidates(
self, limit: int = 20
diff --git a/openspace/skill_engine/evolver.py b/openspace/skill_engine/evolver.py
index ad9afdc3..7c533164 100644
--- a/openspace/skill_engine/evolver.py
+++ b/openspace/skill_engine/evolver.py
@@ -228,12 +228,6 @@ async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]:
The global semaphore is NOT acquired here — it is managed at the
trigger-method level so the concurrency limit covers the whole batch.
"""
- try:
- from gdpval_bench.token_tracker import set_call_source, reset_call_source
- _src_tok = set_call_source("evolver")
- except ImportError:
- _src_tok = None
-
evo_type = ctx.suggestion.evolution_type
try:
if evo_type == EvolutionType.FIX:
@@ -249,9 +243,6 @@ async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]:
targets = "+".join(ctx.suggestion.target_skill_ids) or "(new)"
logger.error(f"Evolution failed [{evo_type.value}] target={targets}: {e}")
return None
- finally:
- if _src_tok is not None:
- reset_call_source(_src_tok)
# Trigger 1: post-analysis
async def process_analysis(
@@ -577,12 +568,6 @@ async def _llm_confirm_evolution(
The confirmation prompt and response are recorded to
``conversations.jsonl`` under agent_name="SkillEvolver.confirm".
"""
- try:
- from gdpval_bench.token_tracker import set_call_source, reset_call_source
- _src_tok = set_call_source("evolver")
- except ImportError:
- _src_tok = None
-
from openspace.recording import RecordingManager
analysis_ctx = self._format_analysis_context(recent_analyses)
@@ -633,9 +618,6 @@ async def _llm_confirm_evolution(
except Exception as e:
logger.warning(f"LLM confirmation failed, defaulting to skip: {e}")
return False
- finally:
- if _src_tok is not None:
- reset_call_source(_src_tok)
@staticmethod
def _parse_confirmation(response: str) -> bool:
diff --git a/openspace/skill_engine/registry.py b/openspace/skill_engine/registry.py
index bb35dd8e..4ea4e744 100644
--- a/openspace/skill_engine/registry.py
+++ b/openspace/skill_engine/registry.py
@@ -453,12 +453,6 @@ async def select_skills_with_llm(
"prompt": prompt,
}
- try:
- from gdpval_bench.token_tracker import set_call_source, reset_call_source
- _src_tok = set_call_source("skill_select")
- except ImportError:
- _src_tok = None
-
try:
llm_kwargs = {}
if model:
@@ -498,9 +492,6 @@ async def select_skills_with_llm(
selection_record["method"] = "llm_failed"
selection_record["selected"] = []
return [], selection_record
- finally:
- if _src_tok is not None:
- reset_call_source(_src_tok)
def _prefilter_skills(
self,
diff --git a/openspace/tool_layer.py b/openspace/tool_layer.py
index 1ea419f3..2945348e 100644
--- a/openspace/tool_layer.py
+++ b/openspace/tool_layer.py
@@ -16,6 +16,7 @@
from openspace.skill_engine import SkillRegistry, ExecutionAnalyzer, SkillStore
from openspace.skill_engine.evolver import SkillEvolver
from openspace.utils.logging import Logger
+from openspace.app.container import AppContainer
logger = Logger.get_logger(__name__)
@@ -76,8 +77,48 @@ def __post_init__(self):
class OpenSpace:
- def __init__(self, config: Optional[OpenSpaceConfig] = None):
+ """High-level SkillGuard orchestration facade.
+
+ Supports two creation paths:
+
+ **Legacy** (backward-compatible)::
+
+ cs = OpenSpace(config=OpenSpaceConfig(...))
+ await cs.initialize()
+
+ **Container-based** (Phase 1 seam — Phase 4 wires initialize)::
+
+ container = await build_container(config, llm=..., ...)
+ cs = OpenSpace.from_container(container, config=config)
+ await cs.initialize() # still constructs services internally
+
+ .. warning::
+
+ In Phase 1, ``initialize()`` does **not** resolve services from
+ the container. The container is stored for use by callers that
+ need typed access (via public properties) and will be fully
+ wired in Phase 4. Do **not** assume that injecting services
+ into the container enforces them at runtime until Phase 4.
+
+ Parameters
+ ----------
+ config:
+ Application config. Defaults to ``OpenSpaceConfig()``.
+ container:
+ Optional :class:`AppContainer`. In Phase 1 the container is
+ stored for property access only — ``initialize()`` still
+ constructs services internally. Phase 4 will wire
+ ``initialize()`` to resolve services from the container.
+ """
+
+ def __init__(
+ self,
+ config: Optional[OpenSpaceConfig] = None,
+ *,
+ container: Optional[AppContainer] = None,
+ ):
self.config = config or OpenSpaceConfig()
+ self._container = container or AppContainer()
self._llm_client: Optional[LLMClient] = None
self._grounding_client: Optional[GroundingClient] = None
@@ -97,6 +138,62 @@ def __init__(self, config: Optional[OpenSpaceConfig] = None):
self._task_done.set() # Initially not running, so "done"
logger.debug("OpenSpace instance created")
+
+ # ── Factory methods ───────────────────────────────────────────────
+
+ @classmethod
+ def from_container(
+ cls,
+ container: AppContainer,
+ config: Optional[OpenSpaceConfig] = None,
+ ) -> "OpenSpace":
+ """Create an OpenSpace instance backed by an AppContainer.
+
+ .. note::
+
+ Phase 1 only stores the container for property access.
+ ``initialize()`` still constructs its own services.
+ Phase 4 will wire ``initialize()`` to resolve from the
+ container, making injected services authoritative.
+ """
+ return cls(config=config, container=container)
+
+ # ── Public property accessors (replace private field access) ──────
+
+ @property
+ def container(self) -> AppContainer:
+ """The underlying :class:`AppContainer`."""
+ return self._container
+
+ @property
+ def llm_client(self) -> Optional[LLMClient]:
+ """The LLM client, or ``None`` if not initialized."""
+ return self._llm_client
+
+ @property
+ def grounding_client(self) -> Optional[GroundingClient]:
+ """The grounding client, or ``None`` if not initialized."""
+ return self._grounding_client
+
+ @property
+ def grounding_config(self) -> Any:
+ """The grounding configuration object."""
+ return self._grounding_config
+
+ @property
+ def skill_registry(self) -> Optional[SkillRegistry]:
+ """The skill registry, or ``None`` if skills are disabled."""
+ return self._skill_registry
+
+ @property
+ def skill_store(self) -> Optional[SkillStore]:
+ """The skill persistence store, or ``None`` if not initialized."""
+ return self._skill_store
+
+ @property
+ def skill_evolver(self) -> Optional[SkillEvolver]:
+ """The skill evolution engine, or ``None`` if not initialized."""
+ return self._skill_evolver
async def initialize(self) -> None:
if self._initialized:
diff --git a/pr-473-diff-r1.txt b/pr-473-diff-r1.txt
new file mode 100644
index 00000000..02ba534c
--- /dev/null
+++ b/pr-473-diff-r1.txt
@@ -0,0 +1,1067 @@
+diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
+new file mode 100644
+index 0000000..4679650
+--- /dev/null
++++ b/openspace/secret/__init__.py
+@@ -0,0 +1 @@
++"""OpenSpace secret management module."""
+diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
+new file mode 100644
+index 0000000..31fb268
+--- /dev/null
++++ b/openspace/secret/broker.py
+@@ -0,0 +1,497 @@
++"""Concrete SecretBrokerPort implementation ΓÇö EPIC 2.6.
++
++Provides:
++- Scoped secret storage (task, session, global) with lease-based access control.
++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
++- Thread-safe operations with bounded storage per scope.
++- Integration with SecretCapability from lease system and auth token scopes.
++
++Design decisions:
++- Fail-closed: missing capability or insufficient scope → deny.
++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
++- Secrets are stored in-memory only ΓÇö no persistence across restarts.
++- Scope hierarchy: task < session < global (each is independent namespace).
++- Revocation is immediate and irreversible within a session.
++
++Security requirements:
++- Callers MUST present a valid SecretCapability from their lease.
++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
++- T0/T1 tiers cannot access secrets (enforced by lease validation).
++- Secret values are encrypted at rest in memory to resist heap inspection.
++
++Issues:
++- #52: SecretBrokerPort concrete implementation
++"""
++
++from __future__ import annotations
++
++import base64
++import hashlib
++import hmac
++import os
++import secrets
++import threading
++import time
++from dataclasses import dataclass, field
++from enum import Enum
++from typing import Optional
++
++from openspace.sandbox.leases import SecretCapability
++
++
++# ---------------------------------------------------------------------------
++# Secret Scope Model
++# ---------------------------------------------------------------------------
++
++
++class SecretScope(str, Enum):
++ """Hierarchical secret scopes matching lease capability model."""
++
++ TASK = "task"
++ SESSION = "session"
++ GLOBAL = "global"
++
++
++# Scope hierarchy for validation (higher index = broader access)
++_SCOPE_ORDER: list[SecretScope] = [
++ SecretScope.TASK,
++ SecretScope.SESSION,
++ SecretScope.GLOBAL,
++]
++
++
++# ---------------------------------------------------------------------------
++# Exceptions
++# ---------------------------------------------------------------------------
++
++
++class SecretBrokerError(Exception):
++ """Base for all secret broker errors."""
++
++
++class SecretAccessDenied(SecretBrokerError):
++ """Caller lacks permission to access the requested secret."""
++
++
++class SecretNotFoundError(SecretBrokerError):
++ """Requested secret does not exist."""
++
++
++class SecretStoreFull(SecretBrokerError):
++ """Secret store has reached its capacity limit."""
++
++
++class SecretKeyInvalid(SecretBrokerError):
++ """Secret key is malformed or invalid."""
++
++
++# ---------------------------------------------------------------------------
++# At-Rest Encryption (no external crypto dependencies)
++# ---------------------------------------------------------------------------
++
++
++class _SecretEncryptor:
++ """XOR-based at-rest encryption using HMAC-derived key stream.
++
++ NOT a general-purpose cipher ΓÇö this provides defense-in-depth against
++ heap inspection only. The real security boundary is access control
++ via capabilities and token scopes.
++ """
++
++ def __init__(self, master_key: bytes) -> None:
++ self._master_key = master_key
++
++ def encrypt(self, plaintext: str) -> bytes:
++ """Encrypt a secret value, returning nonce + ciphertext."""
++ nonce = os.urandom(16)
++ key_stream = self._derive_stream(nonce, len(plaintext.encode("utf-8")))
++ plaintext_bytes = plaintext.encode("utf-8")
++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
++ return nonce + ciphertext
++
++ def decrypt(self, data: bytes) -> str:
++ """Decrypt nonce + ciphertext back to plaintext."""
++ if len(data) < 16:
++ raise SecretBrokerError("Corrupt encrypted data")
++ nonce = data[:16]
++ ciphertext = data[16:]
++ key_stream = self._derive_stream(nonce, len(ciphertext))
++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
++ return plaintext_bytes.decode("utf-8")
++
++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
++ """Derive a key stream of given length from nonce + master key."""
++ stream = b""
++ counter = 0
++ while len(stream) < length:
++ block = hmac.new(
++ self._master_key,
++ nonce + counter.to_bytes(4, "big"),
++ hashlib.sha256,
++ ).digest()
++ stream += block
++ counter += 1
++ return stream[:length]
++
++
++# ---------------------------------------------------------------------------
++# Secret Entry
++# ---------------------------------------------------------------------------
++
++
++@dataclass(frozen=True)
++class SecretEntry:
++ """Metadata and encrypted value for a stored secret."""
++
++ key: str
++ scope: SecretScope
++ encrypted_value: bytes
++ owner: str # subject that created the secret
++ created_at: float
++ expires_at: float | None = None # None = no expiry
++
++
++# ---------------------------------------------------------------------------
++# Secret Store (scoped, encrypted, bounded)
++# ---------------------------------------------------------------------------
++
++_MAX_KEY_LENGTH = 256
++_KEY_PATTERN_CHARS = frozenset(
++ "abcdefghijklmnopqrstuvwxyz"
++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
++ "0123456789"
++ "-_./:"
++)
++
++
++def _validate_key(key: str) -> None:
++ """Validate a secret key name."""
++ if not key:
++ raise SecretKeyInvalid("Secret key cannot be empty")
++ if len(key) > _MAX_KEY_LENGTH:
++ raise SecretKeyInvalid(
++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
++ )
++ invalid = set(key) - _KEY_PATTERN_CHARS
++ if invalid:
++ raise SecretKeyInvalid(
++ f"Secret key contains invalid characters: {sorted(invalid)}"
++ )
++
++
++class SecretStore:
++ """Thread-safe, encrypted, scoped secret storage.
++
++ Secrets are organized by scope (task/session/global) and encrypted
++ at rest using HMAC-derived key streams. Each scope has an
++ independent namespace and capacity limit.
++ """
++
++ MAX_SECRETS_PER_SCOPE = 1000
++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
++
++ def __init__(self, encryption_key: bytes | None = None) -> None:
++ self._lock = threading.Lock()
++ self._encryptor = _SecretEncryptor(
++ encryption_key or secrets.token_bytes(32)
++ )
++ # scope -> key -> SecretEntry
++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
++ scope: {} for scope in SecretScope
++ }
++
++ def put(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store or update a secret.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext secret value (encrypted before storage).
++ scope: Secret scope namespace.
++ owner: Subject identity of the caller.
++ expires_at: Optional epoch expiry.
++
++ Raises:
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If the scope has reached capacity.
++ ValueError: If value exceeds maximum length.
++ """
++ _validate_key(key)
++ if len(value) > self.MAX_VALUE_LENGTH:
++ raise ValueError(
++ f"Secret value exceeds maximum length ({self.MAX_VALUE_LENGTH})"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ # Allow update of existing key without capacity check
++ if key not in scope_store and len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def get(self, key: str, *, scope: SecretScope) -> str | None:
++ """Retrieve and decrypt a secret value.
++
++ Returns None if the key does not exist or has expired.
++ Expired entries are lazily removed.
++ """
++ with self._lock:
++ entry = self._store[scope].get(key)
++ if entry is None:
++ return None
++ # Lazy expiry
++ if entry.expires_at is not None and time.time() > entry.expires_at:
++ del self._store[scope][key]
++ return None
++ return self._encryptor.decrypt(entry.encrypted_value)
++
++ def delete(self, key: str, *, scope: SecretScope) -> bool:
++ """Delete a secret. Returns True if it existed."""
++ with self._lock:
++ return self._store[scope].pop(key, None) is not None
++
++ def list_keys(self, *, scope: SecretScope) -> list[str]:
++ """List all non-expired secret keys in a scope."""
++ now = time.time()
++ with self._lock:
++ result = []
++ expired = []
++ for k, entry in self._store[scope].items():
++ if entry.expires_at is not None and now > entry.expires_at:
++ expired.append(k)
++ else:
++ result.append(k)
++ # Lazy cleanup
++ for k in expired:
++ del self._store[scope][k]
++ return sorted(result)
++
++ def count(self, *, scope: SecretScope) -> int:
++ """Number of non-expired secrets in a scope."""
++ return len(self.list_keys(scope=scope))
++
++ def clear_scope(self, scope: SecretScope) -> int:
++ """Remove all secrets in a scope. Returns count removed."""
++ with self._lock:
++ count = len(self._store[scope])
++ self._store[scope].clear()
++ return count
++
++
++# ---------------------------------------------------------------------------
++# #52 ΓÇö SecretBroker (concrete SecretBrokerPort implementation)
++# ---------------------------------------------------------------------------
++
++
++@dataclass
++class SecretBroker:
++ """Concrete implementation of SecretBrokerPort.
++
++ Enforces lease-based access control via SecretCapability and
++ integrates with the scoped SecretStore for encrypted storage.
++
++ Access control layers:
++ 1. Capability check: caller's SecretCapability from lease
++ 2. Scope check: requested scope must be in capability's allowed_scopes
++ 3. Key check: if allowed_keys is non-empty, key must be listed
++ 4. Count check: caller cannot exceed max_secrets from capability
++ 5. Value encryption: all values encrypted at rest
++
++ Args:
++ store: The backing secret store (shared across brokers).
++ default_capability: Fallback capability if none provided per-call.
++ """
++
++ store: SecretStore = field(default_factory=SecretStore)
++ default_capability: SecretCapability = field(
++ default_factory=SecretCapability
++ )
++
++ def _resolve_scope(self, scope: str) -> SecretScope:
++ """Parse and validate a scope string."""
++ try:
++ return SecretScope(scope)
++ except ValueError:
++ raise SecretAccessDenied(
++ f"Invalid scope '{scope}'. "
++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
++ )
++
++ def _check_capability(
++ self,
++ capability: SecretCapability,
++ *,
++ scope: str,
++ key: str | None = None,
++ writing: bool = False,
++ ) -> SecretScope:
++ """Validate access against a SecretCapability.
++
++ Returns the validated SecretScope.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't allow the operation.
++ """
++ # 1. Max secrets check (0 = no access at all)
++ if capability.max_secrets <= 0:
++ raise SecretAccessDenied("Capability grants no secret access")
++
++ # 2. Scope check
++ resolved_scope = self._resolve_scope(scope)
++ if scope not in capability.allowed_scopes:
++ raise SecretAccessDenied(
++ f"Scope '{scope}' not in allowed scopes: "
++ f"{capability.allowed_scopes}"
++ )
++
++ # 3. Key check (if allowed_keys is set, key must be listed)
++ if key is not None and capability.allowed_keys:
++ if key not in capability.allowed_keys:
++ raise SecretAccessDenied(
++ f"Key '{key}' not in allowed keys"
++ )
++
++ return resolved_scope
++
++ # --- SecretBrokerPort interface ---
++
++ async def get_secret(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> Optional[str]:
++ """Retrieve a secret value.
++
++ Args:
++ key: Secret key to retrieve.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability (uses default if None).
++
++ Returns:
++ Decrypted secret value, or None if not found.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit access.
++ SecretKeyInvalid: If key is malformed.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.get(key, scope=resolved)
++
++ async def put_secret(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: str = "task",
++ owner: str = "system",
++ capability: SecretCapability | None = None,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store a secret value.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext value to encrypt and store.
++ scope: Secret scope namespace.
++ owner: Identity of the caller.
++ capability: Caller's lease capability.
++ expires_at: Optional epoch expiry for the secret.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit write.
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If scope is at capacity.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(
++ cap, scope=scope, key=key, writing=True,
++ )
++
++ # Enforce max_secrets from capability
++ current_count = self.store.count(scope=resolved)
++ # Only check limit for new keys
++ existing = self.store.get(key, scope=resolved)
++ if existing is None and current_count >= cap.max_secrets:
++ raise SecretAccessDenied(
++ f"Would exceed max_secrets limit ({cap.max_secrets}) "
++ f"for scope '{scope}'"
++ )
++
++ self.store.put(
++ key, value, scope=resolved, owner=owner,
++ expires_at=expires_at,
++ )
++
++ async def revoke(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> bool:
++ """Revoke (delete) a secret.
++
++ Args:
++ key: Secret key to revoke.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ True if the secret existed and was deleted.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.delete(key, scope=resolved)
++
++ def list_available(
++ self,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> list[str]:
++ """List available secret keys in a scope.
++
++ Args:
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ Sorted list of accessible key names.
++ """
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope)
++
++ all_keys = self.store.list_keys(scope=resolved)
++
++ # Filter to allowed_keys if set
++ if cap.allowed_keys:
++ allowed = set(cap.allowed_keys)
++ return [k for k in all_keys if k in allowed]
++
++ return all_keys
+diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
+new file mode 100644
+index 0000000..e5067f5
+--- /dev/null
++++ b/tests/test_secret_broker.py
+@@ -0,0 +1,551 @@
++"""Tests for openspace.secret.broker ΓÇö EPIC 2.6.
++
++Covers:
++- #52: SecretBrokerPort concrete implementation
++- Secret scoping (task, session, global)
++- Lease-based access control via SecretCapability
++- At-rest encryption/decryption
++- Key validation and store bounds
++- Thread safety
++"""
++
++from __future__ import annotations
++
++import threading
++import time
++import pytest
++
++from openspace.secret.broker import (
++ SecretBroker,
++ SecretStore,
++ SecretScope,
++ SecretEntry,
++ SecretAccessDenied,
++ SecretNotFoundError,
++ SecretStoreFull,
++ SecretKeyInvalid,
++ SecretBrokerError,
++ _SecretEncryptor,
++ _validate_key,
++)
++from openspace.sandbox.leases import SecretCapability
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# Key Validation
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestKeyValidation:
++ """Secret key naming rules."""
++
++ def test_valid_keys(self) -> None:
++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
++ _validate_key(key) # should not raise
++
++ def test_empty_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="empty"):
++ _validate_key("")
++
++ def test_too_long_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
++ _validate_key("x" * 257)
++
++ def test_invalid_chars_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
++ _validate_key("key with spaces")
++
++ def test_special_chars_rejected(self) -> None:
++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
++ with pytest.raises(SecretKeyInvalid):
++ _validate_key(f"key{ch}")
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# At-Rest Encryption
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretEncryptor:
++ """XOR-based at-rest encryption."""
++
++ def test_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
++ plaintext = "super-secret-value-123"
++ encrypted = enc.encrypt(plaintext)
++ assert enc.decrypt(encrypted) == plaintext
++
++ def test_encrypted_differs_from_plaintext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "my-api-key"
++ encrypted = enc.encrypt(plaintext)
++ assert plaintext.encode() not in encrypted
++
++ def test_different_nonce_different_ciphertext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "same-value"
++ e1 = enc.encrypt(plaintext)
++ e2 = enc.encrypt(plaintext)
++ assert e1 != e2 # different nonce → different output
++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
++
++ def test_corrupt_data_rejected(self) -> None:
++ enc = _SecretEncryptor(b"key")
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"short")
++
++ def test_empty_string(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ encrypted = enc.encrypt("")
++ assert enc.decrypt(encrypted) == ""
++
++ def test_unicode_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "héllo wörld 🔑"
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++ def test_long_value(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "x" * 10000
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# Secret Store
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretStore:
++ """Scoped, encrypted, bounded secret storage."""
++
++ def test_put_and_get(self) -> None:
++ store = SecretStore()
++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
++
++ def test_get_missing_returns_none(self) -> None:
++ store = SecretStore()
++ assert store.get("nope", scope=SecretScope.TASK) is None
++
++ def test_scopes_are_independent(self) -> None:
++ store = SecretStore()
++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
++ assert store.get("key", scope=SecretScope.GLOBAL) is None
++
++ def test_update_existing(self) -> None:
++ store = SecretStore()
++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "v2"
++
++ def test_delete(self) -> None:
++ store = SecretStore()
++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
++ assert store.delete("key", scope=SecretScope.TASK) is True
++ assert store.get("key", scope=SecretScope.TASK) is None
++ assert store.delete("key", scope=SecretScope.TASK) is False
++
++ def test_list_keys(self) -> None:
++ store = SecretStore()
++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
++
++ def test_count(self) -> None:
++ store = SecretStore()
++ assert store.count(scope=SecretScope.TASK) == 0
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ assert store.count(scope=SecretScope.TASK) == 2
++
++ def test_clear_scope(self) -> None:
++ store = SecretStore()
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
++ assert store.clear_scope(SecretScope.TASK) == 2
++ assert store.count(scope=SecretScope.TASK) == 0
++ assert store.count(scope=SecretScope.SESSION) == 1
++
++ def test_capacity_limit(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 3
++ for i in range(3):
++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
++ with pytest.raises(SecretStoreFull, match="full"):
++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
++
++ def test_update_does_not_count_toward_capacity(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 2
++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
++ # Update existing ΓÇö should NOT fail
++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
++
++ def test_value_too_long_rejected(self) -> None:
++ store = SecretStore()
++ with pytest.raises(ValueError, match="maximum length"):
++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
++
++ def test_lazy_expiry_on_get(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ assert store.get("k", scope=SecretScope.TASK) is None
++
++ def test_lazy_expiry_on_list(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ future = time.time() + 3600
++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
++ keys = store.list_keys(scope=SecretScope.TASK)
++ assert keys == ["alive"]
++
++ def test_thread_safety(self) -> None:
++ store = SecretStore()
++ errors: list[Exception] = []
++
++ def write_batch(prefix: str) -> None:
++ try:
++ for i in range(50):
++ store.put(f"{prefix}-{i}", f"val-{i}",
++ scope=SecretScope.TASK, owner="svc")
++ except Exception as e:
++ errors.append(e)
++
++ threads = [
++ threading.Thread(target=write_batch, args=(f"t{n}",))
++ for n in range(4)
++ ]
++ for t in threads:
++ t.start()
++ for t in threads:
++ t.join()
++
++ assert not errors
++ assert store.count(scope=SecretScope.TASK) == 200
++
++ def test_encryption_at_rest(self) -> None:
++ """Stored values are encrypted ΓÇö raw access doesn't reveal plaintext."""
++ store = SecretStore()
++ store.put("secret-key", "super-secret-password",
++ scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["secret-key"]
++ assert b"super-secret-password" not in entry.encrypted_value
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# SecretBroker ΓÇö Capability-Based Access Control
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerCapability:
++ """SecretBroker enforces SecretCapability from leases."""
++
++ def _t2_capability(self) -> SecretCapability:
++ """T2-equivalent: 3 secrets, task scope only."""
++ return SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=3,
++ )
++
++ def _t3_capability(self) -> SecretCapability:
++ """T3-equivalent: 10 secrets, task + session scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ def _t4_capability(self) -> SecretCapability:
++ """T4-equivalent: 50 secrets, all scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=50,
++ )
++
++ def _no_access_capability(self) -> SecretCapability:
++ """T0/T1-equivalent: no secret access."""
++ return SecretCapability(
++ allowed_scopes=[],
++ max_secrets=0,
++ )
++
++ @pytest.mark.asyncio
++ async def test_get_with_valid_capability(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
++ result = await broker.get_secret("api-key", capability=cap)
++ assert result == "secret"
++
++ @pytest.mark.asyncio
++ async def test_get_missing_returns_none(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ result = await broker.get_secret("nope", capability=cap)
++ assert result is None
++
++ @pytest.mark.asyncio
++ async def test_no_access_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._no_access_capability()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability() # task only
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="session", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t3_can_access_session(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_t3_cannot_access_global(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="global", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t4_can_access_all_scopes(self) -> None:
++ broker = SecretBroker()
++ cap = self._t4_capability()
++ for scope in ["task", "session", "global"]:
++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
++ capability=cap, owner="svc")
++ assert await broker.get_secret(f"k-{scope}", scope=scope,
++ capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_allowed_keys_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["db-pass", "api-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.get_secret("other-key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_max_secrets_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=2,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_update_existing_does_not_hit_limit(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=1,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++ # Update should work even at limit
++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
++ assert await broker.get_secret("k1", capability=cap) == "v2"
++
++ @pytest.mark.asyncio
++ async def test_invalid_scope_rejected(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
++ await broker.get_secret("key", scope="invalid", capability=cap)
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# SecretBroker ΓÇö Revocation and Listing
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerOperations:
++ """SecretBroker revoke and list operations."""
++
++ def _cap(self) -> SecretCapability:
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ @pytest.mark.asyncio
++ async def test_revoke_existing(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("k", "v", capability=cap, owner="svc")
++ assert await broker.revoke("k", capability=cap) is True
++ assert await broker.get_secret("k", capability=cap) is None
++
++ @pytest.mark.asyncio
++ async def test_revoke_nonexistent(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert await broker.revoke("nope", capability=cap) is False
++
++ @pytest.mark.asyncio
++ async def test_list_available(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("b", "v", capability=cap, owner="svc")
++ await broker.put_secret("a", "v", capability=cap, owner="svc")
++ keys = broker.list_available(capability=cap)
++ assert keys == ["a", "b"]
++
++ @pytest.mark.asyncio
++ async def test_list_filtered_by_allowed_keys(self) -> None:
++ store = SecretStore()
++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["visible"],
++ max_secrets=5,
++ )
++ broker = SecretBroker(store=store)
++ keys = broker.list_available(capability=cap)
++ assert keys == ["visible"]
++
++ @pytest.mark.asyncio
++ async def test_list_empty_scope(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert broker.list_available(capability=cap) == []
++
++ @pytest.mark.asyncio
++ async def test_revoke_denied_without_scope(self) -> None:
++ broker = SecretBroker()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ with pytest.raises(SecretAccessDenied):
++ await broker.revoke("k", scope="session", capability=cap)
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# SecretBroker ΓÇö Default Capability
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerDefaults:
++ """SecretBroker with default_capability."""
++
++ @pytest.mark.asyncio
++ async def test_uses_default_capability(self) -> None:
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(default_capability=cap)
++ await broker.put_secret("k", "v", owner="svc")
++ assert await broker.get_secret("k") == "v"
++
++ @pytest.mark.asyncio
++ async def test_default_zero_denies(self) -> None:
++ broker = SecretBroker() # default SecretCapability has max_secrets=0
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("k")
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# Security Regression Tests
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerSecurity:
++ """Security invariants for the secret broker."""
++
++ @pytest.mark.asyncio
++ async def test_t0_t1_cannot_access_secrets(self) -> None:
++ """T0/T1 equivalent capabilities deny all access."""
++ broker = SecretBroker()
++ for cap in [
++ SecretCapability(allowed_scopes=[], max_secrets=0),
++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
++ ]:
++ with pytest.raises(SecretAccessDenied):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_escalation_prevented(self) -> None:
++ """T2 (task-only) cannot read session secrets."""
++ store = SecretStore()
++ store.put("session-secret", "classified",
++ scope=SecretScope.SESSION, owner="admin")
++ t2_cap = SecretCapability(
++ allowed_scopes=["task"], max_secrets=3,
++ )
++ broker = SecretBroker(store=store)
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("session-secret", scope="session",
++ capability=t2_cap)
++
++ @pytest.mark.asyncio
++ async def test_key_restriction_enforced(self) -> None:
++ """Allowed_keys list is a hard deny for unlisted keys."""
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["safe-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_encrypted_at_rest_via_broker(self) -> None:
++ """Values stored through broker are encrypted in the store."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
++ # Direct store access ΓÇö value should be encrypted
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["api-key"]
++ assert b"super-secret-123" not in entry.encrypted_value
++ # But broker decrypts it
++ assert await broker.get_secret("api-key") == "super-secret-123"
++
++ @pytest.mark.asyncio
++ async def test_deny_before_allow(self) -> None:
++ """Zero max_secrets denies even if scopes match."""
++ cap = SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=0,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ def test_secret_store_values_not_in_repr(self) -> None:
++ """SecretEntry encrypted_value should not leak plaintext."""
++ store = SecretStore()
++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["key"]
++ r = repr(entry)
++ assert "password123" not in r
++
++ @pytest.mark.asyncio
++ async def test_expired_secret_not_accessible(self) -> None:
++ """Expired secrets return None even through broker."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ past = time.time() - 10
++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
++ assert await broker.get_secret("expired") is None
diff --git a/pr-473-diff-r2.txt b/pr-473-diff-r2.txt
new file mode 100644
index 00000000..a9f99fb4
--- /dev/null
+++ b/pr-473-diff-r2.txt
@@ -0,0 +1,2292 @@
+diff --git a/openspace/sandbox/leases.py b/openspace/sandbox/leases.py
+index 3553776..ebe9e55 100644
+--- a/openspace/sandbox/leases.py
++++ b/openspace/sandbox/leases.py
+@@ -143,7 +143,7 @@ class SecretCapability(BaseModel):
+ default_factory=lambda: ["task"],
+ description="Scopes this lease can access (task, session, global)",
+ )
+- allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = none)")
++ allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = unrestricted)")
+ max_secrets: int = Field(default=0, ge=0, le=50, description="Max secrets accessible (0 = none)")
+
+
+diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
+new file mode 100644
+index 0000000..4679650
+--- /dev/null
++++ b/openspace/secret/__init__.py
+@@ -0,0 +1 @@
++"""OpenSpace secret management module."""
+diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
+new file mode 100644
+index 0000000..f038e76
+--- /dev/null
++++ b/openspace/secret/broker.py
+@@ -0,0 +1,576 @@
++"""Concrete SecretBrokerPort implementation ΓÇö EPIC 2.6.
++
++Provides:
++- Scoped secret storage (task, session, global) with lease-based access control.
++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
++- Thread-safe operations with bounded storage per scope.
++- Integration with SecretCapability from lease system and auth token scopes.
++
++Design decisions:
++- Fail-closed: missing capability or insufficient scope → deny.
++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
++- Secrets are stored in-memory only ΓÇö no persistence across restarts.
++- Scope hierarchy: task < session < global (each is independent namespace).
++- Revocation is immediate and irreversible within a session.
++
++Security requirements:
++- Callers MUST present a valid SecretCapability from their lease.
++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
++- T0/T1 tiers cannot access secrets (enforced by lease validation).
++- Secret values are encrypted at rest in memory to resist heap inspection.
++
++Issues:
++- #52: SecretBrokerPort concrete implementation
++"""
++
++from __future__ import annotations
++
++import base64
++import hashlib
++import hmac
++import os
++import secrets
++import threading
++import time
++from dataclasses import dataclass, field
++from enum import Enum
++from typing import Optional
++
++from openspace.sandbox.leases import SecretCapability
++
++
++# ---------------------------------------------------------------------------
++# Secret Scope Model
++# ---------------------------------------------------------------------------
++
++
++class SecretScope(str, Enum):
++ """Hierarchical secret scopes matching lease capability model."""
++
++ TASK = "task"
++ SESSION = "session"
++ GLOBAL = "global"
++
++
++# Scope hierarchy for validation (higher index = broader access)
++_SCOPE_ORDER: list[SecretScope] = [
++ SecretScope.TASK,
++ SecretScope.SESSION,
++ SecretScope.GLOBAL,
++]
++
++
++# ---------------------------------------------------------------------------
++# Exceptions
++# ---------------------------------------------------------------------------
++
++
++class SecretBrokerError(Exception):
++ """Base for all secret broker errors."""
++
++
++class SecretAccessDenied(SecretBrokerError):
++ """Caller lacks permission to access the requested secret."""
++
++
++class SecretNotFoundError(SecretBrokerError):
++ """Requested secret does not exist."""
++
++
++class SecretStoreFull(SecretBrokerError):
++ """Secret store has reached its capacity limit."""
++
++
++class SecretKeyInvalid(SecretBrokerError):
++ """Secret key is malformed or invalid."""
++
++
++class SecretValueTooLarge(SecretBrokerError):
++ """Secret value exceeds maximum allowed size."""
++
++
++# ---------------------------------------------------------------------------
++# At-Rest Encryption (no external crypto dependencies)
++# ---------------------------------------------------------------------------
++
++
++class _SecretEncryptor:
++ """XOR-based at-rest encryption using HMAC-derived key stream.
++
++ NOT a general-purpose cipher ΓÇö this provides defense-in-depth against
++ heap inspection only. The real security boundary is access control
++ via capabilities and token scopes.
++ """
++
++ def __init__(self, master_key: bytes) -> None:
++ self._master_key = master_key
++
++ def encrypt(self, plaintext: str) -> bytes:
++ """Encrypt a secret value, returning nonce + ciphertext + HMAC tag.
++
++ Layout: nonce (16) || ciphertext (N) || hmac_tag (32)
++ Integrity is verified on decrypt (encrypt-then-MAC).
++ """
++ nonce = os.urandom(16)
++ plaintext_bytes = plaintext.encode("utf-8")
++ key_stream = self._derive_stream(nonce, len(plaintext_bytes))
++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
++ # Encrypt-then-MAC: HMAC over nonce + ciphertext
++ tag = hmac.new(
++ self._master_key, nonce + ciphertext, hashlib.sha256,
++ ).digest()
++ return nonce + ciphertext + tag
++
++ _TAG_LEN = 32 # HMAC-SHA256 output length
++
++ def decrypt(self, data: bytes) -> str:
++ """Decrypt nonce + ciphertext + HMAC tag back to plaintext.
++
++ Raises SecretBrokerError if data is corrupt or tampered with.
++ """
++ # Minimum: 16 (nonce) + 0 (ciphertext can be empty) + 32 (tag)
++ if len(data) < 16 + self._TAG_LEN:
++ raise SecretBrokerError("Corrupt encrypted data")
++ nonce = data[:16]
++ ciphertext = data[16:-self._TAG_LEN]
++ stored_tag = data[-self._TAG_LEN:]
++ # Verify integrity before decryption
++ expected_tag = hmac.new(
++ self._master_key, nonce + ciphertext, hashlib.sha256,
++ ).digest()
++ if not hmac.compare_digest(stored_tag, expected_tag):
++ raise SecretBrokerError("Encrypted data integrity check failed")
++ key_stream = self._derive_stream(nonce, len(ciphertext))
++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
++ return plaintext_bytes.decode("utf-8")
++
++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
++ """Derive a key stream of given length from nonce + master key."""
++ stream = b""
++ counter = 0
++ while len(stream) < length:
++ block = hmac.new(
++ self._master_key,
++ nonce + counter.to_bytes(4, "big"),
++ hashlib.sha256,
++ ).digest()
++ stream += block
++ counter += 1
++ return stream[:length]
++
++
++# ---------------------------------------------------------------------------
++# Secret Entry
++# ---------------------------------------------------------------------------
++
++
++@dataclass(frozen=True)
++class SecretEntry:
++ """Metadata and encrypted value for a stored secret."""
++
++ key: str
++ scope: SecretScope
++ encrypted_value: bytes
++ owner: str # subject that created the secret
++ created_at: float
++ expires_at: float | None = None # None = no expiry
++
++
++# ---------------------------------------------------------------------------
++# Secret Store (scoped, encrypted, bounded)
++# ---------------------------------------------------------------------------
++
++_MAX_KEY_LENGTH = 256
++_KEY_PATTERN_CHARS = frozenset(
++ "abcdefghijklmnopqrstuvwxyz"
++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
++ "0123456789"
++ "-_./:"
++)
++
++
++def _validate_key(key: str) -> None:
++ """Validate a secret key name."""
++ if not key:
++ raise SecretKeyInvalid("Secret key cannot be empty")
++ if len(key) > _MAX_KEY_LENGTH:
++ raise SecretKeyInvalid(
++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
++ )
++ invalid = set(key) - _KEY_PATTERN_CHARS
++ if invalid:
++ raise SecretKeyInvalid(
++ f"Secret key contains invalid characters: {sorted(invalid)}"
++ )
++
++
++class SecretStore:
++ """Thread-safe, encrypted, scoped secret storage.
++
++ Secrets are organized by scope (task/session/global) and encrypted
++ at rest using HMAC-derived key streams. Each scope has an
++ independent namespace and capacity limit.
++ """
++
++ MAX_SECRETS_PER_SCOPE = 1000
++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
++
++ def __init__(self, encryption_key: bytes | None = None) -> None:
++ self._lock = threading.Lock()
++ self._encryptor = _SecretEncryptor(
++ encryption_key or secrets.token_bytes(32)
++ )
++ # scope -> key -> SecretEntry
++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
++ scope: {} for scope in SecretScope
++ }
++
++ def put(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store or update a secret.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext secret value (encrypted before storage).
++ scope: Secret scope namespace.
++ owner: Subject identity of the caller.
++ expires_at: Optional epoch expiry.
++
++ Raises:
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If the scope has reached capacity.
++ ValueError: If value exceeds maximum length.
++ """
++ _validate_key(key)
++ value_bytes_len = len(value.encode("utf-8"))
++ if value_bytes_len > self.MAX_VALUE_LENGTH:
++ raise SecretValueTooLarge(
++ f"Secret value ({value_bytes_len} bytes) exceeds "
++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ # Allow update of existing key without capacity check
++ if key not in scope_store and len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def put_checked(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ cap_limit: int,
++ ) -> None:
++ """Atomic put with capability-level count check.
++
++ Same as put(), but additionally enforces a per-capability secret
++ count limit *inside* the lock, eliminating TOCTOU races between
++ count() and put() at the broker layer.
++
++ Raises:
++ SecretAccessDenied: If cap_limit would be exceeded for new keys.
++ SecretStoreFull: If scope hard limit is reached.
++ """
++ _validate_key(key)
++ value_bytes_len = len(value.encode("utf-8"))
++ if value_bytes_len > self.MAX_VALUE_LENGTH:
++ raise SecretValueTooLarge(
++ f"Secret value ({value_bytes_len} bytes) exceeds "
++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ is_new = key not in scope_store
++ if is_new:
++ # Count non-expired entries for capability limit
++ now = time.time()
++ live_count = sum(
++ 1 for e in scope_store.values()
++ if e.expires_at is None or e.expires_at > now
++ )
++ if live_count >= cap_limit:
++ raise SecretAccessDenied(
++ f"Would exceed max_secrets limit ({cap_limit}) "
++ f"for scope '{scope.value}'"
++ )
++ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def get(self, key: str, *, scope: SecretScope) -> str | None:
++ """Retrieve and decrypt a secret value.
++
++ Returns None if the key does not exist or has expired.
++ Expired entries are lazily removed.
++ """
++ with self._lock:
++ entry = self._store[scope].get(key)
++ if entry is None:
++ return None
++ # Lazy expiry
++ if entry.expires_at is not None and time.time() > entry.expires_at:
++ del self._store[scope][key]
++ return None
++ return self._encryptor.decrypt(entry.encrypted_value)
++
++ def delete(self, key: str, *, scope: SecretScope) -> bool:
++ """Delete a secret. Returns True if it existed."""
++ with self._lock:
++ return self._store[scope].pop(key, None) is not None
++
++ def list_keys(self, *, scope: SecretScope) -> list[str]:
++ """List all non-expired secret keys in a scope."""
++ now = time.time()
++ with self._lock:
++ result = []
++ expired = []
++ for k, entry in self._store[scope].items():
++ if entry.expires_at is not None and now > entry.expires_at:
++ expired.append(k)
++ else:
++ result.append(k)
++ # Lazy cleanup
++ for k in expired:
++ del self._store[scope][k]
++ return sorted(result)
++
++ def count(self, *, scope: SecretScope) -> int:
++ """Number of non-expired secrets in a scope."""
++ return len(self.list_keys(scope=scope))
++
++ def clear_scope(self, scope: SecretScope) -> int:
++ """Remove all secrets in a scope. Returns count removed."""
++ with self._lock:
++ count = len(self._store[scope])
++ self._store[scope].clear()
++ return count
++
++
++# ---------------------------------------------------------------------------
++# #52 ΓÇö SecretBroker (concrete SecretBrokerPort implementation)
++# ---------------------------------------------------------------------------
++
++
++@dataclass
++class SecretBroker:
++ """Concrete implementation of SecretBrokerPort.
++
++ Enforces lease-based access control via SecretCapability and
++ integrates with the scoped SecretStore for encrypted storage.
++
++ Access control layers:
++ 1. Capability check: caller's SecretCapability from lease
++ 2. Scope check: requested scope must be in capability's allowed_scopes
++ 3. Key check: if allowed_keys is non-empty, key must be listed
++ 4. Count check: caller cannot exceed max_secrets from capability
++ 5. Value encryption: all values encrypted at rest
++
++ Args:
++ store: The backing secret store (shared across brokers).
++ default_capability: Fallback capability if none provided per-call.
++ """
++
++ store: SecretStore = field(default_factory=SecretStore)
++ default_capability: SecretCapability = field(
++ default_factory=SecretCapability
++ )
++
++ def _resolve_scope(self, scope: str) -> SecretScope:
++ """Parse and validate a scope string."""
++ try:
++ return SecretScope(scope)
++ except ValueError:
++ raise SecretAccessDenied(
++ f"Invalid scope '{scope}'. "
++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
++ )
++
++ def _check_capability(
++ self,
++ capability: SecretCapability,
++ *,
++ scope: str,
++ key: str | None = None,
++ writing: bool = False,
++ ) -> SecretScope:
++ """Validate access against a SecretCapability.
++
++ Returns the validated SecretScope.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't allow the operation.
++ """
++ # 1. Max secrets check (0 = no access at all)
++ if capability.max_secrets <= 0:
++ raise SecretAccessDenied("Capability grants no secret access")
++
++ # 2. Scope check
++ resolved_scope = self._resolve_scope(scope)
++ if scope not in capability.allowed_scopes:
++ raise SecretAccessDenied(
++ f"Scope '{scope}' not in allowed scopes: "
++ f"{capability.allowed_scopes}"
++ )
++
++ # 3. Key check ΓÇö empty allowed_keys = unrestricted (all tiers use
++ # empty by default; non-empty means explicit whitelist)
++ if key is not None and capability.allowed_keys:
++ if key not in capability.allowed_keys:
++ raise SecretAccessDenied(
++ f"Key '{key}' not in allowed keys"
++ )
++
++ return resolved_scope
++
++ # --- SecretBrokerPort interface ---
++
++ async def get_secret(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> Optional[str]:
++ """Retrieve a secret value.
++
++ Args:
++ key: Secret key to retrieve.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability (uses default if None).
++
++ Returns:
++ Decrypted secret value, or None if not found.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit access.
++ SecretKeyInvalid: If key is malformed.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.get(key, scope=resolved)
++
++ async def put_secret(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: str = "task",
++ owner: str = "system",
++ capability: SecretCapability | None = None,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store a secret value.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext value to encrypt and store.
++ scope: Secret scope namespace.
++ owner: Identity of the caller.
++ capability: Caller's lease capability.
++ expires_at: Optional epoch expiry for the secret.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit write.
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If scope is at capacity.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(
++ cap, scope=scope, key=key, writing=True,
++ )
++
++ # Atomic put with capability count check inside the lock
++ self.store.put_checked(
++ key, value, scope=resolved, owner=owner,
++ expires_at=expires_at, cap_limit=cap.max_secrets,
++ )
++
++ async def revoke(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> bool:
++ """Revoke (delete) a secret.
++
++ Args:
++ key: Secret key to revoke.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ True if the secret existed and was deleted.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.delete(key, scope=resolved)
++
++ def list_available(
++ self,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> list[str]:
++ """List available secret keys in a scope.
++
++ Args:
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ Sorted list of accessible key names.
++ """
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope)
++
++ all_keys = self.store.list_keys(scope=resolved)
++
++ # Filter to allowed_keys if set
++ if cap.allowed_keys:
++ allowed = set(cap.allowed_keys)
++ return [k for k in all_keys if k in allowed]
++
++ return all_keys
+diff --git a/pr-473-diff-r1.txt b/pr-473-diff-r1.txt
+new file mode 100644
+index 0000000..02ba534
+--- /dev/null
++++ b/pr-473-diff-r1.txt
+@@ -0,0 +1,1067 @@
++diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
++new file mode 100644
++index 0000000..4679650
++--- /dev/null
+++++ b/openspace/secret/__init__.py
++@@ -0,0 +1 @@
+++"""OpenSpace secret management module."""
++diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
++new file mode 100644
++index 0000000..31fb268
++--- /dev/null
+++++ b/openspace/secret/broker.py
++@@ -0,0 +1,497 @@
+++"""Concrete SecretBrokerPort implementation ΓÇö EPIC 2.6.
+++
+++Provides:
+++- Scoped secret storage (task, session, global) with lease-based access control.
+++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
+++- Thread-safe operations with bounded storage per scope.
+++- Integration with SecretCapability from lease system and auth token scopes.
+++
+++Design decisions:
+++- Fail-closed: missing capability or insufficient scope → deny.
+++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
+++- Secrets are stored in-memory only ΓÇö no persistence across restarts.
+++- Scope hierarchy: task < session < global (each is independent namespace).
+++- Revocation is immediate and irreversible within a session.
+++
+++Security requirements:
+++- Callers MUST present a valid SecretCapability from their lease.
+++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
+++- T0/T1 tiers cannot access secrets (enforced by lease validation).
+++- Secret values are encrypted at rest in memory to resist heap inspection.
+++
+++Issues:
+++- #52: SecretBrokerPort concrete implementation
+++"""
+++
+++from __future__ import annotations
+++
+++import base64
+++import hashlib
+++import hmac
+++import os
+++import secrets
+++import threading
+++import time
+++from dataclasses import dataclass, field
+++from enum import Enum
+++from typing import Optional
+++
+++from openspace.sandbox.leases import SecretCapability
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Scope Model
+++# ---------------------------------------------------------------------------
+++
+++
+++class SecretScope(str, Enum):
+++ """Hierarchical secret scopes matching lease capability model."""
+++
+++ TASK = "task"
+++ SESSION = "session"
+++ GLOBAL = "global"
+++
+++
+++# Scope hierarchy for validation (higher index = broader access)
+++_SCOPE_ORDER: list[SecretScope] = [
+++ SecretScope.TASK,
+++ SecretScope.SESSION,
+++ SecretScope.GLOBAL,
+++]
+++
+++
+++# ---------------------------------------------------------------------------
+++# Exceptions
+++# ---------------------------------------------------------------------------
+++
+++
+++class SecretBrokerError(Exception):
+++ """Base for all secret broker errors."""
+++
+++
+++class SecretAccessDenied(SecretBrokerError):
+++ """Caller lacks permission to access the requested secret."""
+++
+++
+++class SecretNotFoundError(SecretBrokerError):
+++ """Requested secret does not exist."""
+++
+++
+++class SecretStoreFull(SecretBrokerError):
+++ """Secret store has reached its capacity limit."""
+++
+++
+++class SecretKeyInvalid(SecretBrokerError):
+++ """Secret key is malformed or invalid."""
+++
+++
+++# ---------------------------------------------------------------------------
+++# At-Rest Encryption (no external crypto dependencies)
+++# ---------------------------------------------------------------------------
+++
+++
+++class _SecretEncryptor:
+++ """XOR-based at-rest encryption using HMAC-derived key stream.
+++
+++ NOT a general-purpose cipher ΓÇö this provides defense-in-depth against
+++ heap inspection only. The real security boundary is access control
+++ via capabilities and token scopes.
+++ """
+++
+++ def __init__(self, master_key: bytes) -> None:
+++ self._master_key = master_key
+++
+++ def encrypt(self, plaintext: str) -> bytes:
+++ """Encrypt a secret value, returning nonce + ciphertext."""
+++ nonce = os.urandom(16)
+++ key_stream = self._derive_stream(nonce, len(plaintext.encode("utf-8")))
+++ plaintext_bytes = plaintext.encode("utf-8")
+++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
+++ return nonce + ciphertext
+++
+++ def decrypt(self, data: bytes) -> str:
+++ """Decrypt nonce + ciphertext back to plaintext."""
+++ if len(data) < 16:
+++ raise SecretBrokerError("Corrupt encrypted data")
+++ nonce = data[:16]
+++ ciphertext = data[16:]
+++ key_stream = self._derive_stream(nonce, len(ciphertext))
+++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
+++ return plaintext_bytes.decode("utf-8")
+++
+++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
+++ """Derive a key stream of given length from nonce + master key."""
+++ stream = b""
+++ counter = 0
+++ while len(stream) < length:
+++ block = hmac.new(
+++ self._master_key,
+++ nonce + counter.to_bytes(4, "big"),
+++ hashlib.sha256,
+++ ).digest()
+++ stream += block
+++ counter += 1
+++ return stream[:length]
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Entry
+++# ---------------------------------------------------------------------------
+++
+++
+++@dataclass(frozen=True)
+++class SecretEntry:
+++ """Metadata and encrypted value for a stored secret."""
+++
+++ key: str
+++ scope: SecretScope
+++ encrypted_value: bytes
+++ owner: str # subject that created the secret
+++ created_at: float
+++ expires_at: float | None = None # None = no expiry
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Store (scoped, encrypted, bounded)
+++# ---------------------------------------------------------------------------
+++
+++_MAX_KEY_LENGTH = 256
+++_KEY_PATTERN_CHARS = frozenset(
+++ "abcdefghijklmnopqrstuvwxyz"
+++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+++ "0123456789"
+++ "-_./:"
+++)
+++
+++
+++def _validate_key(key: str) -> None:
+++ """Validate a secret key name."""
+++ if not key:
+++ raise SecretKeyInvalid("Secret key cannot be empty")
+++ if len(key) > _MAX_KEY_LENGTH:
+++ raise SecretKeyInvalid(
+++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
+++ )
+++ invalid = set(key) - _KEY_PATTERN_CHARS
+++ if invalid:
+++ raise SecretKeyInvalid(
+++ f"Secret key contains invalid characters: {sorted(invalid)}"
+++ )
+++
+++
+++class SecretStore:
+++ """Thread-safe, encrypted, scoped secret storage.
+++
+++ Secrets are organized by scope (task/session/global) and encrypted
+++ at rest using HMAC-derived key streams. Each scope has an
+++ independent namespace and capacity limit.
+++ """
+++
+++ MAX_SECRETS_PER_SCOPE = 1000
+++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
+++
+++ def __init__(self, encryption_key: bytes | None = None) -> None:
+++ self._lock = threading.Lock()
+++ self._encryptor = _SecretEncryptor(
+++ encryption_key or secrets.token_bytes(32)
+++ )
+++ # scope -> key -> SecretEntry
+++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
+++ scope: {} for scope in SecretScope
+++ }
+++
+++ def put(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: SecretScope,
+++ owner: str,
+++ expires_at: float | None = None,
+++ ) -> None:
+++ """Store or update a secret.
+++
+++ Args:
+++ key: Secret key name.
+++ value: Plaintext secret value (encrypted before storage).
+++ scope: Secret scope namespace.
+++ owner: Subject identity of the caller.
+++ expires_at: Optional epoch expiry.
+++
+++ Raises:
+++ SecretKeyInvalid: If key is malformed.
+++ SecretStoreFull: If the scope has reached capacity.
+++ ValueError: If value exceeds maximum length.
+++ """
+++ _validate_key(key)
+++ if len(value) > self.MAX_VALUE_LENGTH:
+++ raise ValueError(
+++ f"Secret value exceeds maximum length ({self.MAX_VALUE_LENGTH})"
+++ )
+++
+++ encrypted = self._encryptor.encrypt(value)
+++ entry = SecretEntry(
+++ key=key,
+++ scope=scope,
+++ encrypted_value=encrypted,
+++ owner=owner,
+++ created_at=time.time(),
+++ expires_at=expires_at,
+++ )
+++
+++ with self._lock:
+++ scope_store = self._store[scope]
+++ # Allow update of existing key without capacity check
+++ if key not in scope_store and len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
+++ raise SecretStoreFull(
+++ f"Scope '{scope.value}' is full "
+++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
+++ )
+++ scope_store[key] = entry
+++
+++ def get(self, key: str, *, scope: SecretScope) -> str | None:
+++ """Retrieve and decrypt a secret value.
+++
+++ Returns None if the key does not exist or has expired.
+++ Expired entries are lazily removed.
+++ """
+++ with self._lock:
+++ entry = self._store[scope].get(key)
+++ if entry is None:
+++ return None
+++ # Lazy expiry
+++ if entry.expires_at is not None and time.time() > entry.expires_at:
+++ del self._store[scope][key]
+++ return None
+++ return self._encryptor.decrypt(entry.encrypted_value)
+++
+++ def delete(self, key: str, *, scope: SecretScope) -> bool:
+++ """Delete a secret. Returns True if it existed."""
+++ with self._lock:
+++ return self._store[scope].pop(key, None) is not None
+++
+++ def list_keys(self, *, scope: SecretScope) -> list[str]:
+++ """List all non-expired secret keys in a scope."""
+++ now = time.time()
+++ with self._lock:
+++ result = []
+++ expired = []
+++ for k, entry in self._store[scope].items():
+++ if entry.expires_at is not None and now > entry.expires_at:
+++ expired.append(k)
+++ else:
+++ result.append(k)
+++ # Lazy cleanup
+++ for k in expired:
+++ del self._store[scope][k]
+++ return sorted(result)
+++
+++ def count(self, *, scope: SecretScope) -> int:
+++ """Number of non-expired secrets in a scope."""
+++ return len(self.list_keys(scope=scope))
+++
+++ def clear_scope(self, scope: SecretScope) -> int:
+++ """Remove all secrets in a scope. Returns count removed."""
+++ with self._lock:
+++ count = len(self._store[scope])
+++ self._store[scope].clear()
+++ return count
+++
+++
+++# ---------------------------------------------------------------------------
+++# #52 ΓÇö SecretBroker (concrete SecretBrokerPort implementation)
+++# ---------------------------------------------------------------------------
+++
+++
+++@dataclass
+++class SecretBroker:
+++ """Concrete implementation of SecretBrokerPort.
+++
+++ Enforces lease-based access control via SecretCapability and
+++ integrates with the scoped SecretStore for encrypted storage.
+++
+++ Access control layers:
+++ 1. Capability check: caller's SecretCapability from lease
+++ 2. Scope check: requested scope must be in capability's allowed_scopes
+++ 3. Key check: if allowed_keys is non-empty, key must be listed
+++ 4. Count check: caller cannot exceed max_secrets from capability
+++ 5. Value encryption: all values encrypted at rest
+++
+++ Args:
+++ store: The backing secret store (shared across brokers).
+++ default_capability: Fallback capability if none provided per-call.
+++ """
+++
+++ store: SecretStore = field(default_factory=SecretStore)
+++ default_capability: SecretCapability = field(
+++ default_factory=SecretCapability
+++ )
+++
+++ def _resolve_scope(self, scope: str) -> SecretScope:
+++ """Parse and validate a scope string."""
+++ try:
+++ return SecretScope(scope)
+++ except ValueError:
+++ raise SecretAccessDenied(
+++ f"Invalid scope '{scope}'. "
+++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
+++ )
+++
+++ def _check_capability(
+++ self,
+++ capability: SecretCapability,
+++ *,
+++ scope: str,
+++ key: str | None = None,
+++ writing: bool = False,
+++ ) -> SecretScope:
+++ """Validate access against a SecretCapability.
+++
+++ Returns the validated SecretScope.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't allow the operation.
+++ """
+++ # 1. Max secrets check (0 = no access at all)
+++ if capability.max_secrets <= 0:
+++ raise SecretAccessDenied("Capability grants no secret access")
+++
+++ # 2. Scope check
+++ resolved_scope = self._resolve_scope(scope)
+++ if scope not in capability.allowed_scopes:
+++ raise SecretAccessDenied(
+++ f"Scope '{scope}' not in allowed scopes: "
+++ f"{capability.allowed_scopes}"
+++ )
+++
+++ # 3. Key check (if allowed_keys is set, key must be listed)
+++ if key is not None and capability.allowed_keys:
+++ if key not in capability.allowed_keys:
+++ raise SecretAccessDenied(
+++ f"Key '{key}' not in allowed keys"
+++ )
+++
+++ return resolved_scope
+++
+++ # --- SecretBrokerPort interface ---
+++
+++ async def get_secret(
+++ self,
+++ key: str,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> Optional[str]:
+++ """Retrieve a secret value.
+++
+++ Args:
+++ key: Secret key to retrieve.
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability (uses default if None).
+++
+++ Returns:
+++ Decrypted secret value, or None if not found.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't permit access.
+++ SecretKeyInvalid: If key is malformed.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope, key=key)
+++ return self.store.get(key, scope=resolved)
+++
+++ async def put_secret(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: str = "task",
+++ owner: str = "system",
+++ capability: SecretCapability | None = None,
+++ expires_at: float | None = None,
+++ ) -> None:
+++ """Store a secret value.
+++
+++ Args:
+++ key: Secret key name.
+++ value: Plaintext value to encrypt and store.
+++ scope: Secret scope namespace.
+++ owner: Identity of the caller.
+++ capability: Caller's lease capability.
+++ expires_at: Optional epoch expiry for the secret.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't permit write.
+++ SecretKeyInvalid: If key is malformed.
+++ SecretStoreFull: If scope is at capacity.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(
+++ cap, scope=scope, key=key, writing=True,
+++ )
+++
+++ # Enforce max_secrets from capability
+++ current_count = self.store.count(scope=resolved)
+++ # Only check limit for new keys
+++ existing = self.store.get(key, scope=resolved)
+++ if existing is None and current_count >= cap.max_secrets:
+++ raise SecretAccessDenied(
+++ f"Would exceed max_secrets limit ({cap.max_secrets}) "
+++ f"for scope '{scope}'"
+++ )
+++
+++ self.store.put(
+++ key, value, scope=resolved, owner=owner,
+++ expires_at=expires_at,
+++ )
+++
+++ async def revoke(
+++ self,
+++ key: str,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> bool:
+++ """Revoke (delete) a secret.
+++
+++ Args:
+++ key: Secret key to revoke.
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability.
+++
+++ Returns:
+++ True if the secret existed and was deleted.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope, key=key)
+++ return self.store.delete(key, scope=resolved)
+++
+++ def list_available(
+++ self,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> list[str]:
+++ """List available secret keys in a scope.
+++
+++ Args:
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability.
+++
+++ Returns:
+++ Sorted list of accessible key names.
+++ """
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope)
+++
+++ all_keys = self.store.list_keys(scope=resolved)
+++
+++ # Filter to allowed_keys if set
+++ if cap.allowed_keys:
+++ allowed = set(cap.allowed_keys)
+++ return [k for k in all_keys if k in allowed]
+++
+++ return all_keys
++diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
++new file mode 100644
++index 0000000..e5067f5
++--- /dev/null
+++++ b/tests/test_secret_broker.py
++@@ -0,0 +1,551 @@
+++"""Tests for openspace.secret.broker ΓÇö EPIC 2.6.
+++
+++Covers:
+++- #52: SecretBrokerPort concrete implementation
+++- Secret scoping (task, session, global)
+++- Lease-based access control via SecretCapability
+++- At-rest encryption/decryption
+++- Key validation and store bounds
+++- Thread safety
+++"""
+++
+++from __future__ import annotations
+++
+++import threading
+++import time
+++import pytest
+++
+++from openspace.secret.broker import (
+++ SecretBroker,
+++ SecretStore,
+++ SecretScope,
+++ SecretEntry,
+++ SecretAccessDenied,
+++ SecretNotFoundError,
+++ SecretStoreFull,
+++ SecretKeyInvalid,
+++ SecretBrokerError,
+++ _SecretEncryptor,
+++ _validate_key,
+++)
+++from openspace.sandbox.leases import SecretCapability
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Key Validation
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestKeyValidation:
+++ """Secret key naming rules."""
+++
+++ def test_valid_keys(self) -> None:
+++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
+++ _validate_key(key) # should not raise
+++
+++ def test_empty_key_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="empty"):
+++ _validate_key("")
+++
+++ def test_too_long_key_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
+++ _validate_key("x" * 257)
+++
+++ def test_invalid_chars_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
+++ _validate_key("key with spaces")
+++
+++ def test_special_chars_rejected(self) -> None:
+++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
+++ with pytest.raises(SecretKeyInvalid):
+++ _validate_key(f"key{ch}")
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# At-Rest Encryption
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretEncryptor:
+++ """XOR-based at-rest encryption."""
+++
+++ def test_roundtrip(self) -> None:
+++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
+++ plaintext = "super-secret-value-123"
+++ encrypted = enc.encrypt(plaintext)
+++ assert enc.decrypt(encrypted) == plaintext
+++
+++ def test_encrypted_differs_from_plaintext(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "my-api-key"
+++ encrypted = enc.encrypt(plaintext)
+++ assert plaintext.encode() not in encrypted
+++
+++ def test_different_nonce_different_ciphertext(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "same-value"
+++ e1 = enc.encrypt(plaintext)
+++ e2 = enc.encrypt(plaintext)
+++ assert e1 != e2 # different nonce → different output
+++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
+++
+++ def test_corrupt_data_rejected(self) -> None:
+++ enc = _SecretEncryptor(b"key")
+++ with pytest.raises(SecretBrokerError, match="Corrupt"):
+++ enc.decrypt(b"short")
+++
+++ def test_empty_string(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ encrypted = enc.encrypt("")
+++ assert enc.decrypt(encrypted) == ""
+++
+++ def test_unicode_roundtrip(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "héllo wörld 🔑"
+++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+++
+++ def test_long_value(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "x" * 10000
+++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Secret Store
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretStore:
+++ """Scoped, encrypted, bounded secret storage."""
+++
+++ def test_put_and_get(self) -> None:
+++ store = SecretStore()
+++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
+++
+++ def test_get_missing_returns_none(self) -> None:
+++ store = SecretStore()
+++ assert store.get("nope", scope=SecretScope.TASK) is None
+++
+++ def test_scopes_are_independent(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
+++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
+++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
+++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
+++ assert store.get("key", scope=SecretScope.GLOBAL) is None
+++
+++ def test_update_existing(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
+++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("key", scope=SecretScope.TASK) == "v2"
+++
+++ def test_delete(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
+++ assert store.delete("key", scope=SecretScope.TASK) is True
+++ assert store.get("key", scope=SecretScope.TASK) is None
+++ assert store.delete("key", scope=SecretScope.TASK) is False
+++
+++ def test_list_keys(self) -> None:
+++ store = SecretStore()
+++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
+++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
+++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
+++
+++ def test_count(self) -> None:
+++ store = SecretStore()
+++ assert store.count(scope=SecretScope.TASK) == 0
+++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+++ assert store.count(scope=SecretScope.TASK) == 2
+++
+++ def test_clear_scope(self) -> None:
+++ store = SecretStore()
+++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
+++ assert store.clear_scope(SecretScope.TASK) == 2
+++ assert store.count(scope=SecretScope.TASK) == 0
+++ assert store.count(scope=SecretScope.SESSION) == 1
+++
+++ def test_capacity_limit(self) -> None:
+++ store = SecretStore()
+++ store.MAX_SECRETS_PER_SCOPE = 3
+++ for i in range(3):
+++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
+++ with pytest.raises(SecretStoreFull, match="full"):
+++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
+++
+++ def test_update_does_not_count_toward_capacity(self) -> None:
+++ store = SecretStore()
+++ store.MAX_SECRETS_PER_SCOPE = 2
+++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
+++ # Update existing ΓÇö should NOT fail
+++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
+++
+++ def test_value_too_long_rejected(self) -> None:
+++ store = SecretStore()
+++ with pytest.raises(ValueError, match="maximum length"):
+++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
+++
+++ def test_lazy_expiry_on_get(self) -> None:
+++ store = SecretStore()
+++ past = time.time() - 10
+++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+++ assert store.get("k", scope=SecretScope.TASK) is None
+++
+++ def test_lazy_expiry_on_list(self) -> None:
+++ store = SecretStore()
+++ past = time.time() - 10
+++ future = time.time() + 3600
+++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
+++ keys = store.list_keys(scope=SecretScope.TASK)
+++ assert keys == ["alive"]
+++
+++ def test_thread_safety(self) -> None:
+++ store = SecretStore()
+++ errors: list[Exception] = []
+++
+++ def write_batch(prefix: str) -> None:
+++ try:
+++ for i in range(50):
+++ store.put(f"{prefix}-{i}", f"val-{i}",
+++ scope=SecretScope.TASK, owner="svc")
+++ except Exception as e:
+++ errors.append(e)
+++
+++ threads = [
+++ threading.Thread(target=write_batch, args=(f"t{n}",))
+++ for n in range(4)
+++ ]
+++ for t in threads:
+++ t.start()
+++ for t in threads:
+++ t.join()
+++
+++ assert not errors
+++ assert store.count(scope=SecretScope.TASK) == 200
+++
+++ def test_encryption_at_rest(self) -> None:
+++ """Stored values are encrypted ΓÇö raw access doesn't reveal plaintext."""
+++ store = SecretStore()
+++ store.put("secret-key", "super-secret-password",
+++ scope=SecretScope.TASK, owner="svc")
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["secret-key"]
+++ assert b"super-secret-password" not in entry.encrypted_value
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Capability-Based Access Control
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerCapability:
+++ """SecretBroker enforces SecretCapability from leases."""
+++
+++ def _t2_capability(self) -> SecretCapability:
+++ """T2-equivalent: 3 secrets, task scope only."""
+++ return SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=3,
+++ )
+++
+++ def _t3_capability(self) -> SecretCapability:
+++ """T3-equivalent: 10 secrets, task + session scopes."""
+++ return SecretCapability(
+++ allowed_scopes=["task", "session"],
+++ max_secrets=10,
+++ )
+++
+++ def _t4_capability(self) -> SecretCapability:
+++ """T4-equivalent: 50 secrets, all scopes."""
+++ return SecretCapability(
+++ allowed_scopes=["task", "session", "global"],
+++ max_secrets=50,
+++ )
+++
+++ def _no_access_capability(self) -> SecretCapability:
+++ """T0/T1-equivalent: no secret access."""
+++ return SecretCapability(
+++ allowed_scopes=[],
+++ max_secrets=0,
+++ )
+++
+++ @pytest.mark.asyncio
+++ async def test_get_with_valid_capability(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
+++ result = await broker.get_secret("api-key", capability=cap)
+++ assert result == "secret"
+++
+++ @pytest.mark.asyncio
+++ async def test_get_missing_returns_none(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ result = await broker.get_secret("nope", capability=cap)
+++ assert result is None
+++
+++ @pytest.mark.asyncio
+++ async def test_no_access_denied(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._no_access_capability()
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_scope_denied(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability() # task only
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("key", scope="session", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_t3_can_access_session(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t3_capability()
+++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
+++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_t3_cannot_access_global(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t3_capability()
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("key", scope="global", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_t4_can_access_all_scopes(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t4_capability()
+++ for scope in ["task", "session", "global"]:
+++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
+++ capability=cap, owner="svc")
+++ assert await broker.get_secret(f"k-{scope}", scope=scope,
+++ capability=cap) == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_allowed_keys_enforced(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["db-pass", "api-key"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
+++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+++ await broker.get_secret("other-key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_max_secrets_enforced(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=2,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
+++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
+++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
+++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
+++
+++ @pytest.mark.asyncio
+++ async def test_update_existing_does_not_hit_limit(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=1,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
+++ # Update should work even at limit
+++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
+++ assert await broker.get_secret("k1", capability=cap) == "v2"
+++
+++ @pytest.mark.asyncio
+++ async def test_invalid_scope_rejected(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
+++ await broker.get_secret("key", scope="invalid", capability=cap)
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Revocation and Listing
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerOperations:
+++ """SecretBroker revoke and list operations."""
+++
+++ def _cap(self) -> SecretCapability:
+++ return SecretCapability(
+++ allowed_scopes=["task", "session"],
+++ max_secrets=10,
+++ )
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_existing(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ await broker.put_secret("k", "v", capability=cap, owner="svc")
+++ assert await broker.revoke("k", capability=cap) is True
+++ assert await broker.get_secret("k", capability=cap) is None
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_nonexistent(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ assert await broker.revoke("nope", capability=cap) is False
+++
+++ @pytest.mark.asyncio
+++ async def test_list_available(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ await broker.put_secret("b", "v", capability=cap, owner="svc")
+++ await broker.put_secret("a", "v", capability=cap, owner="svc")
+++ keys = broker.list_available(capability=cap)
+++ assert keys == ["a", "b"]
+++
+++ @pytest.mark.asyncio
+++ async def test_list_filtered_by_allowed_keys(self) -> None:
+++ store = SecretStore()
+++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["visible"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker(store=store)
+++ keys = broker.list_available(capability=cap)
+++ assert keys == ["visible"]
+++
+++ @pytest.mark.asyncio
+++ async def test_list_empty_scope(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ assert broker.list_available(capability=cap) == []
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_denied_without_scope(self) -> None:
+++ broker = SecretBroker()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ with pytest.raises(SecretAccessDenied):
+++ await broker.revoke("k", scope="session", capability=cap)
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Default Capability
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerDefaults:
+++ """SecretBroker with default_capability."""
+++
+++ @pytest.mark.asyncio
+++ async def test_uses_default_capability(self) -> None:
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(default_capability=cap)
+++ await broker.put_secret("k", "v", owner="svc")
+++ assert await broker.get_secret("k") == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_default_zero_denies(self) -> None:
+++ broker = SecretBroker() # default SecretCapability has max_secrets=0
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("k")
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Security Regression Tests
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerSecurity:
+++ """Security invariants for the secret broker."""
+++
+++ @pytest.mark.asyncio
+++ async def test_t0_t1_cannot_access_secrets(self) -> None:
+++ """T0/T1 equivalent capabilities deny all access."""
+++ broker = SecretBroker()
+++ for cap in [
+++ SecretCapability(allowed_scopes=[], max_secrets=0),
+++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
+++ ]:
+++ with pytest.raises(SecretAccessDenied):
+++ await broker.get_secret("key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_scope_escalation_prevented(self) -> None:
+++ """T2 (task-only) cannot read session secrets."""
+++ store = SecretStore()
+++ store.put("session-secret", "classified",
+++ scope=SecretScope.SESSION, owner="admin")
+++ t2_cap = SecretCapability(
+++ allowed_scopes=["task"], max_secrets=3,
+++ )
+++ broker = SecretBroker(store=store)
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("session-secret", scope="session",
+++ capability=t2_cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_key_restriction_enforced(self) -> None:
+++ """Allowed_keys list is a hard deny for unlisted keys."""
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["safe-key"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker()
+++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
+++
+++ @pytest.mark.asyncio
+++ async def test_encrypted_at_rest_via_broker(self) -> None:
+++ """Values stored through broker are encrypted in the store."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
+++ # Direct store access ΓÇö value should be encrypted
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["api-key"]
+++ assert b"super-secret-123" not in entry.encrypted_value
+++ # But broker decrypts it
+++ assert await broker.get_secret("api-key") == "super-secret-123"
+++
+++ @pytest.mark.asyncio
+++ async def test_deny_before_allow(self) -> None:
+++ """Zero max_secrets denies even if scopes match."""
+++ cap = SecretCapability(
+++ allowed_scopes=["task", "session", "global"],
+++ max_secrets=0,
+++ )
+++ broker = SecretBroker()
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("key", capability=cap)
+++
+++ def test_secret_store_values_not_in_repr(self) -> None:
+++ """SecretEntry encrypted_value should not leak plaintext."""
+++ store = SecretStore()
+++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["key"]
+++ r = repr(entry)
+++ assert "password123" not in r
+++
+++ @pytest.mark.asyncio
+++ async def test_expired_secret_not_accessible(self) -> None:
+++ """Expired secrets return None even through broker."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ past = time.time() - 10
+++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
+++ assert await broker.get_secret("expired") is None
+diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
+new file mode 100644
+index 0000000..992e08c
+--- /dev/null
++++ b/tests/test_secret_broker.py
+@@ -0,0 +1,611 @@
++"""Tests for openspace.secret.broker ΓÇö EPIC 2.6.
++
++Covers:
++- #52: SecretBrokerPort concrete implementation
++- Secret scoping (task, session, global)
++- Lease-based access control via SecretCapability
++- At-rest encryption/decryption
++- Key validation and store bounds
++- Thread safety
++"""
++
++from __future__ import annotations
++
++import threading
++import time
++import pytest
++
++from openspace.secret.broker import (
++ SecretBroker,
++ SecretStore,
++ SecretScope,
++ SecretEntry,
++ SecretAccessDenied,
++ SecretNotFoundError,
++ SecretStoreFull,
++ SecretKeyInvalid,
++ SecretBrokerError,
++ SecretValueTooLarge,
++ _SecretEncryptor,
++ _validate_key,
++)
++from openspace.sandbox.leases import SecretCapability
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# Key Validation
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestKeyValidation:
++ """Secret key naming rules."""
++
++ def test_valid_keys(self) -> None:
++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
++ _validate_key(key) # should not raise
++
++ def test_empty_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="empty"):
++ _validate_key("")
++
++ def test_too_long_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
++ _validate_key("x" * 257)
++
++ def test_invalid_chars_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
++ _validate_key("key with spaces")
++
++ def test_special_chars_rejected(self) -> None:
++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
++ with pytest.raises(SecretKeyInvalid):
++ _validate_key(f"key{ch}")
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# At-Rest Encryption
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretEncryptor:
++ """XOR-based at-rest encryption."""
++
++ def test_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
++ plaintext = "super-secret-value-123"
++ encrypted = enc.encrypt(plaintext)
++ assert enc.decrypt(encrypted) == plaintext
++
++ def test_encrypted_differs_from_plaintext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "my-api-key"
++ encrypted = enc.encrypt(plaintext)
++ assert plaintext.encode() not in encrypted
++
++ def test_different_nonce_different_ciphertext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "same-value"
++ e1 = enc.encrypt(plaintext)
++ e2 = enc.encrypt(plaintext)
++ assert e1 != e2 # different nonce → different output
++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
++
++ def test_corrupt_data_rejected(self) -> None:
++ enc = _SecretEncryptor(b"key")
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"short")
++
++ def test_empty_string(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ encrypted = enc.encrypt("")
++ assert enc.decrypt(encrypted) == ""
++
++ def test_unicode_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "héllo wörld 🔑"
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++ def test_long_value(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "x" * 10000
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# Secret Store
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretStore:
++ """Scoped, encrypted, bounded secret storage."""
++
++ def test_put_and_get(self) -> None:
++ store = SecretStore()
++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
++
++ def test_get_missing_returns_none(self) -> None:
++ store = SecretStore()
++ assert store.get("nope", scope=SecretScope.TASK) is None
++
++ def test_scopes_are_independent(self) -> None:
++ store = SecretStore()
++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
++ assert store.get("key", scope=SecretScope.GLOBAL) is None
++
++ def test_update_existing(self) -> None:
++ store = SecretStore()
++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "v2"
++
++ def test_delete(self) -> None:
++ store = SecretStore()
++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
++ assert store.delete("key", scope=SecretScope.TASK) is True
++ assert store.get("key", scope=SecretScope.TASK) is None
++ assert store.delete("key", scope=SecretScope.TASK) is False
++
++ def test_list_keys(self) -> None:
++ store = SecretStore()
++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
++
++ def test_count(self) -> None:
++ store = SecretStore()
++ assert store.count(scope=SecretScope.TASK) == 0
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ assert store.count(scope=SecretScope.TASK) == 2
++
++ def test_clear_scope(self) -> None:
++ store = SecretStore()
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
++ assert store.clear_scope(SecretScope.TASK) == 2
++ assert store.count(scope=SecretScope.TASK) == 0
++ assert store.count(scope=SecretScope.SESSION) == 1
++
++ def test_capacity_limit(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 3
++ for i in range(3):
++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
++ with pytest.raises(SecretStoreFull, match="full"):
++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
++
++ def test_update_does_not_count_toward_capacity(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 2
++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
++ # Update existing ΓÇö should NOT fail
++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
++
++ def test_value_too_long_rejected(self) -> None:
++ store = SecretStore()
++ with pytest.raises(SecretValueTooLarge, match="maximum length"):
++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
++
++ def test_value_too_long_bytes_not_chars(self) -> None:
++ """Byte length is enforced, not character count. Multi-byte chars
++ must be correctly measured against the 64KB limit."""
++ store = SecretStore()
++ # 4-byte emoji × 16385 = 65540 bytes > 64KB, but only 16385 chars
++ value = "\U0001f600" * 16385
++ assert len(value) < store.MAX_VALUE_LENGTH # chars < limit
++ assert len(value.encode("utf-8")) > store.MAX_VALUE_LENGTH # bytes > limit
++ with pytest.raises(SecretValueTooLarge, match="bytes"):
++ store.put("k", value, scope=SecretScope.TASK, owner="svc")
++
++ def test_lazy_expiry_on_get(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ assert store.get("k", scope=SecretScope.TASK) is None
++
++ def test_lazy_expiry_on_list(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ future = time.time() + 3600
++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
++ keys = store.list_keys(scope=SecretScope.TASK)
++ assert keys == ["alive"]
++
++ def test_thread_safety(self) -> None:
++ store = SecretStore()
++ errors: list[Exception] = []
++
++ def write_batch(prefix: str) -> None:
++ try:
++ for i in range(50):
++ store.put(f"{prefix}-{i}", f"val-{i}",
++ scope=SecretScope.TASK, owner="svc")
++ except Exception as e:
++ errors.append(e)
++
++ threads = [
++ threading.Thread(target=write_batch, args=(f"t{n}",))
++ for n in range(4)
++ ]
++ for t in threads:
++ t.start()
++ for t in threads:
++ t.join()
++
++ assert not errors
++ assert store.count(scope=SecretScope.TASK) == 200
++
++ def test_encryption_at_rest(self) -> None:
++ """Stored values are encrypted ΓÇö raw access doesn't reveal plaintext."""
++ store = SecretStore()
++ store.put("secret-key", "super-secret-password",
++ scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["secret-key"]
++ assert b"super-secret-password" not in entry.encrypted_value
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# SecretBroker ΓÇö Capability-Based Access Control
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerCapability:
++ """SecretBroker enforces SecretCapability from leases."""
++
++ def _t2_capability(self) -> SecretCapability:
++ """T2-equivalent: 3 secrets, task scope only."""
++ return SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=3,
++ )
++
++ def _t3_capability(self) -> SecretCapability:
++ """T3-equivalent: 10 secrets, task + session scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ def _t4_capability(self) -> SecretCapability:
++ """T4-equivalent: 50 secrets, all scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=50,
++ )
++
++ def _no_access_capability(self) -> SecretCapability:
++ """T0/T1-equivalent: no secret access."""
++ return SecretCapability(
++ allowed_scopes=[],
++ max_secrets=0,
++ )
++
++ @pytest.mark.asyncio
++ async def test_get_with_valid_capability(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
++ result = await broker.get_secret("api-key", capability=cap)
++ assert result == "secret"
++
++ @pytest.mark.asyncio
++ async def test_get_missing_returns_none(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ result = await broker.get_secret("nope", capability=cap)
++ assert result is None
++
++ @pytest.mark.asyncio
++ async def test_no_access_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._no_access_capability()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability() # task only
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="session", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t3_can_access_session(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_t3_cannot_access_global(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="global", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t4_can_access_all_scopes(self) -> None:
++ broker = SecretBroker()
++ cap = self._t4_capability()
++ for scope in ["task", "session", "global"]:
++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
++ capability=cap, owner="svc")
++ assert await broker.get_secret(f"k-{scope}", scope=scope,
++ capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_allowed_keys_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["db-pass", "api-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.get_secret("other-key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_max_secrets_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=2,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_update_existing_does_not_hit_limit(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=1,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++ # Update should work even at limit
++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
++ assert await broker.get_secret("k1", capability=cap) == "v2"
++
++ @pytest.mark.asyncio
++ async def test_invalid_scope_rejected(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
++ await broker.get_secret("key", scope="invalid", capability=cap)
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# SecretBroker ΓÇö Revocation and Listing
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerOperations:
++ """SecretBroker revoke and list operations."""
++
++ def _cap(self) -> SecretCapability:
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ @pytest.mark.asyncio
++ async def test_revoke_existing(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("k", "v", capability=cap, owner="svc")
++ assert await broker.revoke("k", capability=cap) is True
++ assert await broker.get_secret("k", capability=cap) is None
++
++ @pytest.mark.asyncio
++ async def test_revoke_nonexistent(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert await broker.revoke("nope", capability=cap) is False
++
++ @pytest.mark.asyncio
++ async def test_list_available(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("b", "v", capability=cap, owner="svc")
++ await broker.put_secret("a", "v", capability=cap, owner="svc")
++ keys = broker.list_available(capability=cap)
++ assert keys == ["a", "b"]
++
++ @pytest.mark.asyncio
++ async def test_list_filtered_by_allowed_keys(self) -> None:
++ store = SecretStore()
++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["visible"],
++ max_secrets=5,
++ )
++ broker = SecretBroker(store=store)
++ keys = broker.list_available(capability=cap)
++ assert keys == ["visible"]
++
++ @pytest.mark.asyncio
++ async def test_list_empty_scope(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert broker.list_available(capability=cap) == []
++
++ @pytest.mark.asyncio
++ async def test_revoke_denied_without_scope(self) -> None:
++ broker = SecretBroker()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ with pytest.raises(SecretAccessDenied):
++ await broker.revoke("k", scope="session", capability=cap)
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# SecretBroker ΓÇö Default Capability
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerDefaults:
++ """SecretBroker with default_capability."""
++
++ @pytest.mark.asyncio
++ async def test_uses_default_capability(self) -> None:
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(default_capability=cap)
++ await broker.put_secret("k", "v", owner="svc")
++ assert await broker.get_secret("k") == "v"
++
++ @pytest.mark.asyncio
++ async def test_default_zero_denies(self) -> None:
++ broker = SecretBroker() # default SecretCapability has max_secrets=0
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("k")
++
++
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++# Security Regression Tests
++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++
++
++class TestSecretBrokerSecurity:
++ """Security invariants for the secret broker."""
++
++ @pytest.mark.asyncio
++ async def test_t0_t1_cannot_access_secrets(self) -> None:
++ """T0/T1 equivalent capabilities deny all access."""
++ broker = SecretBroker()
++ for cap in [
++ SecretCapability(allowed_scopes=[], max_secrets=0),
++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
++ ]:
++ with pytest.raises(SecretAccessDenied):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_escalation_prevented(self) -> None:
++ """T2 (task-only) cannot read session secrets."""
++ store = SecretStore()
++ store.put("session-secret", "classified",
++ scope=SecretScope.SESSION, owner="admin")
++ t2_cap = SecretCapability(
++ allowed_scopes=["task"], max_secrets=3,
++ )
++ broker = SecretBroker(store=store)
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("session-secret", scope="session",
++ capability=t2_cap)
++
++ @pytest.mark.asyncio
++ async def test_key_restriction_enforced(self) -> None:
++ """Allowed_keys list is a hard deny for unlisted keys."""
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["safe-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_encrypted_at_rest_via_broker(self) -> None:
++ """Values stored through broker are encrypted in the store."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
++ # Direct store access ΓÇö value should be encrypted
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["api-key"]
++ assert b"super-secret-123" not in entry.encrypted_value
++ # But broker decrypts it
++ assert await broker.get_secret("api-key") == "super-secret-123"
++
++ @pytest.mark.asyncio
++ async def test_deny_before_allow(self) -> None:
++ """Zero max_secrets denies even if scopes match."""
++ cap = SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=0,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ def test_secret_store_values_not_in_repr(self) -> None:
++ """SecretEntry encrypted_value should not leak plaintext."""
++ store = SecretStore()
++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["key"]
++ r = repr(entry)
++ assert "password123" not in r
++
++ @pytest.mark.asyncio
++ async def test_expired_secret_not_accessible(self) -> None:
++ """Expired secrets return None even through broker."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ past = time.time() - 10
++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
++ assert await broker.get_secret("expired") is None
++
++ def test_ciphertext_integrity_check(self) -> None:
++ """Tampered ciphertext is detected by HMAC integrity tag."""
++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
++ encrypted = enc.encrypt("sensitive-data")
++ # Flip a byte in the ciphertext region (after 16-byte nonce, before 32-byte tag)
++ tampered = bytearray(encrypted)
++ tampered[20] ^= 0xFF
++ with pytest.raises(SecretBrokerError, match="integrity check failed"):
++ enc.decrypt(bytes(tampered))
++
++ def test_ciphertext_truncation_detected(self) -> None:
++ """Truncated ciphertext is rejected."""
++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"\x00" * 16) # nonce only, no ciphertext or tag
++
++ @pytest.mark.asyncio
++ async def test_concurrent_put_respects_cap_limit(self) -> None:
++ """Concurrent writers must not exceed capability max_secrets (TOCTOU fix)."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ errors: list[Exception] = []
++ success_count = 0
++ lock = threading.Lock()
++
++ import asyncio
++
++ async def write(i: int) -> None:
++ nonlocal success_count
++ try:
++ await broker.put_secret(
++ f"key-{i}", f"val-{i}", owner="svc",
++ )
++ with lock:
++ success_count += 1
++ except SecretAccessDenied:
++ pass # Expected once limit is reached
++ except Exception as e:
++ with lock:
++ errors.append(e)
++
++ # Attempt 20 concurrent writes with cap of 5
++ await asyncio.gather(*(write(i) for i in range(20)))
++ assert not errors, f"Unexpected errors: {errors}"
++ # Must not exceed cap
++ assert store.count(scope=SecretScope.TASK) <= 5
diff --git a/pr-473-diff-r3.txt b/pr-473-diff-r3.txt
new file mode 100644
index 00000000..615110c6
--- /dev/null
+++ b/pr-473-diff-r3.txt
@@ -0,0 +1,4652 @@
+diff --git a/openspace/sandbox/leases.py b/openspace/sandbox/leases.py
+index 3553776..ebe9e55 100644
+--- a/openspace/sandbox/leases.py
++++ b/openspace/sandbox/leases.py
+@@ -143,7 +143,7 @@ class SecretCapability(BaseModel):
+ default_factory=lambda: ["task"],
+ description="Scopes this lease can access (task, session, global)",
+ )
+- allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = none)")
++ allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = unrestricted)")
+ max_secrets: int = Field(default=0, ge=0, le=50, description="Max secrets accessible (0 = none)")
+
+
+diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
+new file mode 100644
+index 0000000..4679650
+--- /dev/null
++++ b/openspace/secret/__init__.py
+@@ -0,0 +1 @@
++"""OpenSpace secret management module."""
+diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
+new file mode 100644
+index 0000000..7789474
+--- /dev/null
++++ b/openspace/secret/broker.py
+@@ -0,0 +1,592 @@
++"""Concrete SecretBrokerPort implementation — EPIC 2.6.
++
++Provides:
++- Scoped secret storage (task, session, global) with lease-based access control.
++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
++- Thread-safe operations with bounded storage per scope.
++- Integration with SecretCapability from lease system and auth token scopes.
++
++Design decisions:
++- Fail-closed: missing capability or insufficient scope → deny.
++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
++- Secrets are stored in-memory only — no persistence across restarts.
++- Scope hierarchy: task < session < global (each is independent namespace).
++- Revocation is immediate and irreversible within a session.
++
++Security requirements:
++- Callers MUST present a valid SecretCapability from their lease.
++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
++- T0/T1 tiers cannot access secrets (enforced by lease validation).
++- Secret values are encrypted at rest in memory to resist heap inspection.
++
++Issues:
++- #52: SecretBrokerPort concrete implementation
++"""
++
++from __future__ import annotations
++
++import base64
++import hashlib
++import hmac
++import os
++import secrets
++import threading
++import time
++from dataclasses import dataclass, field
++from enum import Enum
++from typing import Optional
++
++from openspace.sandbox.leases import SecretCapability
++
++
++# ---------------------------------------------------------------------------
++# Secret Scope Model
++# ---------------------------------------------------------------------------
++
++
++class SecretScope(str, Enum):
++ """Hierarchical secret scopes matching lease capability model."""
++
++ TASK = "task"
++ SESSION = "session"
++ GLOBAL = "global"
++
++
++# Scope hierarchy for validation (higher index = broader access)
++_SCOPE_ORDER: list[SecretScope] = [
++ SecretScope.TASK,
++ SecretScope.SESSION,
++ SecretScope.GLOBAL,
++]
++
++
++# ---------------------------------------------------------------------------
++# Exceptions
++# ---------------------------------------------------------------------------
++
++
++class SecretBrokerError(Exception):
++ """Base for all secret broker errors."""
++
++
++class SecretAccessDenied(SecretBrokerError):
++ """Caller lacks permission to access the requested secret."""
++
++
++class SecretNotFoundError(SecretBrokerError):
++ """Requested secret does not exist."""
++
++
++class SecretStoreFull(SecretBrokerError):
++ """Secret store has reached its capacity limit."""
++
++
++class SecretKeyInvalid(SecretBrokerError):
++ """Secret key is malformed or invalid."""
++
++
++class SecretValueTooLarge(SecretBrokerError):
++ """Secret value exceeds maximum allowed size."""
++
++
++# ---------------------------------------------------------------------------
++# At-Rest Encryption (no external crypto dependencies)
++# ---------------------------------------------------------------------------
++
++
++class _SecretEncryptor:
++ """XOR-based at-rest encryption using HMAC-derived key stream.
++
++ NOT a general-purpose cipher — this provides defense-in-depth against
++ heap inspection only. The real security boundary is access control
++ via capabilities and token scopes.
++ """
++
++ def __init__(self, master_key: bytes) -> None:
++ self._master_key = master_key
++
++ def encrypt(self, plaintext: str) -> bytes:
++ """Encrypt a secret value, returning nonce + ciphertext + HMAC tag.
++
++ Layout: nonce (16) || ciphertext (N) || hmac_tag (32)
++ Integrity is verified on decrypt (encrypt-then-MAC).
++ """
++ nonce = os.urandom(16)
++ plaintext_bytes = plaintext.encode("utf-8")
++ key_stream = self._derive_stream(nonce, len(plaintext_bytes))
++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
++ # Encrypt-then-MAC: HMAC over nonce + ciphertext
++ tag = hmac.new(
++ self._master_key, nonce + ciphertext, hashlib.sha256,
++ ).digest()
++ return nonce + ciphertext + tag
++
++ _TAG_LEN = 32 # HMAC-SHA256 output length
++
++ def decrypt(self, data: bytes) -> str:
++ """Decrypt nonce + ciphertext + HMAC tag back to plaintext.
++
++ Raises SecretBrokerError if data is corrupt or tampered with.
++ """
++ # Minimum: 16 (nonce) + 0 (ciphertext can be empty) + 32 (tag)
++ if len(data) < 16 + self._TAG_LEN:
++ raise SecretBrokerError("Corrupt encrypted data")
++ nonce = data[:16]
++ ciphertext = data[16:-self._TAG_LEN]
++ stored_tag = data[-self._TAG_LEN:]
++ # Verify integrity before decryption
++ expected_tag = hmac.new(
++ self._master_key, nonce + ciphertext, hashlib.sha256,
++ ).digest()
++ if not hmac.compare_digest(stored_tag, expected_tag):
++ raise SecretBrokerError("Encrypted data integrity check failed")
++ key_stream = self._derive_stream(nonce, len(ciphertext))
++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
++ return plaintext_bytes.decode("utf-8")
++
++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
++ """Derive a key stream of given length from nonce + master key."""
++ stream = b""
++ counter = 0
++ while len(stream) < length:
++ block = hmac.new(
++ self._master_key,
++ nonce + counter.to_bytes(4, "big"),
++ hashlib.sha256,
++ ).digest()
++ stream += block
++ counter += 1
++ return stream[:length]
++
++
++# ---------------------------------------------------------------------------
++# Secret Entry
++# ---------------------------------------------------------------------------
++
++
++@dataclass(frozen=True)
++class SecretEntry:
++ """Metadata and encrypted value for a stored secret."""
++
++ key: str
++ scope: SecretScope
++ encrypted_value: bytes
++ owner: str # subject that created the secret
++ created_at: float
++ expires_at: float | None = None # None = no expiry
++
++
++# ---------------------------------------------------------------------------
++# Secret Store (scoped, encrypted, bounded)
++# ---------------------------------------------------------------------------
++
++_MAX_KEY_LENGTH = 256
++_KEY_PATTERN_CHARS = frozenset(
++ "abcdefghijklmnopqrstuvwxyz"
++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
++ "0123456789"
++ "-_./:"
++)
++
++
++def _validate_key(key: str) -> None:
++ """Validate a secret key name."""
++ if not key:
++ raise SecretKeyInvalid("Secret key cannot be empty")
++ if len(key) > _MAX_KEY_LENGTH:
++ raise SecretKeyInvalid(
++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
++ )
++ invalid = set(key) - _KEY_PATTERN_CHARS
++ if invalid:
++ raise SecretKeyInvalid(
++ f"Secret key contains invalid characters: {sorted(invalid)}"
++ )
++
++
++class SecretStore:
++ """Thread-safe, encrypted, scoped secret storage.
++
++ Secrets are organized by scope (task/session/global) and encrypted
++ at rest using HMAC-derived key streams. Each scope has an
++ independent namespace and capacity limit.
++ """
++
++ MAX_SECRETS_PER_SCOPE = 1000
++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
++
++ def __init__(self, encryption_key: bytes | None = None) -> None:
++ self._lock = threading.Lock()
++ self._encryptor = _SecretEncryptor(
++ encryption_key or secrets.token_bytes(32)
++ )
++ # scope -> key -> SecretEntry
++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
++ scope: {} for scope in SecretScope
++ }
++
++ def put(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store or update a secret.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext secret value (encrypted before storage).
++ scope: Secret scope namespace.
++ owner: Subject identity of the caller.
++ expires_at: Optional epoch expiry.
++
++ Raises:
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If the scope has reached capacity.
++ ValueError: If value exceeds maximum length.
++ """
++ _validate_key(key)
++ value_bytes_len = len(value.encode("utf-8"))
++ if value_bytes_len > self.MAX_VALUE_LENGTH:
++ raise SecretValueTooLarge(
++ f"Secret value ({value_bytes_len} bytes) exceeds "
++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ if key not in scope_store:
++ # Purge expired entries before capacity check
++ now = time.time()
++ expired_keys = [
++ k for k, e in scope_store.items()
++ if e.expires_at is not None and e.expires_at <= now
++ ]
++ for k in expired_keys:
++ del scope_store[k]
++ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def put_checked(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ cap_limit: int,
++ ) -> None:
++ """Atomic put with capability-level count check.
++
++ Same as put(), but additionally enforces a per-capability secret
++ count limit *inside* the lock, eliminating TOCTOU races between
++ count() and put() at the broker layer.
++
++ Raises:
++ SecretAccessDenied: If cap_limit would be exceeded for new keys.
++ SecretStoreFull: If scope hard limit is reached.
++ """
++ _validate_key(key)
++ value_bytes_len = len(value.encode("utf-8"))
++ if value_bytes_len > self.MAX_VALUE_LENGTH:
++ raise SecretValueTooLarge(
++ f"Secret value ({value_bytes_len} bytes) exceeds "
++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ now = time.time()
++ # Purge expired entries first to avoid ghost fullness
++ expired_keys = [
++ k for k, e in scope_store.items()
++ if e.expires_at is not None and e.expires_at <= now
++ ]
++ for k in expired_keys:
++ del scope_store[k]
++ # After purge, expired keys are gone — is_new is accurate
++ is_new = key not in scope_store
++ if is_new:
++ # Capability limit: count only secrets owned by this caller
++ owner_count = sum(
++ 1 for e in scope_store.values()
++ if e.owner == owner
++ )
++ if owner_count >= cap_limit:
++ raise SecretAccessDenied(
++ f"Would exceed max_secrets limit ({cap_limit}) "
++ f"for scope '{scope.value}'"
++ )
++ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def get(self, key: str, *, scope: SecretScope) -> str | None:
++ """Retrieve and decrypt a secret value.
++
++ Returns None if the key does not exist or has expired.
++ Expired entries are lazily removed.
++ """
++ with self._lock:
++ entry = self._store[scope].get(key)
++ if entry is None:
++ return None
++ # Lazy expiry
++ if entry.expires_at is not None and time.time() > entry.expires_at:
++ del self._store[scope][key]
++ return None
++ return self._encryptor.decrypt(entry.encrypted_value)
++
++ def delete(self, key: str, *, scope: SecretScope) -> bool:
++ """Delete a secret. Returns True if it existed."""
++ with self._lock:
++ return self._store[scope].pop(key, None) is not None
++
++ def list_keys(self, *, scope: SecretScope) -> list[str]:
++ """List all non-expired secret keys in a scope."""
++ now = time.time()
++ with self._lock:
++ result = []
++ expired = []
++ for k, entry in self._store[scope].items():
++ if entry.expires_at is not None and now > entry.expires_at:
++ expired.append(k)
++ else:
++ result.append(k)
++ # Lazy cleanup
++ for k in expired:
++ del self._store[scope][k]
++ return sorted(result)
++
++ def count(self, *, scope: SecretScope) -> int:
++ """Number of non-expired secrets in a scope."""
++ return len(self.list_keys(scope=scope))
++
++ def clear_scope(self, scope: SecretScope) -> int:
++ """Remove all secrets in a scope. Returns count removed."""
++ with self._lock:
++ count = len(self._store[scope])
++ self._store[scope].clear()
++ return count
++
++
++# ---------------------------------------------------------------------------
++# #52 — SecretBroker (concrete SecretBrokerPort implementation)
++# ---------------------------------------------------------------------------
++
++
++@dataclass
++class SecretBroker:
++ """Concrete implementation of SecretBrokerPort.
++
++ Enforces lease-based access control via SecretCapability and
++ integrates with the scoped SecretStore for encrypted storage.
++
++ Access control layers:
++ 1. Capability check: caller's SecretCapability from lease
++ 2. Scope check: requested scope must be in capability's allowed_scopes
++ 3. Key check: if allowed_keys is non-empty, key must be listed
++ 4. Count check: caller cannot exceed max_secrets from capability
++ 5. Value encryption: all values encrypted at rest
++
++ Args:
++ store: The backing secret store (shared across brokers).
++ default_capability: Fallback capability if none provided per-call.
++ """
++
++ store: SecretStore = field(default_factory=SecretStore)
++ default_capability: SecretCapability = field(
++ default_factory=SecretCapability
++ )
++
++ def _resolve_scope(self, scope: str) -> SecretScope:
++ """Parse and validate a scope string."""
++ try:
++ return SecretScope(scope)
++ except ValueError:
++ raise SecretAccessDenied(
++ f"Invalid scope '{scope}'. "
++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
++ )
++
++ def _check_capability(
++ self,
++ capability: SecretCapability,
++ *,
++ scope: str,
++ key: str | None = None,
++ writing: bool = False,
++ ) -> SecretScope:
++ """Validate access against a SecretCapability.
++
++ Returns the validated SecretScope.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't allow the operation.
++ """
++ # 1. Max secrets check (0 = no access at all)
++ if capability.max_secrets <= 0:
++ raise SecretAccessDenied("Capability grants no secret access")
++
++ # 2. Scope check
++ resolved_scope = self._resolve_scope(scope)
++ if scope not in capability.allowed_scopes:
++ raise SecretAccessDenied(
++ f"Scope '{scope}' not in allowed scopes: "
++ f"{capability.allowed_scopes}"
++ )
++
++ # 3. Key check — empty allowed_keys = unrestricted (all tiers use
++ # empty by default; non-empty means explicit whitelist)
++ if key is not None and capability.allowed_keys:
++ if key not in capability.allowed_keys:
++ raise SecretAccessDenied(
++ f"Key '{key}' not in allowed keys"
++ )
++
++ return resolved_scope
++
++ # --- SecretBrokerPort interface ---
++
++ async def get_secret(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> Optional[str]:
++ """Retrieve a secret value.
++
++ Args:
++ key: Secret key to retrieve.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability (uses default if None).
++
++ Returns:
++ Decrypted secret value, or None if not found.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit access.
++ SecretKeyInvalid: If key is malformed.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.get(key, scope=resolved)
++
++ async def put_secret(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: str = "task",
++ owner: str = "system",
++ capability: SecretCapability | None = None,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store a secret value.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext value to encrypt and store.
++ scope: Secret scope namespace.
++ owner: Identity of the caller.
++ capability: Caller's lease capability.
++ expires_at: Optional epoch expiry for the secret.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit write.
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If scope is at capacity.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(
++ cap, scope=scope, key=key, writing=True,
++ )
++
++ # Atomic put with capability count check inside the lock
++ self.store.put_checked(
++ key, value, scope=resolved, owner=owner,
++ expires_at=expires_at, cap_limit=cap.max_secrets,
++ )
++
++ async def revoke(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> bool:
++ """Revoke (delete) a secret.
++
++ Args:
++ key: Secret key to revoke.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ True if the secret existed and was deleted.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.delete(key, scope=resolved)
++
++ def list_available(
++ self,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> list[str]:
++ """List available secret keys in a scope.
++
++ Args:
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ Sorted list of accessible key names.
++ """
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope)
++
++ all_keys = self.store.list_keys(scope=resolved)
++
++ # Filter to allowed_keys if set
++ if cap.allowed_keys:
++ allowed = set(cap.allowed_keys)
++ return [k for k in all_keys if k in allowed]
++
++ return all_keys
+diff --git a/pr-473-diff-r1.txt b/pr-473-diff-r1.txt
+new file mode 100644
+index 0000000..02ba534
+--- /dev/null
++++ b/pr-473-diff-r1.txt
+@@ -0,0 +1,1067 @@
++diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
++new file mode 100644
++index 0000000..4679650
++--- /dev/null
+++++ b/openspace/secret/__init__.py
++@@ -0,0 +1 @@
+++"""OpenSpace secret management module."""
++diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
++new file mode 100644
++index 0000000..31fb268
++--- /dev/null
+++++ b/openspace/secret/broker.py
++@@ -0,0 +1,497 @@
+++"""Concrete SecretBrokerPort implementation ΓÇö EPIC 2.6.
+++
+++Provides:
+++- Scoped secret storage (task, session, global) with lease-based access control.
+++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
+++- Thread-safe operations with bounded storage per scope.
+++- Integration with SecretCapability from lease system and auth token scopes.
+++
+++Design decisions:
+++- Fail-closed: missing capability or insufficient scope → deny.
+++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
+++- Secrets are stored in-memory only ΓÇö no persistence across restarts.
+++- Scope hierarchy: task < session < global (each is independent namespace).
+++- Revocation is immediate and irreversible within a session.
+++
+++Security requirements:
+++- Callers MUST present a valid SecretCapability from their lease.
+++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
+++- T0/T1 tiers cannot access secrets (enforced by lease validation).
+++- Secret values are encrypted at rest in memory to resist heap inspection.
+++
+++Issues:
+++- #52: SecretBrokerPort concrete implementation
+++"""
+++
+++from __future__ import annotations
+++
+++import base64
+++import hashlib
+++import hmac
+++import os
+++import secrets
+++import threading
+++import time
+++from dataclasses import dataclass, field
+++from enum import Enum
+++from typing import Optional
+++
+++from openspace.sandbox.leases import SecretCapability
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Scope Model
+++# ---------------------------------------------------------------------------
+++
+++
+++class SecretScope(str, Enum):
+++ """Hierarchical secret scopes matching lease capability model."""
+++
+++ TASK = "task"
+++ SESSION = "session"
+++ GLOBAL = "global"
+++
+++
+++# Scope hierarchy for validation (higher index = broader access)
+++_SCOPE_ORDER: list[SecretScope] = [
+++ SecretScope.TASK,
+++ SecretScope.SESSION,
+++ SecretScope.GLOBAL,
+++]
+++
+++
+++# ---------------------------------------------------------------------------
+++# Exceptions
+++# ---------------------------------------------------------------------------
+++
+++
+++class SecretBrokerError(Exception):
+++ """Base for all secret broker errors."""
+++
+++
+++class SecretAccessDenied(SecretBrokerError):
+++ """Caller lacks permission to access the requested secret."""
+++
+++
+++class SecretNotFoundError(SecretBrokerError):
+++ """Requested secret does not exist."""
+++
+++
+++class SecretStoreFull(SecretBrokerError):
+++ """Secret store has reached its capacity limit."""
+++
+++
+++class SecretKeyInvalid(SecretBrokerError):
+++ """Secret key is malformed or invalid."""
+++
+++
+++# ---------------------------------------------------------------------------
+++# At-Rest Encryption (no external crypto dependencies)
+++# ---------------------------------------------------------------------------
+++
+++
+++class _SecretEncryptor:
+++ """XOR-based at-rest encryption using HMAC-derived key stream.
+++
+++ NOT a general-purpose cipher ΓÇö this provides defense-in-depth against
+++ heap inspection only. The real security boundary is access control
+++ via capabilities and token scopes.
+++ """
+++
+++ def __init__(self, master_key: bytes) -> None:
+++ self._master_key = master_key
+++
+++ def encrypt(self, plaintext: str) -> bytes:
+++ """Encrypt a secret value, returning nonce + ciphertext."""
+++ nonce = os.urandom(16)
+++ key_stream = self._derive_stream(nonce, len(plaintext.encode("utf-8")))
+++ plaintext_bytes = plaintext.encode("utf-8")
+++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
+++ return nonce + ciphertext
+++
+++ def decrypt(self, data: bytes) -> str:
+++ """Decrypt nonce + ciphertext back to plaintext."""
+++ if len(data) < 16:
+++ raise SecretBrokerError("Corrupt encrypted data")
+++ nonce = data[:16]
+++ ciphertext = data[16:]
+++ key_stream = self._derive_stream(nonce, len(ciphertext))
+++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
+++ return plaintext_bytes.decode("utf-8")
+++
+++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
+++ """Derive a key stream of given length from nonce + master key."""
+++ stream = b""
+++ counter = 0
+++ while len(stream) < length:
+++ block = hmac.new(
+++ self._master_key,
+++ nonce + counter.to_bytes(4, "big"),
+++ hashlib.sha256,
+++ ).digest()
+++ stream += block
+++ counter += 1
+++ return stream[:length]
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Entry
+++# ---------------------------------------------------------------------------
+++
+++
+++@dataclass(frozen=True)
+++class SecretEntry:
+++ """Metadata and encrypted value for a stored secret."""
+++
+++ key: str
+++ scope: SecretScope
+++ encrypted_value: bytes
+++ owner: str # subject that created the secret
+++ created_at: float
+++ expires_at: float | None = None # None = no expiry
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Store (scoped, encrypted, bounded)
+++# ---------------------------------------------------------------------------
+++
+++_MAX_KEY_LENGTH = 256
+++_KEY_PATTERN_CHARS = frozenset(
+++ "abcdefghijklmnopqrstuvwxyz"
+++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+++ "0123456789"
+++ "-_./:"
+++)
+++
+++
+++def _validate_key(key: str) -> None:
+++ """Validate a secret key name."""
+++ if not key:
+++ raise SecretKeyInvalid("Secret key cannot be empty")
+++ if len(key) > _MAX_KEY_LENGTH:
+++ raise SecretKeyInvalid(
+++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
+++ )
+++ invalid = set(key) - _KEY_PATTERN_CHARS
+++ if invalid:
+++ raise SecretKeyInvalid(
+++ f"Secret key contains invalid characters: {sorted(invalid)}"
+++ )
+++
+++
+++class SecretStore:
+++ """Thread-safe, encrypted, scoped secret storage.
+++
+++ Secrets are organized by scope (task/session/global) and encrypted
+++ at rest using HMAC-derived key streams. Each scope has an
+++ independent namespace and capacity limit.
+++ """
+++
+++ MAX_SECRETS_PER_SCOPE = 1000
+++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
+++
+++ def __init__(self, encryption_key: bytes | None = None) -> None:
+++ self._lock = threading.Lock()
+++ self._encryptor = _SecretEncryptor(
+++ encryption_key or secrets.token_bytes(32)
+++ )
+++ # scope -> key -> SecretEntry
+++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
+++ scope: {} for scope in SecretScope
+++ }
+++
+++ def put(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: SecretScope,
+++ owner: str,
+++ expires_at: float | None = None,
+++ ) -> None:
+++ """Store or update a secret.
+++
+++ Args:
+++ key: Secret key name.
+++ value: Plaintext secret value (encrypted before storage).
+++ scope: Secret scope namespace.
+++ owner: Subject identity of the caller.
+++ expires_at: Optional epoch expiry.
+++
+++ Raises:
+++ SecretKeyInvalid: If key is malformed.
+++ SecretStoreFull: If the scope has reached capacity.
+++ ValueError: If value exceeds maximum length.
+++ """
+++ _validate_key(key)
+++ if len(value) > self.MAX_VALUE_LENGTH:
+++ raise ValueError(
+++ f"Secret value exceeds maximum length ({self.MAX_VALUE_LENGTH})"
+++ )
+++
+++ encrypted = self._encryptor.encrypt(value)
+++ entry = SecretEntry(
+++ key=key,
+++ scope=scope,
+++ encrypted_value=encrypted,
+++ owner=owner,
+++ created_at=time.time(),
+++ expires_at=expires_at,
+++ )
+++
+++ with self._lock:
+++ scope_store = self._store[scope]
+++ # Allow update of existing key without capacity check
+++ if key not in scope_store and len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
+++ raise SecretStoreFull(
+++ f"Scope '{scope.value}' is full "
+++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
+++ )
+++ scope_store[key] = entry
+++
+++ def get(self, key: str, *, scope: SecretScope) -> str | None:
+++ """Retrieve and decrypt a secret value.
+++
+++ Returns None if the key does not exist or has expired.
+++ Expired entries are lazily removed.
+++ """
+++ with self._lock:
+++ entry = self._store[scope].get(key)
+++ if entry is None:
+++ return None
+++ # Lazy expiry
+++ if entry.expires_at is not None and time.time() > entry.expires_at:
+++ del self._store[scope][key]
+++ return None
+++ return self._encryptor.decrypt(entry.encrypted_value)
+++
+++ def delete(self, key: str, *, scope: SecretScope) -> bool:
+++ """Delete a secret. Returns True if it existed."""
+++ with self._lock:
+++ return self._store[scope].pop(key, None) is not None
+++
+++ def list_keys(self, *, scope: SecretScope) -> list[str]:
+++ """List all non-expired secret keys in a scope."""
+++ now = time.time()
+++ with self._lock:
+++ result = []
+++ expired = []
+++ for k, entry in self._store[scope].items():
+++ if entry.expires_at is not None and now > entry.expires_at:
+++ expired.append(k)
+++ else:
+++ result.append(k)
+++ # Lazy cleanup
+++ for k in expired:
+++ del self._store[scope][k]
+++ return sorted(result)
+++
+++ def count(self, *, scope: SecretScope) -> int:
+++ """Number of non-expired secrets in a scope."""
+++ return len(self.list_keys(scope=scope))
+++
+++ def clear_scope(self, scope: SecretScope) -> int:
+++ """Remove all secrets in a scope. Returns count removed."""
+++ with self._lock:
+++ count = len(self._store[scope])
+++ self._store[scope].clear()
+++ return count
+++
+++
+++# ---------------------------------------------------------------------------
+++# #52 ΓÇö SecretBroker (concrete SecretBrokerPort implementation)
+++# ---------------------------------------------------------------------------
+++
+++
+++@dataclass
+++class SecretBroker:
+++ """Concrete implementation of SecretBrokerPort.
+++
+++ Enforces lease-based access control via SecretCapability and
+++ integrates with the scoped SecretStore for encrypted storage.
+++
+++ Access control layers:
+++ 1. Capability check: caller's SecretCapability from lease
+++ 2. Scope check: requested scope must be in capability's allowed_scopes
+++ 3. Key check: if allowed_keys is non-empty, key must be listed
+++ 4. Count check: caller cannot exceed max_secrets from capability
+++ 5. Value encryption: all values encrypted at rest
+++
+++ Args:
+++ store: The backing secret store (shared across brokers).
+++ default_capability: Fallback capability if none provided per-call.
+++ """
+++
+++ store: SecretStore = field(default_factory=SecretStore)
+++ default_capability: SecretCapability = field(
+++ default_factory=SecretCapability
+++ )
+++
+++ def _resolve_scope(self, scope: str) -> SecretScope:
+++ """Parse and validate a scope string."""
+++ try:
+++ return SecretScope(scope)
+++ except ValueError:
+++ raise SecretAccessDenied(
+++ f"Invalid scope '{scope}'. "
+++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
+++ )
+++
+++ def _check_capability(
+++ self,
+++ capability: SecretCapability,
+++ *,
+++ scope: str,
+++ key: str | None = None,
+++ writing: bool = False,
+++ ) -> SecretScope:
+++ """Validate access against a SecretCapability.
+++
+++ Returns the validated SecretScope.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't allow the operation.
+++ """
+++ # 1. Max secrets check (0 = no access at all)
+++ if capability.max_secrets <= 0:
+++ raise SecretAccessDenied("Capability grants no secret access")
+++
+++ # 2. Scope check
+++ resolved_scope = self._resolve_scope(scope)
+++ if scope not in capability.allowed_scopes:
+++ raise SecretAccessDenied(
+++ f"Scope '{scope}' not in allowed scopes: "
+++ f"{capability.allowed_scopes}"
+++ )
+++
+++ # 3. Key check (if allowed_keys is set, key must be listed)
+++ if key is not None and capability.allowed_keys:
+++ if key not in capability.allowed_keys:
+++ raise SecretAccessDenied(
+++ f"Key '{key}' not in allowed keys"
+++ )
+++
+++ return resolved_scope
+++
+++ # --- SecretBrokerPort interface ---
+++
+++ async def get_secret(
+++ self,
+++ key: str,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> Optional[str]:
+++ """Retrieve a secret value.
+++
+++ Args:
+++ key: Secret key to retrieve.
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability (uses default if None).
+++
+++ Returns:
+++ Decrypted secret value, or None if not found.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't permit access.
+++ SecretKeyInvalid: If key is malformed.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope, key=key)
+++ return self.store.get(key, scope=resolved)
+++
+++ async def put_secret(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: str = "task",
+++ owner: str = "system",
+++ capability: SecretCapability | None = None,
+++ expires_at: float | None = None,
+++ ) -> None:
+++ """Store a secret value.
+++
+++ Args:
+++ key: Secret key name.
+++ value: Plaintext value to encrypt and store.
+++ scope: Secret scope namespace.
+++ owner: Identity of the caller.
+++ capability: Caller's lease capability.
+++ expires_at: Optional epoch expiry for the secret.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't permit write.
+++ SecretKeyInvalid: If key is malformed.
+++ SecretStoreFull: If scope is at capacity.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(
+++ cap, scope=scope, key=key, writing=True,
+++ )
+++
+++ # Enforce max_secrets from capability
+++ current_count = self.store.count(scope=resolved)
+++ # Only check limit for new keys
+++ existing = self.store.get(key, scope=resolved)
+++ if existing is None and current_count >= cap.max_secrets:
+++ raise SecretAccessDenied(
+++ f"Would exceed max_secrets limit ({cap.max_secrets}) "
+++ f"for scope '{scope}'"
+++ )
+++
+++ self.store.put(
+++ key, value, scope=resolved, owner=owner,
+++ expires_at=expires_at,
+++ )
+++
+++ async def revoke(
+++ self,
+++ key: str,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> bool:
+++ """Revoke (delete) a secret.
+++
+++ Args:
+++ key: Secret key to revoke.
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability.
+++
+++ Returns:
+++ True if the secret existed and was deleted.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope, key=key)
+++ return self.store.delete(key, scope=resolved)
+++
+++ def list_available(
+++ self,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> list[str]:
+++ """List available secret keys in a scope.
+++
+++ Args:
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability.
+++
+++ Returns:
+++ Sorted list of accessible key names.
+++ """
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope)
+++
+++ all_keys = self.store.list_keys(scope=resolved)
+++
+++ # Filter to allowed_keys if set
+++ if cap.allowed_keys:
+++ allowed = set(cap.allowed_keys)
+++ return [k for k in all_keys if k in allowed]
+++
+++ return all_keys
++diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
++new file mode 100644
++index 0000000..e5067f5
++--- /dev/null
+++++ b/tests/test_secret_broker.py
++@@ -0,0 +1,551 @@
+++"""Tests for openspace.secret.broker ΓÇö EPIC 2.6.
+++
+++Covers:
+++- #52: SecretBrokerPort concrete implementation
+++- Secret scoping (task, session, global)
+++- Lease-based access control via SecretCapability
+++- At-rest encryption/decryption
+++- Key validation and store bounds
+++- Thread safety
+++"""
+++
+++from __future__ import annotations
+++
+++import threading
+++import time
+++import pytest
+++
+++from openspace.secret.broker import (
+++ SecretBroker,
+++ SecretStore,
+++ SecretScope,
+++ SecretEntry,
+++ SecretAccessDenied,
+++ SecretNotFoundError,
+++ SecretStoreFull,
+++ SecretKeyInvalid,
+++ SecretBrokerError,
+++ _SecretEncryptor,
+++ _validate_key,
+++)
+++from openspace.sandbox.leases import SecretCapability
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Key Validation
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestKeyValidation:
+++ """Secret key naming rules."""
+++
+++ def test_valid_keys(self) -> None:
+++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
+++ _validate_key(key) # should not raise
+++
+++ def test_empty_key_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="empty"):
+++ _validate_key("")
+++
+++ def test_too_long_key_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
+++ _validate_key("x" * 257)
+++
+++ def test_invalid_chars_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
+++ _validate_key("key with spaces")
+++
+++ def test_special_chars_rejected(self) -> None:
+++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
+++ with pytest.raises(SecretKeyInvalid):
+++ _validate_key(f"key{ch}")
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# At-Rest Encryption
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretEncryptor:
+++ """XOR-based at-rest encryption."""
+++
+++ def test_roundtrip(self) -> None:
+++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
+++ plaintext = "super-secret-value-123"
+++ encrypted = enc.encrypt(plaintext)
+++ assert enc.decrypt(encrypted) == plaintext
+++
+++ def test_encrypted_differs_from_plaintext(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "my-api-key"
+++ encrypted = enc.encrypt(plaintext)
+++ assert plaintext.encode() not in encrypted
+++
+++ def test_different_nonce_different_ciphertext(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "same-value"
+++ e1 = enc.encrypt(plaintext)
+++ e2 = enc.encrypt(plaintext)
+++ assert e1 != e2 # different nonce → different output
+++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
+++
+++ def test_corrupt_data_rejected(self) -> None:
+++ enc = _SecretEncryptor(b"key")
+++ with pytest.raises(SecretBrokerError, match="Corrupt"):
+++ enc.decrypt(b"short")
+++
+++ def test_empty_string(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ encrypted = enc.encrypt("")
+++ assert enc.decrypt(encrypted) == ""
+++
+++ def test_unicode_roundtrip(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "héllo wörld 🔑"
+++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+++
+++ def test_long_value(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "x" * 10000
+++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Secret Store
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretStore:
+++ """Scoped, encrypted, bounded secret storage."""
+++
+++ def test_put_and_get(self) -> None:
+++ store = SecretStore()
+++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
+++
+++ def test_get_missing_returns_none(self) -> None:
+++ store = SecretStore()
+++ assert store.get("nope", scope=SecretScope.TASK) is None
+++
+++ def test_scopes_are_independent(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
+++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
+++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
+++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
+++ assert store.get("key", scope=SecretScope.GLOBAL) is None
+++
+++ def test_update_existing(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
+++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("key", scope=SecretScope.TASK) == "v2"
+++
+++ def test_delete(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
+++ assert store.delete("key", scope=SecretScope.TASK) is True
+++ assert store.get("key", scope=SecretScope.TASK) is None
+++ assert store.delete("key", scope=SecretScope.TASK) is False
+++
+++ def test_list_keys(self) -> None:
+++ store = SecretStore()
+++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
+++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
+++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
+++
+++ def test_count(self) -> None:
+++ store = SecretStore()
+++ assert store.count(scope=SecretScope.TASK) == 0
+++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+++ assert store.count(scope=SecretScope.TASK) == 2
+++
+++ def test_clear_scope(self) -> None:
+++ store = SecretStore()
+++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
+++ assert store.clear_scope(SecretScope.TASK) == 2
+++ assert store.count(scope=SecretScope.TASK) == 0
+++ assert store.count(scope=SecretScope.SESSION) == 1
+++
+++ def test_capacity_limit(self) -> None:
+++ store = SecretStore()
+++ store.MAX_SECRETS_PER_SCOPE = 3
+++ for i in range(3):
+++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
+++ with pytest.raises(SecretStoreFull, match="full"):
+++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
+++
+++ def test_update_does_not_count_toward_capacity(self) -> None:
+++ store = SecretStore()
+++ store.MAX_SECRETS_PER_SCOPE = 2
+++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
+++ # Update existing ΓÇö should NOT fail
+++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
+++
+++ def test_value_too_long_rejected(self) -> None:
+++ store = SecretStore()
+++ with pytest.raises(ValueError, match="maximum length"):
+++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
+++
+++ def test_lazy_expiry_on_get(self) -> None:
+++ store = SecretStore()
+++ past = time.time() - 10
+++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+++ assert store.get("k", scope=SecretScope.TASK) is None
+++
+++ def test_lazy_expiry_on_list(self) -> None:
+++ store = SecretStore()
+++ past = time.time() - 10
+++ future = time.time() + 3600
+++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
+++ keys = store.list_keys(scope=SecretScope.TASK)
+++ assert keys == ["alive"]
+++
+++ def test_thread_safety(self) -> None:
+++ store = SecretStore()
+++ errors: list[Exception] = []
+++
+++ def write_batch(prefix: str) -> None:
+++ try:
+++ for i in range(50):
+++ store.put(f"{prefix}-{i}", f"val-{i}",
+++ scope=SecretScope.TASK, owner="svc")
+++ except Exception as e:
+++ errors.append(e)
+++
+++ threads = [
+++ threading.Thread(target=write_batch, args=(f"t{n}",))
+++ for n in range(4)
+++ ]
+++ for t in threads:
+++ t.start()
+++ for t in threads:
+++ t.join()
+++
+++ assert not errors
+++ assert store.count(scope=SecretScope.TASK) == 200
+++
+++ def test_encryption_at_rest(self) -> None:
+++ """Stored values are encrypted ΓÇö raw access doesn't reveal plaintext."""
+++ store = SecretStore()
+++ store.put("secret-key", "super-secret-password",
+++ scope=SecretScope.TASK, owner="svc")
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["secret-key"]
+++ assert b"super-secret-password" not in entry.encrypted_value
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Capability-Based Access Control
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerCapability:
+++ """SecretBroker enforces SecretCapability from leases."""
+++
+++ def _t2_capability(self) -> SecretCapability:
+++ """T2-equivalent: 3 secrets, task scope only."""
+++ return SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=3,
+++ )
+++
+++ def _t3_capability(self) -> SecretCapability:
+++ """T3-equivalent: 10 secrets, task + session scopes."""
+++ return SecretCapability(
+++ allowed_scopes=["task", "session"],
+++ max_secrets=10,
+++ )
+++
+++ def _t4_capability(self) -> SecretCapability:
+++ """T4-equivalent: 50 secrets, all scopes."""
+++ return SecretCapability(
+++ allowed_scopes=["task", "session", "global"],
+++ max_secrets=50,
+++ )
+++
+++ def _no_access_capability(self) -> SecretCapability:
+++ """T0/T1-equivalent: no secret access."""
+++ return SecretCapability(
+++ allowed_scopes=[],
+++ max_secrets=0,
+++ )
+++
+++ @pytest.mark.asyncio
+++ async def test_get_with_valid_capability(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
+++ result = await broker.get_secret("api-key", capability=cap)
+++ assert result == "secret"
+++
+++ @pytest.mark.asyncio
+++ async def test_get_missing_returns_none(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ result = await broker.get_secret("nope", capability=cap)
+++ assert result is None
+++
+++ @pytest.mark.asyncio
+++ async def test_no_access_denied(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._no_access_capability()
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_scope_denied(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability() # task only
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("key", scope="session", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_t3_can_access_session(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t3_capability()
+++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
+++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_t3_cannot_access_global(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t3_capability()
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("key", scope="global", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_t4_can_access_all_scopes(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t4_capability()
+++ for scope in ["task", "session", "global"]:
+++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
+++ capability=cap, owner="svc")
+++ assert await broker.get_secret(f"k-{scope}", scope=scope,
+++ capability=cap) == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_allowed_keys_enforced(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["db-pass", "api-key"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
+++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+++ await broker.get_secret("other-key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_max_secrets_enforced(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=2,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
+++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
+++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
+++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
+++
+++ @pytest.mark.asyncio
+++ async def test_update_existing_does_not_hit_limit(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=1,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
+++ # Update should work even at limit
+++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
+++ assert await broker.get_secret("k1", capability=cap) == "v2"
+++
+++ @pytest.mark.asyncio
+++ async def test_invalid_scope_rejected(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
+++ await broker.get_secret("key", scope="invalid", capability=cap)
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Revocation and Listing
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerOperations:
+++ """SecretBroker revoke and list operations."""
+++
+++ def _cap(self) -> SecretCapability:
+++ return SecretCapability(
+++ allowed_scopes=["task", "session"],
+++ max_secrets=10,
+++ )
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_existing(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ await broker.put_secret("k", "v", capability=cap, owner="svc")
+++ assert await broker.revoke("k", capability=cap) is True
+++ assert await broker.get_secret("k", capability=cap) is None
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_nonexistent(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ assert await broker.revoke("nope", capability=cap) is False
+++
+++ @pytest.mark.asyncio
+++ async def test_list_available(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ await broker.put_secret("b", "v", capability=cap, owner="svc")
+++ await broker.put_secret("a", "v", capability=cap, owner="svc")
+++ keys = broker.list_available(capability=cap)
+++ assert keys == ["a", "b"]
+++
+++ @pytest.mark.asyncio
+++ async def test_list_filtered_by_allowed_keys(self) -> None:
+++ store = SecretStore()
+++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["visible"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker(store=store)
+++ keys = broker.list_available(capability=cap)
+++ assert keys == ["visible"]
+++
+++ @pytest.mark.asyncio
+++ async def test_list_empty_scope(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ assert broker.list_available(capability=cap) == []
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_denied_without_scope(self) -> None:
+++ broker = SecretBroker()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ with pytest.raises(SecretAccessDenied):
+++ await broker.revoke("k", scope="session", capability=cap)
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Default Capability
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerDefaults:
+++ """SecretBroker with default_capability."""
+++
+++ @pytest.mark.asyncio
+++ async def test_uses_default_capability(self) -> None:
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(default_capability=cap)
+++ await broker.put_secret("k", "v", owner="svc")
+++ assert await broker.get_secret("k") == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_default_zero_denies(self) -> None:
+++ broker = SecretBroker() # default SecretCapability has max_secrets=0
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("k")
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Security Regression Tests
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerSecurity:
+++ """Security invariants for the secret broker."""
+++
+++ @pytest.mark.asyncio
+++ async def test_t0_t1_cannot_access_secrets(self) -> None:
+++ """T0/T1 equivalent capabilities deny all access."""
+++ broker = SecretBroker()
+++ for cap in [
+++ SecretCapability(allowed_scopes=[], max_secrets=0),
+++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
+++ ]:
+++ with pytest.raises(SecretAccessDenied):
+++ await broker.get_secret("key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_scope_escalation_prevented(self) -> None:
+++ """T2 (task-only) cannot read session secrets."""
+++ store = SecretStore()
+++ store.put("session-secret", "classified",
+++ scope=SecretScope.SESSION, owner="admin")
+++ t2_cap = SecretCapability(
+++ allowed_scopes=["task"], max_secrets=3,
+++ )
+++ broker = SecretBroker(store=store)
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("session-secret", scope="session",
+++ capability=t2_cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_key_restriction_enforced(self) -> None:
+++ """Allowed_keys list is a hard deny for unlisted keys."""
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["safe-key"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker()
+++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
+++
+++ @pytest.mark.asyncio
+++ async def test_encrypted_at_rest_via_broker(self) -> None:
+++ """Values stored through broker are encrypted in the store."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
+++ # Direct store access ΓÇö value should be encrypted
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["api-key"]
+++ assert b"super-secret-123" not in entry.encrypted_value
+++ # But broker decrypts it
+++ assert await broker.get_secret("api-key") == "super-secret-123"
+++
+++ @pytest.mark.asyncio
+++ async def test_deny_before_allow(self) -> None:
+++ """Zero max_secrets denies even if scopes match."""
+++ cap = SecretCapability(
+++ allowed_scopes=["task", "session", "global"],
+++ max_secrets=0,
+++ )
+++ broker = SecretBroker()
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("key", capability=cap)
+++
+++ def test_secret_store_values_not_in_repr(self) -> None:
+++ """SecretEntry encrypted_value should not leak plaintext."""
+++ store = SecretStore()
+++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["key"]
+++ r = repr(entry)
+++ assert "password123" not in r
+++
+++ @pytest.mark.asyncio
+++ async def test_expired_secret_not_accessible(self) -> None:
+++ """Expired secrets return None even through broker."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ past = time.time() - 10
+++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
+++ assert await broker.get_secret("expired") is None
+diff --git a/pr-473-diff-r2.txt b/pr-473-diff-r2.txt
+new file mode 100644
+index 0000000..a9f99fb
+--- /dev/null
++++ b/pr-473-diff-r2.txt
+@@ -0,0 +1,2292 @@
++diff --git a/openspace/sandbox/leases.py b/openspace/sandbox/leases.py
++index 3553776..ebe9e55 100644
++--- a/openspace/sandbox/leases.py
+++++ b/openspace/sandbox/leases.py
++@@ -143,7 +143,7 @@ class SecretCapability(BaseModel):
++ default_factory=lambda: ["task"],
++ description="Scopes this lease can access (task, session, global)",
++ )
++- allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = none)")
+++ allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = unrestricted)")
++ max_secrets: int = Field(default=0, ge=0, le=50, description="Max secrets accessible (0 = none)")
++
++
++diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
++new file mode 100644
++index 0000000..4679650
++--- /dev/null
+++++ b/openspace/secret/__init__.py
++@@ -0,0 +1 @@
+++"""OpenSpace secret management module."""
++diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
++new file mode 100644
++index 0000000..f038e76
++--- /dev/null
+++++ b/openspace/secret/broker.py
++@@ -0,0 +1,576 @@
+++"""Concrete SecretBrokerPort implementation ΓÇö EPIC 2.6.
+++
+++Provides:
+++- Scoped secret storage (task, session, global) with lease-based access control.
+++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
+++- Thread-safe operations with bounded storage per scope.
+++- Integration with SecretCapability from lease system and auth token scopes.
+++
+++Design decisions:
+++- Fail-closed: missing capability or insufficient scope → deny.
+++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
+++- Secrets are stored in-memory only ΓÇö no persistence across restarts.
+++- Scope hierarchy: task < session < global (each is independent namespace).
+++- Revocation is immediate and irreversible within a session.
+++
+++Security requirements:
+++- Callers MUST present a valid SecretCapability from their lease.
+++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
+++- T0/T1 tiers cannot access secrets (enforced by lease validation).
+++- Secret values are encrypted at rest in memory to resist heap inspection.
+++
+++Issues:
+++- #52: SecretBrokerPort concrete implementation
+++"""
+++
+++from __future__ import annotations
+++
+++import base64
+++import hashlib
+++import hmac
+++import os
+++import secrets
+++import threading
+++import time
+++from dataclasses import dataclass, field
+++from enum import Enum
+++from typing import Optional
+++
+++from openspace.sandbox.leases import SecretCapability
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Scope Model
+++# ---------------------------------------------------------------------------
+++
+++
+++class SecretScope(str, Enum):
+++ """Hierarchical secret scopes matching lease capability model."""
+++
+++ TASK = "task"
+++ SESSION = "session"
+++ GLOBAL = "global"
+++
+++
+++# Scope hierarchy for validation (higher index = broader access)
+++_SCOPE_ORDER: list[SecretScope] = [
+++ SecretScope.TASK,
+++ SecretScope.SESSION,
+++ SecretScope.GLOBAL,
+++]
+++
+++
+++# ---------------------------------------------------------------------------
+++# Exceptions
+++# ---------------------------------------------------------------------------
+++
+++
+++class SecretBrokerError(Exception):
+++ """Base for all secret broker errors."""
+++
+++
+++class SecretAccessDenied(SecretBrokerError):
+++ """Caller lacks permission to access the requested secret."""
+++
+++
+++class SecretNotFoundError(SecretBrokerError):
+++ """Requested secret does not exist."""
+++
+++
+++class SecretStoreFull(SecretBrokerError):
+++ """Secret store has reached its capacity limit."""
+++
+++
+++class SecretKeyInvalid(SecretBrokerError):
+++ """Secret key is malformed or invalid."""
+++
+++
+++class SecretValueTooLarge(SecretBrokerError):
+++ """Secret value exceeds maximum allowed size."""
+++
+++
+++# ---------------------------------------------------------------------------
+++# At-Rest Encryption (no external crypto dependencies)
+++# ---------------------------------------------------------------------------
+++
+++
+++class _SecretEncryptor:
+++ """XOR-based at-rest encryption using HMAC-derived key stream.
+++
+++ NOT a general-purpose cipher ΓÇö this provides defense-in-depth against
+++ heap inspection only. The real security boundary is access control
+++ via capabilities and token scopes.
+++ """
+++
+++ def __init__(self, master_key: bytes) -> None:
+++ self._master_key = master_key
+++
+++ def encrypt(self, plaintext: str) -> bytes:
+++ """Encrypt a secret value, returning nonce + ciphertext + HMAC tag.
+++
+++ Layout: nonce (16) || ciphertext (N) || hmac_tag (32)
+++ Integrity is verified on decrypt (encrypt-then-MAC).
+++ """
+++ nonce = os.urandom(16)
+++ plaintext_bytes = plaintext.encode("utf-8")
+++ key_stream = self._derive_stream(nonce, len(plaintext_bytes))
+++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
+++ # Encrypt-then-MAC: HMAC over nonce + ciphertext
+++ tag = hmac.new(
+++ self._master_key, nonce + ciphertext, hashlib.sha256,
+++ ).digest()
+++ return nonce + ciphertext + tag
+++
+++ _TAG_LEN = 32 # HMAC-SHA256 output length
+++
+++ def decrypt(self, data: bytes) -> str:
+++ """Decrypt nonce + ciphertext + HMAC tag back to plaintext.
+++
+++ Raises SecretBrokerError if data is corrupt or tampered with.
+++ """
+++ # Minimum: 16 (nonce) + 0 (ciphertext can be empty) + 32 (tag)
+++ if len(data) < 16 + self._TAG_LEN:
+++ raise SecretBrokerError("Corrupt encrypted data")
+++ nonce = data[:16]
+++ ciphertext = data[16:-self._TAG_LEN]
+++ stored_tag = data[-self._TAG_LEN:]
+++ # Verify integrity before decryption
+++ expected_tag = hmac.new(
+++ self._master_key, nonce + ciphertext, hashlib.sha256,
+++ ).digest()
+++ if not hmac.compare_digest(stored_tag, expected_tag):
+++ raise SecretBrokerError("Encrypted data integrity check failed")
+++ key_stream = self._derive_stream(nonce, len(ciphertext))
+++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
+++ return plaintext_bytes.decode("utf-8")
+++
+++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
+++ """Derive a key stream of given length from nonce + master key."""
+++ stream = b""
+++ counter = 0
+++ while len(stream) < length:
+++ block = hmac.new(
+++ self._master_key,
+++ nonce + counter.to_bytes(4, "big"),
+++ hashlib.sha256,
+++ ).digest()
+++ stream += block
+++ counter += 1
+++ return stream[:length]
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Entry
+++# ---------------------------------------------------------------------------
+++
+++
+++@dataclass(frozen=True)
+++class SecretEntry:
+++ """Metadata and encrypted value for a stored secret."""
+++
+++ key: str
+++ scope: SecretScope
+++ encrypted_value: bytes
+++ owner: str # subject that created the secret
+++ created_at: float
+++ expires_at: float | None = None # None = no expiry
+++
+++
+++# ---------------------------------------------------------------------------
+++# Secret Store (scoped, encrypted, bounded)
+++# ---------------------------------------------------------------------------
+++
+++_MAX_KEY_LENGTH = 256
+++_KEY_PATTERN_CHARS = frozenset(
+++ "abcdefghijklmnopqrstuvwxyz"
+++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+++ "0123456789"
+++ "-_./:"
+++)
+++
+++
+++def _validate_key(key: str) -> None:
+++ """Validate a secret key name."""
+++ if not key:
+++ raise SecretKeyInvalid("Secret key cannot be empty")
+++ if len(key) > _MAX_KEY_LENGTH:
+++ raise SecretKeyInvalid(
+++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
+++ )
+++ invalid = set(key) - _KEY_PATTERN_CHARS
+++ if invalid:
+++ raise SecretKeyInvalid(
+++ f"Secret key contains invalid characters: {sorted(invalid)}"
+++ )
+++
+++
+++class SecretStore:
+++ """Thread-safe, encrypted, scoped secret storage.
+++
+++ Secrets are organized by scope (task/session/global) and encrypted
+++ at rest using HMAC-derived key streams. Each scope has an
+++ independent namespace and capacity limit.
+++ """
+++
+++ MAX_SECRETS_PER_SCOPE = 1000
+++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
+++
+++ def __init__(self, encryption_key: bytes | None = None) -> None:
+++ self._lock = threading.Lock()
+++ self._encryptor = _SecretEncryptor(
+++ encryption_key or secrets.token_bytes(32)
+++ )
+++ # scope -> key -> SecretEntry
+++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
+++ scope: {} for scope in SecretScope
+++ }
+++
+++ def put(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: SecretScope,
+++ owner: str,
+++ expires_at: float | None = None,
+++ ) -> None:
+++ """Store or update a secret.
+++
+++ Args:
+++ key: Secret key name.
+++ value: Plaintext secret value (encrypted before storage).
+++ scope: Secret scope namespace.
+++ owner: Subject identity of the caller.
+++ expires_at: Optional epoch expiry.
+++
+++ Raises:
+++ SecretKeyInvalid: If key is malformed.
+++ SecretStoreFull: If the scope has reached capacity.
+++ ValueError: If value exceeds maximum length.
+++ """
+++ _validate_key(key)
+++ value_bytes_len = len(value.encode("utf-8"))
+++ if value_bytes_len > self.MAX_VALUE_LENGTH:
+++ raise SecretValueTooLarge(
+++ f"Secret value ({value_bytes_len} bytes) exceeds "
+++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
+++ )
+++
+++ encrypted = self._encryptor.encrypt(value)
+++ entry = SecretEntry(
+++ key=key,
+++ scope=scope,
+++ encrypted_value=encrypted,
+++ owner=owner,
+++ created_at=time.time(),
+++ expires_at=expires_at,
+++ )
+++
+++ with self._lock:
+++ scope_store = self._store[scope]
+++ # Allow update of existing key without capacity check
+++ if key not in scope_store and len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
+++ raise SecretStoreFull(
+++ f"Scope '{scope.value}' is full "
+++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
+++ )
+++ scope_store[key] = entry
+++
+++ def put_checked(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: SecretScope,
+++ owner: str,
+++ expires_at: float | None = None,
+++ cap_limit: int,
+++ ) -> None:
+++ """Atomic put with capability-level count check.
+++
+++ Same as put(), but additionally enforces a per-capability secret
+++ count limit *inside* the lock, eliminating TOCTOU races between
+++ count() and put() at the broker layer.
+++
+++ Raises:
+++ SecretAccessDenied: If cap_limit would be exceeded for new keys.
+++ SecretStoreFull: If scope hard limit is reached.
+++ """
+++ _validate_key(key)
+++ value_bytes_len = len(value.encode("utf-8"))
+++ if value_bytes_len > self.MAX_VALUE_LENGTH:
+++ raise SecretValueTooLarge(
+++ f"Secret value ({value_bytes_len} bytes) exceeds "
+++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
+++ )
+++
+++ encrypted = self._encryptor.encrypt(value)
+++ entry = SecretEntry(
+++ key=key,
+++ scope=scope,
+++ encrypted_value=encrypted,
+++ owner=owner,
+++ created_at=time.time(),
+++ expires_at=expires_at,
+++ )
+++
+++ with self._lock:
+++ scope_store = self._store[scope]
+++ is_new = key not in scope_store
+++ if is_new:
+++ # Count non-expired entries for capability limit
+++ now = time.time()
+++ live_count = sum(
+++ 1 for e in scope_store.values()
+++ if e.expires_at is None or e.expires_at > now
+++ )
+++ if live_count >= cap_limit:
+++ raise SecretAccessDenied(
+++ f"Would exceed max_secrets limit ({cap_limit}) "
+++ f"for scope '{scope.value}'"
+++ )
+++ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
+++ raise SecretStoreFull(
+++ f"Scope '{scope.value}' is full "
+++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
+++ )
+++ scope_store[key] = entry
+++
+++ def get(self, key: str, *, scope: SecretScope) -> str | None:
+++ """Retrieve and decrypt a secret value.
+++
+++ Returns None if the key does not exist or has expired.
+++ Expired entries are lazily removed.
+++ """
+++ with self._lock:
+++ entry = self._store[scope].get(key)
+++ if entry is None:
+++ return None
+++ # Lazy expiry
+++ if entry.expires_at is not None and time.time() > entry.expires_at:
+++ del self._store[scope][key]
+++ return None
+++ return self._encryptor.decrypt(entry.encrypted_value)
+++
+++ def delete(self, key: str, *, scope: SecretScope) -> bool:
+++ """Delete a secret. Returns True if it existed."""
+++ with self._lock:
+++ return self._store[scope].pop(key, None) is not None
+++
+++ def list_keys(self, *, scope: SecretScope) -> list[str]:
+++ """List all non-expired secret keys in a scope."""
+++ now = time.time()
+++ with self._lock:
+++ result = []
+++ expired = []
+++ for k, entry in self._store[scope].items():
+++ if entry.expires_at is not None and now > entry.expires_at:
+++ expired.append(k)
+++ else:
+++ result.append(k)
+++ # Lazy cleanup
+++ for k in expired:
+++ del self._store[scope][k]
+++ return sorted(result)
+++
+++ def count(self, *, scope: SecretScope) -> int:
+++ """Number of non-expired secrets in a scope."""
+++ return len(self.list_keys(scope=scope))
+++
+++ def clear_scope(self, scope: SecretScope) -> int:
+++ """Remove all secrets in a scope. Returns count removed."""
+++ with self._lock:
+++ count = len(self._store[scope])
+++ self._store[scope].clear()
+++ return count
+++
+++
+++# ---------------------------------------------------------------------------
+++# #52 ΓÇö SecretBroker (concrete SecretBrokerPort implementation)
+++# ---------------------------------------------------------------------------
+++
+++
+++@dataclass
+++class SecretBroker:
+++ """Concrete implementation of SecretBrokerPort.
+++
+++ Enforces lease-based access control via SecretCapability and
+++ integrates with the scoped SecretStore for encrypted storage.
+++
+++ Access control layers:
+++ 1. Capability check: caller's SecretCapability from lease
+++ 2. Scope check: requested scope must be in capability's allowed_scopes
+++ 3. Key check: if allowed_keys is non-empty, key must be listed
+++ 4. Count check: caller cannot exceed max_secrets from capability
+++ 5. Value encryption: all values encrypted at rest
+++
+++ Args:
+++ store: The backing secret store (shared across brokers).
+++ default_capability: Fallback capability if none provided per-call.
+++ """
+++
+++ store: SecretStore = field(default_factory=SecretStore)
+++ default_capability: SecretCapability = field(
+++ default_factory=SecretCapability
+++ )
+++
+++ def _resolve_scope(self, scope: str) -> SecretScope:
+++ """Parse and validate a scope string."""
+++ try:
+++ return SecretScope(scope)
+++ except ValueError:
+++ raise SecretAccessDenied(
+++ f"Invalid scope '{scope}'. "
+++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
+++ )
+++
+++ def _check_capability(
+++ self,
+++ capability: SecretCapability,
+++ *,
+++ scope: str,
+++ key: str | None = None,
+++ writing: bool = False,
+++ ) -> SecretScope:
+++ """Validate access against a SecretCapability.
+++
+++ Returns the validated SecretScope.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't allow the operation.
+++ """
+++ # 1. Max secrets check (0 = no access at all)
+++ if capability.max_secrets <= 0:
+++ raise SecretAccessDenied("Capability grants no secret access")
+++
+++ # 2. Scope check
+++ resolved_scope = self._resolve_scope(scope)
+++ if scope not in capability.allowed_scopes:
+++ raise SecretAccessDenied(
+++ f"Scope '{scope}' not in allowed scopes: "
+++ f"{capability.allowed_scopes}"
+++ )
+++
+++ # 3. Key check ΓÇö empty allowed_keys = unrestricted (all tiers use
+++ # empty by default; non-empty means explicit whitelist)
+++ if key is not None and capability.allowed_keys:
+++ if key not in capability.allowed_keys:
+++ raise SecretAccessDenied(
+++ f"Key '{key}' not in allowed keys"
+++ )
+++
+++ return resolved_scope
+++
+++ # --- SecretBrokerPort interface ---
+++
+++ async def get_secret(
+++ self,
+++ key: str,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> Optional[str]:
+++ """Retrieve a secret value.
+++
+++ Args:
+++ key: Secret key to retrieve.
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability (uses default if None).
+++
+++ Returns:
+++ Decrypted secret value, or None if not found.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't permit access.
+++ SecretKeyInvalid: If key is malformed.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope, key=key)
+++ return self.store.get(key, scope=resolved)
+++
+++ async def put_secret(
+++ self,
+++ key: str,
+++ value: str,
+++ *,
+++ scope: str = "task",
+++ owner: str = "system",
+++ capability: SecretCapability | None = None,
+++ expires_at: float | None = None,
+++ ) -> None:
+++ """Store a secret value.
+++
+++ Args:
+++ key: Secret key name.
+++ value: Plaintext value to encrypt and store.
+++ scope: Secret scope namespace.
+++ owner: Identity of the caller.
+++ capability: Caller's lease capability.
+++ expires_at: Optional epoch expiry for the secret.
+++
+++ Raises:
+++ SecretAccessDenied: If capability doesn't permit write.
+++ SecretKeyInvalid: If key is malformed.
+++ SecretStoreFull: If scope is at capacity.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(
+++ cap, scope=scope, key=key, writing=True,
+++ )
+++
+++ # Atomic put with capability count check inside the lock
+++ self.store.put_checked(
+++ key, value, scope=resolved, owner=owner,
+++ expires_at=expires_at, cap_limit=cap.max_secrets,
+++ )
+++
+++ async def revoke(
+++ self,
+++ key: str,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> bool:
+++ """Revoke (delete) a secret.
+++
+++ Args:
+++ key: Secret key to revoke.
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability.
+++
+++ Returns:
+++ True if the secret existed and was deleted.
+++ """
+++ _validate_key(key)
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope, key=key)
+++ return self.store.delete(key, scope=resolved)
+++
+++ def list_available(
+++ self,
+++ *,
+++ scope: str = "task",
+++ capability: SecretCapability | None = None,
+++ ) -> list[str]:
+++ """List available secret keys in a scope.
+++
+++ Args:
+++ scope: Secret scope namespace.
+++ capability: Caller's lease capability.
+++
+++ Returns:
+++ Sorted list of accessible key names.
+++ """
+++ cap = capability or self.default_capability
+++ resolved = self._check_capability(cap, scope=scope)
+++
+++ all_keys = self.store.list_keys(scope=resolved)
+++
+++ # Filter to allowed_keys if set
+++ if cap.allowed_keys:
+++ allowed = set(cap.allowed_keys)
+++ return [k for k in all_keys if k in allowed]
+++
+++ return all_keys
++diff --git a/pr-473-diff-r1.txt b/pr-473-diff-r1.txt
++new file mode 100644
++index 0000000..02ba534
++--- /dev/null
+++++ b/pr-473-diff-r1.txt
++@@ -0,0 +1,1067 @@
+++diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
+++new file mode 100644
+++index 0000000..4679650
+++--- /dev/null
++++++ b/openspace/secret/__init__.py
+++@@ -0,0 +1 @@
++++"""OpenSpace secret management module."""
+++diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
+++new file mode 100644
+++index 0000000..31fb268
+++--- /dev/null
++++++ b/openspace/secret/broker.py
+++@@ -0,0 +1,497 @@
++++"""Concrete SecretBrokerPort implementation ΓÇö EPIC 2.6.
++++
++++Provides:
++++- Scoped secret storage (task, session, global) with lease-based access control.
++++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
++++- Thread-safe operations with bounded storage per scope.
++++- Integration with SecretCapability from lease system and auth token scopes.
++++
++++Design decisions:
++++- Fail-closed: missing capability or insufficient scope → deny.
++++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
++++- Secrets are stored in-memory only ΓÇö no persistence across restarts.
++++- Scope hierarchy: task < session < global (each is independent namespace).
++++- Revocation is immediate and irreversible within a session.
++++
++++Security requirements:
++++- Callers MUST present a valid SecretCapability from their lease.
++++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
++++- T0/T1 tiers cannot access secrets (enforced by lease validation).
++++- Secret values are encrypted at rest in memory to resist heap inspection.
++++
++++Issues:
++++- #52: SecretBrokerPort concrete implementation
++++"""
++++
++++from __future__ import annotations
++++
++++import base64
++++import hashlib
++++import hmac
++++import os
++++import secrets
++++import threading
++++import time
++++from dataclasses import dataclass, field
++++from enum import Enum
++++from typing import Optional
++++
++++from openspace.sandbox.leases import SecretCapability
++++
++++
++++# ---------------------------------------------------------------------------
++++# Secret Scope Model
++++# ---------------------------------------------------------------------------
++++
++++
++++class SecretScope(str, Enum):
++++ """Hierarchical secret scopes matching lease capability model."""
++++
++++ TASK = "task"
++++ SESSION = "session"
++++ GLOBAL = "global"
++++
++++
++++# Scope hierarchy for validation (higher index = broader access)
++++_SCOPE_ORDER: list[SecretScope] = [
++++ SecretScope.TASK,
++++ SecretScope.SESSION,
++++ SecretScope.GLOBAL,
++++]
++++
++++
++++# ---------------------------------------------------------------------------
++++# Exceptions
++++# ---------------------------------------------------------------------------
++++
++++
++++class SecretBrokerError(Exception):
++++ """Base for all secret broker errors."""
++++
++++
++++class SecretAccessDenied(SecretBrokerError):
++++ """Caller lacks permission to access the requested secret."""
++++
++++
++++class SecretNotFoundError(SecretBrokerError):
++++ """Requested secret does not exist."""
++++
++++
++++class SecretStoreFull(SecretBrokerError):
++++ """Secret store has reached its capacity limit."""
++++
++++
++++class SecretKeyInvalid(SecretBrokerError):
++++ """Secret key is malformed or invalid."""
++++
++++
++++# ---------------------------------------------------------------------------
++++# At-Rest Encryption (no external crypto dependencies)
++++# ---------------------------------------------------------------------------
++++
++++
++++class _SecretEncryptor:
++++ """XOR-based at-rest encryption using HMAC-derived key stream.
++++
++++ NOT a general-purpose cipher ΓÇö this provides defense-in-depth against
++++ heap inspection only. The real security boundary is access control
++++ via capabilities and token scopes.
++++ """
++++
++++ def __init__(self, master_key: bytes) -> None:
++++ self._master_key = master_key
++++
++++ def encrypt(self, plaintext: str) -> bytes:
++++ """Encrypt a secret value, returning nonce + ciphertext."""
++++ nonce = os.urandom(16)
++++ key_stream = self._derive_stream(nonce, len(plaintext.encode("utf-8")))
++++ plaintext_bytes = plaintext.encode("utf-8")
++++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
++++ return nonce + ciphertext
++++
++++ def decrypt(self, data: bytes) -> str:
++++ """Decrypt nonce + ciphertext back to plaintext."""
++++ if len(data) < 16:
++++ raise SecretBrokerError("Corrupt encrypted data")
++++ nonce = data[:16]
++++ ciphertext = data[16:]
++++ key_stream = self._derive_stream(nonce, len(ciphertext))
++++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
++++ return plaintext_bytes.decode("utf-8")
++++
++++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
++++ """Derive a key stream of given length from nonce + master key."""
++++ stream = b""
++++ counter = 0
++++ while len(stream) < length:
++++ block = hmac.new(
++++ self._master_key,
++++ nonce + counter.to_bytes(4, "big"),
++++ hashlib.sha256,
++++ ).digest()
++++ stream += block
++++ counter += 1
++++ return stream[:length]
++++
++++
++++# ---------------------------------------------------------------------------
++++# Secret Entry
++++# ---------------------------------------------------------------------------
++++
++++
++++@dataclass(frozen=True)
++++class SecretEntry:
++++ """Metadata and encrypted value for a stored secret."""
++++
++++ key: str
++++ scope: SecretScope
++++ encrypted_value: bytes
++++ owner: str # subject that created the secret
++++ created_at: float
++++ expires_at: float | None = None # None = no expiry
++++
++++
++++# ---------------------------------------------------------------------------
++++# Secret Store (scoped, encrypted, bounded)
++++# ---------------------------------------------------------------------------
++++
++++_MAX_KEY_LENGTH = 256
++++_KEY_PATTERN_CHARS = frozenset(
++++ "abcdefghijklmnopqrstuvwxyz"
++++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
++++ "0123456789"
++++ "-_./:"
++++)
++++
++++
++++def _validate_key(key: str) -> None:
++++ """Validate a secret key name."""
++++ if not key:
++++ raise SecretKeyInvalid("Secret key cannot be empty")
++++ if len(key) > _MAX_KEY_LENGTH:
++++ raise SecretKeyInvalid(
++++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
++++ )
++++ invalid = set(key) - _KEY_PATTERN_CHARS
++++ if invalid:
++++ raise SecretKeyInvalid(
++++ f"Secret key contains invalid characters: {sorted(invalid)}"
++++ )
++++
++++
++++class SecretStore:
++++ """Thread-safe, encrypted, scoped secret storage.
++++
++++ Secrets are organized by scope (task/session/global) and encrypted
++++ at rest using HMAC-derived key streams. Each scope has an
++++ independent namespace and capacity limit.
++++ """
++++
++++ MAX_SECRETS_PER_SCOPE = 1000
++++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
++++
++++ def __init__(self, encryption_key: bytes | None = None) -> None:
++++ self._lock = threading.Lock()
++++ self._encryptor = _SecretEncryptor(
++++ encryption_key or secrets.token_bytes(32)
++++ )
++++ # scope -> key -> SecretEntry
++++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
++++ scope: {} for scope in SecretScope
++++ }
++++
++++ def put(
++++ self,
++++ key: str,
++++ value: str,
++++ *,
++++ scope: SecretScope,
++++ owner: str,
++++ expires_at: float | None = None,
++++ ) -> None:
++++ """Store or update a secret.
++++
++++ Args:
++++ key: Secret key name.
++++ value: Plaintext secret value (encrypted before storage).
++++ scope: Secret scope namespace.
++++ owner: Subject identity of the caller.
++++ expires_at: Optional epoch expiry.
++++
++++ Raises:
++++ SecretKeyInvalid: If key is malformed.
++++ SecretStoreFull: If the scope has reached capacity.
++++ ValueError: If value exceeds maximum length.
++++ """
++++ _validate_key(key)
++++ if len(value) > self.MAX_VALUE_LENGTH:
++++ raise ValueError(
++++ f"Secret value exceeds maximum length ({self.MAX_VALUE_LENGTH})"
++++ )
++++
++++ encrypted = self._encryptor.encrypt(value)
++++ entry = SecretEntry(
++++ key=key,
++++ scope=scope,
++++ encrypted_value=encrypted,
++++ owner=owner,
++++ created_at=time.time(),
++++ expires_at=expires_at,
++++ )
++++
++++ with self._lock:
++++ scope_store = self._store[scope]
++++ # Allow update of existing key without capacity check
++++ if key not in scope_store and len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++++ raise SecretStoreFull(
++++ f"Scope '{scope.value}' is full "
++++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++++ )
++++ scope_store[key] = entry
++++
++++ def get(self, key: str, *, scope: SecretScope) -> str | None:
++++ """Retrieve and decrypt a secret value.
++++
++++ Returns None if the key does not exist or has expired.
++++ Expired entries are lazily removed.
++++ """
++++ with self._lock:
++++ entry = self._store[scope].get(key)
++++ if entry is None:
++++ return None
++++ # Lazy expiry
++++ if entry.expires_at is not None and time.time() > entry.expires_at:
++++ del self._store[scope][key]
++++ return None
++++ return self._encryptor.decrypt(entry.encrypted_value)
++++
++++ def delete(self, key: str, *, scope: SecretScope) -> bool:
++++ """Delete a secret. Returns True if it existed."""
++++ with self._lock:
++++ return self._store[scope].pop(key, None) is not None
++++
++++ def list_keys(self, *, scope: SecretScope) -> list[str]:
++++ """List all non-expired secret keys in a scope."""
++++ now = time.time()
++++ with self._lock:
++++ result = []
++++ expired = []
++++ for k, entry in self._store[scope].items():
++++ if entry.expires_at is not None and now > entry.expires_at:
++++ expired.append(k)
++++ else:
++++ result.append(k)
++++ # Lazy cleanup
++++ for k in expired:
++++ del self._store[scope][k]
++++ return sorted(result)
++++
++++ def count(self, *, scope: SecretScope) -> int:
++++ """Number of non-expired secrets in a scope."""
++++ return len(self.list_keys(scope=scope))
++++
++++ def clear_scope(self, scope: SecretScope) -> int:
++++ """Remove all secrets in a scope. Returns count removed."""
++++ with self._lock:
++++ count = len(self._store[scope])
++++ self._store[scope].clear()
++++ return count
++++
++++
++++# ---------------------------------------------------------------------------
++++# #52 ΓÇö SecretBroker (concrete SecretBrokerPort implementation)
++++# ---------------------------------------------------------------------------
++++
++++
++++@dataclass
++++class SecretBroker:
++++ """Concrete implementation of SecretBrokerPort.
++++
++++ Enforces lease-based access control via SecretCapability and
++++ integrates with the scoped SecretStore for encrypted storage.
++++
++++ Access control layers:
++++ 1. Capability check: caller's SecretCapability from lease
++++ 2. Scope check: requested scope must be in capability's allowed_scopes
++++ 3. Key check: if allowed_keys is non-empty, key must be listed
++++ 4. Count check: caller cannot exceed max_secrets from capability
++++ 5. Value encryption: all values encrypted at rest
++++
++++ Args:
++++ store: The backing secret store (shared across brokers).
++++ default_capability: Fallback capability if none provided per-call.
++++ """
++++
++++ store: SecretStore = field(default_factory=SecretStore)
++++ default_capability: SecretCapability = field(
++++ default_factory=SecretCapability
++++ )
++++
++++ def _resolve_scope(self, scope: str) -> SecretScope:
++++ """Parse and validate a scope string."""
++++ try:
++++ return SecretScope(scope)
++++ except ValueError:
++++ raise SecretAccessDenied(
++++ f"Invalid scope '{scope}'. "
++++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
++++ )
++++
++++ def _check_capability(
++++ self,
++++ capability: SecretCapability,
++++ *,
++++ scope: str,
++++ key: str | None = None,
++++ writing: bool = False,
++++ ) -> SecretScope:
++++ """Validate access against a SecretCapability.
++++
++++ Returns the validated SecretScope.
++++
++++ Raises:
++++ SecretAccessDenied: If capability doesn't allow the operation.
++++ """
++++ # 1. Max secrets check (0 = no access at all)
++++ if capability.max_secrets <= 0:
++++ raise SecretAccessDenied("Capability grants no secret access")
++++
++++ # 2. Scope check
++++ resolved_scope = self._resolve_scope(scope)
++++ if scope not in capability.allowed_scopes:
++++ raise SecretAccessDenied(
++++ f"Scope '{scope}' not in allowed scopes: "
++++ f"{capability.allowed_scopes}"
++++ )
++++
++++ # 3. Key check (if allowed_keys is set, key must be listed)
++++ if key is not None and capability.allowed_keys:
++++ if key not in capability.allowed_keys:
++++ raise SecretAccessDenied(
++++ f"Key '{key}' not in allowed keys"
++++ )
++++
++++ return resolved_scope
++++
++++ # --- SecretBrokerPort interface ---
++++
++++ async def get_secret(
++++ self,
++++ key: str,
++++ *,
++++ scope: str = "task",
++++ capability: SecretCapability | None = None,
++++ ) -> Optional[str]:
++++ """Retrieve a secret value.
++++
++++ Args:
++++ key: Secret key to retrieve.
++++ scope: Secret scope namespace.
++++ capability: Caller's lease capability (uses default if None).
++++
++++ Returns:
++++ Decrypted secret value, or None if not found.
++++
++++ Raises:
++++ SecretAccessDenied: If capability doesn't permit access.
++++ SecretKeyInvalid: If key is malformed.
++++ """
++++ _validate_key(key)
++++ cap = capability or self.default_capability
++++ resolved = self._check_capability(cap, scope=scope, key=key)
++++ return self.store.get(key, scope=resolved)
++++
++++ async def put_secret(
++++ self,
++++ key: str,
++++ value: str,
++++ *,
++++ scope: str = "task",
++++ owner: str = "system",
++++ capability: SecretCapability | None = None,
++++ expires_at: float | None = None,
++++ ) -> None:
++++ """Store a secret value.
++++
++++ Args:
++++ key: Secret key name.
++++ value: Plaintext value to encrypt and store.
++++ scope: Secret scope namespace.
++++ owner: Identity of the caller.
++++ capability: Caller's lease capability.
++++ expires_at: Optional epoch expiry for the secret.
++++
++++ Raises:
++++ SecretAccessDenied: If capability doesn't permit write.
++++ SecretKeyInvalid: If key is malformed.
++++ SecretStoreFull: If scope is at capacity.
++++ """
++++ _validate_key(key)
++++ cap = capability or self.default_capability
++++ resolved = self._check_capability(
++++ cap, scope=scope, key=key, writing=True,
++++ )
++++
++++ # Enforce max_secrets from capability
++++ current_count = self.store.count(scope=resolved)
++++ # Only check limit for new keys
++++ existing = self.store.get(key, scope=resolved)
++++ if existing is None and current_count >= cap.max_secrets:
++++ raise SecretAccessDenied(
++++ f"Would exceed max_secrets limit ({cap.max_secrets}) "
++++ f"for scope '{scope}'"
++++ )
++++
++++ self.store.put(
++++ key, value, scope=resolved, owner=owner,
++++ expires_at=expires_at,
++++ )
++++
++++ async def revoke(
++++ self,
++++ key: str,
++++ *,
++++ scope: str = "task",
++++ capability: SecretCapability | None = None,
++++ ) -> bool:
++++ """Revoke (delete) a secret.
++++
++++ Args:
++++ key: Secret key to revoke.
++++ scope: Secret scope namespace.
++++ capability: Caller's lease capability.
++++
++++ Returns:
++++ True if the secret existed and was deleted.
++++ """
++++ _validate_key(key)
++++ cap = capability or self.default_capability
++++ resolved = self._check_capability(cap, scope=scope, key=key)
++++ return self.store.delete(key, scope=resolved)
++++
++++ def list_available(
++++ self,
++++ *,
++++ scope: str = "task",
++++ capability: SecretCapability | None = None,
++++ ) -> list[str]:
++++ """List available secret keys in a scope.
++++
++++ Args:
++++ scope: Secret scope namespace.
++++ capability: Caller's lease capability.
++++
++++ Returns:
++++ Sorted list of accessible key names.
++++ """
++++ cap = capability or self.default_capability
++++ resolved = self._check_capability(cap, scope=scope)
++++
++++ all_keys = self.store.list_keys(scope=resolved)
++++
++++ # Filter to allowed_keys if set
++++ if cap.allowed_keys:
++++ allowed = set(cap.allowed_keys)
++++ return [k for k in all_keys if k in allowed]
++++
++++ return all_keys
+++diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
+++new file mode 100644
+++index 0000000..e5067f5
+++--- /dev/null
++++++ b/tests/test_secret_broker.py
+++@@ -0,0 +1,551 @@
++++"""Tests for openspace.secret.broker ΓÇö EPIC 2.6.
++++
++++Covers:
++++- #52: SecretBrokerPort concrete implementation
++++- Secret scoping (task, session, global)
++++- Lease-based access control via SecretCapability
++++- At-rest encryption/decryption
++++- Key validation and store bounds
++++- Thread safety
++++"""
++++
++++from __future__ import annotations
++++
++++import threading
++++import time
++++import pytest
++++
++++from openspace.secret.broker import (
++++ SecretBroker,
++++ SecretStore,
++++ SecretScope,
++++ SecretEntry,
++++ SecretAccessDenied,
++++ SecretNotFoundError,
++++ SecretStoreFull,
++++ SecretKeyInvalid,
++++ SecretBrokerError,
++++ _SecretEncryptor,
++++ _validate_key,
++++)
++++from openspace.sandbox.leases import SecretCapability
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# Key Validation
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestKeyValidation:
++++ """Secret key naming rules."""
++++
++++ def test_valid_keys(self) -> None:
++++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
++++ _validate_key(key) # should not raise
++++
++++ def test_empty_key_rejected(self) -> None:
++++ with pytest.raises(SecretKeyInvalid, match="empty"):
++++ _validate_key("")
++++
++++ def test_too_long_key_rejected(self) -> None:
++++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
++++ _validate_key("x" * 257)
++++
++++ def test_invalid_chars_rejected(self) -> None:
++++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
++++ _validate_key("key with spaces")
++++
++++ def test_special_chars_rejected(self) -> None:
++++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
++++ with pytest.raises(SecretKeyInvalid):
++++ _validate_key(f"key{ch}")
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# At-Rest Encryption
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestSecretEncryptor:
++++ """XOR-based at-rest encryption."""
++++
++++ def test_roundtrip(self) -> None:
++++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
++++ plaintext = "super-secret-value-123"
++++ encrypted = enc.encrypt(plaintext)
++++ assert enc.decrypt(encrypted) == plaintext
++++
++++ def test_encrypted_differs_from_plaintext(self) -> None:
++++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++++ plaintext = "my-api-key"
++++ encrypted = enc.encrypt(plaintext)
++++ assert plaintext.encode() not in encrypted
++++
++++ def test_different_nonce_different_ciphertext(self) -> None:
++++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++++ plaintext = "same-value"
++++ e1 = enc.encrypt(plaintext)
++++ e2 = enc.encrypt(plaintext)
++++ assert e1 != e2 # different nonce → different output
++++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
++++
++++ def test_corrupt_data_rejected(self) -> None:
++++ enc = _SecretEncryptor(b"key")
++++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++++ enc.decrypt(b"short")
++++
++++ def test_empty_string(self) -> None:
++++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++++ encrypted = enc.encrypt("")
++++ assert enc.decrypt(encrypted) == ""
++++
++++ def test_unicode_roundtrip(self) -> None:
++++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++++ plaintext = "héllo wörld 🔑"
++++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++++
++++ def test_long_value(self) -> None:
++++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++++ plaintext = "x" * 10000
++++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# Secret Store
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestSecretStore:
++++ """Scoped, encrypted, bounded secret storage."""
++++
++++ def test_put_and_get(self) -> None:
++++ store = SecretStore()
++++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
++++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
++++
++++ def test_get_missing_returns_none(self) -> None:
++++ store = SecretStore()
++++ assert store.get("nope", scope=SecretScope.TASK) is None
++++
++++ def test_scopes_are_independent(self) -> None:
++++ store = SecretStore()
++++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
++++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
++++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
++++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
++++ assert store.get("key", scope=SecretScope.GLOBAL) is None
++++
++++ def test_update_existing(self) -> None:
++++ store = SecretStore()
++++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
++++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
++++ assert store.get("key", scope=SecretScope.TASK) == "v2"
++++
++++ def test_delete(self) -> None:
++++ store = SecretStore()
++++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
++++ assert store.delete("key", scope=SecretScope.TASK) is True
++++ assert store.get("key", scope=SecretScope.TASK) is None
++++ assert store.delete("key", scope=SecretScope.TASK) is False
++++
++++ def test_list_keys(self) -> None:
++++ store = SecretStore()
++++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
++++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
++++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
++++
++++ def test_count(self) -> None:
++++ store = SecretStore()
++++ assert store.count(scope=SecretScope.TASK) == 0
++++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++++ assert store.count(scope=SecretScope.TASK) == 2
++++
++++ def test_clear_scope(self) -> None:
++++ store = SecretStore()
++++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
++++ assert store.clear_scope(SecretScope.TASK) == 2
++++ assert store.count(scope=SecretScope.TASK) == 0
++++ assert store.count(scope=SecretScope.SESSION) == 1
++++
++++ def test_capacity_limit(self) -> None:
++++ store = SecretStore()
++++ store.MAX_SECRETS_PER_SCOPE = 3
++++ for i in range(3):
++++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
++++ with pytest.raises(SecretStoreFull, match="full"):
++++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
++++
++++ def test_update_does_not_count_toward_capacity(self) -> None:
++++ store = SecretStore()
++++ store.MAX_SECRETS_PER_SCOPE = 2
++++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
++++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
++++ # Update existing ΓÇö should NOT fail
++++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
++++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
++++
++++ def test_value_too_long_rejected(self) -> None:
++++ store = SecretStore()
++++ with pytest.raises(ValueError, match="maximum length"):
++++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
++++
++++ def test_lazy_expiry_on_get(self) -> None:
++++ store = SecretStore()
++++ past = time.time() - 10
++++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++++ assert store.get("k", scope=SecretScope.TASK) is None
++++
++++ def test_lazy_expiry_on_list(self) -> None:
++++ store = SecretStore()
++++ past = time.time() - 10
++++ future = time.time() + 3600
++++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
++++ keys = store.list_keys(scope=SecretScope.TASK)
++++ assert keys == ["alive"]
++++
++++ def test_thread_safety(self) -> None:
++++ store = SecretStore()
++++ errors: list[Exception] = []
++++
++++ def write_batch(prefix: str) -> None:
++++ try:
++++ for i in range(50):
++++ store.put(f"{prefix}-{i}", f"val-{i}",
++++ scope=SecretScope.TASK, owner="svc")
++++ except Exception as e:
++++ errors.append(e)
++++
++++ threads = [
++++ threading.Thread(target=write_batch, args=(f"t{n}",))
++++ for n in range(4)
++++ ]
++++ for t in threads:
++++ t.start()
++++ for t in threads:
++++ t.join()
++++
++++ assert not errors
++++ assert store.count(scope=SecretScope.TASK) == 200
++++
++++ def test_encryption_at_rest(self) -> None:
++++ """Stored values are encrypted ΓÇö raw access doesn't reveal plaintext."""
++++ store = SecretStore()
++++ store.put("secret-key", "super-secret-password",
++++ scope=SecretScope.TASK, owner="svc")
++++ with store._lock:
++++ entry = store._store[SecretScope.TASK]["secret-key"]
++++ assert b"super-secret-password" not in entry.encrypted_value
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# SecretBroker ΓÇö Capability-Based Access Control
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestSecretBrokerCapability:
++++ """SecretBroker enforces SecretCapability from leases."""
++++
++++ def _t2_capability(self) -> SecretCapability:
++++ """T2-equivalent: 3 secrets, task scope only."""
++++ return SecretCapability(
++++ allowed_scopes=["task"],
++++ max_secrets=3,
++++ )
++++
++++ def _t3_capability(self) -> SecretCapability:
++++ """T3-equivalent: 10 secrets, task + session scopes."""
++++ return SecretCapability(
++++ allowed_scopes=["task", "session"],
++++ max_secrets=10,
++++ )
++++
++++ def _t4_capability(self) -> SecretCapability:
++++ """T4-equivalent: 50 secrets, all scopes."""
++++ return SecretCapability(
++++ allowed_scopes=["task", "session", "global"],
++++ max_secrets=50,
++++ )
++++
++++ def _no_access_capability(self) -> SecretCapability:
++++ """T0/T1-equivalent: no secret access."""
++++ return SecretCapability(
++++ allowed_scopes=[],
++++ max_secrets=0,
++++ )
++++
++++ @pytest.mark.asyncio
++++ async def test_get_with_valid_capability(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t2_capability()
++++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
++++ result = await broker.get_secret("api-key", capability=cap)
++++ assert result == "secret"
++++
++++ @pytest.mark.asyncio
++++ async def test_get_missing_returns_none(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t2_capability()
++++ result = await broker.get_secret("nope", capability=cap)
++++ assert result is None
++++
++++ @pytest.mark.asyncio
++++ async def test_no_access_denied(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._no_access_capability()
++++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++++ await broker.get_secret("key", capability=cap)
++++
++++ @pytest.mark.asyncio
++++ async def test_scope_denied(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t2_capability() # task only
++++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++++ await broker.get_secret("key", scope="session", capability=cap)
++++
++++ @pytest.mark.asyncio
++++ async def test_t3_can_access_session(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t3_capability()
++++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
++++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
++++
++++ @pytest.mark.asyncio
++++ async def test_t3_cannot_access_global(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t3_capability()
++++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++++ await broker.get_secret("key", scope="global", capability=cap)
++++
++++ @pytest.mark.asyncio
++++ async def test_t4_can_access_all_scopes(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t4_capability()
++++ for scope in ["task", "session", "global"]:
++++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
++++ capability=cap, owner="svc")
++++ assert await broker.get_secret(f"k-{scope}", scope=scope,
++++ capability=cap) == "v"
++++
++++ @pytest.mark.asyncio
++++ async def test_allowed_keys_enforced(self) -> None:
++++ cap = SecretCapability(
++++ allowed_scopes=["task"],
++++ allowed_keys=["db-pass", "api-key"],
++++ max_secrets=5,
++++ )
++++ broker = SecretBroker()
++++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
++++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++++ await broker.get_secret("other-key", capability=cap)
++++
++++ @pytest.mark.asyncio
++++ async def test_max_secrets_enforced(self) -> None:
++++ cap = SecretCapability(
++++ allowed_scopes=["task"],
++++ max_secrets=2,
++++ )
++++ broker = SecretBroker()
++++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
++++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
++++
++++ @pytest.mark.asyncio
++++ async def test_update_existing_does_not_hit_limit(self) -> None:
++++ cap = SecretCapability(
++++ allowed_scopes=["task"],
++++ max_secrets=1,
++++ )
++++ broker = SecretBroker()
++++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++++ # Update should work even at limit
++++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
++++ assert await broker.get_secret("k1", capability=cap) == "v2"
++++
++++ @pytest.mark.asyncio
++++ async def test_invalid_scope_rejected(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._t2_capability()
++++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
++++ await broker.get_secret("key", scope="invalid", capability=cap)
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# SecretBroker ΓÇö Revocation and Listing
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestSecretBrokerOperations:
++++ """SecretBroker revoke and list operations."""
++++
++++ def _cap(self) -> SecretCapability:
++++ return SecretCapability(
++++ allowed_scopes=["task", "session"],
++++ max_secrets=10,
++++ )
++++
++++ @pytest.mark.asyncio
++++ async def test_revoke_existing(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._cap()
++++ await broker.put_secret("k", "v", capability=cap, owner="svc")
++++ assert await broker.revoke("k", capability=cap) is True
++++ assert await broker.get_secret("k", capability=cap) is None
++++
++++ @pytest.mark.asyncio
++++ async def test_revoke_nonexistent(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._cap()
++++ assert await broker.revoke("nope", capability=cap) is False
++++
++++ @pytest.mark.asyncio
++++ async def test_list_available(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._cap()
++++ await broker.put_secret("b", "v", capability=cap, owner="svc")
++++ await broker.put_secret("a", "v", capability=cap, owner="svc")
++++ keys = broker.list_available(capability=cap)
++++ assert keys == ["a", "b"]
++++
++++ @pytest.mark.asyncio
++++ async def test_list_filtered_by_allowed_keys(self) -> None:
++++ store = SecretStore()
++++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
++++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
++++ cap = SecretCapability(
++++ allowed_scopes=["task"],
++++ allowed_keys=["visible"],
++++ max_secrets=5,
++++ )
++++ broker = SecretBroker(store=store)
++++ keys = broker.list_available(capability=cap)
++++ assert keys == ["visible"]
++++
++++ @pytest.mark.asyncio
++++ async def test_list_empty_scope(self) -> None:
++++ broker = SecretBroker()
++++ cap = self._cap()
++++ assert broker.list_available(capability=cap) == []
++++
++++ @pytest.mark.asyncio
++++ async def test_revoke_denied_without_scope(self) -> None:
++++ broker = SecretBroker()
++++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++++ with pytest.raises(SecretAccessDenied):
++++ await broker.revoke("k", scope="session", capability=cap)
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# SecretBroker ΓÇö Default Capability
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestSecretBrokerDefaults:
++++ """SecretBroker with default_capability."""
++++
++++ @pytest.mark.asyncio
++++ async def test_uses_default_capability(self) -> None:
++++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++++ broker = SecretBroker(default_capability=cap)
++++ await broker.put_secret("k", "v", owner="svc")
++++ assert await broker.get_secret("k") == "v"
++++
++++ @pytest.mark.asyncio
++++ async def test_default_zero_denies(self) -> None:
++++ broker = SecretBroker() # default SecretCapability has max_secrets=0
++++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++++ await broker.get_secret("k")
++++
++++
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++# Security Regression Tests
++++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
++++
++++
++++class TestSecretBrokerSecurity:
++++ """Security invariants for the secret broker."""
++++
++++ @pytest.mark.asyncio
++++ async def test_t0_t1_cannot_access_secrets(self) -> None:
++++ """T0/T1 equivalent capabilities deny all access."""
++++ broker = SecretBroker()
++++ for cap in [
++++ SecretCapability(allowed_scopes=[], max_secrets=0),
++++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
++++ ]:
++++ with pytest.raises(SecretAccessDenied):
++++ await broker.get_secret("key", capability=cap)
++++
++++ @pytest.mark.asyncio
++++ async def test_scope_escalation_prevented(self) -> None:
++++ """T2 (task-only) cannot read session secrets."""
++++ store = SecretStore()
++++ store.put("session-secret", "classified",
++++ scope=SecretScope.SESSION, owner="admin")
++++ t2_cap = SecretCapability(
++++ allowed_scopes=["task"], max_secrets=3,
++++ )
++++ broker = SecretBroker(store=store)
++++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++++ await broker.get_secret("session-secret", scope="session",
++++ capability=t2_cap)
++++
++++ @pytest.mark.asyncio
++++ async def test_key_restriction_enforced(self) -> None:
++++ """Allowed_keys list is a hard deny for unlisted keys."""
++++ cap = SecretCapability(
++++ allowed_scopes=["task"],
++++ allowed_keys=["safe-key"],
++++ max_secrets=5,
++++ )
++++ broker = SecretBroker()
++++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
++++
++++ @pytest.mark.asyncio
++++ async def test_encrypted_at_rest_via_broker(self) -> None:
++++ """Values stored through broker are encrypted in the store."""
++++ store = SecretStore()
++++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++++ broker = SecretBroker(store=store, default_capability=cap)
++++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
++++ # Direct store access ΓÇö value should be encrypted
++++ with store._lock:
++++ entry = store._store[SecretScope.TASK]["api-key"]
++++ assert b"super-secret-123" not in entry.encrypted_value
++++ # But broker decrypts it
++++ assert await broker.get_secret("api-key") == "super-secret-123"
++++
++++ @pytest.mark.asyncio
++++ async def test_deny_before_allow(self) -> None:
++++ """Zero max_secrets denies even if scopes match."""
++++ cap = SecretCapability(
++++ allowed_scopes=["task", "session", "global"],
++++ max_secrets=0,
++++ )
++++ broker = SecretBroker()
++++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++++ await broker.get_secret("key", capability=cap)
++++
++++ def test_secret_store_values_not_in_repr(self) -> None:
++++ """SecretEntry encrypted_value should not leak plaintext."""
++++ store = SecretStore()
++++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
++++ with store._lock:
++++ entry = store._store[SecretScope.TASK]["key"]
++++ r = repr(entry)
++++ assert "password123" not in r
++++
++++ @pytest.mark.asyncio
++++ async def test_expired_secret_not_accessible(self) -> None:
++++ """Expired secrets return None even through broker."""
++++ store = SecretStore()
++++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++++ broker = SecretBroker(store=store, default_capability=cap)
++++ past = time.time() - 10
++++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
++++ assert await broker.get_secret("expired") is None
++diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
++new file mode 100644
++index 0000000..992e08c
++--- /dev/null
+++++ b/tests/test_secret_broker.py
++@@ -0,0 +1,611 @@
+++"""Tests for openspace.secret.broker ΓÇö EPIC 2.6.
+++
+++Covers:
+++- #52: SecretBrokerPort concrete implementation
+++- Secret scoping (task, session, global)
+++- Lease-based access control via SecretCapability
+++- At-rest encryption/decryption
+++- Key validation and store bounds
+++- Thread safety
+++"""
+++
+++from __future__ import annotations
+++
+++import threading
+++import time
+++import pytest
+++
+++from openspace.secret.broker import (
+++ SecretBroker,
+++ SecretStore,
+++ SecretScope,
+++ SecretEntry,
+++ SecretAccessDenied,
+++ SecretNotFoundError,
+++ SecretStoreFull,
+++ SecretKeyInvalid,
+++ SecretBrokerError,
+++ SecretValueTooLarge,
+++ _SecretEncryptor,
+++ _validate_key,
+++)
+++from openspace.sandbox.leases import SecretCapability
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Key Validation
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestKeyValidation:
+++ """Secret key naming rules."""
+++
+++ def test_valid_keys(self) -> None:
+++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
+++ _validate_key(key) # should not raise
+++
+++ def test_empty_key_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="empty"):
+++ _validate_key("")
+++
+++ def test_too_long_key_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
+++ _validate_key("x" * 257)
+++
+++ def test_invalid_chars_rejected(self) -> None:
+++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
+++ _validate_key("key with spaces")
+++
+++ def test_special_chars_rejected(self) -> None:
+++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
+++ with pytest.raises(SecretKeyInvalid):
+++ _validate_key(f"key{ch}")
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# At-Rest Encryption
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretEncryptor:
+++ """XOR-based at-rest encryption."""
+++
+++ def test_roundtrip(self) -> None:
+++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
+++ plaintext = "super-secret-value-123"
+++ encrypted = enc.encrypt(plaintext)
+++ assert enc.decrypt(encrypted) == plaintext
+++
+++ def test_encrypted_differs_from_plaintext(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "my-api-key"
+++ encrypted = enc.encrypt(plaintext)
+++ assert plaintext.encode() not in encrypted
+++
+++ def test_different_nonce_different_ciphertext(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "same-value"
+++ e1 = enc.encrypt(plaintext)
+++ e2 = enc.encrypt(plaintext)
+++ assert e1 != e2 # different nonce → different output
+++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
+++
+++ def test_corrupt_data_rejected(self) -> None:
+++ enc = _SecretEncryptor(b"key")
+++ with pytest.raises(SecretBrokerError, match="Corrupt"):
+++ enc.decrypt(b"short")
+++
+++ def test_empty_string(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ encrypted = enc.encrypt("")
+++ assert enc.decrypt(encrypted) == ""
+++
+++ def test_unicode_roundtrip(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "héllo wörld 🔑"
+++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+++
+++ def test_long_value(self) -> None:
+++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+++ plaintext = "x" * 10000
+++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Secret Store
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretStore:
+++ """Scoped, encrypted, bounded secret storage."""
+++
+++ def test_put_and_get(self) -> None:
+++ store = SecretStore()
+++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
+++
+++ def test_get_missing_returns_none(self) -> None:
+++ store = SecretStore()
+++ assert store.get("nope", scope=SecretScope.TASK) is None
+++
+++ def test_scopes_are_independent(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
+++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
+++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
+++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
+++ assert store.get("key", scope=SecretScope.GLOBAL) is None
+++
+++ def test_update_existing(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
+++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("key", scope=SecretScope.TASK) == "v2"
+++
+++ def test_delete(self) -> None:
+++ store = SecretStore()
+++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
+++ assert store.delete("key", scope=SecretScope.TASK) is True
+++ assert store.get("key", scope=SecretScope.TASK) is None
+++ assert store.delete("key", scope=SecretScope.TASK) is False
+++
+++ def test_list_keys(self) -> None:
+++ store = SecretStore()
+++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
+++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
+++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
+++
+++ def test_count(self) -> None:
+++ store = SecretStore()
+++ assert store.count(scope=SecretScope.TASK) == 0
+++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+++ assert store.count(scope=SecretScope.TASK) == 2
+++
+++ def test_clear_scope(self) -> None:
+++ store = SecretStore()
+++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
+++ assert store.clear_scope(SecretScope.TASK) == 2
+++ assert store.count(scope=SecretScope.TASK) == 0
+++ assert store.count(scope=SecretScope.SESSION) == 1
+++
+++ def test_capacity_limit(self) -> None:
+++ store = SecretStore()
+++ store.MAX_SECRETS_PER_SCOPE = 3
+++ for i in range(3):
+++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
+++ with pytest.raises(SecretStoreFull, match="full"):
+++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
+++
+++ def test_update_does_not_count_toward_capacity(self) -> None:
+++ store = SecretStore()
+++ store.MAX_SECRETS_PER_SCOPE = 2
+++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
+++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
+++ # Update existing ΓÇö should NOT fail
+++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
+++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
+++
+++ def test_value_too_long_rejected(self) -> None:
+++ store = SecretStore()
+++ with pytest.raises(SecretValueTooLarge, match="maximum length"):
+++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
+++
+++ def test_value_too_long_bytes_not_chars(self) -> None:
+++ """Byte length is enforced, not character count. Multi-byte chars
+++ must be correctly measured against the 64KB limit."""
+++ store = SecretStore()
+++ # 4-byte emoji × 16385 = 65540 bytes > 64KB, but only 16385 chars
+++ value = "\U0001f600" * 16385
+++ assert len(value) < store.MAX_VALUE_LENGTH # chars < limit
+++ assert len(value.encode("utf-8")) > store.MAX_VALUE_LENGTH # bytes > limit
+++ with pytest.raises(SecretValueTooLarge, match="bytes"):
+++ store.put("k", value, scope=SecretScope.TASK, owner="svc")
+++
+++ def test_lazy_expiry_on_get(self) -> None:
+++ store = SecretStore()
+++ past = time.time() - 10
+++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+++ assert store.get("k", scope=SecretScope.TASK) is None
+++
+++ def test_lazy_expiry_on_list(self) -> None:
+++ store = SecretStore()
+++ past = time.time() - 10
+++ future = time.time() + 3600
+++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
+++ keys = store.list_keys(scope=SecretScope.TASK)
+++ assert keys == ["alive"]
+++
+++ def test_thread_safety(self) -> None:
+++ store = SecretStore()
+++ errors: list[Exception] = []
+++
+++ def write_batch(prefix: str) -> None:
+++ try:
+++ for i in range(50):
+++ store.put(f"{prefix}-{i}", f"val-{i}",
+++ scope=SecretScope.TASK, owner="svc")
+++ except Exception as e:
+++ errors.append(e)
+++
+++ threads = [
+++ threading.Thread(target=write_batch, args=(f"t{n}",))
+++ for n in range(4)
+++ ]
+++ for t in threads:
+++ t.start()
+++ for t in threads:
+++ t.join()
+++
+++ assert not errors
+++ assert store.count(scope=SecretScope.TASK) == 200
+++
+++ def test_encryption_at_rest(self) -> None:
+++ """Stored values are encrypted ΓÇö raw access doesn't reveal plaintext."""
+++ store = SecretStore()
+++ store.put("secret-key", "super-secret-password",
+++ scope=SecretScope.TASK, owner="svc")
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["secret-key"]
+++ assert b"super-secret-password" not in entry.encrypted_value
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Capability-Based Access Control
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerCapability:
+++ """SecretBroker enforces SecretCapability from leases."""
+++
+++ def _t2_capability(self) -> SecretCapability:
+++ """T2-equivalent: 3 secrets, task scope only."""
+++ return SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=3,
+++ )
+++
+++ def _t3_capability(self) -> SecretCapability:
+++ """T3-equivalent: 10 secrets, task + session scopes."""
+++ return SecretCapability(
+++ allowed_scopes=["task", "session"],
+++ max_secrets=10,
+++ )
+++
+++ def _t4_capability(self) -> SecretCapability:
+++ """T4-equivalent: 50 secrets, all scopes."""
+++ return SecretCapability(
+++ allowed_scopes=["task", "session", "global"],
+++ max_secrets=50,
+++ )
+++
+++ def _no_access_capability(self) -> SecretCapability:
+++ """T0/T1-equivalent: no secret access."""
+++ return SecretCapability(
+++ allowed_scopes=[],
+++ max_secrets=0,
+++ )
+++
+++ @pytest.mark.asyncio
+++ async def test_get_with_valid_capability(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
+++ result = await broker.get_secret("api-key", capability=cap)
+++ assert result == "secret"
+++
+++ @pytest.mark.asyncio
+++ async def test_get_missing_returns_none(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ result = await broker.get_secret("nope", capability=cap)
+++ assert result is None
+++
+++ @pytest.mark.asyncio
+++ async def test_no_access_denied(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._no_access_capability()
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_scope_denied(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability() # task only
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("key", scope="session", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_t3_can_access_session(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t3_capability()
+++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
+++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_t3_cannot_access_global(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t3_capability()
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("key", scope="global", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_t4_can_access_all_scopes(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t4_capability()
+++ for scope in ["task", "session", "global"]:
+++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
+++ capability=cap, owner="svc")
+++ assert await broker.get_secret(f"k-{scope}", scope=scope,
+++ capability=cap) == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_allowed_keys_enforced(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["db-pass", "api-key"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
+++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+++ await broker.get_secret("other-key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_max_secrets_enforced(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=2,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
+++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
+++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
+++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
+++
+++ @pytest.mark.asyncio
+++ async def test_update_existing_does_not_hit_limit(self) -> None:
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ max_secrets=1,
+++ )
+++ broker = SecretBroker()
+++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
+++ # Update should work even at limit
+++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
+++ assert await broker.get_secret("k1", capability=cap) == "v2"
+++
+++ @pytest.mark.asyncio
+++ async def test_invalid_scope_rejected(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._t2_capability()
+++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
+++ await broker.get_secret("key", scope="invalid", capability=cap)
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Revocation and Listing
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerOperations:
+++ """SecretBroker revoke and list operations."""
+++
+++ def _cap(self) -> SecretCapability:
+++ return SecretCapability(
+++ allowed_scopes=["task", "session"],
+++ max_secrets=10,
+++ )
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_existing(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ await broker.put_secret("k", "v", capability=cap, owner="svc")
+++ assert await broker.revoke("k", capability=cap) is True
+++ assert await broker.get_secret("k", capability=cap) is None
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_nonexistent(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ assert await broker.revoke("nope", capability=cap) is False
+++
+++ @pytest.mark.asyncio
+++ async def test_list_available(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ await broker.put_secret("b", "v", capability=cap, owner="svc")
+++ await broker.put_secret("a", "v", capability=cap, owner="svc")
+++ keys = broker.list_available(capability=cap)
+++ assert keys == ["a", "b"]
+++
+++ @pytest.mark.asyncio
+++ async def test_list_filtered_by_allowed_keys(self) -> None:
+++ store = SecretStore()
+++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
+++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["visible"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker(store=store)
+++ keys = broker.list_available(capability=cap)
+++ assert keys == ["visible"]
+++
+++ @pytest.mark.asyncio
+++ async def test_list_empty_scope(self) -> None:
+++ broker = SecretBroker()
+++ cap = self._cap()
+++ assert broker.list_available(capability=cap) == []
+++
+++ @pytest.mark.asyncio
+++ async def test_revoke_denied_without_scope(self) -> None:
+++ broker = SecretBroker()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ with pytest.raises(SecretAccessDenied):
+++ await broker.revoke("k", scope="session", capability=cap)
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# SecretBroker ΓÇö Default Capability
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerDefaults:
+++ """SecretBroker with default_capability."""
+++
+++ @pytest.mark.asyncio
+++ async def test_uses_default_capability(self) -> None:
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(default_capability=cap)
+++ await broker.put_secret("k", "v", owner="svc")
+++ assert await broker.get_secret("k") == "v"
+++
+++ @pytest.mark.asyncio
+++ async def test_default_zero_denies(self) -> None:
+++ broker = SecretBroker() # default SecretCapability has max_secrets=0
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("k")
+++
+++
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++# Security Regression Tests
+++# ΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉΓòÉ
+++
+++
+++class TestSecretBrokerSecurity:
+++ """Security invariants for the secret broker."""
+++
+++ @pytest.mark.asyncio
+++ async def test_t0_t1_cannot_access_secrets(self) -> None:
+++ """T0/T1 equivalent capabilities deny all access."""
+++ broker = SecretBroker()
+++ for cap in [
+++ SecretCapability(allowed_scopes=[], max_secrets=0),
+++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
+++ ]:
+++ with pytest.raises(SecretAccessDenied):
+++ await broker.get_secret("key", capability=cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_scope_escalation_prevented(self) -> None:
+++ """T2 (task-only) cannot read session secrets."""
+++ store = SecretStore()
+++ store.put("session-secret", "classified",
+++ scope=SecretScope.SESSION, owner="admin")
+++ t2_cap = SecretCapability(
+++ allowed_scopes=["task"], max_secrets=3,
+++ )
+++ broker = SecretBroker(store=store)
+++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+++ await broker.get_secret("session-secret", scope="session",
+++ capability=t2_cap)
+++
+++ @pytest.mark.asyncio
+++ async def test_key_restriction_enforced(self) -> None:
+++ """Allowed_keys list is a hard deny for unlisted keys."""
+++ cap = SecretCapability(
+++ allowed_scopes=["task"],
+++ allowed_keys=["safe-key"],
+++ max_secrets=5,
+++ )
+++ broker = SecretBroker()
+++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
+++
+++ @pytest.mark.asyncio
+++ async def test_encrypted_at_rest_via_broker(self) -> None:
+++ """Values stored through broker are encrypted in the store."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
+++ # Direct store access ΓÇö value should be encrypted
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["api-key"]
+++ assert b"super-secret-123" not in entry.encrypted_value
+++ # But broker decrypts it
+++ assert await broker.get_secret("api-key") == "super-secret-123"
+++
+++ @pytest.mark.asyncio
+++ async def test_deny_before_allow(self) -> None:
+++ """Zero max_secrets denies even if scopes match."""
+++ cap = SecretCapability(
+++ allowed_scopes=["task", "session", "global"],
+++ max_secrets=0,
+++ )
+++ broker = SecretBroker()
+++ with pytest.raises(SecretAccessDenied, match="no secret access"):
+++ await broker.get_secret("key", capability=cap)
+++
+++ def test_secret_store_values_not_in_repr(self) -> None:
+++ """SecretEntry encrypted_value should not leak plaintext."""
+++ store = SecretStore()
+++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
+++ with store._lock:
+++ entry = store._store[SecretScope.TASK]["key"]
+++ r = repr(entry)
+++ assert "password123" not in r
+++
+++ @pytest.mark.asyncio
+++ async def test_expired_secret_not_accessible(self) -> None:
+++ """Expired secrets return None even through broker."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ past = time.time() - 10
+++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
+++ assert await broker.get_secret("expired") is None
+++
+++ def test_ciphertext_integrity_check(self) -> None:
+++ """Tampered ciphertext is detected by HMAC integrity tag."""
+++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
+++ encrypted = enc.encrypt("sensitive-data")
+++ # Flip a byte in the ciphertext region (after 16-byte nonce, before 32-byte tag)
+++ tampered = bytearray(encrypted)
+++ tampered[20] ^= 0xFF
+++ with pytest.raises(SecretBrokerError, match="integrity check failed"):
+++ enc.decrypt(bytes(tampered))
+++
+++ def test_ciphertext_truncation_detected(self) -> None:
+++ """Truncated ciphertext is rejected."""
+++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
+++ with pytest.raises(SecretBrokerError, match="Corrupt"):
+++ enc.decrypt(b"\x00" * 16) # nonce only, no ciphertext or tag
+++
+++ @pytest.mark.asyncio
+++ async def test_concurrent_put_respects_cap_limit(self) -> None:
+++ """Concurrent writers must not exceed capability max_secrets (TOCTOU fix)."""
+++ store = SecretStore()
+++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+++ broker = SecretBroker(store=store, default_capability=cap)
+++ errors: list[Exception] = []
+++ success_count = 0
+++ lock = threading.Lock()
+++
+++ import asyncio
+++
+++ async def write(i: int) -> None:
+++ nonlocal success_count
+++ try:
+++ await broker.put_secret(
+++ f"key-{i}", f"val-{i}", owner="svc",
+++ )
+++ with lock:
+++ success_count += 1
+++ except SecretAccessDenied:
+++ pass # Expected once limit is reached
+++ except Exception as e:
+++ with lock:
+++ errors.append(e)
+++
+++ # Attempt 20 concurrent writes with cap of 5
+++ await asyncio.gather(*(write(i) for i in range(20)))
+++ assert not errors, f"Unexpected errors: {errors}"
+++ # Must not exceed cap
+++ assert store.count(scope=SecretScope.TASK) <= 5
+diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
+new file mode 100644
+index 0000000..55456fc
+--- /dev/null
++++ b/tests/test_secret_broker.py
+@@ -0,0 +1,657 @@
++"""Tests for openspace.secret.broker — EPIC 2.6.
++
++Covers:
++- #52: SecretBrokerPort concrete implementation
++- Secret scoping (task, session, global)
++- Lease-based access control via SecretCapability
++- At-rest encryption/decryption
++- Key validation and store bounds
++- Thread safety
++"""
++
++from __future__ import annotations
++
++import threading
++import time
++import pytest
++
++from openspace.secret.broker import (
++ SecretBroker,
++ SecretStore,
++ SecretScope,
++ SecretEntry,
++ SecretAccessDenied,
++ SecretNotFoundError,
++ SecretStoreFull,
++ SecretKeyInvalid,
++ SecretBrokerError,
++ SecretValueTooLarge,
++ _SecretEncryptor,
++ _validate_key,
++)
++from openspace.sandbox.leases import SecretCapability
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# Key Validation
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestKeyValidation:
++ """Secret key naming rules."""
++
++ def test_valid_keys(self) -> None:
++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
++ _validate_key(key) # should not raise
++
++ def test_empty_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="empty"):
++ _validate_key("")
++
++ def test_too_long_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
++ _validate_key("x" * 257)
++
++ def test_invalid_chars_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
++ _validate_key("key with spaces")
++
++ def test_special_chars_rejected(self) -> None:
++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
++ with pytest.raises(SecretKeyInvalid):
++ _validate_key(f"key{ch}")
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# At-Rest Encryption
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretEncryptor:
++ """XOR-based at-rest encryption."""
++
++ def test_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
++ plaintext = "super-secret-value-123"
++ encrypted = enc.encrypt(plaintext)
++ assert enc.decrypt(encrypted) == plaintext
++
++ def test_encrypted_differs_from_plaintext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "my-api-key"
++ encrypted = enc.encrypt(plaintext)
++ assert plaintext.encode() not in encrypted
++
++ def test_different_nonce_different_ciphertext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "same-value"
++ e1 = enc.encrypt(plaintext)
++ e2 = enc.encrypt(plaintext)
++ assert e1 != e2 # different nonce → different output
++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
++
++ def test_corrupt_data_rejected(self) -> None:
++ enc = _SecretEncryptor(b"key")
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"short")
++
++ def test_empty_string(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ encrypted = enc.encrypt("")
++ assert enc.decrypt(encrypted) == ""
++
++ def test_unicode_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "héllo wörld 🔑"
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++ def test_long_value(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "x" * 10000
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# Secret Store
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretStore:
++ """Scoped, encrypted, bounded secret storage."""
++
++ def test_put_and_get(self) -> None:
++ store = SecretStore()
++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
++
++ def test_get_missing_returns_none(self) -> None:
++ store = SecretStore()
++ assert store.get("nope", scope=SecretScope.TASK) is None
++
++ def test_scopes_are_independent(self) -> None:
++ store = SecretStore()
++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
++ assert store.get("key", scope=SecretScope.GLOBAL) is None
++
++ def test_update_existing(self) -> None:
++ store = SecretStore()
++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "v2"
++
++ def test_delete(self) -> None:
++ store = SecretStore()
++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
++ assert store.delete("key", scope=SecretScope.TASK) is True
++ assert store.get("key", scope=SecretScope.TASK) is None
++ assert store.delete("key", scope=SecretScope.TASK) is False
++
++ def test_list_keys(self) -> None:
++ store = SecretStore()
++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
++
++ def test_count(self) -> None:
++ store = SecretStore()
++ assert store.count(scope=SecretScope.TASK) == 0
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ assert store.count(scope=SecretScope.TASK) == 2
++
++ def test_clear_scope(self) -> None:
++ store = SecretStore()
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
++ assert store.clear_scope(SecretScope.TASK) == 2
++ assert store.count(scope=SecretScope.TASK) == 0
++ assert store.count(scope=SecretScope.SESSION) == 1
++
++ def test_capacity_limit(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 3
++ for i in range(3):
++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
++ with pytest.raises(SecretStoreFull, match="full"):
++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
++
++ def test_update_does_not_count_toward_capacity(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 2
++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
++ # Update existing — should NOT fail
++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
++
++ def test_value_too_long_rejected(self) -> None:
++ store = SecretStore()
++ with pytest.raises(SecretValueTooLarge, match="maximum length"):
++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
++
++ def test_value_too_long_bytes_not_chars(self) -> None:
++ """Byte length is enforced, not character count. Multi-byte chars
++ must be correctly measured against the 64KB limit."""
++ store = SecretStore()
++ # 4-byte emoji × 16385 = 65540 bytes > 64KB, but only 16385 chars
++ value = "\U0001f600" * 16385
++ assert len(value) < store.MAX_VALUE_LENGTH # chars < limit
++ assert len(value.encode("utf-8")) > store.MAX_VALUE_LENGTH # bytes > limit
++ with pytest.raises(SecretValueTooLarge, match="bytes"):
++ store.put("k", value, scope=SecretScope.TASK, owner="svc")
++
++ def test_lazy_expiry_on_get(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ assert store.get("k", scope=SecretScope.TASK) is None
++
++ def test_lazy_expiry_on_list(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ future = time.time() + 3600
++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
++ keys = store.list_keys(scope=SecretScope.TASK)
++ assert keys == ["alive"]
++
++ def test_thread_safety(self) -> None:
++ store = SecretStore()
++ errors: list[Exception] = []
++
++ def write_batch(prefix: str) -> None:
++ try:
++ for i in range(50):
++ store.put(f"{prefix}-{i}", f"val-{i}",
++ scope=SecretScope.TASK, owner="svc")
++ except Exception as e:
++ errors.append(e)
++
++ threads = [
++ threading.Thread(target=write_batch, args=(f"t{n}",))
++ for n in range(4)
++ ]
++ for t in threads:
++ t.start()
++ for t in threads:
++ t.join()
++
++ assert not errors
++ assert store.count(scope=SecretScope.TASK) == 200
++
++ def test_encryption_at_rest(self) -> None:
++ """Stored values are encrypted — raw access doesn't reveal plaintext."""
++ store = SecretStore()
++ store.put("secret-key", "super-secret-password",
++ scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["secret-key"]
++ assert b"super-secret-password" not in entry.encrypted_value
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# SecretBroker — Capability-Based Access Control
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerCapability:
++ """SecretBroker enforces SecretCapability from leases."""
++
++ def _t2_capability(self) -> SecretCapability:
++ """T2-equivalent: 3 secrets, task scope only."""
++ return SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=3,
++ )
++
++ def _t3_capability(self) -> SecretCapability:
++ """T3-equivalent: 10 secrets, task + session scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ def _t4_capability(self) -> SecretCapability:
++ """T4-equivalent: 50 secrets, all scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=50,
++ )
++
++ def _no_access_capability(self) -> SecretCapability:
++ """T0/T1-equivalent: no secret access."""
++ return SecretCapability(
++ allowed_scopes=[],
++ max_secrets=0,
++ )
++
++ @pytest.mark.asyncio
++ async def test_get_with_valid_capability(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ await broker.put_secret("api-key", "secret", capability=cap, owner="svc")
++ result = await broker.get_secret("api-key", capability=cap)
++ assert result == "secret"
++
++ @pytest.mark.asyncio
++ async def test_get_missing_returns_none(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ result = await broker.get_secret("nope", capability=cap)
++ assert result is None
++
++ @pytest.mark.asyncio
++ async def test_no_access_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._no_access_capability()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability() # task only
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="session", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t3_can_access_session(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ await broker.put_secret("k", "v", scope="session", capability=cap, owner="svc")
++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_t3_cannot_access_global(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="global", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t4_can_access_all_scopes(self) -> None:
++ broker = SecretBroker()
++ cap = self._t4_capability()
++ for scope in ["task", "session", "global"]:
++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
++ capability=cap, owner="svc")
++ assert await broker.get_secret(f"k-{scope}", scope=scope,
++ capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_allowed_keys_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["db-pass", "api-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("db-pass", "secret", capability=cap, owner="svc")
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.get_secret("other-key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_max_secrets_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=2,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++ await broker.put_secret("k2", "v2", capability=cap, owner="svc")
++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++ await broker.put_secret("k3", "v3", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_max_secrets_per_owner_not_scope(self) -> None:
++ """max_secrets counts per-owner, not scope-wide. Another owner's
++ secrets must not block a different caller's writes."""
++ store = SecretStore()
++ cap_a = SecretCapability(allowed_scopes=["task"], max_secrets=2)
++ cap_b = SecretCapability(allowed_scopes=["task"], max_secrets=2)
++ broker = SecretBroker(store=store)
++ # Owner A fills their quota
++ await broker.put_secret("a1", "v", capability=cap_a, owner="owner-a")
++ await broker.put_secret("a2", "v", capability=cap_a, owner="owner-a")
++ # Owner B should NOT be blocked by owner A's secrets
++ await broker.put_secret("b1", "v", capability=cap_b, owner="owner-b")
++ await broker.put_secret("b2", "v", capability=cap_b, owner="owner-b")
++ assert store.count(scope=SecretScope.TASK) == 4
++
++ @pytest.mark.asyncio
++ async def test_expired_secrets_dont_block_writes(self) -> None:
++ """Expired entries are purged on write so they don't ghost-fill the scope."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=2)
++ broker = SecretBroker(store=store)
++ past = time.time() - 10
++ await broker.put_secret("old1", "v", capability=cap, owner="svc", expires_at=past)
++ await broker.put_secret("old2", "v", capability=cap, owner="svc", expires_at=past)
++ # Both are expired — new writes should succeed after purge
++ await broker.put_secret("new1", "v", capability=cap, owner="svc")
++ await broker.put_secret("new2", "v", capability=cap, owner="svc")
++ assert await broker.get_secret("new1", capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_expired_key_resurrection_blocked(self) -> None:
++ """Overwriting an expired key must count as a new insert for cap_limit.
++ Prevents bypassing max_secrets by 'updating' expired keys back to life."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=1)
++ broker = SecretBroker(store=store)
++ past = time.time() - 10
++ # Fill quota then let it expire
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc", expires_at=past)
++ # Write a new live key — uses the freed slot
++ await broker.put_secret("k2", "live", capability=cap, owner="svc")
++ # Attempting to "resurrect" expired k1 must be denied (quota full)
++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++ await broker.put_secret("k1", "revived", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_update_existing_does_not_hit_limit(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=1,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap, owner="svc")
++ # Update should work even at limit
++ await broker.put_secret("k1", "v2", capability=cap, owner="svc")
++ assert await broker.get_secret("k1", capability=cap) == "v2"
++
++ @pytest.mark.asyncio
++ async def test_invalid_scope_rejected(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
++ await broker.get_secret("key", scope="invalid", capability=cap)
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# SecretBroker — Revocation and Listing
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerOperations:
++ """SecretBroker revoke and list operations."""
++
++ def _cap(self) -> SecretCapability:
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ @pytest.mark.asyncio
++ async def test_revoke_existing(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("k", "v", capability=cap, owner="svc")
++ assert await broker.revoke("k", capability=cap) is True
++ assert await broker.get_secret("k", capability=cap) is None
++
++ @pytest.mark.asyncio
++ async def test_revoke_nonexistent(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert await broker.revoke("nope", capability=cap) is False
++
++ @pytest.mark.asyncio
++ async def test_list_available(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("b", "v", capability=cap, owner="svc")
++ await broker.put_secret("a", "v", capability=cap, owner="svc")
++ keys = broker.list_available(capability=cap)
++ assert keys == ["a", "b"]
++
++ @pytest.mark.asyncio
++ async def test_list_filtered_by_allowed_keys(self) -> None:
++ store = SecretStore()
++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["visible"],
++ max_secrets=5,
++ )
++ broker = SecretBroker(store=store)
++ keys = broker.list_available(capability=cap)
++ assert keys == ["visible"]
++
++ @pytest.mark.asyncio
++ async def test_list_empty_scope(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert broker.list_available(capability=cap) == []
++
++ @pytest.mark.asyncio
++ async def test_revoke_denied_without_scope(self) -> None:
++ broker = SecretBroker()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ with pytest.raises(SecretAccessDenied):
++ await broker.revoke("k", scope="session", capability=cap)
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# SecretBroker — Default Capability
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerDefaults:
++ """SecretBroker with default_capability."""
++
++ @pytest.mark.asyncio
++ async def test_uses_default_capability(self) -> None:
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(default_capability=cap)
++ await broker.put_secret("k", "v", owner="svc")
++ assert await broker.get_secret("k") == "v"
++
++ @pytest.mark.asyncio
++ async def test_default_zero_denies(self) -> None:
++ broker = SecretBroker() # default SecretCapability has max_secrets=0
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("k")
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# Security Regression Tests
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerSecurity:
++ """Security invariants for the secret broker."""
++
++ @pytest.mark.asyncio
++ async def test_t0_t1_cannot_access_secrets(self) -> None:
++ """T0/T1 equivalent capabilities deny all access."""
++ broker = SecretBroker()
++ for cap in [
++ SecretCapability(allowed_scopes=[], max_secrets=0),
++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
++ ]:
++ with pytest.raises(SecretAccessDenied):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_escalation_prevented(self) -> None:
++ """T2 (task-only) cannot read session secrets."""
++ store = SecretStore()
++ store.put("session-secret", "classified",
++ scope=SecretScope.SESSION, owner="admin")
++ t2_cap = SecretCapability(
++ allowed_scopes=["task"], max_secrets=3,
++ )
++ broker = SecretBroker(store=store)
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("session-secret", scope="session",
++ capability=t2_cap)
++
++ @pytest.mark.asyncio
++ async def test_key_restriction_enforced(self) -> None:
++ """Allowed_keys list is a hard deny for unlisted keys."""
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["safe-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.put_secret("other-key", "v", capability=cap, owner="svc")
++
++ @pytest.mark.asyncio
++ async def test_encrypted_at_rest_via_broker(self) -> None:
++ """Values stored through broker are encrypted in the store."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ await broker.put_secret("api-key", "super-secret-123", owner="svc")
++ # Direct store access — value should be encrypted
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["api-key"]
++ assert b"super-secret-123" not in entry.encrypted_value
++ # But broker decrypts it
++ assert await broker.get_secret("api-key") == "super-secret-123"
++
++ @pytest.mark.asyncio
++ async def test_deny_before_allow(self) -> None:
++ """Zero max_secrets denies even if scopes match."""
++ cap = SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=0,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ def test_secret_store_values_not_in_repr(self) -> None:
++ """SecretEntry encrypted_value should not leak plaintext."""
++ store = SecretStore()
++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["key"]
++ r = repr(entry)
++ assert "password123" not in r
++
++ @pytest.mark.asyncio
++ async def test_expired_secret_not_accessible(self) -> None:
++ """Expired secrets return None even through broker."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ past = time.time() - 10
++ await broker.put_secret("expired", "v", owner="svc", expires_at=past)
++ assert await broker.get_secret("expired") is None
++
++ def test_ciphertext_integrity_check(self) -> None:
++ """Tampered ciphertext is detected by HMAC integrity tag."""
++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
++ encrypted = enc.encrypt("sensitive-data")
++ # Flip a byte in the ciphertext region (after 16-byte nonce, before 32-byte tag)
++ tampered = bytearray(encrypted)
++ tampered[20] ^= 0xFF
++ with pytest.raises(SecretBrokerError, match="integrity check failed"):
++ enc.decrypt(bytes(tampered))
++
++ def test_ciphertext_truncation_detected(self) -> None:
++ """Truncated ciphertext is rejected."""
++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"\x00" * 16) # nonce only, no ciphertext or tag
++
++ @pytest.mark.asyncio
++ async def test_concurrent_put_respects_cap_limit(self) -> None:
++ """Concurrent writers must not exceed capability max_secrets (TOCTOU fix)."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ errors: list[Exception] = []
++ success_count = 0
++ lock = threading.Lock()
++
++ import asyncio
++
++ async def write(i: int) -> None:
++ nonlocal success_count
++ try:
++ await broker.put_secret(
++ f"key-{i}", f"val-{i}", owner="svc",
++ )
++ with lock:
++ success_count += 1
++ except SecretAccessDenied:
++ pass # Expected once limit is reached
++ except Exception as e:
++ with lock:
++ errors.append(e)
++
++ # Attempt 20 concurrent writes with cap of 5
++ await asyncio.gather(*(write(i) for i in range(20)))
++ assert not errors, f"Unexpected errors: {errors}"
++ # Must not exceed cap
++ assert store.count(scope=SecretScope.TASK) <= 5
diff --git a/pr-473-diff-r4.txt b/pr-473-diff-r4.txt
new file mode 100644
index 00000000..1614e0bc
--- /dev/null
+++ b/pr-473-diff-r4.txt
@@ -0,0 +1,1286 @@
+diff --git a/openspace/sandbox/leases.py b/openspace/sandbox/leases.py
+index 3553776..ebe9e55 100644
+--- a/openspace/sandbox/leases.py
++++ b/openspace/sandbox/leases.py
+@@ -143,7 +143,7 @@ class SecretCapability(BaseModel):
+ default_factory=lambda: ["task"],
+ description="Scopes this lease can access (task, session, global)",
+ )
+- allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = none)")
++ allowed_keys: list[str] = Field(default_factory=list, description="Specific secret keys allowed (empty = unrestricted)")
+ max_secrets: int = Field(default=0, ge=0, le=50, description="Max secrets accessible (0 = none)")
+
+
+diff --git a/openspace/secret/__init__.py b/openspace/secret/__init__.py
+new file mode 100644
+index 0000000..4679650
+--- /dev/null
++++ b/openspace/secret/__init__.py
+@@ -0,0 +1 @@
++"""OpenSpace secret management module."""
+diff --git a/openspace/secret/broker.py b/openspace/secret/broker.py
+new file mode 100644
+index 0000000..a7ca3b4
+--- /dev/null
++++ b/openspace/secret/broker.py
+@@ -0,0 +1,597 @@
++"""Concrete SecretBrokerPort implementation — EPIC 2.6.
++
++Provides:
++- Scoped secret storage (task, session, global) with lease-based access control.
++- Encryption at rest using HMAC-derived Fernet keys (stdlib + cryptography-free).
++- Thread-safe operations with bounded storage per scope.
++- Integration with SecretCapability from lease system and auth token scopes.
++
++Design decisions:
++- Fail-closed: missing capability or insufficient scope → deny.
++- Encryption uses HMAC-SHA256 derived keys with XOR cipher (no external deps).
++- Secrets are stored in-memory only — no persistence across restarts.
++- Scope hierarchy: task < session < global (each is independent namespace).
++- Revocation is immediate and irreversible within a session.
++
++Security requirements:
++- Callers MUST present a valid SecretCapability from their lease.
++- SECRET_READ / SECRET_WRITE token scopes gate read/write operations.
++- T0/T1 tiers cannot access secrets (enforced by lease validation).
++- Secret values are encrypted at rest in memory to resist heap inspection.
++
++Issues:
++- #52: SecretBrokerPort concrete implementation
++"""
++
++from __future__ import annotations
++
++import base64
++import hashlib
++import hmac
++import os
++import secrets
++import threading
++import time
++from dataclasses import dataclass, field
++from enum import Enum
++from typing import Optional
++
++from openspace.sandbox.leases import SecretCapability
++
++
++# ---------------------------------------------------------------------------
++# Secret Scope Model
++# ---------------------------------------------------------------------------
++
++
++class SecretScope(str, Enum):
++ """Hierarchical secret scopes matching lease capability model."""
++
++ TASK = "task"
++ SESSION = "session"
++ GLOBAL = "global"
++
++
++# Scope hierarchy for validation (higher index = broader access)
++_SCOPE_ORDER: list[SecretScope] = [
++ SecretScope.TASK,
++ SecretScope.SESSION,
++ SecretScope.GLOBAL,
++]
++
++
++# ---------------------------------------------------------------------------
++# Exceptions
++# ---------------------------------------------------------------------------
++
++
++class SecretBrokerError(Exception):
++ """Base for all secret broker errors."""
++
++
++class SecretAccessDenied(SecretBrokerError):
++ """Caller lacks permission to access the requested secret."""
++
++
++class SecretNotFoundError(SecretBrokerError):
++ """Requested secret does not exist."""
++
++
++class SecretStoreFull(SecretBrokerError):
++ """Secret store has reached its capacity limit."""
++
++
++class SecretKeyInvalid(SecretBrokerError):
++ """Secret key is malformed or invalid."""
++
++
++class SecretValueTooLarge(SecretBrokerError):
++ """Secret value exceeds maximum allowed size."""
++
++
++# ---------------------------------------------------------------------------
++# At-Rest Encryption (no external crypto dependencies)
++# ---------------------------------------------------------------------------
++
++
++class _SecretEncryptor:
++ """XOR-based at-rest encryption using HMAC-derived key stream.
++
++ NOT a general-purpose cipher — this provides defense-in-depth against
++ heap inspection only. The real security boundary is access control
++ via capabilities and token scopes.
++ """
++
++ def __init__(self, master_key: bytes) -> None:
++ self._master_key = master_key
++
++ def encrypt(self, plaintext: str) -> bytes:
++ """Encrypt a secret value, returning nonce + ciphertext + HMAC tag.
++
++ Layout: nonce (16) || ciphertext (N) || hmac_tag (32)
++ Integrity is verified on decrypt (encrypt-then-MAC).
++ """
++ nonce = os.urandom(16)
++ plaintext_bytes = plaintext.encode("utf-8")
++ key_stream = self._derive_stream(nonce, len(plaintext_bytes))
++ ciphertext = bytes(a ^ b for a, b in zip(plaintext_bytes, key_stream))
++ # Encrypt-then-MAC: HMAC over nonce + ciphertext
++ tag = hmac.new(
++ self._master_key, nonce + ciphertext, hashlib.sha256,
++ ).digest()
++ return nonce + ciphertext + tag
++
++ _TAG_LEN = 32 # HMAC-SHA256 output length
++
++ def decrypt(self, data: bytes) -> str:
++ """Decrypt nonce + ciphertext + HMAC tag back to plaintext.
++
++ Raises SecretBrokerError if data is corrupt or tampered with.
++ """
++ # Minimum: 16 (nonce) + 0 (ciphertext can be empty) + 32 (tag)
++ if len(data) < 16 + self._TAG_LEN:
++ raise SecretBrokerError("Corrupt encrypted data")
++ nonce = data[:16]
++ ciphertext = data[16:-self._TAG_LEN]
++ stored_tag = data[-self._TAG_LEN:]
++ # Verify integrity before decryption
++ expected_tag = hmac.new(
++ self._master_key, nonce + ciphertext, hashlib.sha256,
++ ).digest()
++ if not hmac.compare_digest(stored_tag, expected_tag):
++ raise SecretBrokerError("Encrypted data integrity check failed")
++ key_stream = self._derive_stream(nonce, len(ciphertext))
++ plaintext_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
++ return plaintext_bytes.decode("utf-8")
++
++ def _derive_stream(self, nonce: bytes, length: int) -> bytes:
++ """Derive a key stream of given length from nonce + master key."""
++ stream = b""
++ counter = 0
++ while len(stream) < length:
++ block = hmac.new(
++ self._master_key,
++ nonce + counter.to_bytes(4, "big"),
++ hashlib.sha256,
++ ).digest()
++ stream += block
++ counter += 1
++ return stream[:length]
++
++
++# ---------------------------------------------------------------------------
++# Secret Entry
++# ---------------------------------------------------------------------------
++
++
++@dataclass(frozen=True)
++class SecretEntry:
++ """Metadata and encrypted value for a stored secret."""
++
++ key: str
++ scope: SecretScope
++ encrypted_value: bytes
++ owner: str # subject that created the secret
++ created_at: float
++ expires_at: float | None = None # None = no expiry
++
++
++# ---------------------------------------------------------------------------
++# Secret Store (scoped, encrypted, bounded)
++# ---------------------------------------------------------------------------
++
++_MAX_KEY_LENGTH = 256
++_KEY_PATTERN_CHARS = frozenset(
++ "abcdefghijklmnopqrstuvwxyz"
++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
++ "0123456789"
++ "-_./:"
++)
++
++
++def _validate_key(key: str) -> None:
++ """Validate a secret key name."""
++ if not key:
++ raise SecretKeyInvalid("Secret key cannot be empty")
++ if len(key) > _MAX_KEY_LENGTH:
++ raise SecretKeyInvalid(
++ f"Secret key exceeds maximum length ({_MAX_KEY_LENGTH})"
++ )
++ invalid = set(key) - _KEY_PATTERN_CHARS
++ if invalid:
++ raise SecretKeyInvalid(
++ f"Secret key contains invalid characters: {sorted(invalid)}"
++ )
++
++
++class SecretStore:
++ """Thread-safe, encrypted, scoped secret storage.
++
++ Secrets are organized by scope (task/session/global) and encrypted
++ at rest using HMAC-derived key streams. Each scope has an
++ independent namespace and capacity limit.
++ """
++
++ MAX_SECRETS_PER_SCOPE = 1000
++ MAX_VALUE_LENGTH = 65_536 # 64KB per secret value
++
++ def __init__(self, encryption_key: bytes | None = None) -> None:
++ self._lock = threading.Lock()
++ self._encryptor = _SecretEncryptor(
++ encryption_key or secrets.token_bytes(32)
++ )
++ # scope -> key -> SecretEntry
++ self._store: dict[SecretScope, dict[str, SecretEntry]] = {
++ scope: {} for scope in SecretScope
++ }
++
++ def put(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store or update a secret.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext secret value (encrypted before storage).
++ scope: Secret scope namespace.
++ owner: Subject identity of the caller.
++ expires_at: Optional epoch expiry.
++
++ Raises:
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If the scope has reached capacity.
++ ValueError: If value exceeds maximum length.
++ """
++ _validate_key(key)
++ value_bytes_len = len(value.encode("utf-8"))
++ if value_bytes_len > self.MAX_VALUE_LENGTH:
++ raise SecretValueTooLarge(
++ f"Secret value ({value_bytes_len} bytes) exceeds "
++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ if key not in scope_store:
++ # Purge expired entries before capacity check
++ now = time.time()
++ expired_keys = [
++ k for k, e in scope_store.items()
++ if e.expires_at is not None and e.expires_at <= now
++ ]
++ for k in expired_keys:
++ del scope_store[k]
++ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def put_checked(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: SecretScope,
++ owner: str,
++ expires_at: float | None = None,
++ cap_limit: int,
++ ) -> None:
++ """Atomic put with capability-level count check.
++
++ Same as put(), but additionally enforces a per-capability secret
++ count limit *inside* the lock, eliminating TOCTOU races between
++ count() and put() at the broker layer.
++
++ Raises:
++ SecretAccessDenied: If cap_limit would be exceeded for new keys.
++ SecretStoreFull: If scope hard limit is reached.
++ """
++ _validate_key(key)
++ value_bytes_len = len(value.encode("utf-8"))
++ if value_bytes_len > self.MAX_VALUE_LENGTH:
++ raise SecretValueTooLarge(
++ f"Secret value ({value_bytes_len} bytes) exceeds "
++ f"maximum length ({self.MAX_VALUE_LENGTH} bytes)"
++ )
++
++ encrypted = self._encryptor.encrypt(value)
++ entry = SecretEntry(
++ key=key,
++ scope=scope,
++ encrypted_value=encrypted,
++ owner=owner,
++ created_at=time.time(),
++ expires_at=expires_at,
++ )
++
++ with self._lock:
++ scope_store = self._store[scope]
++ now = time.time()
++ # Purge expired entries first to avoid ghost fullness
++ expired_keys = [
++ k for k, e in scope_store.items()
++ if e.expires_at is not None and e.expires_at <= now
++ ]
++ for k in expired_keys:
++ del scope_store[k]
++ # After purge, expired keys are gone — is_new is accurate
++ is_new = key not in scope_store
++ if is_new:
++ # Capability limit: count only secrets owned by this caller
++ owner_count = sum(
++ 1 for e in scope_store.values()
++ if e.owner == owner
++ )
++ if owner_count >= cap_limit:
++ raise SecretAccessDenied(
++ f"Would exceed max_secrets limit ({cap_limit}) "
++ f"for scope '{scope.value}'"
++ )
++ if len(scope_store) >= self.MAX_SECRETS_PER_SCOPE:
++ raise SecretStoreFull(
++ f"Scope '{scope.value}' is full "
++ f"({self.MAX_SECRETS_PER_SCOPE} secrets)"
++ )
++ scope_store[key] = entry
++
++ def get(self, key: str, *, scope: SecretScope) -> str | None:
++ """Retrieve and decrypt a secret value.
++
++ Returns None if the key does not exist or has expired.
++ Expired entries are lazily removed.
++ """
++ with self._lock:
++ entry = self._store[scope].get(key)
++ if entry is None:
++ return None
++ # Lazy expiry — consistent >= boundary (expired at exact expiry time)
++ if entry.expires_at is not None and time.time() >= entry.expires_at:
++ del self._store[scope][key]
++ return None
++ return self._encryptor.decrypt(entry.encrypted_value)
++
++ def delete(self, key: str, *, scope: SecretScope) -> bool:
++ """Delete a secret. Returns True if it existed."""
++ with self._lock:
++ return self._store[scope].pop(key, None) is not None
++
++ def list_keys(self, *, scope: SecretScope) -> list[str]:
++ """List all non-expired secret keys in a scope."""
++ now = time.time()
++ with self._lock:
++ result = []
++ expired = []
++ for k, entry in self._store[scope].items():
++ if entry.expires_at is not None and now >= entry.expires_at:
++ expired.append(k)
++ else:
++ result.append(k)
++ # Lazy cleanup
++ for k in expired:
++ del self._store[scope][k]
++ return sorted(result)
++
++ def count(self, *, scope: SecretScope) -> int:
++ """Number of non-expired secrets in a scope."""
++ return len(self.list_keys(scope=scope))
++
++ def clear_scope(self, scope: SecretScope) -> int:
++ """Remove all secrets in a scope. Returns count removed."""
++ with self._lock:
++ count = len(self._store[scope])
++ self._store[scope].clear()
++ return count
++
++
++# ---------------------------------------------------------------------------
++# #52 — SecretBroker (concrete SecretBrokerPort implementation)
++# ---------------------------------------------------------------------------
++
++
++@dataclass
++class SecretBroker:
++ """Concrete implementation of SecretBrokerPort.
++
++ Enforces lease-based access control via SecretCapability and
++ integrates with the scoped SecretStore for encrypted storage.
++
++ Access control layers:
++ 1. Capability check: caller's SecretCapability from lease
++ 2. Scope check: requested scope must be in capability's allowed_scopes
++ 3. Key check: if allowed_keys is non-empty, key must be listed
++ 4. Count check: caller cannot exceed max_secrets from capability
++ 5. Value encryption: all values encrypted at rest
++
++ Security: ``owner`` is bound at construction by the container from the
++ authenticated session identity. It MUST NOT be caller-supplied — this
++ prevents quota bypass via owner rotation.
++
++ Args:
++ store: The backing secret store (shared across brokers).
++ default_capability: Fallback capability if none provided per-call.
++ owner: Authenticated identity bound by the container (not caller-supplied).
++ """
++
++ store: SecretStore = field(default_factory=SecretStore)
++ default_capability: SecretCapability = field(
++ default_factory=SecretCapability
++ )
++ owner: str = "system"
++
++ def _resolve_scope(self, scope: str) -> SecretScope:
++ """Parse and validate a scope string."""
++ try:
++ return SecretScope(scope)
++ except ValueError:
++ raise SecretAccessDenied(
++ f"Invalid scope '{scope}'. "
++ f"Must be one of: {', '.join(s.value for s in SecretScope)}"
++ )
++
++ def _check_capability(
++ self,
++ capability: SecretCapability,
++ *,
++ scope: str,
++ key: str | None = None,
++ writing: bool = False,
++ ) -> SecretScope:
++ """Validate access against a SecretCapability.
++
++ Returns the validated SecretScope.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't allow the operation.
++ """
++ # 1. Max secrets check (0 = no access at all)
++ if capability.max_secrets <= 0:
++ raise SecretAccessDenied("Capability grants no secret access")
++
++ # 2. Scope check
++ resolved_scope = self._resolve_scope(scope)
++ if scope not in capability.allowed_scopes:
++ raise SecretAccessDenied(
++ f"Scope '{scope}' not in allowed scopes: "
++ f"{capability.allowed_scopes}"
++ )
++
++ # 3. Key check — empty allowed_keys = unrestricted (all tiers use
++ # empty by default; non-empty means explicit whitelist)
++ if key is not None and capability.allowed_keys:
++ if key not in capability.allowed_keys:
++ raise SecretAccessDenied(
++ f"Key '{key}' not in allowed keys"
++ )
++
++ return resolved_scope
++
++ # --- SecretBrokerPort interface ---
++
++ async def get_secret(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> Optional[str]:
++ """Retrieve a secret value.
++
++ Args:
++ key: Secret key to retrieve.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability (uses default if None).
++
++ Returns:
++ Decrypted secret value, or None if not found.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit access.
++ SecretKeyInvalid: If key is malformed.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.get(key, scope=resolved)
++
++ async def put_secret(
++ self,
++ key: str,
++ value: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ expires_at: float | None = None,
++ ) -> None:
++ """Store a secret value.
++
++ Args:
++ key: Secret key name.
++ value: Plaintext value to encrypt and store.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++ expires_at: Optional epoch expiry for the secret.
++
++ Raises:
++ SecretAccessDenied: If capability doesn't permit write.
++ SecretKeyInvalid: If key is malformed.
++ SecretStoreFull: If scope is at capacity.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(
++ cap, scope=scope, key=key, writing=True,
++ )
++
++ # Atomic put with capability count check inside the lock
++ # owner is bound at construction — not caller-supplied
++ self.store.put_checked(
++ key, value, scope=resolved, owner=self.owner,
++ expires_at=expires_at, cap_limit=cap.max_secrets,
++ )
++
++ async def revoke(
++ self,
++ key: str,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> bool:
++ """Revoke (delete) a secret.
++
++ Args:
++ key: Secret key to revoke.
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ True if the secret existed and was deleted.
++ """
++ _validate_key(key)
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope, key=key)
++ return self.store.delete(key, scope=resolved)
++
++ def list_available(
++ self,
++ *,
++ scope: str = "task",
++ capability: SecretCapability | None = None,
++ ) -> list[str]:
++ """List available secret keys in a scope.
++
++ Args:
++ scope: Secret scope namespace.
++ capability: Caller's lease capability.
++
++ Returns:
++ Sorted list of accessible key names.
++ """
++ cap = capability or self.default_capability
++ resolved = self._check_capability(cap, scope=scope)
++
++ all_keys = self.store.list_keys(scope=resolved)
++
++ # Filter to allowed_keys if set
++ if cap.allowed_keys:
++ allowed = set(cap.allowed_keys)
++ return [k for k in all_keys if k in allowed]
++
++ return all_keys
+diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
+new file mode 100644
+index 0000000..2104d51
+--- /dev/null
++++ b/tests/test_secret_broker.py
+@@ -0,0 +1,657 @@
++"""Tests for openspace.secret.broker — EPIC 2.6.
++
++Covers:
++- #52: SecretBrokerPort concrete implementation
++- Secret scoping (task, session, global)
++- Lease-based access control via SecretCapability
++- At-rest encryption/decryption
++- Key validation and store bounds
++- Thread safety
++"""
++
++from __future__ import annotations
++
++import threading
++import time
++import pytest
++
++from openspace.secret.broker import (
++ SecretBroker,
++ SecretStore,
++ SecretScope,
++ SecretEntry,
++ SecretAccessDenied,
++ SecretNotFoundError,
++ SecretStoreFull,
++ SecretKeyInvalid,
++ SecretBrokerError,
++ SecretValueTooLarge,
++ _SecretEncryptor,
++ _validate_key,
++)
++from openspace.sandbox.leases import SecretCapability
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# Key Validation
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestKeyValidation:
++ """Secret key naming rules."""
++
++ def test_valid_keys(self) -> None:
++ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
++ _validate_key(key) # should not raise
++
++ def test_empty_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="empty"):
++ _validate_key("")
++
++ def test_too_long_key_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="maximum length"):
++ _validate_key("x" * 257)
++
++ def test_invalid_chars_rejected(self) -> None:
++ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
++ _validate_key("key with spaces")
++
++ def test_special_chars_rejected(self) -> None:
++ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
++ with pytest.raises(SecretKeyInvalid):
++ _validate_key(f"key{ch}")
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# At-Rest Encryption
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretEncryptor:
++ """XOR-based at-rest encryption."""
++
++ def test_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
++ plaintext = "super-secret-value-123"
++ encrypted = enc.encrypt(plaintext)
++ assert enc.decrypt(encrypted) == plaintext
++
++ def test_encrypted_differs_from_plaintext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "my-api-key"
++ encrypted = enc.encrypt(plaintext)
++ assert plaintext.encode() not in encrypted
++
++ def test_different_nonce_different_ciphertext(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "same-value"
++ e1 = enc.encrypt(plaintext)
++ e2 = enc.encrypt(plaintext)
++ assert e1 != e2 # different nonce → different output
++ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
++
++ def test_corrupt_data_rejected(self) -> None:
++ enc = _SecretEncryptor(b"key")
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"short")
++
++ def test_empty_string(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ encrypted = enc.encrypt("")
++ assert enc.decrypt(encrypted) == ""
++
++ def test_unicode_roundtrip(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "héllo wörld 🔑"
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++ def test_long_value(self) -> None:
++ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
++ plaintext = "x" * 10000
++ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# Secret Store
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretStore:
++ """Scoped, encrypted, bounded secret storage."""
++
++ def test_put_and_get(self) -> None:
++ store = SecretStore()
++ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
++ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
++
++ def test_get_missing_returns_none(self) -> None:
++ store = SecretStore()
++ assert store.get("nope", scope=SecretScope.TASK) is None
++
++ def test_scopes_are_independent(self) -> None:
++ store = SecretStore()
++ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "task-val"
++ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
++ assert store.get("key", scope=SecretScope.GLOBAL) is None
++
++ def test_update_existing(self) -> None:
++ store = SecretStore()
++ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
++ assert store.get("key", scope=SecretScope.TASK) == "v2"
++
++ def test_delete(self) -> None:
++ store = SecretStore()
++ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
++ assert store.delete("key", scope=SecretScope.TASK) is True
++ assert store.get("key", scope=SecretScope.TASK) is None
++ assert store.delete("key", scope=SecretScope.TASK) is False
++
++ def test_list_keys(self) -> None:
++ store = SecretStore()
++ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
++ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
++ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
++
++ def test_count(self) -> None:
++ store = SecretStore()
++ assert store.count(scope=SecretScope.TASK) == 0
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ assert store.count(scope=SecretScope.TASK) == 2
++
++ def test_clear_scope(self) -> None:
++ store = SecretStore()
++ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
++ assert store.clear_scope(SecretScope.TASK) == 2
++ assert store.count(scope=SecretScope.TASK) == 0
++ assert store.count(scope=SecretScope.SESSION) == 1
++
++ def test_capacity_limit(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 3
++ for i in range(3):
++ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
++ with pytest.raises(SecretStoreFull, match="full"):
++ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
++
++ def test_update_does_not_count_toward_capacity(self) -> None:
++ store = SecretStore()
++ store.MAX_SECRETS_PER_SCOPE = 2
++ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
++ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
++ # Update existing — should NOT fail
++ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
++ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
++
++ def test_value_too_long_rejected(self) -> None:
++ store = SecretStore()
++ with pytest.raises(SecretValueTooLarge, match="maximum length"):
++ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
++
++ def test_value_too_long_bytes_not_chars(self) -> None:
++ """Byte length is enforced, not character count. Multi-byte chars
++ must be correctly measured against the 64KB limit."""
++ store = SecretStore()
++ # 4-byte emoji × 16385 = 65540 bytes > 64KB, but only 16385 chars
++ value = "\U0001f600" * 16385
++ assert len(value) < store.MAX_VALUE_LENGTH # chars < limit
++ assert len(value.encode("utf-8")) > store.MAX_VALUE_LENGTH # bytes > limit
++ with pytest.raises(SecretValueTooLarge, match="bytes"):
++ store.put("k", value, scope=SecretScope.TASK, owner="svc")
++
++ def test_lazy_expiry_on_get(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ assert store.get("k", scope=SecretScope.TASK) is None
++
++ def test_lazy_expiry_on_list(self) -> None:
++ store = SecretStore()
++ past = time.time() - 10
++ future = time.time() + 3600
++ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
++ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
++ keys = store.list_keys(scope=SecretScope.TASK)
++ assert keys == ["alive"]
++
++ def test_thread_safety(self) -> None:
++ store = SecretStore()
++ errors: list[Exception] = []
++
++ def write_batch(prefix: str) -> None:
++ try:
++ for i in range(50):
++ store.put(f"{prefix}-{i}", f"val-{i}",
++ scope=SecretScope.TASK, owner="svc")
++ except Exception as e:
++ errors.append(e)
++
++ threads = [
++ threading.Thread(target=write_batch, args=(f"t{n}",))
++ for n in range(4)
++ ]
++ for t in threads:
++ t.start()
++ for t in threads:
++ t.join()
++
++ assert not errors
++ assert store.count(scope=SecretScope.TASK) == 200
++
++ def test_encryption_at_rest(self) -> None:
++ """Stored values are encrypted — raw access doesn't reveal plaintext."""
++ store = SecretStore()
++ store.put("secret-key", "super-secret-password",
++ scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["secret-key"]
++ assert b"super-secret-password" not in entry.encrypted_value
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# SecretBroker — Capability-Based Access Control
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerCapability:
++ """SecretBroker enforces SecretCapability from leases."""
++
++ def _t2_capability(self) -> SecretCapability:
++ """T2-equivalent: 3 secrets, task scope only."""
++ return SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=3,
++ )
++
++ def _t3_capability(self) -> SecretCapability:
++ """T3-equivalent: 10 secrets, task + session scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ def _t4_capability(self) -> SecretCapability:
++ """T4-equivalent: 50 secrets, all scopes."""
++ return SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=50,
++ )
++
++ def _no_access_capability(self) -> SecretCapability:
++ """T0/T1-equivalent: no secret access."""
++ return SecretCapability(
++ allowed_scopes=[],
++ max_secrets=0,
++ )
++
++ @pytest.mark.asyncio
++ async def test_get_with_valid_capability(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ await broker.put_secret("api-key", "secret", capability=cap)
++ result = await broker.get_secret("api-key", capability=cap)
++ assert result == "secret"
++
++ @pytest.mark.asyncio
++ async def test_get_missing_returns_none(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ result = await broker.get_secret("nope", capability=cap)
++ assert result is None
++
++ @pytest.mark.asyncio
++ async def test_no_access_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._no_access_capability()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_denied(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability() # task only
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="session", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t3_can_access_session(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ await broker.put_secret("k", "v", scope="session", capability=cap)
++ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_t3_cannot_access_global(self) -> None:
++ broker = SecretBroker()
++ cap = self._t3_capability()
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("key", scope="global", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_t4_can_access_all_scopes(self) -> None:
++ broker = SecretBroker()
++ cap = self._t4_capability()
++ for scope in ["task", "session", "global"]:
++ await broker.put_secret(f"k-{scope}", "v", scope=scope,
++ capability=cap)
++ assert await broker.get_secret(f"k-{scope}", scope=scope,
++ capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_allowed_keys_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["db-pass", "api-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("db-pass", "secret", capability=cap)
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.get_secret("other-key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_max_secrets_enforced(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=2,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap)
++ await broker.put_secret("k2", "v2", capability=cap)
++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++ await broker.put_secret("k3", "v3", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_max_secrets_per_owner_not_scope(self) -> None:
++ """max_secrets counts per-owner, not scope-wide. Another owner's
++ secrets must not block a different caller's writes."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=2)
++ broker_a = SecretBroker(store=store, default_capability=cap, owner="owner-a")
++ broker_b = SecretBroker(store=store, default_capability=cap, owner="owner-b")
++ # Owner A fills their quota
++ await broker_a.put_secret("a1", "v")
++ await broker_a.put_secret("a2", "v")
++ # Owner B should NOT be blocked by owner A's secrets
++ await broker_b.put_secret("b1", "v")
++ await broker_b.put_secret("b2", "v")
++ assert store.count(scope=SecretScope.TASK) == 4
++
++ @pytest.mark.asyncio
++ async def test_expired_secrets_dont_block_writes(self) -> None:
++ """Expired entries are purged on write so they don't ghost-fill the scope."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=2)
++ broker = SecretBroker(store=store)
++ past = time.time() - 10
++ await broker.put_secret("old1", "v", capability=cap, expires_at=past)
++ await broker.put_secret("old2", "v", capability=cap, expires_at=past)
++ # Both are expired — new writes should succeed after purge
++ await broker.put_secret("new1", "v", capability=cap)
++ await broker.put_secret("new2", "v", capability=cap)
++ assert await broker.get_secret("new1", capability=cap) == "v"
++
++ @pytest.mark.asyncio
++ async def test_expired_key_resurrection_blocked(self) -> None:
++ """Overwriting an expired key must count as a new insert for cap_limit.
++ Prevents bypassing max_secrets by 'updating' expired keys back to life."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=1)
++ broker = SecretBroker(store=store)
++ past = time.time() - 10
++ # Fill quota then let it expire
++ await broker.put_secret("k1", "v1", capability=cap, expires_at=past)
++ # Write a new live key — uses the freed slot
++ await broker.put_secret("k2", "live", capability=cap)
++ # Attempting to "resurrect" expired k1 must be denied (quota full)
++ with pytest.raises(SecretAccessDenied, match="max_secrets"):
++ await broker.put_secret("k1", "revived", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_update_existing_does_not_hit_limit(self) -> None:
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ max_secrets=1,
++ )
++ broker = SecretBroker()
++ await broker.put_secret("k1", "v1", capability=cap)
++ # Update should work even at limit
++ await broker.put_secret("k1", "v2", capability=cap)
++ assert await broker.get_secret("k1", capability=cap) == "v2"
++
++ @pytest.mark.asyncio
++ async def test_invalid_scope_rejected(self) -> None:
++ broker = SecretBroker()
++ cap = self._t2_capability()
++ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
++ await broker.get_secret("key", scope="invalid", capability=cap)
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# SecretBroker — Revocation and Listing
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerOperations:
++ """SecretBroker revoke and list operations."""
++
++ def _cap(self) -> SecretCapability:
++ return SecretCapability(
++ allowed_scopes=["task", "session"],
++ max_secrets=10,
++ )
++
++ @pytest.mark.asyncio
++ async def test_revoke_existing(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("k", "v", capability=cap)
++ assert await broker.revoke("k", capability=cap) is True
++ assert await broker.get_secret("k", capability=cap) is None
++
++ @pytest.mark.asyncio
++ async def test_revoke_nonexistent(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert await broker.revoke("nope", capability=cap) is False
++
++ @pytest.mark.asyncio
++ async def test_list_available(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ await broker.put_secret("b", "v", capability=cap)
++ await broker.put_secret("a", "v", capability=cap)
++ keys = broker.list_available(capability=cap)
++ assert keys == ["a", "b"]
++
++ @pytest.mark.asyncio
++ async def test_list_filtered_by_allowed_keys(self) -> None:
++ store = SecretStore()
++ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
++ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["visible"],
++ max_secrets=5,
++ )
++ broker = SecretBroker(store=store)
++ keys = broker.list_available(capability=cap)
++ assert keys == ["visible"]
++
++ @pytest.mark.asyncio
++ async def test_list_empty_scope(self) -> None:
++ broker = SecretBroker()
++ cap = self._cap()
++ assert broker.list_available(capability=cap) == []
++
++ @pytest.mark.asyncio
++ async def test_revoke_denied_without_scope(self) -> None:
++ broker = SecretBroker()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ with pytest.raises(SecretAccessDenied):
++ await broker.revoke("k", scope="session", capability=cap)
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# SecretBroker — Default Capability
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerDefaults:
++ """SecretBroker with default_capability."""
++
++ @pytest.mark.asyncio
++ async def test_uses_default_capability(self) -> None:
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(default_capability=cap)
++ await broker.put_secret("k", "v")
++ assert await broker.get_secret("k") == "v"
++
++ @pytest.mark.asyncio
++ async def test_default_zero_denies(self) -> None:
++ broker = SecretBroker() # default SecretCapability has max_secrets=0
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("k")
++
++
++# ═══════════════════════════════════════════════════════════════════════
++# Security Regression Tests
++# ═══════════════════════════════════════════════════════════════════════
++
++
++class TestSecretBrokerSecurity:
++ """Security invariants for the secret broker."""
++
++ @pytest.mark.asyncio
++ async def test_t0_t1_cannot_access_secrets(self) -> None:
++ """T0/T1 equivalent capabilities deny all access."""
++ broker = SecretBroker()
++ for cap in [
++ SecretCapability(allowed_scopes=[], max_secrets=0),
++ SecretCapability(allowed_scopes=["task"], max_secrets=0),
++ ]:
++ with pytest.raises(SecretAccessDenied):
++ await broker.get_secret("key", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_scope_escalation_prevented(self) -> None:
++ """T2 (task-only) cannot read session secrets."""
++ store = SecretStore()
++ store.put("session-secret", "classified",
++ scope=SecretScope.SESSION, owner="admin")
++ t2_cap = SecretCapability(
++ allowed_scopes=["task"], max_secrets=3,
++ )
++ broker = SecretBroker(store=store)
++ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
++ await broker.get_secret("session-secret", scope="session",
++ capability=t2_cap)
++
++ @pytest.mark.asyncio
++ async def test_key_restriction_enforced(self) -> None:
++ """Allowed_keys list is a hard deny for unlisted keys."""
++ cap = SecretCapability(
++ allowed_scopes=["task"],
++ allowed_keys=["safe-key"],
++ max_secrets=5,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
++ await broker.put_secret("other-key", "v", capability=cap)
++
++ @pytest.mark.asyncio
++ async def test_encrypted_at_rest_via_broker(self) -> None:
++ """Values stored through broker are encrypted in the store."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ await broker.put_secret("api-key", "super-secret-123")
++ # Direct store access — value should be encrypted
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["api-key"]
++ assert b"super-secret-123" not in entry.encrypted_value
++ # But broker decrypts it
++ assert await broker.get_secret("api-key") == "super-secret-123"
++
++ @pytest.mark.asyncio
++ async def test_deny_before_allow(self) -> None:
++ """Zero max_secrets denies even if scopes match."""
++ cap = SecretCapability(
++ allowed_scopes=["task", "session", "global"],
++ max_secrets=0,
++ )
++ broker = SecretBroker()
++ with pytest.raises(SecretAccessDenied, match="no secret access"):
++ await broker.get_secret("key", capability=cap)
++
++ def test_secret_store_values_not_in_repr(self) -> None:
++ """SecretEntry encrypted_value should not leak plaintext."""
++ store = SecretStore()
++ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
++ with store._lock:
++ entry = store._store[SecretScope.TASK]["key"]
++ r = repr(entry)
++ assert "password123" not in r
++
++ @pytest.mark.asyncio
++ async def test_expired_secret_not_accessible(self) -> None:
++ """Expired secrets return None even through broker."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ past = time.time() - 10
++ await broker.put_secret("expired", "v", expires_at=past)
++ assert await broker.get_secret("expired") is None
++
++ def test_ciphertext_integrity_check(self) -> None:
++ """Tampered ciphertext is detected by HMAC integrity tag."""
++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
++ encrypted = enc.encrypt("sensitive-data")
++ # Flip a byte in the ciphertext region (after 16-byte nonce, before 32-byte tag)
++ tampered = bytearray(encrypted)
++ tampered[20] ^= 0xFF
++ with pytest.raises(SecretBrokerError, match="integrity check failed"):
++ enc.decrypt(bytes(tampered))
++
++ def test_ciphertext_truncation_detected(self) -> None:
++ """Truncated ciphertext is rejected."""
++ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
++ with pytest.raises(SecretBrokerError, match="Corrupt"):
++ enc.decrypt(b"\x00" * 16) # nonce only, no ciphertext or tag
++
++ @pytest.mark.asyncio
++ async def test_concurrent_put_respects_cap_limit(self) -> None:
++ """Concurrent writers must not exceed capability max_secrets (TOCTOU fix)."""
++ store = SecretStore()
++ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
++ broker = SecretBroker(store=store, default_capability=cap)
++ errors: list[Exception] = []
++ success_count = 0
++ lock = threading.Lock()
++
++ import asyncio
++
++ async def write(i: int) -> None:
++ nonlocal success_count
++ try:
++ await broker.put_secret(
++ f"key-{i}", f"val-{i}",
++ )
++ with lock:
++ success_count += 1
++ except SecretAccessDenied:
++ pass # Expected once limit is reached
++ except Exception as e:
++ with lock:
++ errors.append(e)
++
++ # Attempt 20 concurrent writes with cap of 5
++ await asyncio.gather(*(write(i) for i in range(20)))
++ assert not errors, f"Unexpected errors: {errors}"
++ # Must not exceed cap
++ assert store.count(scope=SecretScope.TASK) <= 5
diff --git a/pyproject.toml b/pyproject.toml
index 06c199c3..88229848 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,46 +15,48 @@ authors = [
dependencies = [
"litellm>=1.70.0,<1.82.7", # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious)
- "python-dotenv>=1.0.0",
- "openai>=1.0.0",
- "jsonschema>=4.25.0",
- "mcp>=1.0.0",
- "anthropic>=0.71.0",
- "pillow>=12.0.0",
- "numpy>=1.24.0",
- "colorama>=0.4.6",
- "flask>=3.1.0",
- "pyautogui>=0.9.54",
- "pydantic>=2.12.0",
- "requests>=2.32.0",
+ "python-dotenv>=1.0.0,<2",
+ "openai>=1.0.0,<3",
+ "jsonschema>=4.25.0,<5",
+ "mcp>=1.0.0,<2",
+ "anthropic>=0.71.0,<1",
+ "pillow>=11.0.0,<13",
+ "numpy>=1.24.0,<3",
+ "colorama>=0.4.6,<1",
+ "flask>=3.1.0,<4",
+ "pyautogui>=0.9.54,<1",
+ "pydantic>=2.12.0,<3",
+ "requests>=2.32.0,<3",
+ "structlog>=24.0.0,<26",
]
[project.optional-dependencies]
macos = [
- "pyobjc-core>=12.0",
- "pyobjc-framework-cocoa>=12.0",
- "pyobjc-framework-quartz>=12.0",
- "atomacos>=3.2.0",
+ "pyobjc-core>=12.0,<13",
+ "pyobjc-framework-cocoa>=12.0,<13",
+ "pyobjc-framework-quartz>=12.0,<13",
+ "atomacos>=3.2.0,<4",
]
linux = [
- "python-xlib>=0.33",
- "pyatspi>=2.38.0",
- "numpy>=1.24.0",
+ "python-xlib>=0.33,<1",
+ "pyatspi>=2.38.0,<3",
+ "numpy>=1.24.0,<3",
]
windows = [
- "pywinauto>=0.6.8",
- "pywin32>=306",
- "PyGetWindow>=0.0.9",
+ "pywinauto>=0.6.8,<1",
+ "pywin32>=306,<400",
+ "PyGetWindow>=0.0.9,<1",
]
dev = [
- "pytest>=7.0.0",
- "pytest-asyncio>=0.21.0",
- "black>=23.0.0",
- "flake8>=6.0.0",
- "mypy>=1.0.0",
+ "pytest>=7.0.0,<10",
+ "pytest-asyncio>=0.21.0,<2",
+ "pytest-timeout>=2.2.0,<3",
+ "pytest-cov>=4.0.0,<8",
+ "ruff>=0.5.0,<1",
+ "mypy>=1.0.0,<2",
]
all = [
@@ -73,6 +75,14 @@ openspace-download-skill = "openspace.cloud.cli.download_skill:main"
openspace-upload-skill = "openspace.cloud.cli.upload_skill:main"
openspace-dashboard = "openspace.dashboard_server:main"
+[tool.ruff]
+target-version = "py313"
+line-length = 120
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I"]
+ignore = ["E501"] # line length handled separately
+
[tool.setuptools]
packages = {find = {where = ["."], include = ["openspace*"]}}
@@ -82,4 +92,56 @@ openspace = [
"config/*.json.example",
"local_server/config.json",
"local_server/README.md",
+ "security/blocklist.yml",
+]
+
+# ---------------------------------------------------------------------------
+# pytest
+# ---------------------------------------------------------------------------
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"
+timeout = 30
+filterwarnings = [
+ "ignore::DeprecationWarning:litellm.*",
+ "ignore::DeprecationWarning:openai.*",
+ "ignore::pydantic.warnings.PydanticDeprecatedSince20",
+ "ignore::pytest.PytestUnraisableExceptionWarning",
+]
+markers = [
+ "unit: fast isolated tests (no I/O, no network)",
+ "integration: tests that touch real services or filesystem",
+ "contract: tests that validate API contracts",
+ "property: property-based tests (hypothesis)",
+ "statistical: tests with statistical assertions",
+ "e2e: end-to-end tests",
+ "slow: tests that take > 5 seconds",
+]
+
+# ---------------------------------------------------------------------------
+# coverage
+# ---------------------------------------------------------------------------
+
+[tool.coverage.run]
+source = ["openspace"]
+omit = [
+ "openspace/__main__.py",
+ "openspace/dashboard_server.py",
+ "openspace/local_server/*",
+ "openspace/platforms/*",
+ "openspace/recording/*",
+ "tests/*",
+]
+
+[tool.coverage.report]
+fail_under = 20
+show_missing = true
+exclude_lines = [
+ "pragma: no cover",
+ "if __name__ == .__main__.",
+ "if TYPE_CHECKING:",
+ "raise NotImplementedError",
+ "@(abc\\.)?abstractmethod",
+ "\\.\\.\\.",
]
diff --git a/requirements.txt b/requirements.txt
index 6371308a..88f3839f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,31 +1,22 @@
-# OpenSpace core dependencies
-litellm>=1.70.0,<1.82.7 # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious)
-python-dotenv>=1.0.0
-openai>=1.0.0
-jsonschema>=4.25.0
-mcp>=1.0.0
-anthropic>=0.71.0
-pillow>=12.0.0
-numpy>=1.24.0
-colorama>=0.4.6
+# ──────────────────────────────────────────────────────
+# Dependencies are managed in pyproject.toml (single source of truth).
+# Install with: pip install -e ".[dev]"
+#
+# This file exists for tools that only read requirements.txt.
+# It re-exports the core deps from pyproject.toml.
+# ──────────────────────────────────────────────────────
-# Local server dependencies (cross-platform)
-flask>=3.1.0
-pyautogui>=0.9.54
-pydantic>=2.12.0
-requests>=2.32.0
-
-# # macOS-specific dependencies (local server)
-# pyobjc-core>=12.0; sys_platform == 'darwin'
-# pyobjc-framework-cocoa>=12.0; sys_platform == 'darwin'
-# pyobjc-framework-quartz>=12.0; sys_platform == 'darwin'
-# atomacos>=3.2.0; sys_platform == 'darwin'
-
-# # Linux-specific dependencies (local server)
-# python-xlib>=0.33; sys_platform == 'linux'
-# pyatspi>=2.38.0; sys_platform == 'linux'
-
-# # Windows-specific dependencies (local server)
-# pywinauto>=0.6.8; sys_platform == 'win32'
-# pywin32>=306; sys_platform == 'win32'
-# PyGetWindow>=0.0.9; sys_platform == 'win32'
+litellm>=1.70.0,<1.82.7
+python-dotenv>=1.0.0,<2
+openai>=1.0.0,<3
+jsonschema>=4.25.0,<5
+mcp>=1.0.0,<2
+anthropic>=0.71.0,<1
+pillow>=11.0.0,<13
+numpy>=1.24.0,<3
+colorama>=0.4.6,<1
+flask>=3.1.0,<4
+pyautogui>=0.9.54,<1
+pydantic>=2.12.0,<3
+requests>=2.32.0,<3
+structlog>=24.0.0,<26
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..8a88dfdc
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,182 @@
+"""Shared pytest fixtures for the OpenSpace test suite.
+
+Provides:
+ - ``mock_llm_client`` — deterministic LLM mock (no real API calls)
+ - ``in_memory_store`` — SQLite-backed SkillStore using :memory:
+ - ``work_dir`` — clean temporary directory for filesystem-heavy tests
+ - ``temp_skill_dir`` — temporary directory pre-populated with sample skills
+ - ``mock_env`` — safely set / restore environment variables
+"""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+import textwrap
+from pathlib import Path
+from typing import Any, Dict, Generator, Iterator
+
+import pytest
+
+from tests.mocks.llm import MockLLMClient
+
+
+# ---------------------------------------------------------------------------
+# mock_llm_client — deterministic LLM responses
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def mock_llm_client() -> MockLLMClient:
+ """Return a ``MockLLMClient`` with a single default response.
+
+ Override by creating the fixture with custom responses::
+
+ @pytest.fixture
+ def mock_llm_client():
+ return MockLLMClient(responses=["custom answer"])
+ """
+ return MockLLMClient()
+
+
+# ---------------------------------------------------------------------------
+# in_memory_store — ephemeral SkillStore backed by :memory: SQLite
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def in_memory_store(tmp_path: Path):
+ """Create a SkillStore backed by a temporary SQLite database.
+
+ Uses a temp-file database (not ``:memory:``) so that the SkillStore
+ class can open its own connection with the path it expects.
+ The file is cleaned up automatically by pytest's ``tmp_path``.
+ """
+ from openspace.skill_engine.store import SkillStore
+
+ db_path = tmp_path / "test_openspace.db"
+ store = SkillStore(db_path=db_path)
+ yield store
+ store.close()
+
+
+# ---------------------------------------------------------------------------
+# work_dir — clean temporary directory for filesystem-heavy tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def work_dir(tmp_path: Path) -> Path:
+ """Return a clean temporary directory for filesystem-heavy tests.
+
+ NOTE: This is a convenience alias for ``tmp_path``, NOT a security
+ sandbox. Code under test can still access paths outside this
+ directory. For true filesystem isolation, use E2B or a chroot.
+ """
+ return tmp_path
+
+
+# ---------------------------------------------------------------------------
+# temp_skill_dir — directory with sample SKILL.md files
+# ---------------------------------------------------------------------------
+
+_SAMPLE_SKILL_TEMPLATE = textwrap.dedent("""\
+ ---
+ name: {name}
+ description: {description}
+ ---
+
+ # {name}
+
+ This is a sample skill for testing purposes.
+
+ ## Steps
+
+ 1. Do the first thing.
+ 2. Do the second thing.
+ 3. Verify the result.
+""")
+
+
+@pytest.fixture
+def temp_skill_dir(tmp_path: Path) -> Path:
+ """Create a temporary skills directory with three sample skills.
+
+ Directory layout::
+
+ tmp/skills/
+ ├── weather_lookup/
+ │ └── SKILL.md
+ ├── code_review/
+ │ └── SKILL.md
+ └── file_organizer/
+ └── SKILL.md
+ """
+ skills_root = tmp_path / "skills"
+ skills_root.mkdir()
+
+ samples = [
+ ("weather_lookup", "Look up current weather for a city"),
+ ("code_review", "Review code changes and suggest improvements"),
+ ("file_organizer", "Organize files in a directory by type"),
+ ]
+
+ for name, description in samples:
+ skill_dir = skills_root / name
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ _SAMPLE_SKILL_TEMPLATE.format(name=name, description=description),
+ encoding="utf-8",
+ )
+
+ return skills_root
+
+
+# ---------------------------------------------------------------------------
+# mock_env — safe environment variable manipulation
+# ---------------------------------------------------------------------------
+
+
+class _EnvPatcher:
+ """Context-manager / callable that sets env vars and restores originals."""
+
+ def __init__(self) -> None:
+ self._originals: Dict[str, str | None] = {}
+
+ def set(self, key: str, value: str) -> None:
+ """Set an environment variable, recording the original value."""
+ if key not in self._originals:
+ self._originals[key] = os.environ.get(key)
+ os.environ[key] = value
+
+ def delete(self, key: str) -> None:
+ """Remove an environment variable, recording the original value."""
+ if key not in self._originals:
+ self._originals[key] = os.environ.get(key)
+ os.environ.pop(key, None)
+
+ def restore(self) -> None:
+ """Restore all modified variables to their original values."""
+ for key, original in self._originals.items():
+ if original is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = original
+ self._originals.clear()
+
+
+@pytest.fixture
+def mock_env() -> Generator[_EnvPatcher, None, None]:
+ """Fixture that provides an ``_EnvPatcher`` for safe env-var manipulation.
+
+ Usage::
+
+ def test_something(mock_env):
+ mock_env.set("MY_VAR", "value")
+ mock_env.delete("OTHER_VAR")
+ # ...test runs with modified env...
+ # originals automatically restored
+ """
+ patcher = _EnvPatcher()
+ yield patcher
+ patcher.restore()
diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py
new file mode 100644
index 00000000..97ec2914
--- /dev/null
+++ b/tests/mocks/__init__.py
@@ -0,0 +1,5 @@
+"""Test mocks for OpenSpace."""
+
+from .llm import MockLLMClient
+
+__all__ = ["MockLLMClient"]
diff --git a/tests/mocks/llm.py b/tests/mocks/llm.py
new file mode 100644
index 00000000..41be8790
--- /dev/null
+++ b/tests/mocks/llm.py
@@ -0,0 +1,164 @@
+"""Deterministic LLM mock for testing.
+
+Provides ``MockLLMClient`` — a drop-in replacement for
+``openspace.llm.LLMClient`` that returns pre-configured responses
+from a response pool instead of calling a real LLM API.
+
+Usage in tests::
+
+ client = MockLLMClient(responses=["Hello!", "Goodbye!"])
+ result = await client.complete("Say hi")
+ assert result["message"]["content"] == "Hello!"
+
+Responses cycle: after the pool is exhausted it wraps around.
+"""
+
+from __future__ import annotations
+
+import json
+from itertools import cycle
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence, Union
+
+
+_RESPONSES_DIR = Path(__file__).parent / "responses"
+
+# Pre-built response templates
+CHAT_RESPONSE = _RESPONSES_DIR / "chat_completion.json"
+TOOL_CALL_RESPONSE = _RESPONSES_DIR / "tool_call.json"
+SKILL_ANALYSIS_RESPONSE = _RESPONSES_DIR / "skill_analysis.json"
+
+
+def _make_completion(
+ content: str,
+ *,
+ model: str = "mock-model",
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
+) -> Dict[str, Any]:
+ """Build a response dict matching the production LLMClient.complete() shape.
+
+ Production returns::
+
+ {
+ "message": {"role": "assistant", "content": "..."},
+ "tool_results": [...],
+ "messages": [...],
+ "has_tool_calls": bool,
+ "iteration_summary": ""
+ }
+ """
+ message: Dict[str, Any] = {"role": "assistant", "content": content}
+ tool_results: List[Dict[str, Any]] = []
+ has_tool_calls = False
+
+ if tool_calls:
+ message["tool_calls"] = tool_calls
+ message["content"] = "" # Production uses empty string, not None
+ has_tool_calls = True
+
+ return {
+ "message": message,
+ "tool_results": tool_results,
+ "messages": [message],
+ "has_tool_calls": has_tool_calls,
+ "iteration_summary": None, # Production defaults to None, not ""
+ }
+
+
+def load_response_file(path: Union[str, Path]) -> Dict[str, Any]:
+ """Load a JSON response template from disk."""
+ with open(path) as f:
+ return json.load(f)
+
+
+class MockLLMClient:
+ """Deterministic mock that replaces ``LLMClient`` for testing.
+
+ Parameters
+ ----------
+ responses : sequence of str | dict
+ If str — each string becomes the assistant ``content`` of a
+ chat-completion response.
+ If dict — used as-is (must look like a litellm response).
+ model : str
+ Model name echoed in responses.
+ record_calls : bool
+ When ``True`` (default), every ``complete()`` invocation is
+ appended to ``self.calls`` for later assertion.
+ """
+
+ def __init__(
+ self,
+ responses: Optional[Sequence[Union[str, Dict[str, Any]]]] = None,
+ *,
+ model: str = "mock-model",
+ record_calls: bool = True,
+ ) -> None:
+ if responses is None:
+ responses = ["Mock LLM response."]
+
+ built: List[Dict[str, Any]] = []
+ for r in responses:
+ if isinstance(r, str):
+ built.append(_make_completion(r, model=model))
+ else:
+ built.append(r)
+
+ self.model = model
+ self._pool = cycle(built)
+ self._responses_list = built
+ self.record_calls = record_calls
+ self.calls: List[Dict[str, Any]] = []
+ self.call_count = 0
+
+ # Mirror real LLMClient attributes for compatibility
+ self.enable_thinking = False
+ self.rate_limit_delay = 0.0
+ self.max_retries = 1
+ self.retry_delay = 0.0
+ self.timeout = 30.0
+ self.summarize_threshold_chars = 200000
+ self.enable_tool_result_summarization = False
+ self.litellm_kwargs: Dict[str, Any] = {}
+
+ async def complete(
+ self,
+ messages: Union[List[Dict], str],
+ tools: Optional[List] = None,
+ execute_tools: bool = False,
+ summary_prompt: Optional[str] = None,
+ tool_result_callback: Optional[Any] = None,
+ **kwargs: Any,
+ ) -> Dict[str, Any]:
+ """Return the next response from the pool.
+
+ Matches the signature of ``LLMClient.complete()`` so it can be
+ used as a drop-in replacement. Tool execution is always
+ skipped — tests should assert on tool_calls in the response
+ if needed.
+ """
+ if isinstance(messages, str):
+ messages = [{"role": "user", "content": messages}]
+
+ call_record = {
+ "messages": messages,
+ "tools": tools,
+ "execute_tools": execute_tools,
+ "kwargs": kwargs,
+ }
+
+ if self.record_calls:
+ self.calls.append(call_record)
+
+ self.call_count += 1
+ return next(self._pool)
+
+ def get_last_call(self) -> Optional[Dict[str, Any]]:
+ """Return the most recent call record, or None."""
+ return self.calls[-1] if self.calls else None
+
+ def reset(self) -> None:
+ """Reset call history and re-cycle the response pool."""
+ self.calls.clear()
+ self.call_count = 0
+ self._pool = cycle(self._responses_list)
diff --git a/tests/mocks/responses/chat_completion.json b/tests/mocks/responses/chat_completion.json
new file mode 100644
index 00000000..e0ca7241
--- /dev/null
+++ b/tests/mocks/responses/chat_completion.json
@@ -0,0 +1,21 @@
+{
+ "id": "chatcmpl-mock-001",
+ "object": "chat.completion",
+ "created": 1700000000,
+ "model": "mock-model",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "This is a deterministic mock response."
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 10,
+ "completion_tokens": 8,
+ "total_tokens": 18
+ }
+}
diff --git a/tests/mocks/responses/skill_analysis.json b/tests/mocks/responses/skill_analysis.json
new file mode 100644
index 00000000..f9a3f72b
--- /dev/null
+++ b/tests/mocks/responses/skill_analysis.json
@@ -0,0 +1,21 @@
+{
+ "id": "chatcmpl-mock-003",
+ "object": "chat.completion",
+ "created": 1700000000,
+ "model": "mock-model",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "{\"task_completed\": true, \"execution_note\": \"Task executed successfully using the skill.\", \"skill_judgments\": [{\"skill_id\": \"test_skill__imp_12345678\", \"skill_applied\": true, \"note\": \"Skill was applied correctly.\"}], \"evolution_suggestions\": []}"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 50,
+ "completion_tokens": 40,
+ "total_tokens": 90
+ }
+}
diff --git a/tests/mocks/responses/tool_call.json b/tests/mocks/responses/tool_call.json
new file mode 100644
index 00000000..2196da09
--- /dev/null
+++ b/tests/mocks/responses/tool_call.json
@@ -0,0 +1,31 @@
+{
+ "id": "chatcmpl-mock-002",
+ "object": "chat.completion",
+ "created": 1700000000,
+ "model": "mock-model",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_mock_001",
+ "type": "function",
+ "function": {
+ "name": "read_file",
+ "arguments": "{\"path\": \"skills/example/SKILL.md\"}"
+ }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 15,
+ "completion_tokens": 20,
+ "total_tokens": 35
+ }
+}
diff --git a/tests/test_appcontainer.py b/tests/test_appcontainer.py
new file mode 100644
index 00000000..1ac27e4e
--- /dev/null
+++ b/tests/test_appcontainer.py
@@ -0,0 +1,379 @@
+"""Tests for EPIC 1.3 — AppContainer (Composition Root).
+
+Issues #64-67:
+- #64: AppContainer dataclass
+- #65: build_container factory
+- #66: build_test_container factory with mocks
+- #67: startup() / shutdown() lifecycle hooks
+
+Validates:
+- Container construction with all ports Optional
+- Lifecycle hooks (startup/shutdown) ordering and error handling
+- require() accessor raises on missing services
+- build_container wires services and registers shutdown hooks
+- build_test_container provides working stubs
+- Protocol compliance of test stubs
+"""
+
+from __future__ import annotations
+
+import asyncio
+import pytest
+from typing import Any, Dict, List, Optional
+
+from openspace.app.container import AppContainer
+from openspace.app.factory import (
+ build_container,
+ build_test_container,
+ _StubLLM,
+ _StubSkillStore,
+ _StubTelemetry,
+)
+from openspace.domain.ports import LLMClientPort, SkillStorePort, TelemetryPort
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Container construction
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestContainerConstruction:
+ """#64 — AppContainer dataclass creation."""
+
+ def test_empty_container(self):
+ """All services default to None."""
+ c = AppContainer()
+ assert c.llm is None
+ assert c.agent_executor is None
+ assert c.skill_store is None
+ assert c.sandbox is None
+ assert c.telemetry is None
+ assert c.auth is None
+ assert c.secret_broker is None
+ assert c.capability_lease_resolver is None
+ assert c.policy_engine is None
+ assert c.tool_backend is None
+ assert c.skill_evolution is None
+ assert c.analysis is None
+ assert c.cloud_skill is None
+ assert c.is_started is False
+
+ def test_partial_wiring(self):
+ """Container can be partially wired."""
+ stub_llm = _StubLLM()
+ c = AppContainer(llm=stub_llm)
+ assert c.llm is stub_llm
+ assert c.skill_store is None
+
+ def test_full_wiring(self):
+ """Container accepts all 13 service slots."""
+ stub = _StubLLM()
+ c = AppContainer(
+ llm=stub,
+ agent_executor=None,
+ telemetry=None,
+ skill_store=None,
+ skill_evolution=None,
+ analysis=None,
+ cloud_skill=None,
+ sandbox=None,
+ policy_engine=None,
+ auth=None,
+ secret_broker=None,
+ capability_lease_resolver=None,
+ tool_backend=None,
+ )
+ assert c.llm is stub
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Lifecycle hooks
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestLifecycle:
+ """#67 — startup() / shutdown() lifecycle hooks."""
+
+ @pytest.mark.asyncio
+ async def test_startup_runs_hooks_in_order(self):
+ order = []
+ c = AppContainer()
+ c.register_startup_hook(lambda: _async_record(order, "a"))
+ c.register_startup_hook(lambda: _async_record(order, "b"))
+ c.register_startup_hook(lambda: _async_record(order, "c"))
+ await c.startup()
+ assert order == ["a", "b", "c"]
+ assert c.is_started is True
+
+ @pytest.mark.asyncio
+ async def test_shutdown_runs_hooks_in_reverse(self):
+ order = []
+ c = AppContainer()
+ c.register_shutdown_hook(lambda: _async_record(order, "x"))
+ c.register_shutdown_hook(lambda: _async_record(order, "y"))
+ c.register_shutdown_hook(lambda: _async_record(order, "z"))
+ c._started = True
+ await c.shutdown()
+ assert order == ["z", "y", "x"]
+ assert c.is_started is False
+
+ @pytest.mark.asyncio
+ async def test_startup_raises_if_already_started(self):
+ c = AppContainer()
+ await c.startup()
+ with pytest.raises(RuntimeError, match="already started"):
+ await c.startup()
+
+ @pytest.mark.asyncio
+ async def test_shutdown_idempotent_when_not_started(self):
+ """Shutdown on un-started container is a safe no-op."""
+ c = AppContainer()
+ await c.shutdown() # should not raise
+ assert c.is_started is False
+
+ @pytest.mark.asyncio
+ async def test_shutdown_collects_errors(self):
+ """All hooks run even if some fail; first error is raised."""
+ order = []
+ c = AppContainer()
+
+ async def failing_hook():
+ order.append("fail")
+ raise ValueError("hook failed")
+
+ c.register_shutdown_hook(lambda: _async_record(order, "first"))
+ c.register_shutdown_hook(failing_hook)
+ c.register_shutdown_hook(lambda: _async_record(order, "last"))
+ c._started = True
+
+ with pytest.raises(ValueError, match="hook failed"):
+ await c.shutdown()
+
+ # All hooks ran (reverse order: last, failing, first)
+ assert order == ["last", "fail", "first"]
+ assert c.is_started is False
+
+ @pytest.mark.asyncio
+ async def test_full_lifecycle(self):
+ """startup → use → shutdown cycle."""
+ log = []
+ c = AppContainer(llm=_StubLLM())
+ c.register_startup_hook(lambda: _async_record(log, "started"))
+ c.register_shutdown_hook(lambda: _async_record(log, "stopped"))
+
+ await c.startup()
+ assert c.is_started
+ # Use a service
+ result = await c.require("llm").complete("hello")
+ assert result["content"] == "stub response"
+
+ await c.shutdown()
+ assert not c.is_started
+ assert log == ["started", "stopped"]
+
+ @pytest.mark.asyncio
+ async def test_partial_startup_allows_shutdown_cleanup(self):
+ """If startup fails mid-way, shutdown() still runs to clean up."""
+ log = []
+
+ async def hook_a():
+ log.append("a_started")
+
+ async def hook_b():
+ log.append("b_failed")
+ raise RuntimeError("hook B exploded")
+
+ c = AppContainer()
+ c.register_startup_hook(hook_a)
+ c.register_startup_hook(hook_b)
+ c.register_shutdown_hook(lambda: _async_record(log, "cleanup"))
+
+ with pytest.raises(RuntimeError, match="hook B exploded"):
+ await c.startup()
+
+ # Container is marked started so shutdown can clean up
+ assert c.is_started is True
+ await c.shutdown()
+ assert c.is_started is False
+ assert log == ["a_started", "b_failed", "cleanup"]
+
+
+# ══════════════════════════════════════════════════════════════════════
+# require() accessor
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestRequire:
+ def test_require_returns_service(self):
+ stub = _StubLLM()
+ c = AppContainer(llm=stub)
+ assert c.require("llm") is stub
+
+ def test_require_raises_on_missing(self):
+ c = AppContainer()
+ with pytest.raises(RuntimeError, match="not wired"):
+ c.require("llm")
+
+ def test_require_raises_on_unknown(self):
+ c = AppContainer()
+ with pytest.raises(AttributeError, match="Unknown service"):
+ c.require("nonexistent_service")
+
+
+# ══════════════════════════════════════════════════════════════════════
+# build_container factory
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestBuildContainer:
+ """#65 — build_container factory."""
+
+ @pytest.mark.asyncio
+ async def test_build_empty(self):
+ c = await build_container()
+ assert c.llm is None
+ assert c.is_started is False
+
+ @pytest.mark.asyncio
+ async def test_build_with_services(self):
+ stub_llm = _StubLLM()
+ stub_store = _StubSkillStore()
+ c = await build_container(llm=stub_llm, skill_store=stub_store)
+ assert c.llm is stub_llm
+ assert c.skill_store is stub_store
+
+ @pytest.mark.asyncio
+ async def test_sandbox_shutdown_hook_registered(self):
+ """build_container auto-registers sandbox.stop as shutdown hook."""
+
+ class FakeSandbox:
+ stopped = False
+
+ async def start(self) -> bool:
+ return True
+
+ async def stop(self) -> None:
+ self.stopped = True
+
+ async def execute_safe(self, command: str, **kw: Any) -> Any:
+ return None
+
+ @property
+ def is_active(self) -> bool:
+ return True
+
+ sb = FakeSandbox()
+ c = await build_container(sandbox=sb)
+ c._started = True
+ await c.shutdown()
+ assert sb.stopped is True
+
+ @pytest.mark.asyncio
+ async def test_telemetry_shutdown_hook_registered(self):
+ """build_container auto-registers telemetry.shutdown as shutdown hook."""
+ telem = _StubTelemetry()
+ c = await build_container(telemetry=telem)
+ c._started = True
+ # Shutdown should call telemetry.shutdown() — no error means success
+ await c.shutdown()
+
+
+# ══════════════════════════════════════════════════════════════════════
+# build_test_container factory
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestBuildTestContainer:
+ """#66 — build_test_container with stubs."""
+
+ def test_default_stubs(self):
+ c = build_test_container()
+ assert c.llm is not None
+ assert c.skill_store is not None
+ assert c.telemetry is not None
+
+ def test_override_stubs(self):
+ custom_llm = _StubLLM()
+ c = build_test_container(llm=custom_llm)
+ assert c.llm is custom_llm
+
+ @pytest.mark.asyncio
+ async def test_stub_llm_works(self):
+ c = build_test_container()
+ result = await c.llm.complete("hello")
+ assert result["content"] == "stub response"
+ assert c.llm.estimate_tokens("hello world") >= 1
+
+ @pytest.mark.asyncio
+ async def test_stub_skill_store_crud(self):
+ """StubSkillStore supports full CRUD cycle."""
+ from dataclasses import dataclass
+
+ @dataclass
+ class FakeManifest:
+ skill_id: str = "test-skill"
+
+ c = build_test_container()
+ store = c.skill_store
+
+ # Save
+ manifest = FakeManifest()
+ await store.save_record(manifest)
+ assert store.count() == 1
+
+ # Load
+ loaded = store.load_record("test-skill")
+ assert loaded is manifest
+
+ # Load all
+ all_records = store.load_all()
+ assert "test-skill" in all_records
+
+ # Load active
+ active = store.load_active()
+ assert "test-skill" in active
+
+ # Delete
+ deleted = await store.delete_record("test-skill")
+ assert deleted is True
+ assert store.count() == 0
+
+ # Delete non-existent
+ assert await store.delete_record("nope") is False
+
+ def test_stub_telemetry_captures(self):
+ c = build_test_container()
+ telem = c.telemetry
+ telem.capture("event1", {"key": "val"})
+ telem.capture("event2")
+ assert len(telem.events) == 2
+ assert telem.events[0] == ("event1", {"key": "val"})
+ telem.flush() # no-op, should not raise
+ telem.shutdown() # no-op, should not raise
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Protocol compliance
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestProtocolCompliance:
+ """Stubs satisfy their Protocol interfaces at runtime."""
+
+ def test_stub_llm_is_llm_port(self):
+ assert isinstance(_StubLLM(), LLMClientPort)
+
+ def test_stub_store_is_store_port(self):
+ assert isinstance(_StubSkillStore(), SkillStorePort)
+
+ def test_stub_telemetry_is_telemetry_port(self):
+ assert isinstance(_StubTelemetry(), TelemetryPort)
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Helpers
+# ══════════════════════════════════════════════════════════════════════
+
+
+async def _async_record(log: list, value: str) -> None:
+ log.append(value)
diff --git a/tests/test_architecture_boundaries.py b/tests/test_architecture_boundaries.py
new file mode 100644
index 00000000..37c67761
--- /dev/null
+++ b/tests/test_architecture_boundaries.py
@@ -0,0 +1,430 @@
+"""Architecture boundary tests — EPIC 1.7.
+
+Issues:
+- #80: Import-graph test: domain layer imports ZERO infrastructure modules
+- #81: MCP handlers access ZERO private fields (_store, _registry, etc.)
+- #82: File-size guard: no .py file exceeds 15 KB (warning-only in Phase 1)
+- #83: CI integration (tests run in pytest → already in CI Tier 1)
+
+Phase 1 approach:
+- Import violations in domain/ are **hard failures** (ZERO tolerance)
+- Private-field access in MCP handlers is a **hard failure**
+- File-size violations emit ``pytest.warnings`` but do NOT fail
+ (enforcement deferred to Phase 7)
+"""
+
+from __future__ import annotations
+
+import ast
+import os
+import warnings
+from pathlib import Path
+from typing import Set
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_OPENSPACE = _REPO_ROOT / "openspace"
+_DOMAIN = _OPENSPACE / "domain"
+
+# stdlib top-level modules that are always allowed everywhere
+_STDLIB_PREFIXES: frozenset[str] = frozenset(
+ {
+ "__future__",
+ "abc",
+ "ast",
+ "asyncio",
+ "base64",
+ "collections",
+ "contextlib",
+ "copy",
+ "contextvars",
+ "dataclasses",
+ "datetime",
+ "decimal",
+ "enum",
+ "functools",
+ "hashlib",
+ "hmac",
+ "importlib",
+ "inspect",
+ "io",
+ "itertools",
+ "json",
+ "logging",
+ "math",
+ "operator",
+ "os",
+ "pathlib",
+ "pprint",
+ "re",
+ "secrets",
+ "shutil",
+ "signal",
+ "socket",
+ "string",
+ "struct",
+ "subprocess",
+ "sys",
+ "tempfile",
+ "textwrap",
+ "threading",
+ "time",
+ "traceback",
+ "types",
+ "typing",
+ "typing_extensions",
+ "unittest",
+ "urllib",
+ "uuid",
+ "warnings",
+ }
+)
+
+# Third-party packages explicitly allowed in domain layer
+_DOMAIN_ALLOWED_THIRD_PARTY: frozenset[str] = frozenset(
+ {
+ "structlog", # structured logging (EPIC 1.6)
+ }
+)
+
+# All allowed import roots for domain layer
+_DOMAIN_ALLOWED_ROOTS: frozenset[str] = (
+ _STDLIB_PREFIXES | _DOMAIN_ALLOWED_THIRD_PARTY | frozenset({"openspace.domain"})
+)
+
+
+def _collect_py_files(directory: Path) -> list[Path]:
+ """Recursively collect all .py files under *directory*."""
+ return sorted(directory.rglob("*.py"))
+
+
+def _extract_imports(filepath: Path) -> list[str]:
+ """Return top-level module names imported by *filepath* using AST."""
+ try:
+ source = filepath.read_text(encoding="utf-8", errors="replace")
+ tree = ast.parse(source, filename=str(filepath))
+ except SyntaxError as exc:
+ # Fail loudly — a syntax error in domain/ means the file can't be
+ # validated and should not silently pass boundary checks.
+ raise AssertionError(
+ f"SyntaxError in {filepath} — cannot validate imports: {exc}"
+ ) from exc
+
+ modules: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ modules.append(alias.name)
+ elif isinstance(node, ast.ImportFrom):
+ # Relative imports (level > 0) are intra-package — always allowed
+ if node.level and node.level > 0:
+ continue
+ if node.module:
+ modules.append(node.module)
+ return modules
+
+
+def _import_root(module: str) -> str:
+ """Return the top-level package/module name."""
+ return module.split(".")[0]
+
+
+def _is_allowed_domain_import(module: str) -> bool:
+ """Check if *module* is allowed inside the domain layer."""
+ root = _import_root(module)
+ # stdlib or allowed third-party
+ if root in _DOMAIN_ALLOWED_ROOTS:
+ return True
+ # intra-domain (openspace.domain.*)
+ if module.startswith("openspace.domain"):
+ return True
+ # relative imports within domain resolve to openspace.domain
+ # (already handled by ast — from .ports import X becomes module="ports")
+ # Single-segment names matching stdlib
+ if root in _STDLIB_PREFIXES:
+ return True
+ return False
+
+
+# ---------------------------------------------------------------------------
+# #80 — Domain Import Purity
+# ---------------------------------------------------------------------------
+
+
+class TestDomainImportPurity:
+ """Domain layer (openspace/domain/) must import ZERO infrastructure modules.
+
+ Allowed: stdlib, structlog, openspace.domain.*
+ Forbidden: anything else in openspace.* or third-party infra packages.
+ """
+
+ def test_known_cross_layer_count(self) -> None:
+ """Guard: the known-violations allowlist must not silently grow."""
+ assert len(self._KNOWN_CROSS_LAYER) == 3, (
+ f"If you fixed a cross-layer import, remove it from _KNOWN_CROSS_LAYER. "
+ f"If you added one, stop — fix it instead. Count: {len(self._KNOWN_CROSS_LAYER)}"
+ )
+
+ @staticmethod
+ def _domain_files() -> list[Path]:
+ return _collect_py_files(_DOMAIN)
+
+ def test_domain_files_exist(self) -> None:
+ """Sanity: domain directory has Python files to test."""
+ files = self._domain_files()
+ assert len(files) >= 3, f"Expected ≥3 domain files, got {len(files)}"
+
+ # Known Phase 1 cross-layer imports (tech debt — to be fixed in later phases).
+ # The test ensures NO NEW violations are introduced.
+ _KNOWN_CROSS_LAYER: frozenset[tuple[str, str]] = frozenset(
+ {
+ ("openspace/domain/enums.py", "openspace.skill_engine.types"),
+ ("openspace/domain/enums.py", "openspace.grounding.core.types"),
+ ("openspace/domain/enums.py", "openspace.grounding.core.exceptions"),
+ }
+ )
+
+ def test_domain_imports_no_infrastructure(self) -> None:
+ """Every import in openspace/domain/ must be stdlib, structlog, or domain-local."""
+ violations: list[str] = []
+
+ for filepath in self._domain_files():
+ rel = filepath.relative_to(_REPO_ROOT)
+ rel_posix = rel.as_posix()
+ for module in _extract_imports(filepath):
+ if not _is_allowed_domain_import(module):
+ if (rel_posix, module) not in self._KNOWN_CROSS_LAYER:
+ violations.append(f" {rel}: imports '{module}'")
+
+ if violations:
+ detail = "\n".join(violations)
+ pytest.fail(
+ f"Domain layer has {len(violations)} NEW forbidden import(s):\n{detail}"
+ )
+
+ def test_domain_does_not_import_openspace_infra(self) -> None:
+ """Domain must not import from openspace.* outside openspace.domain."""
+ violations: list[str] = []
+
+ for filepath in self._domain_files():
+ rel = filepath.relative_to(_REPO_ROOT)
+ rel_posix = rel.as_posix()
+ for module in _extract_imports(filepath):
+ if module.startswith("openspace.") and not module.startswith(
+ "openspace.domain"
+ ):
+ if (rel_posix, module) not in self._KNOWN_CROSS_LAYER:
+ violations.append(f" {rel}: imports '{module}'")
+
+ if violations:
+ detail = "\n".join(violations)
+ pytest.fail(
+ f"Domain layer has {len(violations)} cross-layer import(s):\n{detail}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #81 — MCP Handler Private-Field Guard
+# ---------------------------------------------------------------------------
+
+# Private fields that belong to OpenSpace internals
+_PRIVATE_FIELD_PATTERNS: frozenset[str] = frozenset(
+ {
+ "_store",
+ "_registry",
+ "_config",
+ "_llm",
+ "_llm_client",
+ "_evolver",
+ "_grounding_client",
+ "_grounding_config",
+ "_skill_registry",
+ "_skill_store",
+ "_skill_evolver",
+ "_telemetry",
+ "_auth_provider",
+ "_sandbox",
+ "_container",
+ }
+)
+
+_MCP_HANDLER_FILES: list[Path] = [
+ _OPENSPACE / "mcp_server.py",
+]
+
+
+class TestMCPHandlerBoundary:
+ """MCP handlers must access OpenSpace through public API only.
+
+ They must never reach into private fields (_store, _registry, etc.)
+ """
+
+ def test_known_private_access_count(self) -> None:
+ """Guard: the known-violations allowlist must not silently grow."""
+ assert len(self._KNOWN_PRIVATE_ACCESS) == 9, (
+ f"If you fixed a private-field access, remove it from _KNOWN_PRIVATE_ACCESS. "
+ f"If you added one, stop — use a public property. Count: {len(self._KNOWN_PRIVATE_ACCESS)}"
+ )
+
+ def test_mcp_handler_files_exist(self) -> None:
+ """Sanity: MCP handler files we're guarding actually exist."""
+ for f in _MCP_HANDLER_FILES:
+ assert f.exists(), f"Expected MCP handler file: {f}"
+
+ # Known Phase 1 private-field access (tech debt — to be replaced with
+ # public property accessors as delegation is fully wired in Phase 4).
+ _KNOWN_PRIVATE_ACCESS: frozenset[tuple[str, int, str]] = frozenset(
+ {
+ ("openspace/mcp_server.py", 191, "_skill_store"),
+ ("openspace/mcp_server.py", 208, "_grounding_config"),
+ ("openspace/mcp_server.py", 300, "_skill_registry"),
+ ("openspace/mcp_server.py", 435, "_skill_registry"),
+ ("openspace/mcp_server.py", 732, "_skill_registry"),
+ ("openspace/mcp_server.py", 636, "_skill_registry"),
+ ("openspace/mcp_server.py", 751, "_skill_evolver"),
+ ("openspace/mcp_server.py", 735, "_skill_evolver"),
+ ("openspace/mcp_server.py", 424, "_grounding_config"),
+ }
+ )
+
+ def test_no_private_field_access(self) -> None:
+ """MCP handlers must not access private fields of OpenSpace."""
+ violations: list[str] = []
+
+ for filepath in _MCP_HANDLER_FILES:
+ rel = filepath.relative_to(_REPO_ROOT)
+ rel_posix = rel.as_posix()
+ source = filepath.read_text(encoding="utf-8", errors="replace")
+ try:
+ tree = ast.parse(source, filename=str(filepath))
+ except SyntaxError as exc:
+ raise AssertionError(
+ f"SyntaxError in {filepath} — cannot validate boundary: {exc}"
+ ) from exc
+
+ for node in ast.walk(tree):
+ # Detect obj._field attribute access
+ if isinstance(node, ast.Attribute) and isinstance(
+ node.attr, str
+ ):
+ attr = node.attr
+ if attr in _PRIVATE_FIELD_PATTERNS:
+ # Exclude self-references (class defining its own privates)
+ if isinstance(node.value, ast.Name) and node.value.id == "self":
+ continue
+ if (rel_posix, node.lineno, attr) in self._KNOWN_PRIVATE_ACCESS:
+ continue
+ violations.append(
+ f" {rel}:{node.lineno}: accesses '.{attr}'"
+ )
+
+ # Detect getattr(obj, "_field") calls
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
+ if node.func.id == "getattr" and len(node.args) >= 2:
+ arg = node.args[1]
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
+ if arg.value in _PRIVATE_FIELD_PATTERNS:
+ if (rel_posix, node.lineno, arg.value) in self._KNOWN_PRIVATE_ACCESS:
+ continue
+ violations.append(
+ f" {rel}:{node.lineno}: getattr(..., '{arg.value}')"
+ )
+
+ if violations:
+ detail = "\n".join(violations)
+ pytest.fail(
+ f"MCP handlers access {len(violations)} private field(s):\n{detail}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# #82 — File Size Guard (warning-only in Phase 1)
+# ---------------------------------------------------------------------------
+
+_FILE_SIZE_LIMIT_KB = 15
+_FILE_SIZE_LIMIT_BYTES = _FILE_SIZE_LIMIT_KB * 1024
+
+
+class TestFileSizeGuard:
+ """No .py file should exceed 15 KB.
+
+ Phase 1: warn only (test always passes).
+ Phase 7: this becomes a hard failure.
+ """
+
+ @staticmethod
+ def _all_source_files() -> list[Path]:
+ """Collect all .py files under openspace/."""
+ return _collect_py_files(_OPENSPACE)
+
+ def test_source_files_exist(self) -> None:
+ """Sanity: we have source files to check."""
+ assert len(self._all_source_files()) >= 10
+
+ def test_file_sizes_within_limit(self) -> None:
+ """Warn about files exceeding 15 KB (does NOT fail in Phase 1)."""
+ oversized: list[tuple[str, int]] = []
+
+ for filepath in self._all_source_files():
+ size = filepath.stat().st_size
+ if size > _FILE_SIZE_LIMIT_BYTES:
+ rel = str(filepath.relative_to(_REPO_ROOT))
+ oversized.append((rel, size))
+
+ if oversized:
+ oversized.sort(key=lambda x: -x[1])
+ for rel, size in oversized:
+ kb = size / 1024
+ warnings.warn(
+ f"File exceeds {_FILE_SIZE_LIMIT_KB}KB: {rel} ({kb:.1f}KB)",
+ stacklevel=1,
+ )
+ # Phase 1: warn only — do NOT fail
+ # Phase 7 will change this to:
+ # pytest.fail(f"{len(oversized)} file(s) exceed {_FILE_SIZE_LIMIT_KB}KB")
+
+
+# ---------------------------------------------------------------------------
+# #83 — CI Integration (meta-test)
+# ---------------------------------------------------------------------------
+
+
+class TestCIIntegration:
+ """Verify architecture tests are discoverable by pytest (CI Tier 1).
+
+ Since these tests live in tests/ and CI runs ``pytest tests/``,
+ they are automatically part of CI Tier 1. This meta-test validates
+ that assumption by checking our test file is in the right location.
+ """
+
+ def test_boundary_tests_in_tests_directory(self) -> None:
+ """This file must live under tests/ to be CI-discoverable."""
+ this_file = Path(__file__).resolve()
+ tests_dir = _REPO_ROOT / "tests"
+ assert str(this_file).startswith(
+ str(tests_dir)
+ ), f"Boundary tests must be under {tests_dir}"
+
+ def test_architecture_test_count(self) -> None:
+ """We should have a meaningful number of architecture checks."""
+ # Count test methods in this module (sanity that we haven't
+ # accidentally disabled everything)
+ import inspect
+
+ test_classes = [
+ TestDomainImportPurity,
+ TestMCPHandlerBoundary,
+ TestFileSizeGuard,
+ ]
+ test_count = sum(
+ 1
+ for cls in test_classes
+ for name, _ in inspect.getmembers(cls, predicate=inspect.isfunction)
+ if name.startswith("test_")
+ )
+ assert test_count >= 8, f"Expected ≥8 architecture tests, got {test_count}"
diff --git a/tests/test_ast_scanner.py b/tests/test_ast_scanner.py
new file mode 100644
index 00000000..83056285
--- /dev/null
+++ b/tests/test_ast_scanner.py
@@ -0,0 +1,385 @@
+"""Comprehensive tests for the AST dangerous-API scanner.
+
+Covers every pattern from blocklist.yml, severity levels,
+the check_code_safety() integration function, blocklist loading,
+and edge cases (nested functions, classes, lambdas, comprehensions).
+"""
+
+from __future__ import annotations
+
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from openspace.security.ast_scanner import (
+ BlocklistPattern,
+ DangerousAPIVisitor,
+ Finding,
+ Severity,
+ load_blocklist,
+ scan_code,
+ scan_file,
+)
+from openspace.security import check_code_safety
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _names(findings: list[Finding]) -> set[str]:
+ """Return set of pattern_name values from findings."""
+ return {f.pattern_name for f in findings}
+
+
+def _severities(findings: list[Finding]) -> set[Severity]:
+ """Return set of severity values from findings."""
+ return {f.severity for f in findings}
+
+
+# ---------------------------------------------------------------------------
+# Issue #18 — Core pattern detection
+# ---------------------------------------------------------------------------
+
+class TestDangerousPatterns:
+ """Each dangerous API from the spec MUST be detected."""
+
+ @pytest.mark.parametrize("code,expected_pattern", [
+ ("eval('1+1')", "eval"),
+ ("exec('pass')", "exec"),
+ ("result = eval(input())", "eval"),
+ ])
+ def test_eval_exec(self, code, expected_pattern):
+ findings = scan_code(code)
+ assert expected_pattern in _names(findings)
+
+ @pytest.mark.parametrize("code,expected_pattern", [
+ ("import os; os.system('ls')", "os_system"),
+ ("import os; os.popen('ls')", "os_popen"),
+ ])
+ def test_os_command_execution(self, code, expected_pattern):
+ findings = scan_code(code)
+ assert expected_pattern in _names(findings)
+
+ @pytest.mark.parametrize("code", [
+ "import subprocess; subprocess.run(['ls'])",
+ "import subprocess; subprocess.Popen(['ls'])",
+ "import subprocess; subprocess.call(['ls'])",
+ "import subprocess; subprocess.check_output(['ls'])",
+ ])
+ def test_subprocess(self, code):
+ findings = scan_code(code)
+ assert "subprocess" in _names(findings) or "subprocess_import" in _names(findings)
+
+ def test_dynamic_import(self):
+ findings = scan_code("mod = __import__('os')")
+ assert "dynamic_import" in _names(findings)
+
+ @pytest.mark.parametrize("code", [
+ "import socket; socket.socket()",
+ "import socket; s = socket.create_connection(('host', 80))",
+ ])
+ def test_socket(self, code):
+ findings = scan_code(code)
+ assert "socket" in _names(findings) or "socket_import" in _names(findings)
+
+ @pytest.mark.parametrize("code", [
+ "import ctypes; ctypes.cdll.LoadLibrary('libc.so')",
+ "import ctypes; ctypes.CDLL('libc.so.6')",
+ ])
+ def test_ctypes(self, code):
+ findings = scan_code(code)
+ assert "ctypes" in _names(findings) or "ctypes_import" in _names(findings)
+
+ def test_os_environ_attribute(self):
+ code = "import os\nval = os.environ"
+ findings = scan_code(code)
+ assert "env_access" in _names(findings)
+
+ def test_os_getenv(self):
+ findings = scan_code("import os; os.getenv('SECRET')")
+ assert "env_getenv" in _names(findings)
+
+ @pytest.mark.parametrize("path", ["/proc/self/environ", "/etc/passwd", "/etc/shadow"])
+ def test_sensitive_file_open(self, path):
+ findings = scan_code(f"f = open('{path}')")
+ assert "sensitive_file_open" in _names(findings)
+
+ def test_compile_exec_mode(self):
+ code = "code_obj = compile('pass', '', 'exec')"
+ findings = scan_code(code)
+ assert "compile_exec" in _names(findings)
+
+ def test_compile_eval_mode_not_flagged(self):
+ """compile() with 'eval' mode is NOT flagged by compile_exec pattern."""
+ code = "code_obj = compile('1+1', '', 'eval')"
+ findings = scan_code(code)
+ # compile_exec should not fire (mode is 'eval', not 'exec')
+ compile_exec_findings = [f for f in findings if f.pattern_name == "compile_exec"]
+ assert len(compile_exec_findings) == 0
+
+ def test_getattr_on_module(self):
+ code = "import os\ngetattr(os, 'system')('ls')"
+ findings = scan_code(code)
+ assert "getattr_injection" in _names(findings)
+
+ def test_setattr_on_module(self):
+ code = "import os\nsetattr(os, 'foo', 'bar')"
+ findings = scan_code(code)
+ assert "setattr_injection" in _names(findings)
+
+
+# ---------------------------------------------------------------------------
+# Safe code — no findings
+# ---------------------------------------------------------------------------
+
+class TestSafeCode:
+ """Safe, everyday Python should produce zero findings."""
+
+ @pytest.mark.parametrize("code", [
+ "x = 1 + 2",
+ "def greet(name): return f'Hello, {name}'",
+ "data = [i**2 for i in range(10)]",
+ "import json; json.loads('{}')",
+ "from pathlib import Path; p = Path('.')",
+ "class Foo:\n def bar(self): return 42",
+ "open('myfile.txt', 'r')", # non-sensitive path
+ "compile('1+1', '', 'eval')", # eval mode, not exec
+ ])
+ def test_safe_code_clean(self, code):
+ findings = scan_code(code)
+ # Filter out MEDIUM / informational — only CRITICAL/HIGH matter
+ serious = [f for f in findings if f.severity in (Severity.CRITICAL, Severity.HIGH)]
+ assert serious == []
+
+
+# ---------------------------------------------------------------------------
+# Severity levels
+# ---------------------------------------------------------------------------
+
+class TestSeverityLevels:
+ """Verify severity assignments match the blocklist."""
+
+ def test_eval_is_critical(self):
+ findings = scan_code("eval('x')")
+ evals = [f for f in findings if f.pattern_name == "eval"]
+ assert all(f.severity == Severity.CRITICAL for f in evals)
+
+ def test_exec_is_critical(self):
+ findings = scan_code("exec('x')")
+ execs = [f for f in findings if f.pattern_name == "exec"]
+ assert all(f.severity == Severity.CRITICAL for f in execs)
+
+ def test_os_system_is_critical(self):
+ findings = scan_code("import os; os.system('ls')")
+ hits = [f for f in findings if f.pattern_name == "os_system"]
+ assert all(f.severity == Severity.CRITICAL for f in hits)
+
+ def test_subprocess_call_is_critical(self):
+ findings = scan_code("import subprocess; subprocess.run(['ls'])")
+ hits = [f for f in findings if f.pattern_name == "subprocess"]
+ assert all(f.severity == Severity.CRITICAL for f in hits)
+
+ def test_socket_import_is_high(self):
+ findings = scan_code("import socket")
+ hits = [f for f in findings if f.pattern_name == "socket_import"]
+ assert all(f.severity == Severity.HIGH for f in hits)
+
+ def test_env_access_is_high(self):
+ """Upgraded to HIGH in EPIC 0.3b (secret isolation)."""
+ findings = scan_code("import os\nos.environ")
+ hits = [f for f in findings if f.pattern_name == "env_access"]
+ assert all(f.severity == Severity.HIGH for f in hits)
+
+
+# ---------------------------------------------------------------------------
+# Issue #19 — Blocklist loading
+# ---------------------------------------------------------------------------
+
+class TestBlocklist:
+ """Blocklist YAML loading and extensibility."""
+
+ def test_default_blocklist_loads(self):
+ patterns = load_blocklist()
+ assert len(patterns) > 0
+ names = {p.name for p in patterns}
+ assert "eval" in names
+ assert "exec" in names
+ assert "subprocess" in names
+
+ def test_pattern_fields(self):
+ patterns = load_blocklist()
+ for p in patterns:
+ assert p.name
+ assert p.description
+ assert p.severity in Severity
+ assert p.ast_type in ("Call", "Attribute", "Import")
+ assert isinstance(p.targets, list)
+ assert len(p.targets) > 0
+
+ def test_custom_blocklist(self, tmp_path):
+ custom = tmp_path / "custom.yml"
+ custom.write_text(textwrap.dedent("""\
+ patterns:
+ - name: custom_danger
+ description: "Custom dangerous function"
+ severity: HIGH
+ ast_type: Call
+ targets:
+ - my_dangerous_func
+ """), encoding="utf-8")
+
+ patterns = load_blocklist(extra_paths=[custom])
+ names = {p.name for p in patterns}
+ assert "custom_danger" in names
+ # default patterns still present
+ assert "eval" in names
+
+ def test_missing_blocklist_file_ignored(self, tmp_path):
+ fake = tmp_path / "nonexistent.yml"
+ patterns = load_blocklist(extra_paths=[fake])
+ # Should still load default patterns without error
+ assert len(patterns) > 0
+
+
+# ---------------------------------------------------------------------------
+# Issue #20 — check_code_safety integration
+# ---------------------------------------------------------------------------
+
+class TestCheckCodeSafety:
+ """Integration function for the execution pipeline."""
+
+ def test_safe_code_passes(self):
+ is_safe, findings = check_code_safety("x = 1 + 2")
+ assert is_safe is True
+
+ def test_critical_code_rejected(self):
+ is_safe, findings = check_code_safety("eval('malicious')")
+ assert is_safe is False
+ assert any(f.severity == Severity.CRITICAL for f in findings)
+
+ def test_high_only_allowed(self):
+ """HIGH-severity findings do NOT block execution."""
+ is_safe, findings = check_code_safety("import socket")
+ assert is_safe is True
+ assert any(f.severity == Severity.HIGH for f in findings)
+
+ def test_medium_only_allowed(self):
+ """MEDIUM-severity findings do NOT block execution."""
+ is_safe, findings = check_code_safety("getattr(os, 'path')")
+ assert is_safe is True
+
+ def test_multiple_findings_returned(self):
+ code = "eval('x')\nexec('y')\nimport os; os.system('z')"
+ is_safe, findings = check_code_safety(code)
+ assert is_safe is False
+ assert len(findings) >= 3
+
+ def test_syntax_error_returns_finding(self):
+ is_safe, findings = check_code_safety("def (invalid syntax")
+ # Syntax errors produce a finding but are not CRITICAL
+ assert len(findings) == 1
+ assert findings[0].pattern_name == "syntax_error"
+
+
+# ---------------------------------------------------------------------------
+# Issue #21 — Edge cases
+# ---------------------------------------------------------------------------
+
+class TestEdgeCases:
+ """Dangerous code hiding in nested contexts."""
+
+ def test_eval_in_nested_function(self):
+ code = textwrap.dedent("""\
+ def outer():
+ def inner():
+ return eval('42')
+ return inner()
+ """)
+ assert "eval" in _names(scan_code(code))
+
+ def test_exec_in_class_method(self):
+ code = textwrap.dedent("""\
+ class Sneaky:
+ def run(self):
+ exec('import os')
+ """)
+ assert "exec" in _names(scan_code(code))
+
+ def test_eval_in_list_comprehension(self):
+ code = "[eval(x) for x in ['1', '2', '3']]"
+ assert "eval" in _names(scan_code(code))
+
+ def test_eval_in_lambda(self):
+ code = "f = lambda x: eval(x)"
+ assert "eval" in _names(scan_code(code))
+
+ def test_os_system_in_conditional(self):
+ code = textwrap.dedent("""\
+ import os
+ if True:
+ os.system('echo hi')
+ """)
+ assert "os_system" in _names(scan_code(code))
+
+ def test_subprocess_in_try_except(self):
+ code = textwrap.dedent("""\
+ import subprocess
+ try:
+ subprocess.run(['ls'])
+ except Exception:
+ pass
+ """)
+ findings = scan_code(code)
+ assert "subprocess" in _names(findings) or "subprocess_import" in _names(findings)
+
+ def test_nested_attribute_chain(self):
+ """os.path is safe; os.system is not."""
+ safe_code = "import os; p = os.path.join('a', 'b')"
+ findings = scan_code(safe_code)
+ critical = [f for f in findings if f.severity == Severity.CRITICAL]
+ assert critical == []
+
+ def test_chained_dangerous_calls(self):
+ code = "eval(exec('import os'))"
+ findings = scan_code(code)
+ assert "eval" in _names(findings)
+ assert "exec" in _names(findings)
+
+ def test_from_import_subprocess(self):
+ code = "from subprocess import run; run(['ls'])"
+ findings = scan_code(code)
+ # Should detect the import
+ assert "subprocess_import" in _names(findings)
+
+ def test_finding_has_line_col(self):
+ code = "x = 1\neval('2')"
+ findings = scan_code(code)
+ evals = [f for f in findings if f.pattern_name == "eval"]
+ assert len(evals) == 1
+ assert evals[0].line == 2
+ assert evals[0].col >= 0
+
+
+# ---------------------------------------------------------------------------
+# scan_file
+# ---------------------------------------------------------------------------
+
+class TestScanFile:
+ def test_scan_existing_file(self, tmp_path):
+ f = tmp_path / "danger.py"
+ f.write_text("eval('42')", encoding="utf-8")
+ findings = scan_file(f)
+ assert "eval" in _names(findings)
+
+ def test_scan_missing_file(self, tmp_path):
+ findings = scan_file(tmp_path / "nope.py")
+ assert len(findings) == 1
+ assert findings[0].pattern_name == "file_read_error"
+
+ def test_scan_safe_file(self, tmp_path):
+ f = tmp_path / "safe.py"
+ f.write_text("x = 42\n", encoding="utf-8")
+ findings = scan_file(f)
+ assert findings == []
diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py
new file mode 100644
index 00000000..a4029af8
--- /dev/null
+++ b/tests/test_auth_integration.py
@@ -0,0 +1,344 @@
+"""Integration tests: full auth + rate-limit middleware chain.
+
+Exercises BearerTokenMiddleware → RateLimitMiddleware → app
+in the same order as run_mcp_server() wires them (line 911-912).
+
+Validates:
+ - Valid bearer token → 200 (request reaches app)
+ - Missing/invalid token → 401 (rejected by BearerTokenMiddleware)
+ - Rate limit exceeded → 429 (rejected by RateLimitMiddleware)
+ - Auth rejects BEFORE rate-limit state is created (middleware order)
+ - Rate limit headers present on successful requests
+ - Per-IP independent rate limiting
+ - Rate limit recovery after sliding window expires
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from openspace.auth.bearer import BearerTokenMiddleware
+from openspace.auth.rate_limit import (
+ RATE_LIMIT_PER_IP_ENV,
+ RATE_LIMIT_PER_TOKEN_ENV,
+ RATE_LIMIT_WINDOW_ENV,
+ RateLimitMiddleware,
+)
+
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+VALID_TOKEN = "integration-test-token-" + "x" * 32 # 54 chars, well above 32
+WRONG_TOKEN = "wrong-token-value-pad-" + "y" * 32
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _http_scope(
+ path: str = "/test",
+ client_ip: str = "127.0.0.1",
+ headers: dict[str, str] | None = None,
+) -> dict:
+ """Build an HTTP ASGI scope with optional headers and client IP."""
+ raw_headers = []
+ for k, v in (headers or {}).items():
+ raw_headers.append([k.encode(), v.encode()])
+ return {
+ "type": "http",
+ "path": path,
+ "headers": raw_headers,
+ "client": (client_ip, 12345),
+ }
+
+
+class ResponseCollector:
+ """Collects ASGI send() calls into status, headers, body."""
+
+ def __init__(self) -> None:
+ self.status: int | None = None
+ self.headers: dict[str, str] = {}
+ self.body: bytes = b""
+
+ async def __call__(self, message: dict) -> None:
+ if message["type"] == "http.response.start":
+ self.status = message["status"]
+ for k, v in message.get("headers", []):
+ self.headers[k.decode()] = v.decode()
+ elif message["type"] == "http.response.body":
+ self.body += message.get("body", b"")
+
+ @property
+ def json(self) -> dict:
+ return json.loads(self.body)
+
+
+async def _send(
+ chain,
+ *,
+ client_ip: str = "127.0.0.1",
+ token: str | None = None,
+ path: str = "/test",
+) -> ResponseCollector:
+ """Send a single request through the middleware chain."""
+ headers: dict[str, str] = {}
+ if token is not None:
+ headers["authorization"] = f"Bearer {token}"
+ scope = _http_scope(path=path, client_ip=client_ip, headers=headers)
+ collector = ResponseCollector()
+ await chain(scope, AsyncMock(), collector)
+ return collector
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def dummy_app():
+ """ASGI app that counts calls and returns 200 JSON."""
+
+ async def app(scope, receive, send):
+ app.call_count += 1
+ body = json.dumps({"status": "ok"}).encode()
+ await send({
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [
+ [b"content-type", b"application/json"],
+ [b"content-length", str(len(body)).encode()],
+ ],
+ })
+ await send({"type": "http.response.body", "body": body})
+
+ app.call_count = 0
+ return app
+
+
+@pytest.fixture
+def tight_rate_env(monkeypatch):
+ """Configure tight rate limits: 3 req / 60s window."""
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "3")
+ monkeypatch.setenv(RATE_LIMIT_PER_TOKEN_ENV, "3")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+
+
+@pytest.fixture
+def chain(dummy_app, tight_rate_env):
+ """Full middleware chain matching run_mcp_server() wiring:
+
+ BearerTokenMiddleware(RateLimitMiddleware(app))
+ """
+ rate_limited = RateLimitMiddleware(dummy_app)
+ return BearerTokenMiddleware(rate_limited, VALID_TOKEN)
+
+
+# ---------------------------------------------------------------------------
+# End-to-end chain tests
+# ---------------------------------------------------------------------------
+
+
+class TestFullChainEndToEnd:
+ """Core integration: auth + rate-limit chain behaves correctly."""
+
+ @pytest.mark.asyncio
+ async def test_valid_token_returns_200(self, chain, dummy_app):
+ """Valid bearer token → request reaches app → 200."""
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 200
+ assert resp.json["status"] == "ok"
+ assert dummy_app.call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_missing_auth_header_returns_401(self, chain, dummy_app):
+ """No Authorization header → 401 from bearer middleware."""
+ resp = await _send(chain) # no token
+ assert resp.status == 401
+ assert resp.json["error"] == "unauthorized"
+ assert "missing" in resp.json["detail"].lower()
+ assert dummy_app.call_count == 0
+
+ @pytest.mark.asyncio
+ async def test_wrong_token_returns_401(self, chain, dummy_app):
+ """Wrong bearer token → 401 from bearer middleware."""
+ resp = await _send(chain, token=WRONG_TOKEN)
+ assert resp.status == 401
+ assert resp.json["error"] == "unauthorized"
+ assert "invalid" in resp.json["detail"].lower()
+ assert dummy_app.call_count == 0
+
+ @pytest.mark.asyncio
+ async def test_non_bearer_scheme_returns_401(self, chain, dummy_app):
+ """Authorization header with non-Bearer scheme → 401."""
+ scope = _http_scope(headers={"authorization": "Basic dXNlcjpwYXNz"})
+ collector = ResponseCollector()
+ await chain(scope, AsyncMock(), collector)
+ assert collector.status == 401
+ assert dummy_app.call_count == 0
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_exceeded_returns_429(self, chain, dummy_app):
+ """Valid token but rate limit (3 req) exceeded → 429."""
+ for i in range(3):
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 200, f"Request {i + 1}/3 should pass"
+
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 429
+ body = resp.json
+ assert body["error"] == "rate_limited"
+ assert "retry-after" in resp.headers
+ assert dummy_app.call_count == 3 # only first 3 reached the app
+
+
+# ---------------------------------------------------------------------------
+# Middleware order tests
+# ---------------------------------------------------------------------------
+
+
+class TestMiddlewareOrder:
+ """Auth rejects BEFORE rate-limit state is created.
+
+ This is the key security property: unauthenticated floods are
+ cheap hmac rejections that never pollute rate-limit buckets,
+ preventing memory DoS via fake tokens.
+ """
+
+ @pytest.mark.asyncio
+ async def test_invalid_token_does_not_consume_rate_limit(
+ self, chain, dummy_app,
+ ):
+ """Flood with bad tokens → valid requests still have full quota."""
+ for _ in range(20):
+ resp = await _send(chain, token=WRONG_TOKEN)
+ assert resp.status == 401
+
+ # All 3 valid requests should pass (quota untouched)
+ for i in range(3):
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 200, (
+ f"Request {i + 1} should pass: "
+ "auth rejections must not consume rate limit"
+ )
+ assert dummy_app.call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_missing_token_does_not_consume_rate_limit(
+ self, chain, dummy_app,
+ ):
+ """Flood with no auth → valid requests still have full quota."""
+ for _ in range(20):
+ resp = await _send(chain) # no token
+ assert resp.status == 401
+
+ for i in range(3):
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 200
+ assert dummy_app.call_count == 3
+
+
+# ---------------------------------------------------------------------------
+# Rate limit header tests
+# ---------------------------------------------------------------------------
+
+
+class TestRateLimitHeaders:
+ """Verify rate limit headers on successful and rejected requests."""
+
+ @pytest.mark.asyncio
+ async def test_success_includes_ratelimit_headers(self, chain):
+ """200 responses include x-ratelimit-* headers."""
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 200
+ assert "x-ratelimit-remaining" in resp.headers
+ assert "x-ratelimit-limit" in resp.headers
+ assert "x-ratelimit-window" in resp.headers
+
+ @pytest.mark.asyncio
+ async def test_remaining_decreases_with_requests(self, chain):
+ """x-ratelimit-remaining decreases after each request."""
+ r1 = await _send(chain, token=VALID_TOKEN)
+ r2 = await _send(chain, token=VALID_TOKEN)
+ remaining1 = int(r1.headers["x-ratelimit-remaining"])
+ remaining2 = int(r2.headers["x-ratelimit-remaining"])
+ assert remaining2 < remaining1
+
+ @pytest.mark.asyncio
+ async def test_401_has_no_ratelimit_headers(self, chain):
+ """Auth-rejected requests don't include rate limit headers."""
+ resp = await _send(chain, token=WRONG_TOKEN)
+ assert resp.status == 401
+ assert "x-ratelimit-remaining" not in resp.headers
+ assert "x-ratelimit-limit" not in resp.headers
+
+ @pytest.mark.asyncio
+ async def test_429_has_retry_after(self, chain):
+ """Rate-limited responses include retry-after header."""
+ for _ in range(3):
+ await _send(chain, token=VALID_TOKEN)
+
+ resp = await _send(chain, token=VALID_TOKEN)
+ assert resp.status == 429
+ retry = int(resp.headers["retry-after"])
+ assert retry >= 1
+
+
+# ---------------------------------------------------------------------------
+# Per-IP and recovery tests
+# ---------------------------------------------------------------------------
+
+
+class TestPerIPRateLimiting:
+ """Per-IP independent rate limiting through the full chain."""
+
+ @pytest.mark.asyncio
+ async def test_different_ips_have_independent_limits(
+ self, chain, dummy_app,
+ ):
+ """Two IPs each get their own rate limit quota."""
+ # Exhaust 10.0.0.1
+ for _ in range(3):
+ resp = await _send(
+ chain, client_ip="10.0.0.1", token=VALID_TOKEN,
+ )
+ assert resp.status == 200
+
+ # 10.0.0.1 is now rate limited
+ resp = await _send(chain, client_ip="10.0.0.1", token=VALID_TOKEN)
+ assert resp.status == 429
+
+ # 10.0.0.2 should still have full quota
+ resp = await _send(chain, client_ip="10.0.0.2", token=VALID_TOKEN)
+ assert resp.status == 200
+ assert dummy_app.call_count == 4 # 3 + 1
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_recovery_after_window(self, dummy_app, monkeypatch):
+ """After the sliding window expires, requests are allowed again."""
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "1")
+ monkeypatch.setenv(RATE_LIMIT_PER_TOKEN_ENV, "1")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "1") # 1-second window
+
+ rate_limited = RateLimitMiddleware(dummy_app)
+ short_chain = BearerTokenMiddleware(rate_limited, VALID_TOKEN)
+
+ resp = await _send(short_chain, token=VALID_TOKEN)
+ assert resp.status == 200
+
+ resp = await _send(short_chain, token=VALID_TOKEN)
+ assert resp.status == 429
+
+ await asyncio.sleep(1.1)
+
+ resp = await _send(short_chain, token=VALID_TOKEN)
+ assert resp.status == 200
+ assert dummy_app.call_count == 2
diff --git a/tests/test_auth_provider.py b/tests/test_auth_provider.py
new file mode 100644
index 00000000..dca83353
--- /dev/null
+++ b/tests/test_auth_provider.py
@@ -0,0 +1,1061 @@
+"""Tests for openspace.auth.provider — EPIC 2.5.
+
+Covers:
+- #104: Token creation, validation, claims model
+- #105: Per-tool authorization (scopes, tiers, subject block/allow)
+- #106: Trust-tier gating / ceiling enforcement
+- #107: Token lifecycle (revocation, expiry, registry)
+- #51: AuthProvider integration (AuthPort concrete implementation)
+"""
+
+from __future__ import annotations
+
+import base64
+import time
+import threading
+import json
+import pytest
+
+from openspace.auth.provider import (
+ AuthClaims,
+ AuthProvider,
+ AuthError,
+ TokenInvalidError,
+ TokenExpiredError,
+ TokenRevokedError,
+ InsufficientScopeError,
+ InsufficientTierError,
+ ToolNotAuthorizedError,
+ RegistryFullError,
+ TokenScope,
+ TokenRegistry,
+ ToolPolicy,
+ TIER_DEFAULT_SCOPES,
+ DEFAULT_TOOL_POLICIES,
+ _MAX_TTL_SECONDS,
+ create_token,
+ validate_token,
+ authorize_tool,
+ authorize_lease,
+ check_tier_ceiling,
+)
+from openspace.sandbox.leases import TrustTier
+
+# Shared test secret (>= 32 chars)
+TEST_SECRET = "test-secret-key-for-hmac-signing-at-least-32-chars"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #104 — Token Claims Model
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestTokenScope:
+ """Token scope enum values."""
+
+ def test_scope_values(self) -> None:
+ assert TokenScope.TOOL_EXECUTE.value == "tool:execute"
+ assert TokenScope.SECRET_READ.value == "secret:read"
+ assert TokenScope.LEASE_ADMIN.value == "lease:admin"
+
+ def test_tier_default_scopes_monotonic(self) -> None:
+ """Higher tiers should have superset scopes of lower tiers."""
+ tiers = [TrustTier.T0_UNTRUSTED, TrustTier.T1_BASIC,
+ TrustTier.T2_STANDARD, TrustTier.T3_ELEVATED, TrustTier.T4_FULL]
+ for i in range(len(tiers) - 1):
+ lower = TIER_DEFAULT_SCOPES[tiers[i]]
+ higher = TIER_DEFAULT_SCOPES[tiers[i + 1]]
+ assert lower <= higher, (
+ f"{tiers[i].value} scopes are not subset of {tiers[i+1].value}"
+ )
+
+
+class TestAuthClaims:
+ """AuthClaims immutability and helper methods."""
+
+ def _make_claims(self, **overrides) -> AuthClaims:
+ defaults = dict(
+ subject="test-service",
+ trust_tier=TrustTier.T2_STANDARD,
+ scopes=frozenset({TokenScope.TOOL_EXECUTE, TokenScope.TOOL_SEARCH}),
+ issued_at=time.time(),
+ expires_at=time.time() + 3600,
+ token_id="test-id-123",
+ )
+ defaults.update(overrides)
+ return AuthClaims(**defaults)
+
+ def test_frozen(self) -> None:
+ claims = self._make_claims()
+ with pytest.raises(AttributeError):
+ claims.subject = "hacked" # type: ignore[misc]
+
+ def test_is_expired_false(self) -> None:
+ claims = self._make_claims(expires_at=time.time() + 3600)
+ assert not claims.is_expired
+
+ def test_is_expired_true(self) -> None:
+ claims = self._make_claims(expires_at=time.time() - 1)
+ assert claims.is_expired
+
+ def test_remaining_seconds(self) -> None:
+ claims = self._make_claims(expires_at=time.time() + 100)
+ assert 99 <= claims.remaining_seconds <= 101
+
+ def test_remaining_seconds_expired(self) -> None:
+ claims = self._make_claims(expires_at=time.time() - 10)
+ assert claims.remaining_seconds == 0.0
+
+ def test_has_scope(self) -> None:
+ claims = self._make_claims()
+ assert claims.has_scope(TokenScope.TOOL_EXECUTE)
+ assert not claims.has_scope(TokenScope.TOOL_ADMIN)
+
+ def test_has_any_scope(self) -> None:
+ claims = self._make_claims()
+ assert claims.has_any_scope(TokenScope.TOOL_ADMIN, TokenScope.TOOL_EXECUTE)
+ assert not claims.has_any_scope(TokenScope.TOOL_ADMIN, TokenScope.SECRET_WRITE)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #104 — Token Creation & Validation
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestTokenCreation:
+ """Create HMAC-signed tokens."""
+
+ def test_create_basic(self) -> None:
+ token = create_token(secret=TEST_SECRET, subject="svc-a")
+ assert "." in token
+ assert len(token) > 50
+
+ def test_create_with_custom_scopes(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET,
+ subject="svc-b",
+ trust_tier=TrustTier.T3_ELEVATED,
+ scopes=frozenset({TokenScope.SECRET_READ}),
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.scopes == frozenset({TokenScope.SECRET_READ})
+
+ def test_create_with_tier(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET,
+ subject="svc-c",
+ trust_tier=TrustTier.T3_ELEVATED,
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.trust_tier == TrustTier.T3_ELEVATED
+ assert claims.scopes == TIER_DEFAULT_SCOPES[TrustTier.T3_ELEVATED]
+
+ def test_create_with_custom_ttl(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET, subject="svc-d", ttl_seconds=60,
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.remaining_seconds <= 60
+
+ def test_create_with_custom_token_id(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET, subject="svc-e", token_id="my-custom-id",
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.token_id == "my-custom-id"
+
+ def test_create_short_secret_rejected(self) -> None:
+ with pytest.raises(ValueError, match="at least"):
+ create_token(secret="short", subject="x")
+
+ def test_create_zero_ttl_rejected(self) -> None:
+ with pytest.raises(ValueError, match="positive"):
+ create_token(secret=TEST_SECRET, subject="x", ttl_seconds=0)
+
+ def test_create_exceeds_max_ttl_rejected(self) -> None:
+ """F2: TTL above MAX_TTL_SECONDS is rejected."""
+ with pytest.raises(ValueError, match="must not exceed"):
+ create_token(
+ secret=TEST_SECRET, subject="x",
+ ttl_seconds=_MAX_TTL_SECONDS + 1,
+ )
+
+ def test_create_at_max_ttl_succeeds(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET, subject="x",
+ ttl_seconds=_MAX_TTL_SECONDS,
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.remaining_seconds <= _MAX_TTL_SECONDS
+
+ def test_scope_tier_mismatch_rejected(self) -> None:
+ """F3: T1 token cannot be minted with T3 scopes."""
+ with pytest.raises(ValueError, match="exceed tier"):
+ create_token(
+ secret=TEST_SECRET, subject="svc",
+ trust_tier=TrustTier.T1_BASIC,
+ scopes=frozenset({TokenScope.SECRET_WRITE, TokenScope.LEASE_ADMIN}),
+ )
+
+ def test_scope_subset_of_tier_allowed(self) -> None:
+ """Explicit scopes that are a subset of tier defaults succeed."""
+ token = create_token(
+ secret=TEST_SECRET, subject="svc",
+ trust_tier=TrustTier.T2_STANDARD,
+ scopes=frozenset({TokenScope.TOOL_SEARCH}),
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.scopes == frozenset({TokenScope.TOOL_SEARCH})
+
+
+class TestTokenValidation:
+ """Validate HMAC-signed tokens."""
+
+ def test_roundtrip(self) -> None:
+ token = create_token(secret=TEST_SECRET, subject="roundtrip-svc")
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.subject == "roundtrip-svc"
+ assert claims.trust_tier == TrustTier.T1_BASIC
+
+ def test_wrong_secret_rejected(self) -> None:
+ token = create_token(secret=TEST_SECRET, subject="svc")
+ wrong_secret = "a-completely-different-secret-key-at-least-32-chars"
+ with pytest.raises(TokenInvalidError, match="signature"):
+ validate_token(token, secret=wrong_secret)
+
+ def test_tampered_payload_rejected(self) -> None:
+ token = create_token(secret=TEST_SECRET, subject="svc")
+ parts = token.split(".")
+ # Tamper with a character in payload
+ tampered = parts[0][:-1] + ("A" if parts[0][-1] != "A" else "B")
+ tampered_token = f"{tampered}.{parts[1]}"
+ with pytest.raises(TokenInvalidError):
+ validate_token(tampered_token, secret=TEST_SECRET)
+
+ def test_tampered_signature_rejected(self) -> None:
+ token = create_token(secret=TEST_SECRET, subject="svc")
+ parts = token.split(".")
+ bad_sig = parts[1][:-1] + ("X" if parts[1][-1] != "X" else "Y")
+ with pytest.raises(TokenInvalidError, match="signature"):
+ validate_token(f"{parts[0]}.{bad_sig}", secret=TEST_SECRET)
+
+ def test_missing_separator_rejected(self) -> None:
+ with pytest.raises(TokenInvalidError, match="separator"):
+ validate_token("no-dot-here", secret=TEST_SECRET)
+
+ def test_too_many_parts_rejected(self) -> None:
+ with pytest.raises(TokenInvalidError, match="2 parts"):
+ validate_token("a.b.c", secret=TEST_SECRET)
+
+ def test_expired_token_rejected(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET, subject="svc", ttl_seconds=1,
+ )
+ # Manually create an already-expired token
+ import json, base64
+ payload_b64 = token.split(".")[0]
+ padding = 4 - (len(payload_b64) % 4)
+ if padding != 4:
+ payload_b64 += "=" * padding
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
+ payload["exp"] = time.time() - 10
+ payload["iat"] = time.time() - 20
+ new_payload = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+ new_b64 = base64.urlsafe_b64encode(new_payload.encode()).rstrip(b"=").decode()
+
+ import hashlib, hmac as _hmac
+ sig = _hmac.new(
+ TEST_SECRET.encode(), new_b64.encode(), hashlib.sha256
+ ).digest()
+ sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
+
+ expired_token = f"{new_b64}.{sig_b64}"
+ with pytest.raises(TokenExpiredError):
+ validate_token(expired_token, secret=TEST_SECRET)
+
+ def test_t0_token_no_scopes(self) -> None:
+ token = create_token(
+ secret=TEST_SECRET, subject="svc",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ )
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.scopes == frozenset()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #105 — Per-Tool Authorization
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestAuthorizeToolScope:
+ """Scope-based tool authorization."""
+
+ def _claims(self, scopes, tier=TrustTier.T4_FULL) -> AuthClaims:
+ return AuthClaims(
+ subject="svc", trust_tier=tier, scopes=frozenset(scopes),
+ issued_at=time.time(), expires_at=time.time() + 3600,
+ token_id="t1",
+ )
+
+ def test_sufficient_scopes_pass(self) -> None:
+ claims = self._claims([TokenScope.TOOL_EXECUTE])
+ policy = ToolPolicy(
+ tool_name="run",
+ required_scopes=frozenset({TokenScope.TOOL_EXECUTE}),
+ )
+ authorize_tool(claims, policy) # no raise
+
+ def test_missing_scope_raises(self) -> None:
+ claims = self._claims([TokenScope.TOOL_SEARCH])
+ policy = ToolPolicy(
+ tool_name="run",
+ required_scopes=frozenset({TokenScope.TOOL_EXECUTE}),
+ )
+ with pytest.raises(InsufficientScopeError, match="tool:execute"):
+ authorize_tool(claims, policy)
+
+ def test_superset_scopes_pass(self) -> None:
+ claims = self._claims([
+ TokenScope.TOOL_EXECUTE, TokenScope.TOOL_ADMIN, TokenScope.TOOL_SEARCH,
+ ])
+ policy = ToolPolicy(
+ tool_name="run",
+ required_scopes=frozenset({TokenScope.TOOL_EXECUTE}),
+ )
+ authorize_tool(claims, policy)
+
+ def test_empty_required_scopes_pass(self) -> None:
+ claims = self._claims([])
+ policy = ToolPolicy(tool_name="run", required_scopes=frozenset())
+ authorize_tool(claims, policy)
+
+
+class TestAuthorizeToolTier:
+ """Trust-tier tool authorization."""
+
+ def _claims(self, tier) -> AuthClaims:
+ return AuthClaims(
+ subject="svc", trust_tier=tier,
+ scopes=frozenset({TokenScope.TOOL_EXECUTE, TokenScope.TOOL_ADMIN}),
+ issued_at=time.time(), expires_at=time.time() + 3600,
+ token_id="t1",
+ )
+
+ def test_sufficient_tier_pass(self) -> None:
+ claims = self._claims(TrustTier.T3_ELEVATED)
+ policy = ToolPolicy(tool_name="x", min_trust_tier=TrustTier.T2_STANDARD)
+ authorize_tool(claims, policy)
+
+ def test_exact_tier_pass(self) -> None:
+ claims = self._claims(TrustTier.T2_STANDARD)
+ policy = ToolPolicy(tool_name="x", min_trust_tier=TrustTier.T2_STANDARD)
+ authorize_tool(claims, policy)
+
+ def test_insufficient_tier_raises(self) -> None:
+ claims = self._claims(TrustTier.T1_BASIC)
+ policy = ToolPolicy(tool_name="x", min_trust_tier=TrustTier.T3_ELEVATED)
+ with pytest.raises(InsufficientTierError, match="T3"):
+ authorize_tool(claims, policy)
+
+
+class TestAuthorizeToolSubject:
+ """Subject-based tool authorization (block/allow lists)."""
+
+ def _claims(self, subject="svc-a") -> AuthClaims:
+ return AuthClaims(
+ subject=subject, trust_tier=TrustTier.T4_FULL,
+ scopes=frozenset({TokenScope.TOOL_EXECUTE}),
+ issued_at=time.time(), expires_at=time.time() + 3600,
+ token_id="t1",
+ )
+
+ def test_blocked_subject_rejected(self) -> None:
+ policy = ToolPolicy(
+ tool_name="x",
+ blocked_subjects=frozenset({"svc-a"}),
+ )
+ with pytest.raises(ToolNotAuthorizedError, match="blocked"):
+ authorize_tool(self._claims("svc-a"), policy)
+
+ def test_non_blocked_subject_passes(self) -> None:
+ policy = ToolPolicy(
+ tool_name="x",
+ blocked_subjects=frozenset({"svc-b"}),
+ )
+ authorize_tool(self._claims("svc-a"), policy)
+
+ def test_allowed_subject_passes(self) -> None:
+ policy = ToolPolicy(
+ tool_name="x",
+ allowed_subjects=frozenset({"svc-a", "svc-b"}),
+ )
+ authorize_tool(self._claims("svc-a"), policy)
+
+ def test_not_in_allowed_list_rejected(self) -> None:
+ policy = ToolPolicy(
+ tool_name="x",
+ allowed_subjects=frozenset({"svc-b"}),
+ )
+ with pytest.raises(ToolNotAuthorizedError, match="not in the allowed"):
+ authorize_tool(self._claims("svc-a"), policy)
+
+ def test_empty_allowed_means_open(self) -> None:
+ """No allowed_subjects = no subject restriction."""
+ policy = ToolPolicy(tool_name="x", allowed_subjects=frozenset())
+ authorize_tool(self._claims("anyone"), policy)
+
+ def test_blocked_before_allowed(self) -> None:
+ """Deny-before-allow: blocked check runs first."""
+ policy = ToolPolicy(
+ tool_name="x",
+ blocked_subjects=frozenset({"svc-a"}),
+ allowed_subjects=frozenset({"svc-a"}),
+ )
+ with pytest.raises(ToolNotAuthorizedError, match="blocked"):
+ authorize_tool(self._claims("svc-a"), policy)
+
+
+class TestDefaultToolPolicies:
+ """Default policies for built-in MCP tools."""
+
+ def test_execute_task_requires_t2(self) -> None:
+ policy = DEFAULT_TOOL_POLICIES["execute_task"]
+ assert policy.min_trust_tier == TrustTier.T2_STANDARD
+ assert TokenScope.TOOL_EXECUTE in policy.required_scopes
+
+ def test_search_skills_requires_t1(self) -> None:
+ policy = DEFAULT_TOOL_POLICIES["search_skills"]
+ assert policy.min_trust_tier == TrustTier.T1_BASIC
+
+ def test_upload_skill_requires_admin(self) -> None:
+ policy = DEFAULT_TOOL_POLICIES["upload_skill"]
+ assert TokenScope.TOOL_ADMIN in policy.required_scopes
+ assert policy.min_trust_tier == TrustTier.T3_ELEVATED
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #106 — Trust-Tier Gating
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestTierCeiling:
+ """Trust-tier ceiling enforcement for lease requests."""
+
+ def _claims(self, tier) -> AuthClaims:
+ return AuthClaims(
+ subject="svc", trust_tier=tier, scopes=frozenset(),
+ issued_at=time.time(), expires_at=time.time() + 3600,
+ token_id="t1",
+ )
+
+ def test_same_tier_passes(self) -> None:
+ check_tier_ceiling(self._claims(TrustTier.T2_STANDARD), TrustTier.T2_STANDARD)
+
+ def test_lower_tier_passes(self) -> None:
+ check_tier_ceiling(self._claims(TrustTier.T3_ELEVATED), TrustTier.T2_STANDARD)
+
+ def test_higher_tier_rejected(self) -> None:
+ with pytest.raises(InsufficientTierError, match="ceiling"):
+ check_tier_ceiling(self._claims(TrustTier.T1_BASIC), TrustTier.T3_ELEVATED)
+
+ def test_t0_cannot_request_t1(self) -> None:
+ with pytest.raises(InsufficientTierError):
+ check_tier_ceiling(
+ self._claims(TrustTier.T0_UNTRUSTED), TrustTier.T1_BASIC
+ )
+
+ def test_t4_can_request_any(self) -> None:
+ for tier in TrustTier:
+ check_tier_ceiling(self._claims(TrustTier.T4_FULL), tier)
+
+
+class TestAuthorizeLease:
+ """authorize_lease: tier ceiling + lease scope enforcement."""
+
+ def _claims(self, tier, scopes=frozenset()) -> AuthClaims:
+ return AuthClaims(
+ subject="svc", trust_tier=tier, scopes=scopes,
+ issued_at=time.time(), expires_at=time.time() + 3600,
+ token_id="t1",
+ )
+
+ def test_acquire_with_scope_passes(self) -> None:
+ claims = self._claims(
+ TrustTier.T2_STANDARD,
+ frozenset({TokenScope.LEASE_ACQUIRE}),
+ )
+ authorize_lease(claims, TrustTier.T2_STANDARD)
+
+ def test_acquire_without_scope_rejected(self) -> None:
+ """Token with sufficient tier but missing LEASE_ACQUIRE is rejected."""
+ claims = self._claims(
+ TrustTier.T2_STANDARD,
+ frozenset({TokenScope.TOOL_EXECUTE}),
+ )
+ with pytest.raises(InsufficientScopeError, match="lease:acquire"):
+ authorize_lease(claims, TrustTier.T1_BASIC)
+
+ def test_admin_requires_lease_admin_scope(self) -> None:
+ claims = self._claims(
+ TrustTier.T4_FULL,
+ frozenset({TokenScope.LEASE_ACQUIRE}),
+ )
+ with pytest.raises(InsufficientScopeError, match="lease:admin"):
+ authorize_lease(claims, TrustTier.T1_BASIC, admin=True)
+
+ def test_admin_with_scope_passes(self) -> None:
+ claims = self._claims(
+ TrustTier.T4_FULL,
+ frozenset({TokenScope.LEASE_ADMIN}),
+ )
+ authorize_lease(claims, TrustTier.T4_FULL, admin=True)
+
+ def test_tier_still_enforced(self) -> None:
+ """Even with LEASE_ACQUIRE, tier ceiling is enforced."""
+ claims = self._claims(
+ TrustTier.T1_BASIC,
+ frozenset({TokenScope.LEASE_ACQUIRE}),
+ )
+ with pytest.raises(InsufficientTierError):
+ authorize_lease(claims, TrustTier.T3_ELEVATED)
+
+ def test_empty_scopes_rejected(self) -> None:
+ """Token with no scopes cannot acquire leases."""
+ claims = self._claims(TrustTier.T2_STANDARD, frozenset())
+ with pytest.raises(InsufficientScopeError):
+ authorize_lease(claims, TrustTier.T1_BASIC)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #107 — Token Registry (Revocation)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestTokenRegistry:
+ """Token revocation registry."""
+
+ def test_new_token_not_revoked(self) -> None:
+ reg = TokenRegistry()
+ assert not reg.is_revoked("abc")
+
+ def test_revoke_marks_as_revoked(self) -> None:
+ reg = TokenRegistry()
+ reg.revoke("abc")
+ assert reg.is_revoked("abc")
+
+ def test_revoke_idempotent(self) -> None:
+ reg = TokenRegistry()
+ reg.revoke("abc")
+ reg.revoke("abc")
+ assert reg.revoked_count() == 1
+
+ def test_multiple_revocations(self) -> None:
+ reg = TokenRegistry()
+ for i in range(100):
+ reg.revoke(f"token-{i}")
+ assert reg.revoked_count() == 100
+ assert reg.is_revoked("token-50")
+ assert not reg.is_revoked("token-999")
+
+ def test_expired_entries_gc(self) -> None:
+ """Only expired revocation entries are garbage-collected."""
+ reg = TokenRegistry()
+ reg.MAX_REVOKED = 5
+ past = time.time() - 100 # already expired
+ future = time.time() + 3600 # still valid
+ # Fill with 3 expired + 3 unexpired entries → over MAX_REVOKED(5)
+ for i in range(3):
+ reg.revoke(f"expired-{i}", expires_at=past)
+ for i in range(3):
+ reg.revoke(f"valid-{i}", expires_at=future)
+ # Expired entries should be GC'd, unexpired retained
+ for i in range(3):
+ assert not reg.is_revoked(f"expired-{i}"), "expired should be GC'd"
+ for i in range(3):
+ assert reg.is_revoked(f"valid-{i}"), "unexpired must be kept"
+ assert reg.revoked_count() == 3
+
+ def test_fifo_flooding_does_not_resurrect_revoked_token(self) -> None:
+ """Regression: flooding with revocations must NOT evict unexpired entries.
+
+ PoC from /8eyes R1: revoke stolen admin token, then flood with 10K+
+ other revocations — the victim token MUST stay revoked.
+ """
+ reg = TokenRegistry()
+ reg.MAX_REVOKED = 10
+ future = time.time() + 3600
+ # Revoke the "victim" — unexpired, must NEVER be evicted
+ reg.revoke("victim", expires_at=future)
+ # Flood with additional revocations (all also unexpired)
+ for i in range(20):
+ reg.revoke(f"flood-{i}", expires_at=future)
+ # Victim must still be revoked
+ assert reg.is_revoked("victim"), "FIFO flooding must not resurrect victim"
+ # All flood tokens also retained (none are expired)
+ for i in range(20):
+ assert reg.is_revoked(f"flood-{i}")
+
+ def test_revoke_without_expiry_never_evicted(self) -> None:
+ """Tokens revoked without expires_at are kept indefinitely."""
+ reg = TokenRegistry()
+ reg.MAX_REVOKED = 3
+ past = time.time() - 100
+ reg.revoke("permanent") # no expires_at
+ for i in range(5):
+ reg.revoke(f"exp-{i}", expires_at=past)
+ # Permanent entry must survive
+ assert reg.is_revoked("permanent")
+
+ def test_thread_safety(self) -> None:
+ reg = TokenRegistry()
+ errors: list[Exception] = []
+
+ def revoke_batch(start: int) -> None:
+ try:
+ for i in range(100):
+ reg.revoke(f"t-{start}-{i}")
+ except Exception as e:
+ errors.append(e)
+
+ threads = [threading.Thread(target=revoke_batch, args=(n,)) for n in range(5)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors
+ assert reg.revoked_count() == 500
+
+ def test_hard_ceiling_raises_registry_full(self) -> None:
+ """Registry raises RegistryFullError at HARD_MAX."""
+ reg = TokenRegistry()
+ reg.MAX_REVOKED = 5
+ reg.HARD_MAX = 10
+ future = time.time() + 3600
+ for i in range(10):
+ reg.revoke(f"t-{i}", expires_at=future)
+ with pytest.raises(RegistryFullError, match="full"):
+ reg.revoke("one-too-many", expires_at=future)
+ # All 10 still revoked
+ assert reg.revoked_count() == 10
+
+ def test_lazy_gc_on_is_revoked(self) -> None:
+ """Expired entries are cleaned up during is_revoked reads."""
+ reg = TokenRegistry()
+ reg._GC_INTERVAL = 3 # trigger after 3 reads
+ future = time.time() + 3600
+ past = time.time() - 100
+ # Add unexpired entry first (won't be GC'd during revoke)
+ reg.revoke("alive", expires_at=future)
+ # Manually inject expired entries to bypass revoke-time GC
+ with reg._lock:
+ reg._revoked.add("expired-1")
+ reg._revoked_order.append(("expired-1", past))
+ reg._revoked.add("expired-2")
+ reg._revoked_order.append(("expired-2", past))
+ assert reg.revoked_count() == 3
+ # Trigger lazy GC via reads
+ for _ in range(3):
+ reg.is_revoked("anything")
+ # Expired should be gone, alive should remain
+ assert reg.revoked_count() == 1
+ assert reg.is_revoked("alive")
+
+ def test_revoke_with_expiry_passthrough(self) -> None:
+ """AuthProvider.revoke_token passes expires_at to registry."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ exp = time.time() + 3600
+ provider.revoke_token("tid", expires_at=exp)
+ assert provider.registry.is_revoked("tid")
+ assert provider.registry._revoked_order[-1][1] == exp
+
+ def test_high_level_revoke_extracts_expiry(self) -> None:
+ """AuthProvider.revoke(token) auto-extracts expires_at from claims."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(subject="svc", token_id="auto-exp")
+ provider.revoke(token)
+ assert provider.registry.is_revoked("auto-exp")
+ # Should store actual expiry, not inf
+ _, stored_exp = provider.registry._revoked_order[-1]
+ assert stored_exp != float("inf")
+ assert stored_exp > time.time()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #51 — AuthProvider Integration
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestAuthProvider:
+ """AuthProvider as concrete AuthPort implementation."""
+
+ def _provider(self, **kwargs) -> AuthProvider:
+ return AuthProvider(signing_secret=TEST_SECRET, **kwargs)
+
+ def test_create_and_validate(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(subject="svc-a")
+ claims = provider.validate_and_check(token)
+ assert claims.subject == "svc-a"
+
+ def test_short_secret_rejected(self) -> None:
+ with pytest.raises(ValueError, match="at least"):
+ AuthProvider(signing_secret="short")
+
+ def test_revocation_flow(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(subject="svc", token_id="revoke-me")
+ # Valid before revocation
+ claims = provider.validate_and_check(token)
+ assert claims.token_id == "revoke-me"
+ # Revoke using high-level API (extracts expiry automatically)
+ provider.revoke(token)
+ with pytest.raises(TokenRevokedError):
+ provider.validate_and_check(token)
+
+ def test_authorize_success(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T2_STANDARD,
+ )
+ claims = provider.authorize(token, "execute_task")
+ assert claims.subject == "svc"
+
+ def test_authorize_insufficient_tier(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T1_BASIC,
+ )
+ with pytest.raises(InsufficientTierError):
+ provider.authorize(token, "execute_task")
+
+ def test_authorize_unknown_tool_requires_admin(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T2_STANDARD,
+ )
+ with pytest.raises(InsufficientTierError):
+ provider.authorize(token, "unknown_tool")
+
+ def test_authorize_unknown_tool_with_admin(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(
+ subject="admin", trust_tier=TrustTier.T4_FULL,
+ )
+ claims = provider.authorize(token, "unknown_tool")
+ assert claims.subject == "admin"
+
+ def test_tier_ceiling(self) -> None:
+ provider = self._provider()
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T2_STANDARD,
+ )
+ claims = provider.validate_and_check(token)
+ provider.check_tier_ceiling(claims, TrustTier.T2_STANDARD)
+ with pytest.raises(InsufficientTierError):
+ provider.check_tier_ceiling(claims, TrustTier.T3_ELEVATED)
+
+
+class TestAuthPortProtocol:
+ """AuthProvider satisfies AuthPort protocol."""
+
+ @pytest.mark.asyncio
+ async def test_authenticate_valid(self) -> None:
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(subject="svc")
+ assert await provider.authenticate(token) is True
+
+ @pytest.mark.asyncio
+ async def test_authenticate_invalid(self) -> None:
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ assert await provider.authenticate("garbage") is False
+
+ @pytest.mark.asyncio
+ async def test_authenticate_revoked(self) -> None:
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(subject="svc", token_id="rev1")
+ provider.revoke(token)
+ assert await provider.authenticate(token) is False
+
+ @pytest.mark.asyncio
+ async def test_validate_token_valid(self) -> None:
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(subject="svc-x")
+ valid, subject = await provider.validate_token(token)
+ assert valid is True
+ assert subject == "svc-x"
+
+ @pytest.mark.asyncio
+ async def test_validate_token_invalid(self) -> None:
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ valid, msg = await provider.validate_token("bad-token")
+ assert valid is False
+ assert "separator" in msg.lower() or "malformed" in msg.lower()
+
+
+class TestAuthProviderCustomPolicies:
+ """Custom tool policies in AuthProvider."""
+
+ def test_custom_policy_enforced(self) -> None:
+ custom = {
+ "my_tool": ToolPolicy(
+ tool_name="my_tool",
+ required_scopes=frozenset({TokenScope.SECRET_READ}),
+ min_trust_tier=TrustTier.T3_ELEVATED,
+ ),
+ }
+ provider = AuthProvider(signing_secret=TEST_SECRET, tool_policies=custom)
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T3_ELEVATED,
+ )
+ # T3 has SECRET_READ in default scopes
+ claims = provider.authorize(token, "my_tool")
+ assert claims.subject == "svc"
+
+ def test_custom_policy_blocks_low_tier(self) -> None:
+ custom = {
+ "my_tool": ToolPolicy(
+ tool_name="my_tool",
+ min_trust_tier=TrustTier.T3_ELEVATED,
+ ),
+ }
+ provider = AuthProvider(signing_secret=TEST_SECRET, tool_policies=custom)
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T1_BASIC,
+ )
+ with pytest.raises(InsufficientTierError):
+ provider.authorize(token, "my_tool")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Security Regression Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecurityRegressions:
+ """Ensure security invariants hold."""
+
+ def test_token_not_reusable_after_revocation(self) -> None:
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(subject="svc", token_id="sec-1")
+ provider.validate_and_check(token) # OK
+ provider.revoke(token)
+ with pytest.raises(TokenRevokedError):
+ provider.validate_and_check(token)
+
+ def test_cannot_forge_token_with_different_secret(self) -> None:
+ legit = create_token(secret=TEST_SECRET, subject="admin",
+ trust_tier=TrustTier.T4_FULL)
+ forged_secret = "attacker-secret-that-is-at-least-32-characters"
+ forged = create_token(secret=forged_secret, subject="admin",
+ trust_tier=TrustTier.T4_FULL)
+ # Legit works
+ validate_token(legit, secret=TEST_SECRET)
+ # Forged fails
+ with pytest.raises(TokenInvalidError, match="signature"):
+ validate_token(forged, secret=TEST_SECRET)
+
+ def test_tier_escalation_prevented(self) -> None:
+ """T1 token cannot access T3 tool."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(
+ subject="basic-svc", trust_tier=TrustTier.T1_BASIC,
+ )
+ with pytest.raises(InsufficientTierError):
+ provider.authorize(token, "fix_skill")
+
+ def test_scope_escalation_prevented(self) -> None:
+ """T2 token without TOOL_ADMIN cannot access admin tools."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(
+ subject="svc", trust_tier=TrustTier.T2_STANDARD,
+ )
+ with pytest.raises(InsufficientTierError):
+ provider.authorize(token, "upload_skill")
+
+ def test_t0_cannot_do_anything(self) -> None:
+ """T0 tokens have no scopes and fail all tool authorization."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ token = provider.create_token(
+ subject="untrusted", trust_tier=TrustTier.T0_UNTRUSTED,
+ )
+ for tool_name in DEFAULT_TOOL_POLICIES:
+ with pytest.raises(AuthError):
+ provider.authorize(token, tool_name)
+
+ def test_timing_safe_comparison(self) -> None:
+ """Token validation uses hmac.compare_digest (timing-safe)."""
+ import hmac as _hmac
+ # This is a design assertion — hmac.compare_digest is used in
+ # _compute_signature verification path
+ assert hasattr(_hmac, "compare_digest")
+
+ def test_deny_before_allow_in_tool_auth(self) -> None:
+ """Blocked subjects are rejected even if in allowed list."""
+ claims = AuthClaims(
+ subject="evil", trust_tier=TrustTier.T4_FULL,
+ scopes=frozenset({TokenScope.TOOL_EXECUTE}),
+ issued_at=time.time(), expires_at=time.time() + 3600,
+ token_id="t1",
+ )
+ policy = ToolPolicy(
+ tool_name="x",
+ blocked_subjects=frozenset({"evil"}),
+ allowed_subjects=frozenset({"evil"}),
+ )
+ with pytest.raises(ToolNotAuthorizedError, match="blocked"):
+ authorize_tool(claims, policy)
+
+ def test_secret_not_in_repr(self) -> None:
+ """F4: signing_secret must not appear in repr()."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ r = repr(provider)
+ assert TEST_SECRET not in r
+ assert "signing_secret" not in r
+
+ def test_secret_immutable_after_init(self) -> None:
+ """F4: signing_secret cannot be mutated after construction."""
+ provider = AuthProvider(signing_secret=TEST_SECRET)
+ with pytest.raises(AttributeError, match="immutable"):
+ provider.signing_secret = "new-secret-at-least-32-characters-long"
+
+ def test_error_messages_do_not_leak_timestamps(self) -> None:
+ """F6: error messages should not contain server timestamps."""
+ # Create an expired token by time manipulation
+ token = create_token(
+ secret=TEST_SECRET, subject="x", ttl_seconds=1,
+ )
+ # Wait for expiry
+ time.sleep(1.1)
+ with pytest.raises(TokenExpiredError) as exc_info:
+ validate_token(token, secret=TEST_SECRET)
+ msg = str(exc_info.value)
+ assert "now:" not in msg
+ assert "expired at" not in msg.lower() or "has expired" in msg.lower()
+
+ def test_error_messages_do_not_leak_internals(self) -> None:
+ """F6: invalid token errors should be generic."""
+ with pytest.raises(TokenInvalidError) as exc_info:
+ validate_token("not.atoken", secret=TEST_SECRET)
+ msg = str(exc_info.value)
+ # Should not contain stack traces or internal details
+ assert "Traceback" not in msg
+
+ def test_nan_expires_at_rejected(self) -> None:
+ """NaN in expires_at must not bypass expiry check."""
+ payload = {
+ "sub": "attacker", "tier": "T1", "scopes": [],
+ "iat": time.time(), "exp": float("nan"), "jti": "nan-test",
+ }
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+ payload_b64 = base64.urlsafe_b64encode(
+ payload_json.encode()
+ ).rstrip(b"=").decode("ascii")
+ import hmac as _hmac, hashlib as _hashlib
+ sig = _hmac.new(
+ TEST_SECRET.encode(), payload_b64.encode(), _hashlib.sha256
+ ).digest()
+ sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode("ascii")
+ evil_token = f"{payload_b64}.{sig_b64}"
+ with pytest.raises(TokenInvalidError, match="finite"):
+ validate_token(evil_token, secret=TEST_SECRET)
+
+ def test_infinity_expires_at_rejected(self) -> None:
+ """Infinity in expires_at must not create immortal tokens."""
+ payload = {
+ "sub": "attacker", "tier": "T1", "scopes": [],
+ "iat": time.time(), "exp": float("inf"), "jti": "inf-test",
+ }
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+ payload_b64 = base64.urlsafe_b64encode(
+ payload_json.encode()
+ ).rstrip(b"=").decode("ascii")
+ import hmac as _hmac, hashlib as _hashlib
+ sig = _hmac.new(
+ TEST_SECRET.encode(), payload_b64.encode(), _hashlib.sha256
+ ).digest()
+ sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode("ascii")
+ evil_token = f"{payload_b64}.{sig_b64}"
+ with pytest.raises(TokenInvalidError, match="finite"):
+ validate_token(evil_token, secret=TEST_SECRET)
+
+ def test_exp_before_iat_rejected(self) -> None:
+ """Token where exp <= iat is rejected."""
+ now = time.time()
+ payload = {
+ "sub": "attacker", "tier": "T1", "scopes": [],
+ "iat": now, "exp": now + 3600, "jti": "backwards",
+ }
+ # Manually set exp <= iat AFTER normal creation to bypass create_token
+ payload["exp"] = now - 1
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+ payload_b64 = base64.urlsafe_b64encode(
+ payload_json.encode()
+ ).rstrip(b"=").decode("ascii")
+ import hmac as _hmac, hashlib as _hashlib
+ sig = _hmac.new(
+ TEST_SECRET.encode(), payload_b64.encode(), _hashlib.sha256
+ ).digest()
+ sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode("ascii")
+ evil_token = f"{payload_b64}.{sig_b64}"
+ # Could raise either TokenExpiredError or TokenInvalidError
+ with pytest.raises(TokenInvalidError):
+ validate_token(evil_token, secret=TEST_SECRET)
+
+ def test_audience_mismatch_rejected(self) -> None:
+ """Token minted for service-A is rejected by service-B."""
+ token = create_token(
+ secret=TEST_SECRET, subject="svc",
+ audience="service-a",
+ )
+ # Accepted by service-a
+ claims = validate_token(
+ token, secret=TEST_SECRET, expected_audience="service-a"
+ )
+ assert claims.audience == "service-a"
+ # Rejected by service-b
+ with pytest.raises(TokenInvalidError, match="audience"):
+ validate_token(
+ token, secret=TEST_SECRET, expected_audience="service-b"
+ )
+
+ def test_audience_not_enforced_when_empty(self) -> None:
+ """Tokens without audience work when validator has no expectation."""
+ token = create_token(secret=TEST_SECRET, subject="svc")
+ claims = validate_token(token, secret=TEST_SECRET)
+ assert claims.audience == ""
+
+ def test_provider_audience_binding(self) -> None:
+ """AuthProvider enforces audience on validate_and_check."""
+ provider_a = AuthProvider(
+ signing_secret=TEST_SECRET, audience="svc-a"
+ )
+ provider_b = AuthProvider(
+ signing_secret=TEST_SECRET, audience="svc-b"
+ )
+ token = provider_a.create_token(subject="user")
+ # Works on provider_a
+ claims = provider_a.validate_and_check(token)
+ assert claims.audience == "svc-a"
+ # Rejected by provider_b
+ with pytest.raises(TokenInvalidError, match="audience"):
+ provider_b.validate_and_check(token)
+
+ def test_cross_service_revoke_blocked(self) -> None:
+ """Provider B cannot revoke provider A's tokens via high-level API."""
+ shared_registry = TokenRegistry()
+ provider_a = AuthProvider(
+ signing_secret=TEST_SECRET, audience="svc-a",
+ registry=shared_registry,
+ )
+ provider_b = AuthProvider(
+ signing_secret=TEST_SECRET, audience="svc-b",
+ registry=shared_registry,
+ )
+ token_a = provider_a.create_token(subject="user", token_id="cross-rev")
+ # provider_b cannot revoke provider_a's token
+ with pytest.raises(TokenInvalidError, match="audience"):
+ provider_b.revoke(token_a)
+ # Token is still valid on provider_a
+ claims = provider_a.validate_and_check(token_a)
+ assert claims.token_id == "cross-rev"
diff --git a/tests/test_auto_import_disabled.py b/tests/test_auto_import_disabled.py
new file mode 100644
index 00000000..60e84366
--- /dev/null
+++ b/tests/test_auto_import_disabled.py
@@ -0,0 +1,306 @@
+"""Tests for EPIC 0.4 — Disable auto-import of cloud skills.
+
+Verifies that all cloud auto-import paths are gated behind the
+``auto_import_enabled`` config flag, which defaults to ``False``.
+"""
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Config flag defaults
+# ---------------------------------------------------------------------------
+
+class TestAutoImportConfigDefaults:
+ """SkillConfig.auto_import_enabled must default to False."""
+
+ def test_default_is_false(self):
+ from openspace.config.grounding import SkillConfig
+ cfg = SkillConfig()
+ assert cfg.auto_import_enabled is False
+
+ def test_explicit_true(self):
+ from openspace.config.grounding import SkillConfig
+ cfg = SkillConfig(auto_import_enabled=True)
+ assert cfg.auto_import_enabled is True
+
+ def test_explicit_false(self):
+ from openspace.config.grounding import SkillConfig
+ cfg = SkillConfig(auto_import_enabled=False)
+ assert cfg.auto_import_enabled is False
+
+ def test_serialization_roundtrip(self):
+ from openspace.config.grounding import SkillConfig
+ cfg = SkillConfig(auto_import_enabled=True)
+ data = cfg.model_dump()
+ assert data["auto_import_enabled"] is True
+ restored = SkillConfig(**data)
+ assert restored.auto_import_enabled is True
+
+
+# ---------------------------------------------------------------------------
+# _is_auto_import_enabled() helper
+# ---------------------------------------------------------------------------
+
+class TestIsAutoImportEnabled:
+ """_is_auto_import_enabled() must reflect SkillConfig state."""
+
+ def test_returns_false_when_no_instance(self):
+ import openspace.mcp_server as srv
+ original = srv._openspace_instance
+ try:
+ srv._openspace_instance = None
+ assert srv._is_auto_import_enabled() is False
+ finally:
+ srv._openspace_instance = original
+
+ def test_returns_false_when_not_initialized(self):
+ import openspace.mcp_server as srv
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = False
+ srv._openspace_instance = mock_os
+ assert srv._is_auto_import_enabled() is False
+ finally:
+ srv._openspace_instance = original
+
+ def test_returns_false_when_config_missing(self):
+ import openspace.mcp_server as srv
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_os._grounding_config = None
+ srv._openspace_instance = mock_os
+ assert srv._is_auto_import_enabled() is False
+ finally:
+ srv._openspace_instance = original
+
+ def test_returns_false_when_skills_config_missing(self):
+ import openspace.mcp_server as srv
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_gc = MagicMock()
+ mock_gc.skills = None
+ mock_os._grounding_config = mock_gc
+ srv._openspace_instance = mock_os
+ assert srv._is_auto_import_enabled() is False
+ finally:
+ srv._openspace_instance = original
+
+ def test_returns_false_when_flag_is_false(self):
+ import openspace.mcp_server as srv
+ from openspace.config.grounding import SkillConfig
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_gc = MagicMock()
+ mock_gc.skills = SkillConfig(auto_import_enabled=False)
+ mock_os._grounding_config = mock_gc
+ srv._openspace_instance = mock_os
+ assert srv._is_auto_import_enabled() is False
+ finally:
+ srv._openspace_instance = original
+
+ def test_returns_true_when_flag_is_true(self):
+ import openspace.mcp_server as srv
+ from openspace.config.grounding import SkillConfig
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_gc = MagicMock()
+ mock_gc.skills = SkillConfig(auto_import_enabled=True)
+ mock_os._grounding_config = mock_gc
+ srv._openspace_instance = mock_os
+ assert srv._is_auto_import_enabled() is True
+ finally:
+ srv._openspace_instance = original
+
+
+# ---------------------------------------------------------------------------
+# _cloud_search_and_import() gating
+# ---------------------------------------------------------------------------
+
+class TestCloudSearchAndImportGating:
+ """_cloud_search_and_import must return [] when auto-import is disabled."""
+
+ @pytest.fixture(autouse=True)
+ def _patch_auto_import(self):
+ with patch("openspace.mcp_server._is_auto_import_enabled", return_value=False):
+ yield
+
+ async def test_returns_empty_when_disabled(self):
+ from openspace.mcp_server import _cloud_search_and_import
+ result = await _cloud_search_and_import("build a web scraper")
+ assert result == []
+
+ async def test_never_calls_cloud_when_disabled(self):
+ """Cloud search module should never be imported when disabled."""
+ with patch("openspace.mcp_server._is_auto_import_enabled", return_value=False):
+ from openspace.mcp_server import _cloud_search_and_import
+ # If cloud modules were imported, this would fail on missing deps
+ result = await _cloud_search_and_import("anything")
+ assert result == []
+
+
+class TestCloudSearchAndImportEnabled:
+ """When auto-import IS enabled, cloud search proceeds normally."""
+
+ async def test_proceeds_when_enabled(self):
+ """Verify the guard allows through when enabled (will fail on
+ missing cloud module, proving the guard was passed)."""
+ with patch("openspace.mcp_server._is_auto_import_enabled", return_value=True):
+ from openspace.mcp_server import _cloud_search_and_import
+ # Cloud modules won't be available in test env, so this should
+ # return [] via the except branch, but it should NOT return
+ # before trying (i.e., the guard didn't block it)
+ result = await _cloud_search_and_import("test task")
+ # Non-fatal — returns [] on error, which is fine
+ assert isinstance(result, list)
+
+
+# ---------------------------------------------------------------------------
+# _do_import_cloud_skill() gating
+# ---------------------------------------------------------------------------
+
+class TestDoImportCloudSkillGating:
+ """_do_import_cloud_skill must refuse when auto-import is disabled."""
+
+ async def test_blocked_when_disabled(self):
+ with patch("openspace.mcp_server._is_auto_import_enabled", return_value=False):
+ from openspace.mcp_server import _do_import_cloud_skill
+ result = await _do_import_cloud_skill("some-skill-id")
+ assert result["status"] == "blocked"
+ assert "auto_import_enabled" in result["reason"]
+
+ async def test_allowed_when_enabled(self):
+ """When enabled, should attempt to actually import (and fail on
+ missing cloud client — proving the guard was passed)."""
+ with patch("openspace.mcp_server._is_auto_import_enabled", return_value=True):
+ from openspace.mcp_server import _do_import_cloud_skill
+ with pytest.raises(Exception):
+ # Will fail because cloud client isn't configured
+ await _do_import_cloud_skill("fake-skill-id")
+
+
+# ---------------------------------------------------------------------------
+# search_skills() auto_import parameter gating
+# ---------------------------------------------------------------------------
+
+class TestSearchSkillsAutoImportGating:
+ """search_skills() must not auto-import when config flag is False,
+ even if the caller passes auto_import=True."""
+
+ @pytest.fixture(autouse=True)
+ def _patch_openspace(self):
+ """Patch _get_openspace to avoid full initialization."""
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_os._skill_registry = MagicMock()
+ mock_os._skill_registry.list_skills.return_value = []
+ mock_os._grounding_config = MagicMock()
+ mock_os._grounding_config.skills = MagicMock(auto_import_enabled=False)
+
+ mock_hybrid = AsyncMock(return_value=[
+ {"name": "cloud_skill", "source": "cloud", "visibility": "public", "skill_id": "s1"},
+ ])
+
+ with patch("openspace.mcp_server._get_openspace", new_callable=AsyncMock, return_value=mock_os), \
+ patch("openspace.mcp_server._get_store") as mock_store, \
+ patch("openspace.cloud.search.hybrid_search_skills", mock_hybrid, create=True), \
+ patch("openspace.mcp_server._is_auto_import_enabled", return_value=False), \
+ patch("openspace.mcp_server._do_import_cloud_skill", new_callable=AsyncMock) as mock_import:
+ mock_store.return_value = MagicMock()
+ self.mock_import = mock_import
+ self.mock_hybrid = mock_hybrid
+ yield
+
+ async def test_auto_import_param_true_but_config_false(self):
+ """Even with auto_import=True in the call, config flag blocks import."""
+ from openspace.mcp_server import search_skills
+ result_json = await search_skills(query="web scraper", auto_import=True)
+ result = json.loads(result_json)
+ # Import should never have been called
+ self.mock_import.assert_not_called()
+ # Results should still be returned (search works, import doesn't)
+ assert "results" in result
+ assert len(result["results"]) == 1
+
+ async def test_no_import_summary_when_disabled(self):
+ from openspace.mcp_server import search_skills
+ result_json = await search_skills(query="web scraper", auto_import=True)
+ result = json.loads(result_json)
+ # No import summary should be present
+ assert "auto_import_summary" not in result
+
+
+# ---------------------------------------------------------------------------
+# execute_task() cloud import gating
+# ---------------------------------------------------------------------------
+
+class TestExecuteTaskCloudImportGating:
+ """execute_task(search_scope='all') must not import when disabled."""
+
+ async def test_cloud_import_blocked_in_execute_task(self):
+ """Cloud import in execute_task goes through _cloud_search_and_import,
+ which is gated. Verify the chain works."""
+ with patch("openspace.mcp_server._is_auto_import_enabled", return_value=False), \
+ patch("openspace.mcp_server._cloud_search_and_import", new_callable=AsyncMock) as mock_cloud:
+ # Even though _cloud_search_and_import has its own guard,
+ # verify that when called, it returns [] without side effects
+ mock_cloud.return_value = []
+ from openspace.mcp_server import _cloud_search_and_import
+ result = await _cloud_search_and_import("any task")
+ assert result == []
+
+
+# ---------------------------------------------------------------------------
+# Integration: full config → gating chain
+# ---------------------------------------------------------------------------
+
+class TestConfigToGatingIntegration:
+ """End-to-end: setting SkillConfig.auto_import_enabled flows through
+ to _is_auto_import_enabled() and gates all import paths."""
+
+ def test_config_false_gates_helper(self):
+ import openspace.mcp_server as srv
+ from openspace.config.grounding import SkillConfig
+
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_gc = MagicMock()
+ mock_gc.skills = SkillConfig(auto_import_enabled=False)
+ mock_os._grounding_config = mock_gc
+ srv._openspace_instance = mock_os
+
+ assert srv._is_auto_import_enabled() is False
+ finally:
+ srv._openspace_instance = original
+
+ def test_config_true_enables_helper(self):
+ import openspace.mcp_server as srv
+ from openspace.config.grounding import SkillConfig
+
+ original = srv._openspace_instance
+ try:
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_gc = MagicMock()
+ mock_gc.skills = SkillConfig(auto_import_enabled=True)
+ mock_os._grounding_config = mock_gc
+ srv._openspace_instance = mock_os
+
+ assert srv._is_auto_import_enabled() is True
+ finally:
+ srv._openspace_instance = original
diff --git a/tests/test_benchmark_extraction.py b/tests/test_benchmark_extraction.py
new file mode 100644
index 00000000..e6160f9b
--- /dev/null
+++ b/tests/test_benchmark_extraction.py
@@ -0,0 +1,149 @@
+"""Tests for EPIC 0.9 — Benchmark extraction from production code.
+
+Verifies that production ``openspace/`` modules have no runtime coupling
+to ``gdpval_bench`` (the benchmark harness). The benchmark package is
+CLI-only and must never be imported during MCP server startup or
+tool execution.
+"""
+from __future__ import annotations
+
+import ast
+import importlib
+import sys
+from pathlib import Path
+from typing import List, Set
+
+import pytest
+
+# Production package root
+_OPENSPACE_ROOT = Path(__file__).resolve().parent.parent / "openspace"
+
+# Files that are explicitly allowed to reference gdpval_bench
+# (dashboard is not part of the MCP server)
+_ALLOWED_FILES: Set[str] = {
+ "dashboard_server.py",
+}
+
+
+def _find_python_files(root: Path) -> List[Path]:
+ """Recursively find all .py files under *root*."""
+ return sorted(root.rglob("*.py"))
+
+
+def _has_gdpval_import(source: str) -> List[str]:
+ """Return list of gdpval_bench import statements found in *source*."""
+ findings: List[str] = []
+ try:
+ tree = ast.parse(source)
+ except SyntaxError:
+ return findings
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ if alias.name.startswith("gdpval_bench"):
+ findings.append(f"import {alias.name} (line {node.lineno})")
+ elif isinstance(node, ast.ImportFrom):
+ if node.module and node.module.startswith("gdpval_bench"):
+ names = ", ".join(a.name for a in node.names)
+ findings.append(
+ f"from {node.module} import {names} (line {node.lineno})"
+ )
+ return findings
+
+
+class TestNoBenchmarkImports:
+ """Production code must not import gdpval_bench."""
+
+ def test_no_gdpval_imports_in_production(self):
+ """Scan all .py files under openspace/ for gdpval_bench imports."""
+ violations: List[str] = []
+ for py_file in _find_python_files(_OPENSPACE_ROOT):
+ if py_file.name in _ALLOWED_FILES:
+ continue
+ source = py_file.read_text(encoding="utf-8", errors="replace")
+ findings = _has_gdpval_import(source)
+ if findings:
+ rel = py_file.relative_to(_OPENSPACE_ROOT.parent)
+ for f in findings:
+ violations.append(f"{rel}: {f}")
+
+ assert violations == [], (
+ "Production code must not import gdpval_bench:\n"
+ + "\n".join(f" - {v}" for v in violations)
+ )
+
+ def test_allowed_files_list_is_minimal(self):
+ """Ensure _ALLOWED_FILES only contains files that actually exist."""
+ for name in _ALLOWED_FILES:
+ assert (_OPENSPACE_ROOT / name).exists(), (
+ f"{name} is in _ALLOWED_FILES but doesn't exist"
+ )
+
+ @pytest.mark.parametrize("module_path", [
+ "openspace.llm.client",
+ "openspace.skill_engine.registry",
+ "openspace.skill_engine.evolver",
+ "openspace.skill_engine.analyzer",
+ "openspace.grounding.core.quality.manager",
+ ])
+ def test_previously_coupled_modules_clean(self, module_path):
+ """Verify the 5 modules that previously imported gdpval_bench are clean."""
+ py_file = _OPENSPACE_ROOT.parent / module_path.replace(".", "/")
+ py_file = py_file.with_suffix(".py")
+ assert py_file.exists(), f"{module_path} not found at {py_file}"
+
+ source = py_file.read_text(encoding="utf-8")
+ findings = _has_gdpval_import(source)
+ assert findings == [], (
+ f"{module_path} still imports gdpval_bench:\n"
+ + "\n".join(f" - {f}" for f in findings)
+ )
+
+
+class TestBenchmarkIsolation:
+ """gdpval_bench must be completely separate from openspace runtime."""
+
+ def test_gdpval_bench_not_in_sys_modules_after_openspace_import(self):
+ """Importing openspace modules must not pull in gdpval_bench."""
+ # Clear any cached gdpval_bench modules
+ gdpval_mods = [k for k in sys.modules if k.startswith("gdpval_bench")]
+ saved = {k: sys.modules.pop(k) for k in gdpval_mods}
+
+ try:
+ # Force reimport of the previously-coupled modules
+ for mod_name in [
+ "openspace.skill_engine.registry",
+ "openspace.skill_engine.evolver",
+ "openspace.skill_engine.analyzer",
+ ]:
+ try:
+ if mod_name in sys.modules:
+ importlib.reload(sys.modules[mod_name])
+ else:
+ importlib.import_module(mod_name)
+ except (ImportError, ModuleNotFoundError):
+ # Skip modules with missing optional deps (e.g. litellm)
+ pass
+
+ # Verify no gdpval_bench modules crept in
+ leaked = [k for k in sys.modules if k.startswith("gdpval_bench")]
+ assert leaked == [], (
+ f"gdpval_bench leaked into sys.modules: {leaked}"
+ )
+ finally:
+ # Restore
+ sys.modules.update(saved)
+
+ def test_token_tracker_not_referenced_in_strings(self):
+ """No string references to token_tracker in production code."""
+ for py_file in _find_python_files(_OPENSPACE_ROOT):
+ if py_file.name in _ALLOWED_FILES:
+ continue
+ source = py_file.read_text(encoding="utf-8", errors="replace")
+ if "token_tracker" in source:
+ rel = py_file.relative_to(_OPENSPACE_ROOT.parent)
+ pytest.fail(
+ f"{rel} still references 'token_tracker' — "
+ "benchmark coupling not fully removed"
+ )
diff --git a/tests/test_capability_leases.py b/tests/test_capability_leases.py
new file mode 100644
index 00000000..c27f9e41
--- /dev/null
+++ b/tests/test_capability_leases.py
@@ -0,0 +1,549 @@
+"""Tests for EPIC 2.1 — Capability Lease System.
+
+Issues:
+- #84: YAML schema (LeaseSchema Pydantic model)
+- #85: Parser + validator
+- #86: Default tier templates (T0–T4)
+- #87: Lease resolver (InMemoryLeaseResolver)
+- #88: Comprehensive test coverage
+
+Validates:
+- Schema validation and rejection of invalid inputs
+- Tier consistency rules (T0 restrictions)
+- Default templates for all 5 tiers
+- Lease lifecycle (acquire, validate, release, expiry)
+- Lease → SandboxPolicy conversion
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from pydantic import ValidationError
+
+from openspace.domain.ports import CapabilityLeaseResolverPort
+from openspace.domain.types import CapabilityLease, SandboxPolicy
+from openspace.sandbox.leases import (
+ REQUIRED_BLOCKED_COMMANDS,
+ REQUIRED_BLOCKED_DOMAINS,
+ REQUIRED_DENIED_PATHS,
+ TIER_DEFAULTS,
+ FilesystemCapability,
+ InMemoryLeaseResolver,
+ LeaseSchema,
+ NetworkCapability,
+ ProcessCapability,
+ ResourceCapability,
+ SecretCapability,
+ TrustTier,
+ get_tier_default,
+ lease_to_sandbox_policy,
+ parse_lease,
+ validate_lease,
+)
+
+
+# ---------------------------------------------------------------------------
+# Schema Tests (#84)
+# ---------------------------------------------------------------------------
+
+
+class TestLeaseSchema:
+ """LeaseSchema Pydantic model validates correctly."""
+
+ def test_minimal_valid_schema(self) -> None:
+ schema = LeaseSchema(name="test")
+ assert schema.name == "test"
+ assert schema.trust_tier == TrustTier.T1_BASIC
+ assert schema.ttl_seconds == 300
+
+ def test_full_schema(self) -> None:
+ schema = LeaseSchema(
+ name="full-test",
+ trust_tier=TrustTier.T3_ELEVATED,
+ ttl_seconds=1200,
+ filesystem=FilesystemCapability(read_paths=["**"], write_paths=["workspace/**"]),
+ network=NetworkCapability(outbound_enabled=True, max_connections=10),
+ process=ProcessCapability(allow_shell=True, max_processes=5),
+ resources=ResourceCapability(max_memory_mb=2048),
+ secrets=SecretCapability(max_secrets=5, allowed_scopes=["task", "session"]),
+ )
+ assert schema.trust_tier == TrustTier.T3_ELEVATED
+ assert schema.network.outbound_enabled is True
+ assert schema.process.allow_shell is True
+
+ def test_ttl_too_short_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="ttl_seconds"):
+ LeaseSchema(name="bad", ttl_seconds=5)
+
+ def test_ttl_too_long_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="ttl_seconds"):
+ LeaseSchema(name="bad", ttl_seconds=7200)
+
+ def test_memory_below_minimum_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="max_memory_mb"):
+ LeaseSchema(name="bad", resources=ResourceCapability(max_memory_mb=32))
+
+ def test_empty_deny_list_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="denied_paths"):
+ FilesystemCapability(denied_paths=[])
+
+ def test_empty_blocked_domains_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="blocked_domains"):
+ NetworkCapability(blocked_domains=[])
+
+ def test_default_denied_paths_include_sensitive(self) -> None:
+ fs = FilesystemCapability()
+ assert "/etc/shadow" in fs.denied_paths
+ assert "~/.ssh/*" in fs.denied_paths
+ assert "**/.env" in fs.denied_paths
+
+ def test_default_blocked_domains_include_metadata(self) -> None:
+ net = NetworkCapability()
+ assert "169.254.169.254" in net.blocked_domains
+ assert "metadata.google.internal" in net.blocked_domains
+
+
+class TestTierConsistency:
+ """T0 tier must enforce maximum restrictions."""
+
+ def test_t0_cannot_enable_network(self) -> None:
+ with pytest.raises(ValidationError, match="T0.*outbound network"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ network=NetworkCapability(outbound_enabled=True),
+ )
+
+ def test_t0_cannot_allow_shell(self) -> None:
+ with pytest.raises(ValidationError, match="T0.*shell"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ process=ProcessCapability(allow_shell=True),
+ )
+
+ def test_t0_cannot_access_secrets(self) -> None:
+ with pytest.raises(ValidationError, match="T0.*secrets"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ secrets=SecretCapability(max_secrets=1),
+ )
+
+ def test_t1_default_no_network(self) -> None:
+ t1 = get_tier_default(TrustTier.T1_BASIC)
+ assert t1.network.outbound_enabled is False
+
+ def test_t2_allows_network(self) -> None:
+ t2 = get_tier_default(TrustTier.T2_STANDARD)
+ assert t2.network.outbound_enabled is True
+
+ def test_t4_allows_everything(self) -> None:
+ t4 = get_tier_default(TrustTier.T4_FULL)
+ assert t4.process.allow_shell is True
+ assert t4.network.outbound_enabled is True
+ assert t4.secrets.max_secrets == 50
+ assert t4.resources.max_memory_mb == 8192
+
+
+# ---------------------------------------------------------------------------
+# Parser Tests (#85)
+# ---------------------------------------------------------------------------
+
+
+class TestLeaseParser:
+ """parse_lease and validate_lease handle dict input correctly."""
+
+ def test_parse_valid_dict(self) -> None:
+ data = {"name": "test-skill", "trust_tier": "T2", "ttl_seconds": 600}
+ schema = parse_lease(data)
+ assert schema.name == "test-skill"
+ assert schema.trust_tier == TrustTier.T2_STANDARD
+
+ def test_parse_with_nested_capabilities(self) -> None:
+ data = {
+ "name": "net-skill",
+ "trust_tier": "T2",
+ "network": {"outbound_enabled": True, "allowed_domains": ["api.example.com"]},
+ }
+ schema = parse_lease(data)
+ assert schema.network.outbound_enabled is True
+ assert "api.example.com" in schema.network.allowed_domains
+
+ def test_parse_invalid_tier_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ parse_lease({"name": "bad", "trust_tier": "T99"})
+
+ def test_validate_returns_empty_on_valid(self) -> None:
+ errors = validate_lease({"name": "valid"})
+ assert errors == []
+
+ def test_validate_returns_errors_on_invalid(self) -> None:
+ errors = validate_lease({"name": "bad", "ttl_seconds": 1})
+ assert len(errors) > 0
+ assert any("ttl_seconds" in e for e in errors)
+
+ def test_validate_missing_name(self) -> None:
+ errors = validate_lease({})
+ assert len(errors) > 0
+ assert any("name" in e for e in errors)
+
+
+# ---------------------------------------------------------------------------
+# Tier Defaults Tests (#86)
+# ---------------------------------------------------------------------------
+
+
+class TestTierDefaults:
+ """Default templates exist for all 5 tiers."""
+
+ def test_all_tiers_have_defaults(self) -> None:
+ for tier in TrustTier:
+ default = get_tier_default(tier)
+ assert default.trust_tier == tier
+ assert default.name != ""
+
+ def test_tier_count(self) -> None:
+ assert len(TIER_DEFAULTS) == 5
+
+ def test_tiers_ordered_by_permissiveness(self) -> None:
+ """Each tier should allow ≥ the resources of the previous tier."""
+ tiers = [TrustTier.T0_UNTRUSTED, TrustTier.T1_BASIC, TrustTier.T2_STANDARD,
+ TrustTier.T3_ELEVATED, TrustTier.T4_FULL]
+ for i in range(1, len(tiers)):
+ prev = get_tier_default(tiers[i - 1])
+ curr = get_tier_default(tiers[i])
+ assert curr.resources.max_memory_mb >= prev.resources.max_memory_mb
+ assert curr.process.max_processes >= prev.process.max_processes
+ assert curr.ttl_seconds >= prev.ttl_seconds
+
+ def test_t0_is_most_restrictive(self) -> None:
+ t0 = get_tier_default(TrustTier.T0_UNTRUSTED)
+ assert t0.network.outbound_enabled is False
+ assert t0.process.allow_shell is False
+ assert t0.secrets.max_secrets == 0
+ assert t0.filesystem.read_paths == []
+ assert t0.filesystem.write_paths == []
+
+ def test_defaults_are_valid_schemas(self) -> None:
+ """All defaults must pass their own validation."""
+ for tier, schema in TIER_DEFAULTS.items():
+ errors = validate_lease(schema.model_dump())
+ assert errors == [], f"Tier {tier.value} default has errors: {errors}"
+
+
+# ---------------------------------------------------------------------------
+# Lease Resolver Tests (#87)
+# ---------------------------------------------------------------------------
+
+
+class TestInMemoryLeaseResolver:
+ """InMemoryLeaseResolver implements the port correctly."""
+
+ def test_implements_port(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ assert isinstance(resolver, CapabilityLeaseResolverPort)
+
+ @pytest.mark.asyncio
+ async def test_acquire_returns_lease(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ lease = await resolver.acquire("filesystem.read", trust_tier="T1", ttl_seconds=60)
+ assert lease is not None
+ assert isinstance(lease, CapabilityLease)
+ assert lease.capability == "filesystem.read"
+ assert lease.trust_tier == "T1"
+ assert lease.revoked is False
+
+ @pytest.mark.asyncio
+ async def test_validate_active_lease(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ lease = await resolver.acquire("network.outbound", ttl_seconds=60)
+ assert lease is not None
+ assert await resolver.validate(lease.lease_id) is True
+
+ @pytest.mark.asyncio
+ async def test_validate_nonexistent_lease(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ assert await resolver.validate("nonexistent") is False
+
+ @pytest.mark.asyncio
+ async def test_release_revokes_lease(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ lease = await resolver.acquire("process.shell", ttl_seconds=60)
+ assert lease is not None
+
+ result = await resolver.release(lease.lease_id)
+ assert result is True
+ assert await resolver.validate(lease.lease_id) is False
+
+ @pytest.mark.asyncio
+ async def test_release_nonexistent_returns_false(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ assert await resolver.release("nonexistent") is False
+
+ @pytest.mark.asyncio
+ async def test_expired_lease_invalid(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ lease = await resolver.acquire("test", ttl_seconds=10)
+ assert lease is not None
+
+ # Manually expire the lease
+ expired = CapabilityLease(
+ lease_id=lease.lease_id,
+ capability=lease.capability,
+ granted_to=lease.granted_to,
+ trust_tier=lease.trust_tier,
+ expires_at=datetime.now(timezone.utc) - timedelta(seconds=10),
+ revoked=False,
+ )
+ resolver._leases[lease.lease_id] = expired
+ assert await resolver.validate(lease.lease_id) is False
+
+ @pytest.mark.asyncio
+ async def test_list_active_filters_revoked(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ l1 = await resolver.acquire("cap-1", ttl_seconds=60)
+ l2 = await resolver.acquire("cap-2", ttl_seconds=60)
+ assert l1 is not None and l2 is not None
+
+ await resolver.release(l1.lease_id)
+ active = await resolver.list_active()
+ assert len(active) == 1
+ assert active[0].lease_id == l2.lease_id
+ async def test_list_active_filters_by_grantee(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ await resolver.acquire("cap-1", ttl_seconds=60)
+ active = await resolver.list_active(granted_to="current_task")
+ assert len(active) == 1
+ active_other = await resolver.list_active(granted_to="other_task")
+ assert len(active_other) == 0
+
+ @pytest.mark.asyncio
+ async def test_multiple_leases_independent(self) -> None:
+ resolver = InMemoryLeaseResolver()
+ l1 = await resolver.acquire("fs.read", ttl_seconds=60)
+ l2 = await resolver.acquire("net.out", ttl_seconds=60)
+ assert l1 is not None and l2 is not None
+ assert l1.lease_id != l2.lease_id
+
+ await resolver.release(l1.lease_id)
+ assert await resolver.validate(l1.lease_id) is False
+ assert await resolver.validate(l2.lease_id) is True
+
+
+# ---------------------------------------------------------------------------
+# SandboxPolicy Conversion Tests
+# ---------------------------------------------------------------------------
+
+
+class TestLeaseSandboxConversion:
+ """lease_to_sandbox_policy correctly maps lease → policy."""
+
+ def test_t0_produces_restrictive_policy(self) -> None:
+ t0 = get_tier_default(TrustTier.T0_UNTRUSTED)
+ policy = lease_to_sandbox_policy(t0)
+ assert isinstance(policy, SandboxPolicy)
+ assert policy.sandbox_enabled is True
+ assert policy.trust_tier == "T0"
+ assert policy.max_memory_mb == 128
+ assert policy.max_execution_time_s == 30
+
+ def test_t4_produces_permissive_policy(self) -> None:
+ t4 = get_tier_default(TrustTier.T4_FULL)
+ policy = lease_to_sandbox_policy(t4)
+ assert policy.trust_tier == "T4"
+ assert policy.max_memory_mb == 8192
+ assert policy.max_execution_time_s == 3600
+
+ def test_policy_always_sandbox_enabled(self) -> None:
+ """Lease-derived policies always have sandbox enabled."""
+ for tier in TrustTier:
+ policy = lease_to_sandbox_policy(get_tier_default(tier))
+ assert policy.sandbox_enabled is True
+
+ def test_blocked_commands_preserved(self) -> None:
+ t1 = get_tier_default(TrustTier.T1_BASIC)
+ policy = lease_to_sandbox_policy(t1)
+ assert "rm" in policy.blocked_commands
+ assert "shutdown" in policy.blocked_commands
+
+ def test_allowed_domains_mapped(self) -> None:
+ t2 = get_tier_default(TrustTier.T2_STANDARD)
+ policy = lease_to_sandbox_policy(t2)
+ assert "pypi.org" in policy.allowed_domains
+
+ def test_custom_lease_to_policy(self) -> None:
+ custom = LeaseSchema(
+ name="custom",
+ trust_tier=TrustTier.T2_STANDARD,
+ process=ProcessCapability(
+ allowed_commands=["python", "pip"],
+ max_execution_time_s=120,
+ ),
+ resources=ResourceCapability(max_memory_mb=1024),
+ )
+ policy = lease_to_sandbox_policy(custom)
+ assert "python" in policy.allowed_commands
+ assert "pip" in policy.allowed_commands
+ assert policy.max_execution_time_s == 120
+ assert policy.max_memory_mb == 1024
+
+
+# ---------------------------------------------------------------------------
+# Security Regression Tests (R1 review fixes)
+# ---------------------------------------------------------------------------
+
+
+class TestSecurityRegressions:
+ """Regression tests for review findings (R1 + R2)."""
+
+ def test_custom_denied_paths_still_include_required(self) -> None:
+ """Caller cannot strip required denied_paths by supplying custom list."""
+ fs = FilesystemCapability(denied_paths=["/my/custom/path"])
+ for required in REQUIRED_DENIED_PATHS:
+ assert required in fs.denied_paths, f"{required} must always be in denied_paths"
+
+ def test_custom_blocked_domains_still_include_required(self) -> None:
+ """Caller cannot strip required blocked_domains — ALL metadata endpoints enforced."""
+ net = NetworkCapability(blocked_domains=["evil.example.com"])
+ for required in REQUIRED_BLOCKED_DOMAINS:
+ assert required in net.blocked_domains, f"{required} must always be in blocked_domains"
+
+ def test_get_tier_default_returns_independent_copy(self) -> None:
+ """Mutating a returned default must not affect future calls."""
+ d1 = get_tier_default(TrustTier.T0_UNTRUSTED)
+ d1.filesystem.read_paths.append("/hacked")
+ d2 = get_tier_default(TrustTier.T0_UNTRUSTED)
+ assert "/hacked" not in d2.filesystem.read_paths
+
+ def test_blocked_domains_include_additional_metadata(self) -> None:
+ """Default blocked domains include AWS/Alibaba/IPv6 metadata."""
+ net = NetworkCapability()
+ assert "metadata.internal" in net.blocked_domains
+ assert "100.100.100.200" in net.blocked_domains
+ assert "fd00:ec2::254" in net.blocked_domains
+
+ @pytest.mark.asyncio
+ async def test_concurrent_lease_operations(self) -> None:
+ """Resolver handles concurrent acquire/release safely."""
+ resolver = InMemoryLeaseResolver()
+ tasks = [resolver.acquire(f"cap-{i}", ttl_seconds=60) for i in range(10)]
+ leases = await asyncio.gather(*tasks)
+ lease_ids = {l.lease_id for l in leases if l}
+ assert len(lease_ids) == 10
+
+ # --- R2 regressions ---
+
+ @pytest.mark.asyncio
+ async def test_acquire_rejects_invalid_trust_tier(self) -> None:
+ """Resolver rejects unknown trust_tier values."""
+ resolver = InMemoryLeaseResolver()
+ with pytest.raises(ValueError, match="Invalid trust_tier"):
+ await resolver.acquire("test", trust_tier="BOGUS")
+
+ @pytest.mark.asyncio
+ async def test_acquire_rejects_out_of_range_ttl(self) -> None:
+ """Resolver rejects TTL outside 10–3600."""
+ resolver = InMemoryLeaseResolver()
+ with pytest.raises(ValueError, match="ttl_seconds"):
+ await resolver.acquire("test", ttl_seconds=5)
+ with pytest.raises(ValueError, match="ttl_seconds"):
+ await resolver.acquire("test", ttl_seconds=99999)
+
+ def test_t0_cannot_have_write_paths(self) -> None:
+ """T0 cannot declare explicit write paths."""
+ with pytest.raises(ValidationError, match="T0.*write paths"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ filesystem=FilesystemCapability(write_paths=["workspace/**"]),
+ process=ProcessCapability(max_processes=1, allow_shell=False),
+ secrets=SecretCapability(max_secrets=0),
+ )
+
+ def test_t0_must_be_temp_dir_only(self) -> None:
+ """T0 must restrict writes to temp directories."""
+ with pytest.raises(ValidationError, match="T0.*temp dir"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ filesystem=FilesystemCapability(temp_dir_only=False),
+ process=ProcessCapability(max_processes=1, allow_shell=False),
+ secrets=SecretCapability(max_secrets=0),
+ )
+
+ def test_t0_cannot_exceed_memory_cap(self) -> None:
+ """T0 cannot exceed 256MB memory."""
+ with pytest.raises(ValidationError, match="T0.*256MB"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ resources=ResourceCapability(max_memory_mb=512),
+ process=ProcessCapability(max_processes=1, allow_shell=False),
+ secrets=SecretCapability(max_secrets=0),
+ )
+
+ def test_t0_max_one_process(self) -> None:
+ """T0 cannot spawn more than 1 process."""
+ with pytest.raises(ValidationError, match="T0.*1 process"):
+ LeaseSchema(
+ name="bad-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ process=ProcessCapability(max_processes=5),
+ secrets=SecretCapability(max_secrets=0),
+ )
+
+ def test_custom_denied_paths_preserve_ssh_and_env(self) -> None:
+ """Custom denied_paths cannot strip ~/.ssh/* or **/.env."""
+ fs = FilesystemCapability(denied_paths=["/my/only/path"])
+ assert "~/.ssh/*" in fs.denied_paths
+ assert "**/.env" in fs.denied_paths
+
+ def test_empty_blocked_commands_rejected(self) -> None:
+ """blocked_commands cannot be empty."""
+ with pytest.raises(ValidationError, match="blocked_commands"):
+ ProcessCapability(blocked_commands=[])
+
+ def test_custom_blocked_commands_still_include_required(self) -> None:
+ """Custom blocked_commands cannot strip required safety commands."""
+ proc = ProcessCapability(blocked_commands=["my-custom-cmd"])
+ for required in REQUIRED_BLOCKED_COMMANDS:
+ assert required in proc.blocked_commands, f"{required} must always be in blocked_commands"
+
+ # --- R4 regressions ---
+
+ def test_blocked_commands_preserve_full_baseline(self) -> None:
+ """Custom blocked_commands preserves rmdir, kill, pkill too."""
+ proc = ProcessCapability(blocked_commands=["custom"])
+ for cmd in ("rmdir", "kill", "pkill", "rm", "mkfs", "dd", "shutdown", "reboot"):
+ assert cmd in proc.blocked_commands, f"{cmd} must always be in blocked_commands"
+
+ def test_t1_cannot_enable_network(self) -> None:
+ """T1 (basic) cannot have outbound network."""
+ with pytest.raises(ValidationError, match="T1.*network"):
+ LeaseSchema(
+ name="bad-t1",
+ trust_tier=TrustTier.T1_BASIC,
+ network=NetworkCapability(outbound_enabled=True),
+ )
+
+ def test_t1_cannot_allow_shell(self) -> None:
+ """T1 (basic) cannot allow shell."""
+ with pytest.raises(ValidationError, match="T1.*shell"):
+ LeaseSchema(
+ name="bad-t1",
+ trust_tier=TrustTier.T1_BASIC,
+ process=ProcessCapability(allow_shell=True),
+ )
+
+ def test_t2_cannot_allow_shell(self) -> None:
+ """T2 (standard) cannot allow shell."""
+ with pytest.raises(ValidationError, match="T2.*shell"):
+ LeaseSchema(
+ name="bad-t2",
+ trust_tier=TrustTier.T2_STANDARD,
+ process=ProcessCapability(allow_shell=True),
+ )
diff --git a/tests/test_delegation.py b/tests/test_delegation.py
new file mode 100644
index 00000000..d9b11b76
--- /dev/null
+++ b/tests/test_delegation.py
@@ -0,0 +1,216 @@
+"""Tests for EPIC 1.4 — OpenSpace Delegation.
+
+Issues #68-71:
+- #68: OpenSpace.__init__ accepts AppContainer
+- #69: Public property accessors expose container services (Phase 1 seam)
+- #70: Backward-compatible factory for existing callers
+- #71: Regression tests for identical behavior
+
+Validates:
+- Legacy creation path (OpenSpaceConfig only) still works
+- Container-based creation path (from_container) works
+- Public property accessors replace private field access pattern
+- Backward compatibility — no behavior change for existing callers
+"""
+
+from __future__ import annotations
+
+import pytest
+from typing import Any, Dict, List, Optional
+from unittest.mock import MagicMock
+
+from openspace.app.container import AppContainer
+from openspace.app.factory import _StubLLM, _StubTelemetry, _StubSkillStore
+
+# tool_layer imports litellm which may not be available in all test envs
+try:
+ from openspace.tool_layer import OpenSpace, OpenSpaceConfig
+ _HAS_TOOL_LAYER = True
+except (ImportError, ModuleNotFoundError):
+ _HAS_TOOL_LAYER = False
+
+pytestmark = pytest.mark.skipif(
+ not _HAS_TOOL_LAYER,
+ reason="openspace.tool_layer requires litellm (not installed or broken)",
+)
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Legacy creation path (backward compatibility)
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestLegacyCreation:
+ """#70 — backward-compatible factory for existing callers."""
+
+ def test_default_config(self):
+ """OpenSpace() with no args still works."""
+ cs = OpenSpace()
+ assert cs.config is not None
+ assert cs.is_initialized() is False
+ assert cs.is_running() is False
+
+ def test_explicit_config(self):
+ """OpenSpace(config=...) still works."""
+ config = OpenSpaceConfig(llm_model="test/model")
+ cs = OpenSpace(config=config)
+ assert cs.get_config() is config
+ assert cs.config.llm_model == "test/model"
+
+ def test_legacy_has_empty_container(self):
+ """Legacy path gets an empty AppContainer."""
+ cs = OpenSpace()
+ assert cs.container is not None
+ assert cs.container.llm is None
+
+ def test_private_fields_still_exist(self):
+ """Private fields remain for internal use during transition."""
+ cs = OpenSpace()
+ assert cs._llm_client is None
+ assert cs._grounding_client is None
+ assert cs._skill_registry is None
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Container-based creation path
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestContainerCreation:
+ """#68 — OpenSpace.__init__ accepts AppContainer."""
+
+ def test_from_container(self):
+ """from_container() classmethod creates OpenSpace with container."""
+ container = AppContainer(llm=_StubLLM())
+ cs = OpenSpace.from_container(container)
+ assert cs.container is container
+ assert cs.container.llm is not None
+
+ def test_from_container_with_config(self):
+ """from_container() accepts optional config."""
+ config = OpenSpaceConfig(llm_model="test/model")
+ container = AppContainer(llm=_StubLLM())
+ cs = OpenSpace.from_container(container, config=config)
+ assert cs.get_config() is config
+ assert cs.container is container
+
+ def test_init_with_container_kwarg(self):
+ """Direct __init__ with container= keyword arg."""
+ container = AppContainer(telemetry=_StubTelemetry())
+ cs = OpenSpace(container=container)
+ assert cs.container is container
+ assert cs.container.telemetry is not None
+
+ def test_container_default_config(self):
+ """from_container() uses default config if none provided."""
+ cs = OpenSpace.from_container(AppContainer())
+ assert cs.config is not None
+ assert cs.config.llm_model is not None
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Public property accessors
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestPropertyAccessors:
+ """#69 — Public properties replace private field access."""
+
+ def test_llm_client_property(self):
+ cs = OpenSpace()
+ assert cs.llm_client is None
+ # Property reflects internal state
+ cs._llm_client = MagicMock()
+ assert cs.llm_client is cs._llm_client
+
+ def test_grounding_client_property(self):
+ cs = OpenSpace()
+ assert cs.grounding_client is None
+ cs._grounding_client = MagicMock()
+ assert cs.grounding_client is cs._grounding_client
+
+ def test_grounding_config_property(self):
+ cs = OpenSpace()
+ assert cs.grounding_config is None
+ cs._grounding_config = {"test": True}
+ assert cs.grounding_config == {"test": True}
+
+ def test_skill_registry_property(self):
+ cs = OpenSpace()
+ assert cs.skill_registry is None
+ cs._skill_registry = MagicMock()
+ assert cs.skill_registry is cs._skill_registry
+
+ def test_skill_store_property(self):
+ cs = OpenSpace()
+ assert cs.skill_store is None
+ cs._skill_store = MagicMock()
+ assert cs.skill_store is cs._skill_store
+
+ def test_skill_evolver_property(self):
+ cs = OpenSpace()
+ assert cs.skill_evolver is None
+ cs._skill_evolver = MagicMock()
+ assert cs.skill_evolver is cs._skill_evolver
+
+ def test_container_property(self):
+ container = AppContainer(llm=_StubLLM())
+ cs = OpenSpace(container=container)
+ assert cs.container is container
+
+
+# ══════════════════════════════════════════════════════════════════════
+# Regression — identical behavior before/after
+# ══════════════════════════════════════════════════════════════════════
+
+
+class TestRegression:
+ """#71 — identical behavior before and after delegation refactor."""
+
+ def test_not_initialized_by_default(self):
+ """Both paths start un-initialized."""
+ legacy = OpenSpace()
+ container_based = OpenSpace.from_container(AppContainer())
+ assert legacy.is_initialized() is False
+ assert container_based.is_initialized() is False
+
+ def test_not_running_by_default(self):
+ """Both paths start not-running."""
+ legacy = OpenSpace()
+ container_based = OpenSpace.from_container(AppContainer())
+ assert legacy.is_running() is False
+ assert container_based.is_running() is False
+
+ def test_config_accessible(self):
+ """get_config() works for both paths."""
+ config = OpenSpaceConfig(llm_model="test/model")
+ legacy = OpenSpace(config=config)
+ container_based = OpenSpace.from_container(AppContainer(), config=config)
+ assert legacy.get_config() is config
+ assert container_based.get_config() is config
+
+ def test_context_manager_protocol(self):
+ """Both paths support async context manager protocol."""
+ cs = OpenSpace()
+ assert hasattr(cs, "__aenter__")
+ assert hasattr(cs, "__aexit__")
+
+ def test_all_public_methods_exist(self):
+ """Public API surface unchanged."""
+ cs = OpenSpace()
+ for method in [
+ "initialize", "execute", "cleanup",
+ "is_initialized", "is_running", "get_config",
+ "list_backends", "list_sessions",
+ ]:
+ assert hasattr(cs, method), f"Missing public method: {method}"
+
+ def test_new_properties_exist(self):
+ """New public property accessors are available."""
+ cs = OpenSpace()
+ for prop in [
+ "container", "llm_client", "grounding_client",
+ "grounding_config", "skill_registry", "skill_store",
+ "skill_evolver",
+ ]:
+ assert hasattr(cs, prop), f"Missing property: {prop}"
diff --git a/tests/test_dependency_security.py b/tests/test_dependency_security.py
new file mode 100644
index 00000000..8ce01c09
--- /dev/null
+++ b/tests/test_dependency_security.py
@@ -0,0 +1,119 @@
+"""Tests for EPIC 0.10 — Dependency security.
+
+Verifies that all dependencies in pyproject.toml have upper-bound
+version constraints and that critical security infrastructure
+(Dependabot, pip-audit CI job) is in place.
+"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+
+try:
+ import tomllib
+except ImportError:
+ import tomli as tomllib # type: ignore[no-redef]
+
+_PROJECT_ROOT = Path(__file__).resolve().parent.parent
+_PYPROJECT = _PROJECT_ROOT / "pyproject.toml"
+
+
+@pytest.fixture(scope="module")
+def pyproject():
+ return tomllib.loads(_PYPROJECT.read_text(encoding="utf-8"))
+
+
+# ---------------------------------------------------------------------------
+# Version constraint tests
+# ---------------------------------------------------------------------------
+
+_UPPER_BOUND_RE = re.compile(r"<\d|!=")
+
+
+class TestDependencyPinning:
+ """All deps must have upper-bound version constraints."""
+
+ def _check_deps(self, deps: list[str], section: str):
+ violations = []
+ for dep in deps:
+ # Strip extras markers like ; sys_platform == 'darwin'
+ spec = dep.split(";")[0].strip()
+ if not _UPPER_BOUND_RE.search(spec):
+ violations.append(spec)
+ assert violations == [], (
+ f"[{section}] deps without upper bounds:\n"
+ + "\n".join(f" - {v}" for v in violations)
+ )
+
+ def test_core_deps_have_upper_bounds(self, pyproject):
+ deps = pyproject["project"]["dependencies"]
+ self._check_deps(deps, "dependencies")
+
+ def test_dev_deps_have_upper_bounds(self, pyproject):
+ deps = pyproject["project"]["optional-dependencies"]["dev"]
+ self._check_deps(deps, "dev")
+
+ def test_macos_deps_have_upper_bounds(self, pyproject):
+ deps = pyproject["project"]["optional-dependencies"]["macos"]
+ self._check_deps(deps, "macos")
+
+ def test_linux_deps_have_upper_bounds(self, pyproject):
+ deps = pyproject["project"]["optional-dependencies"]["linux"]
+ self._check_deps(deps, "linux")
+
+ def test_windows_deps_have_upper_bounds(self, pyproject):
+ deps = pyproject["project"]["optional-dependencies"]["windows"]
+ self._check_deps(deps, "windows")
+
+ def test_litellm_has_security_cap(self, pyproject):
+ """litellm must keep <1.82.7 cap (PYSEC-2026-2)."""
+ deps = pyproject["project"]["dependencies"]
+ litellm_specs = [d for d in deps if d.startswith("litellm")]
+ assert len(litellm_specs) == 1
+ assert "<1.82.7" in litellm_specs[0]
+
+
+# ---------------------------------------------------------------------------
+# Infrastructure tests
+# ---------------------------------------------------------------------------
+
+class TestSecurityInfrastructure:
+ """Dependabot and pip-audit must be configured."""
+
+ def test_dependabot_config_exists(self):
+ path = _PROJECT_ROOT / ".github" / "dependabot.yml"
+ assert path.exists(), "Missing .github/dependabot.yml"
+ content = path.read_text()
+ assert "pip" in content, "Dependabot must monitor pip ecosystem"
+ assert "github-actions" in content, "Dependabot must monitor GH Actions"
+
+ def test_ci_has_pip_audit_job(self):
+ ci_path = _PROJECT_ROOT / ".github" / "workflows" / "ci.yml"
+ assert ci_path.exists(), "Missing CI workflow"
+ content = ci_path.read_text()
+ assert "pip-audit" in content, "CI must include pip-audit job"
+
+ def test_requirements_txt_synced(self):
+ """requirements.txt must match pyproject.toml core deps."""
+ req_path = _PROJECT_ROOT / "requirements.txt"
+ assert req_path.exists()
+ req_content = req_path.read_text()
+
+ pyproject_data = tomllib.loads(_PYPROJECT.read_text(encoding="utf-8"))
+ core_deps = pyproject_data["project"]["dependencies"]
+
+ for dep in core_deps:
+ # Extract package name (before any version specifier)
+ pkg_name = re.split(r"[><=!~\[]", dep)[0].strip().lower()
+ assert pkg_name in req_content.lower(), (
+ f"{pkg_name} in pyproject.toml but missing from requirements.txt"
+ )
+
+ def test_no_black_or_flake8_in_dev_deps(self, pyproject):
+ """Dev deps should use ruff, not black+flake8 (replaced in EPIC 0.8)."""
+ dev_deps = pyproject["project"]["optional-dependencies"]["dev"]
+ dep_names = [re.split(r"[><=!~\[]", d)[0].strip().lower() for d in dev_deps]
+ assert "black" not in dep_names, "Use ruff instead of black"
+ assert "flake8" not in dep_names, "Use ruff instead of flake8"
diff --git a/tests/test_domain_exceptions.py b/tests/test_domain_exceptions.py
new file mode 100644
index 00000000..b41587a0
--- /dev/null
+++ b/tests/test_domain_exceptions.py
@@ -0,0 +1,550 @@
+"""Tests for EPIC 1.5 — Domain Exception Hierarchy (Issues #72-75).
+
+Validates:
+- Exception hierarchy structure and inheritance
+- Error code mapping for all exception types
+- Serialization (to_dict)
+- Context propagation
+- Centralized map_to_mcp_error_code()
+- Retryable flag behavior
+- Client-safe message handling
+- NotFoundError resource type formatting
+- Integration with existing errors.py helpers
+"""
+
+from __future__ import annotations
+
+import pytest
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Hierarchy & Inheritance Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestExceptionHierarchy:
+ """All domain exceptions inherit from OpenSpaceError."""
+
+ def test_all_exceptions_importable(self):
+ from openspace.domain.exceptions import (
+ ConfigurationError,
+ DependencyError,
+ EvolutionError,
+ ExecutionError,
+ ExternalServiceError,
+ InternalError,
+ NotFoundError,
+ OpenSpaceError,
+ OperationTimeoutError,
+ PermissionDeniedError,
+ SandboxError,
+ ValidationError,
+ )
+
+ all_exc = [
+ ConfigurationError,
+ DependencyError,
+ EvolutionError,
+ ExecutionError,
+ ExternalServiceError,
+ InternalError,
+ NotFoundError,
+ OpenSpaceError,
+ OperationTimeoutError,
+ PermissionDeniedError,
+ SandboxError,
+ ValidationError,
+ ]
+ assert len(all_exc) == 12
+
+ def test_all_subclass_openspace_error(self):
+ from openspace.domain.exceptions import (
+ ConfigurationError,
+ DependencyError,
+ EvolutionError,
+ ExecutionError,
+ ExternalServiceError,
+ InternalError,
+ NotFoundError,
+ OpenSpaceError,
+ OperationTimeoutError,
+ PermissionDeniedError,
+ SandboxError,
+ ValidationError,
+ )
+
+ for exc_cls in [
+ ConfigurationError,
+ DependencyError,
+ EvolutionError,
+ ExecutionError,
+ ExternalServiceError,
+ InternalError,
+ NotFoundError,
+ OperationTimeoutError,
+ PermissionDeniedError,
+ SandboxError,
+ ValidationError,
+ ]:
+ assert issubclass(exc_cls, OpenSpaceError), (
+ f"{exc_cls.__name__} does not inherit OpenSpaceError"
+ )
+ assert issubclass(exc_cls, Exception)
+
+ def test_openspace_error_is_exception(self):
+ from openspace.domain.exceptions import OpenSpaceError
+
+ assert issubclass(OpenSpaceError, Exception)
+
+ def test_can_catch_all_with_openspace_error(self):
+ from openspace.domain.exceptions import (
+ ExecutionError,
+ NotFoundError,
+ OpenSpaceError,
+ ValidationError,
+ )
+
+ for exc_cls in [ValidationError, NotFoundError, ExecutionError]:
+ try:
+ raise exc_cls("test")
+ except OpenSpaceError:
+ pass # Expected
+ else:
+ pytest.fail(f"{exc_cls.__name__} not caught by OpenSpaceError")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Error Code Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestErrorCodes:
+ """Each exception type has the correct error_code."""
+
+ @pytest.mark.parametrize(
+ "exc_cls,expected_code",
+ [
+ ("ValidationError", "VALIDATION_ERROR"),
+ ("ConfigurationError", "VALIDATION_ERROR"),
+ ("NotFoundError", "SKILL_NOT_FOUND"),
+ ("PermissionDeniedError", "PERMISSION_DENIED"),
+ ("OperationTimeoutError", "TIMEOUT_ERROR"),
+ ("ExecutionError", "EXECUTION_ERROR"),
+ ("DependencyError", "EXECUTION_ERROR"),
+ ("ExternalServiceError", "EXECUTION_ERROR"),
+ ("SandboxError", "EXECUTION_ERROR"),
+ ("EvolutionError", "EXECUTION_ERROR"),
+ ("InternalError", "INTERNAL_ERROR"),
+ ("OpenSpaceError", "INTERNAL_ERROR"),
+ ],
+ )
+ def test_error_code_mapping(self, exc_cls: str, expected_code: str):
+ import openspace.domain.exceptions as mod
+
+ cls = getattr(mod, exc_cls)
+ exc = cls("test message")
+ assert exc.error_code == expected_code
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Serialization Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSerialization:
+ """to_dict() and __str__/__repr__ work correctly."""
+
+ def test_to_dict_basic(self):
+ from openspace.domain.exceptions import ValidationError
+
+ exc = ValidationError("bad input", field="name")
+ d = exc.to_dict()
+ assert d["error_code"] == "VALIDATION_ERROR"
+ assert d["message"] == "bad input"
+ assert d["retryable"] is False
+ assert d["context"]["field"] == "name"
+
+ def test_to_dict_with_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ exc = ExternalServiceError(
+ "API down", service="cloud", status_code=503
+ )
+ d = exc.to_dict()
+ assert d["retryable"] is True
+ assert d["context"]["service"] == "cloud"
+
+ def test_str_includes_error_code(self):
+ from openspace.domain.exceptions import NotFoundError
+
+ exc = NotFoundError("skill", skill_id="abc-123")
+ s = str(exc)
+ assert "[SKILL_NOT_FOUND]" in s
+ assert "skill not found: abc-123" in s
+
+ def test_repr_includes_class_name(self):
+ from openspace.domain.exceptions import ExecutionError
+
+ exc = ExecutionError("task failed", task_id="t42")
+ r = repr(exc)
+ assert "ExecutionError" in r
+ assert "task failed" in r
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Context Propagation Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestContextPropagation:
+ """Context kwargs are preserved in .context dict."""
+
+ def test_context_stored(self):
+ from openspace.domain.exceptions import ExecutionError
+
+ exc = ExecutionError(
+ "boom", task_id="t1", tool_name="bash", iteration=3
+ )
+ assert exc.context["task_id"] == "t1"
+ assert exc.context["tool_name"] == "bash"
+ assert exc.context["iteration"] == 3
+
+ def test_empty_context(self):
+ from openspace.domain.exceptions import ValidationError
+
+ exc = ValidationError("no context")
+ assert exc.context == {}
+
+ def test_not_found_resource_type(self):
+ from openspace.domain.exceptions import NotFoundError
+
+ exc = NotFoundError("session", session_name="default")
+ assert exc.resource_type == "session"
+ assert "session not found: default" in exc.message
+
+ def test_not_found_without_id(self):
+ from openspace.domain.exceptions import NotFoundError
+
+ exc = NotFoundError("skill")
+ assert "skill not found" in exc.message
+ assert ":" not in exc.message # No ID appended
+
+ def test_dependency_error_stores_dependency(self):
+ from openspace.domain.exceptions import DependencyError
+
+ exc = DependencyError("npm not found", dependency="npm")
+ assert exc.dependency == "npm"
+
+ def test_external_service_error_stores_service(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ exc = ExternalServiceError(
+ "timeout", service="cloud-api", status_code=504
+ )
+ assert exc.service == "cloud-api"
+ assert exc.status_code == 504
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Retryable Flag Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestRetryable:
+ """Retryable defaults are correct and overridable."""
+
+ def test_default_not_retryable(self):
+ from openspace.domain.exceptions import ValidationError
+
+ assert ValidationError("x").retryable is False
+
+ def test_timeout_default_retryable(self):
+ from openspace.domain.exceptions import OperationTimeoutError
+
+ assert OperationTimeoutError("x").retryable is True
+
+ def test_external_service_default_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ # No status_code → default retryable
+ assert ExternalServiceError("x").retryable is True
+
+ def test_external_service_4xx_not_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ assert ExternalServiceError("x", status_code=401).retryable is False
+
+ def test_external_service_any_4xx_not_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ # All 4xx (except 429) should be non-retryable
+ for code in [400, 402, 403, 404, 405, 406, 407, 408, 410, 411,
+ 412, 413, 414, 415, 416, 417, 418, 421, 422, 423,
+ 424, 425, 426, 428, 431, 451]:
+ exc = ExternalServiceError("x", status_code=code)
+ assert exc.retryable is False, f"status_code={code} should NOT be retryable"
+
+ def test_external_service_429_is_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ assert ExternalServiceError("x", status_code=429).retryable is True
+ assert ExternalServiceError("x", status_code=403).retryable is False
+ assert ExternalServiceError("x", status_code=404).retryable is False
+
+ def test_external_service_5xx_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ assert ExternalServiceError("x", status_code=500).retryable is True
+ assert ExternalServiceError("x", status_code=503).retryable is True
+ assert ExternalServiceError("x", status_code=429).retryable is True
+
+ def test_external_service_edge_case_status_codes(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ # Boundary: 399 is outside 4xx range → optimistic retry
+ assert ExternalServiceError("x", status_code=399).retryable is True
+ # Boundary: 500 is start of 5xx → retryable
+ assert ExternalServiceError("x", status_code=500).retryable is True
+ # Boundary: 599 is end of 5xx → retryable
+ assert ExternalServiceError("x", status_code=599).retryable is True
+ # Boundary: 600+ is unknown → optimistic retry
+ assert ExternalServiceError("x", status_code=600).retryable is True
+ # Boundary: 200 is success range → optimistic retry (shouldn't happen but safe)
+ assert ExternalServiceError("x", status_code=200).retryable is True
+
+ def test_override_retryable(self):
+ from openspace.domain.exceptions import ValidationError
+
+ exc = ValidationError("retry me", retryable=True)
+ assert exc.retryable is True
+
+ def test_override_non_retryable(self):
+ from openspace.domain.exceptions import OperationTimeoutError
+
+ exc = OperationTimeoutError("no retry", retryable=False)
+ assert exc.retryable is False
+
+ def test_external_service_override_retryable(self):
+ from openspace.domain.exceptions import ExternalServiceError
+
+ # Override: force retryable even on 401
+ exc = ExternalServiceError("x", status_code=401, retryable=True)
+ assert exc.retryable is True
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Client-Safe Message Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestClientMessage:
+ """safe_message / client_message handling."""
+
+ def test_client_message_defaults_to_generic(self):
+ from openspace.domain.exceptions import ExecutionError
+
+ exc = ExecutionError("internal details here")
+ # Without safe_message, client_message returns generic (never raw)
+ assert exc.client_message == "An internal error occurred"
+
+ def test_client_message_uses_safe_message(self):
+ from openspace.domain.exceptions import ExecutionError
+
+ exc = ExecutionError(
+ "NullPointerException at line 42",
+ safe_message="Task execution failed",
+ )
+ assert exc.client_message == "Task execution failed"
+ assert exc.message == "NullPointerException at line 42"
+
+ def test_to_safe_dict_redacts(self):
+ from openspace.domain.exceptions import ExecutionError
+
+ exc = ExecutionError(
+ "secret path /opt/secrets/key.pem",
+ safe_message="Task failed",
+ task_id="t-42",
+ )
+ safe = exc.to_safe_dict()
+ assert safe["message"] == "Task failed"
+ assert "context" not in safe # No context in safe dict
+ assert safe["error_code"] == "EXECUTION_ERROR"
+
+ def test_to_safe_dict_without_safe_message(self):
+ from openspace.domain.exceptions import ExecutionError
+
+ exc = ExecutionError("internal details")
+ safe = exc.to_safe_dict()
+ assert safe["message"] == "An internal error occurred"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Centralized Mapping Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestMapToMCPErrorCode:
+ """map_to_mcp_error_code() handles all exception types."""
+
+ def test_domain_exceptions_map_correctly(self):
+ from openspace.domain.exceptions import (
+ ConfigurationError,
+ DependencyError,
+ ExecutionError,
+ ExternalServiceError,
+ InternalError,
+ NotFoundError,
+ OperationTimeoutError,
+ PermissionDeniedError,
+ SandboxError,
+ ValidationError,
+ map_to_mcp_error_code,
+ )
+
+ assert map_to_mcp_error_code(ValidationError("x")) == "VALIDATION_ERROR"
+ assert map_to_mcp_error_code(ConfigurationError("x")) == "VALIDATION_ERROR"
+ assert map_to_mcp_error_code(NotFoundError("x")) == "SKILL_NOT_FOUND"
+ assert map_to_mcp_error_code(PermissionDeniedError("x")) == "PERMISSION_DENIED"
+ assert map_to_mcp_error_code(OperationTimeoutError("x")) == "TIMEOUT_ERROR"
+ assert map_to_mcp_error_code(ExecutionError("x")) == "EXECUTION_ERROR"
+ assert map_to_mcp_error_code(DependencyError("x")) == "EXECUTION_ERROR"
+ assert map_to_mcp_error_code(ExternalServiceError("x")) == "EXECUTION_ERROR"
+ assert map_to_mcp_error_code(SandboxError("x")) == "EXECUTION_ERROR"
+ assert map_to_mcp_error_code(InternalError("x")) == "INTERNAL_ERROR"
+
+ def test_unknown_exception_maps_to_internal(self):
+ from openspace.domain.exceptions import map_to_mcp_error_code
+
+ assert map_to_mcp_error_code(RuntimeError("oops")) == "INTERNAL_ERROR"
+ assert map_to_mcp_error_code(Exception("generic")) == "INTERNAL_ERROR"
+
+ def test_builtin_timeout_maps_to_timeout(self):
+ from openspace.domain.exceptions import map_to_mcp_error_code
+
+ assert map_to_mcp_error_code(TimeoutError("t")) == "TIMEOUT_ERROR"
+
+ def test_builtin_permission_maps_to_denied(self):
+ from openspace.domain.exceptions import map_to_mcp_error_code
+
+ assert map_to_mcp_error_code(PermissionError("p")) == "PERMISSION_DENIED"
+
+ def test_builtin_file_not_found_maps_to_not_found(self):
+ from openspace.domain.exceptions import map_to_mcp_error_code
+
+ assert map_to_mcp_error_code(FileNotFoundError("f")) == "SKILL_NOT_FOUND"
+
+ def test_builtin_value_error_maps_to_validation(self):
+ from openspace.domain.exceptions import map_to_mcp_error_code
+
+ assert map_to_mcp_error_code(ValueError("bad")) == "VALIDATION_ERROR"
+
+ def test_base_openspace_error_maps_to_internal(self):
+ from openspace.domain.exceptions import OpenSpaceError, map_to_mcp_error_code
+
+ assert map_to_mcp_error_code(OpenSpaceError("x")) == "INTERNAL_ERROR"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Integration with existing errors.py
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestIntegrationWithExistingErrors:
+ """Domain exceptions work with existing error helpers."""
+
+ def test_sanitize_error_handles_domain_exception(self):
+ from openspace.domain.exceptions import ExecutionError
+ from openspace.errors import sanitize_error
+
+ exc = ExecutionError("simple error message")
+ safe = sanitize_error(exc)
+ assert "simple error message" in safe
+
+ def test_handle_mcp_exception_with_domain_exception(self):
+ from openspace.domain.exceptions import ValidationError
+ from openspace.errors import handle_mcp_exception
+
+ import json
+
+ result = handle_mcp_exception(
+ ValidationError("bad input", safe_message="Invalid request"),
+ tool_name="execute_task",
+ error_code="VALIDATION_ERROR",
+ )
+ parsed = json.loads(result)
+ assert parsed["isError"] is True
+ assert parsed["error_code"] == "VALIDATION_ERROR"
+ assert parsed["message"] == "Invalid request" # Uses client_message
+ assert "correlation_id" in parsed
+
+ def test_handle_mcp_exception_prefers_domain_error_code(self):
+ from openspace.domain.exceptions import NotFoundError
+ from openspace.errors import handle_mcp_exception
+
+ import json
+
+ # Even though we pass error_code=EXECUTION_ERROR, the domain
+ # exception's error_code should win
+ result = handle_mcp_exception(
+ NotFoundError("skill", skill_id="abc"),
+ tool_name="fix_skill",
+ error_code="EXECUTION_ERROR",
+ )
+ parsed = json.loads(result)
+ assert parsed["error_code"] == "SKILL_NOT_FOUND"
+
+ def test_handle_mcp_exception_generic_fallback_without_safe_message(self):
+ from openspace.domain.exceptions import ExecutionError
+ from openspace.errors import handle_mcp_exception
+
+ import json
+
+ # Without safe_message, client_message returns generic fallback
+ result = handle_mcp_exception(
+ ExecutionError("secret internal stack trace here"),
+ tool_name="execute_task",
+ error_code="EXECUTION_ERROR",
+ )
+ parsed = json.loads(result)
+ assert parsed["isError"] is True
+ assert parsed["message"] == "An internal error occurred"
+ assert "secret" not in parsed["message"]
+
+ def test_handle_mcp_exception_maps_builtin_timeout(self):
+ from openspace.errors import handle_mcp_exception
+
+ import json
+
+ result = handle_mcp_exception(
+ TimeoutError("connection timed out"),
+ tool_name="call_api",
+ error_code="EXECUTION_ERROR", # caller passes generic
+ )
+ parsed = json.loads(result)
+ assert parsed["error_code"] == "TIMEOUT_ERROR" # centralized mapping wins
+
+ def test_handle_mcp_exception_maps_builtin_permission(self):
+ from openspace.errors import handle_mcp_exception
+
+ import json
+
+ result = handle_mcp_exception(
+ PermissionError("access denied"),
+ tool_name="read_file",
+ error_code="EXECUTION_ERROR",
+ )
+ parsed = json.loads(result)
+ assert parsed["error_code"] == "PERMISSION_DENIED"
+
+ def test_handle_mcp_exception_maps_builtin_value_error(self):
+ from openspace.errors import handle_mcp_exception
+
+ import json
+
+ result = handle_mcp_exception(
+ ValueError("invalid argument"),
+ tool_name="parse_input",
+ error_code="EXECUTION_ERROR",
+ )
+ parsed = json.loads(result)
+ assert parsed["error_code"] == "VALIDATION_ERROR"
diff --git a/tests/test_domain_types_protocols.py b/tests/test_domain_types_protocols.py
new file mode 100644
index 00000000..4de02513
--- /dev/null
+++ b/tests/test_domain_types_protocols.py
@@ -0,0 +1,620 @@
+"""Tests for EPIC 1.1 (Protocol Interfaces) and EPIC 1.2 (Domain Types).
+
+Validates:
+- All 13 Protocol interfaces are importable and runtime-checkable
+- All frozen domain types are truly immutable
+- Serialization round-trips work correctly
+- Enum consolidation preserves original values
+- Protocol structural compliance (concrete classes satisfy protocols)
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from dataclasses import FrozenInstanceError, replace
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+import pytest
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Protocol Import & Structural Tests (EPIC 1.1)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestProtocolImports:
+ """All 13 protocols must be importable and runtime-checkable."""
+
+ def test_all_protocols_importable(self):
+ from openspace.domain.ports import (
+ AgentExecutorPort,
+ AnalysisPort,
+ AuthPort,
+ CapabilityLeaseResolverPort,
+ CloudSkillPort,
+ LLMClientPort,
+ PolicyEnginePort,
+ SandboxPort,
+ SecretBrokerPort,
+ SkillEvolutionPort,
+ SkillStorePort,
+ TelemetryPort,
+ ToolBackendPort,
+ )
+
+ protocols = [
+ AgentExecutorPort,
+ AnalysisPort,
+ AuthPort,
+ CapabilityLeaseResolverPort,
+ CloudSkillPort,
+ LLMClientPort,
+ PolicyEnginePort,
+ SandboxPort,
+ SecretBrokerPort,
+ SkillEvolutionPort,
+ SkillStorePort,
+ TelemetryPort,
+ ToolBackendPort,
+ ]
+ assert len(protocols) == 13
+
+ def test_protocols_are_runtime_checkable(self):
+ from openspace.domain.ports import (
+ AgentExecutorPort,
+ AnalysisPort,
+ AuthPort,
+ CapabilityLeaseResolverPort,
+ CloudSkillPort,
+ LLMClientPort,
+ PolicyEnginePort,
+ SandboxPort,
+ SecretBrokerPort,
+ SkillEvolutionPort,
+ SkillStorePort,
+ TelemetryPort,
+ ToolBackendPort,
+ )
+
+ for proto in [
+ AgentExecutorPort,
+ AnalysisPort,
+ AuthPort,
+ CapabilityLeaseResolverPort,
+ CloudSkillPort,
+ LLMClientPort,
+ PolicyEnginePort,
+ SandboxPort,
+ SecretBrokerPort,
+ SkillEvolutionPort,
+ SkillStorePort,
+ TelemetryPort,
+ ToolBackendPort,
+ ]:
+ assert hasattr(proto, "__protocol_attrs__") or hasattr(
+ proto, "_is_runtime_protocol"
+ ), f"{proto.__name__} is not runtime_checkable"
+
+
+class TestProtocolCompliance:
+ """Verify concrete classes have the required methods.
+
+ Note: Some ports define a *domain-layer* signature that differs from
+ the concrete implementation (e.g. SkillStorePort uses SkillManifest,
+ but SkillStore uses SkillRecord). Phase 1.3 (AppContainer) will
+ introduce thin adapters to bridge the gap. These tests verify that
+ the concrete classes have the *method names* the port requires.
+ """
+
+ def test_skill_store_has_required_methods(self):
+ """SkillStore has the methods that SkillStorePort requires."""
+ from openspace.skill_engine.store import SkillStore
+
+ required_methods = [
+ "save_record",
+ "load_record",
+ "load_all",
+ "load_active",
+ "delete_record",
+ "count",
+ ]
+ for method in required_methods:
+ assert hasattr(SkillStore, method), (
+ f"SkillStore missing method: {method}"
+ )
+
+ def test_sandbox_has_required_methods(self):
+ """BaseSandbox has the methods that SandboxPort requires."""
+ from openspace.grounding.core.security.sandbox import BaseSandbox
+
+ required_methods = ["start", "stop", "execute_safe"]
+ for method in required_methods:
+ assert hasattr(BaseSandbox, method), (
+ f"BaseSandbox missing method: {method}"
+ )
+
+ def test_telemetry_has_required_methods(self):
+ """Telemetry has capture/flush/shutdown (adapter bridges signature)."""
+ try:
+ from openspace.utils.telemetry.telemetry import Telemetry
+ except ImportError:
+ pytest.skip("Telemetry module not available")
+
+ required_methods = ["capture", "flush", "shutdown"]
+ for method in required_methods:
+ assert hasattr(Telemetry, method), (
+ f"Telemetry missing method: {method}"
+ )
+
+ def test_llm_client_has_complete(self):
+ """LLMClient has the complete method."""
+ try:
+ from openspace.llm.client import LLMClient
+ except ImportError:
+ pytest.skip("LLMClient not importable (litellm version issue)")
+
+ assert hasattr(LLMClient, "complete"), "LLMClient missing 'complete'"
+
+ def test_policy_engine_has_required_methods(self):
+ """SecurityPolicyManager has the methods PolicyEnginePort requires."""
+ from openspace.grounding.core.security.policies import SecurityPolicyManager
+
+ required_methods = [
+ "check_command_allowed",
+ "check_domain_allowed",
+ "get_policy",
+ ]
+ for method in required_methods:
+ assert hasattr(SecurityPolicyManager, method), (
+ f"SecurityPolicyManager missing: {method}"
+ )
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Frozen Domain Types Tests (EPIC 1.2)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestTaskTypes:
+ """TaskRequest and TaskResult are frozen and serializable."""
+
+ def test_task_request_frozen(self):
+ from openspace.domain.types import TaskRequest
+
+ req = TaskRequest(task="test task", task_id="t1")
+ with pytest.raises(FrozenInstanceError):
+ req.task = "mutated" # type: ignore[misc]
+
+ def test_task_request_from_dict(self):
+ from openspace.domain.types import TaskRequest
+
+ data = {
+ "task": "do something",
+ "task_id": "t42",
+ "workspace_dir": "/tmp",
+ "max_iterations": 5,
+ "search_scope": "local",
+ "skill_dirs": ["/skills/a", "/skills/b"],
+ "context": {"key": "value"},
+ }
+ req = TaskRequest.from_dict(data)
+ assert req.task == "do something"
+ assert req.task_id == "t42"
+ assert req.max_iterations == 5
+ assert req.skill_dirs == ("/skills/a", "/skills/b")
+ assert req.context_dict == {"key": "value"}
+
+ def test_task_request_deep_freezes_nested_context(self):
+ from openspace.domain.types import TaskRequest
+
+ data = {
+ "task": "test",
+ "context": {
+ "nested": {"a": [1, 2, 3]},
+ "list_val": [{"x": 1}],
+ },
+ }
+ req = TaskRequest.from_dict(data)
+ # Nested dicts become tuples of tuples, lists become tuples
+ # The entire structure should be hashable (deeply frozen)
+ assert isinstance(req.context, tuple)
+ for key, val in req.context:
+ assert isinstance(val, tuple), f"Value for {key} not frozen: {type(val)}"
+
+ def test_task_result_frozen(self):
+ from openspace.domain.types import TaskResult
+
+ result = TaskResult(task_id="t1", status="success", response="done")
+ with pytest.raises(FrozenInstanceError):
+ result.status = "error" # type: ignore[misc]
+
+ def test_task_result_ok_property(self):
+ from openspace.domain.types import TaskResult
+
+ ok = TaskResult(task_id="t1", status="success")
+ fail = TaskResult(task_id="t2", status="error")
+ assert ok.ok is True
+ assert fail.ok is False
+
+ def test_task_result_roundtrip(self):
+ from openspace.domain.types import TaskResult, ToolExecution
+
+ original = TaskResult(
+ task_id="t1",
+ status="success",
+ response="done",
+ execution_time=1.5,
+ iterations=3,
+ skills_used=("skill-a",),
+ evolved_skills=("skill-b",),
+ tool_executions=(
+ ToolExecution(
+ tool_name="bash",
+ arguments=(("cmd", "ls"),),
+ status="success",
+ duration_ms=42.0,
+ ),
+ ToolExecution(
+ tool_name="read_file",
+ arguments=(("path", "/tmp/f"),),
+ status="error",
+ duration_ms=1.0,
+ error="not found",
+ ),
+ ),
+ warnings=("w1",),
+ )
+ d = original.to_dict()
+ restored = TaskResult.from_dict(d)
+ assert restored.task_id == original.task_id
+ assert restored.status == original.status
+ assert restored.skills_used == original.skills_used
+ assert restored.evolved_skills == original.evolved_skills
+ assert len(restored.tool_executions) == 2
+ assert restored.tool_executions[0].tool_name == "bash"
+ assert restored.tool_executions[0].duration_ms == 42.0
+ assert restored.tool_executions[1].error == "not found"
+
+ def test_task_result_replace(self):
+ from openspace.domain.types import TaskResult
+
+ original = TaskResult(task_id="t1", status="error", error="boom")
+ fixed = replace(original, status="success", error=None)
+ assert fixed.status == "success"
+ assert fixed.error is None
+ assert original.status == "error" # Original unchanged
+
+
+class TestSkillTypes:
+ """SkillIdentity and SkillManifest are frozen."""
+
+ def test_skill_identity_hashable(self):
+ from openspace.domain.types import SkillIdentity
+
+ s1 = SkillIdentity(skill_id="s1", name="Skill One")
+ s2 = SkillIdentity(skill_id="s1", name="Skill One Modified")
+ # Same skill_id → same hash
+ assert hash(s1) == hash(s2)
+ # Can be used in sets
+ skills = {s1, s2}
+ assert len(skills) == 2 # Different objects (frozen, full eq)
+
+ def test_skill_identity_frozen(self):
+ from openspace.domain.types import SkillIdentity
+
+ s = SkillIdentity(skill_id="s1", name="test")
+ with pytest.raises(FrozenInstanceError):
+ s.name = "mutated" # type: ignore[misc]
+
+ def test_skill_manifest_frozen(self):
+ from openspace.domain.types import SkillManifest
+
+ m = SkillManifest(
+ skill_id="s1",
+ name="Test",
+ description="A test skill",
+ tags=("tag1", "tag2"),
+ )
+ with pytest.raises(FrozenInstanceError):
+ m.is_active = False # type: ignore[misc]
+
+ def test_skill_manifest_effective_rate(self):
+ from openspace.domain.types import SkillManifest
+
+ zero = SkillManifest(
+ skill_id="s1", name="t", description="d", total_selections=0
+ )
+ assert zero.effective_rate == 0.0
+
+ active = SkillManifest(
+ skill_id="s2",
+ name="t",
+ description="d",
+ total_selections=10,
+ total_applied=7,
+ )
+ assert active.effective_rate == pytest.approx(0.7)
+
+ def test_skill_manifest_to_dict(self):
+ from openspace.domain.types import SkillManifest
+
+ now = datetime.now()
+ m = SkillManifest(
+ skill_id="s1",
+ name="Test",
+ description="desc",
+ tags=("a", "b"),
+ first_seen=now,
+ )
+ d = m.to_dict()
+ assert d["skill_id"] == "s1"
+ assert d["tags"] == ["a", "b"]
+ assert d["first_seen"] == now.isoformat()
+
+
+class TestEvolutionTypes:
+ """EvolutionRequest and EvolutionResult are frozen."""
+
+ def test_evolution_request_frozen(self):
+ from openspace.domain.types import EvolutionRequest
+
+ req = EvolutionRequest(
+ evolution_type="fix", trigger="analysis", target_skill_ids=("s1",)
+ )
+ with pytest.raises(FrozenInstanceError):
+ req.direction = "mutated" # type: ignore[misc]
+
+ def test_evolution_result_frozen(self):
+ from openspace.domain.types import EvolutionResult
+
+ res = EvolutionResult(
+ success=True,
+ evolved_skill_id="s2",
+ evolution_type="fix",
+ )
+ with pytest.raises(FrozenInstanceError):
+ res.success = False # type: ignore[misc]
+
+
+class TestAnalysisTypes:
+ """Analysis snapshots are frozen."""
+
+ def test_execution_analysis_snapshot_frozen(self):
+ from openspace.domain.types import ExecutionAnalysisSnapshot
+
+ snap = ExecutionAnalysisSnapshot(
+ task_id="t1",
+ timestamp=datetime.now(),
+ task_completed=True,
+ tool_issues=("issue1",),
+ )
+ with pytest.raises(FrozenInstanceError):
+ snap.task_completed = False # type: ignore[misc]
+
+ def test_analysis_snapshot_nested_immutability(self):
+ from openspace.domain.types import (
+ ExecutionAnalysisSnapshot,
+ SkillJudgmentSnapshot,
+ )
+
+ snap = ExecutionAnalysisSnapshot(
+ task_id="t1",
+ timestamp=datetime.now(),
+ skill_judgments=(
+ SkillJudgmentSnapshot(skill_id="s1", skill_applied=True),
+ ),
+ )
+ assert snap.skill_judgments[0].skill_applied is True
+ with pytest.raises(FrozenInstanceError):
+ snap.skill_judgments[0].skill_applied = False # type: ignore[misc]
+
+
+class TestSearchTypes:
+ """Search result types are frozen."""
+
+ def test_search_result_frozen(self):
+ from openspace.domain.types import SkillSearchResult
+
+ r = SkillSearchResult(
+ skill_id="s1", name="Test", description="desc", score=0.9
+ )
+ with pytest.raises(FrozenInstanceError):
+ r.score = 0.1 # type: ignore[misc]
+
+ def test_search_response_frozen(self):
+ from openspace.domain.types import SkillSearchResponse, SkillSearchResult
+
+ resp = SkillSearchResponse(
+ query="test",
+ results=(
+ SkillSearchResult(
+ skill_id="s1", name="Test", description="d", score=0.9
+ ),
+ ),
+ total_count=1,
+ )
+ assert len(resp.results) == 1
+ with pytest.raises(FrozenInstanceError):
+ resp.total_count = 99 # type: ignore[misc]
+
+
+class TestSecurityTypes:
+ """Sandbox policy and capability lease are frozen."""
+
+ def test_sandbox_policy_frozen(self):
+ from openspace.domain.types import SandboxPolicy
+
+ p = SandboxPolicy(sandbox_enabled=True, trust_tier="basic")
+ with pytest.raises(FrozenInstanceError):
+ p.sandbox_enabled = False # type: ignore[misc]
+
+ def test_capability_lease_frozen(self):
+ from openspace.domain.types import CapabilityLease
+
+ lease = CapabilityLease(
+ lease_id="L1",
+ capability="fs.read",
+ granted_to="task-42",
+ trust_tier="standard",
+ )
+ with pytest.raises(FrozenInstanceError):
+ lease.revoked = True # type: ignore[misc]
+
+
+class TestToolTypes:
+ """Tool descriptor and call result are frozen."""
+
+ def test_tool_descriptor_frozen(self):
+ from openspace.domain.types import ToolDescriptor
+
+ t = ToolDescriptor(name="bash", description="Run shell commands")
+ with pytest.raises(FrozenInstanceError):
+ t.name = "changed" # type: ignore[misc]
+
+ def test_tool_call_result_frozen(self):
+ from openspace.domain.types import ToolCallResult
+
+ r = ToolCallResult(status="success", content="output")
+ with pytest.raises(FrozenInstanceError):
+ r.content = "mutated" # type: ignore[misc]
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Enum Consolidation Tests (EPIC 1.2, Issue #62)
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestEnumConsolidation:
+ """New enums exist and re-exports preserve original values."""
+
+ def test_re_exported_enums_match_originals(self):
+ """Re-exported enums are the exact same objects."""
+ from openspace.domain.enums import EvolutionType as DomainEvType
+ from openspace.skill_engine.types import EvolutionType as OrigEvType
+
+ assert DomainEvType is OrigEvType
+
+ from openspace.domain.enums import BackendType as DomainBT
+ from openspace.grounding.core.types import BackendType as OrigBT
+
+ assert DomainBT is OrigBT
+
+ from openspace.domain.enums import SkillCategory as DomainSC
+ from openspace.skill_engine.types import SkillCategory as OrigSC
+
+ assert DomainSC is OrigSC
+
+ def test_new_task_status_enum(self):
+ from openspace.domain.enums import TaskStatus
+
+ assert TaskStatus.SUCCESS.value == "success"
+ assert TaskStatus.FAILED.value == "failed"
+ assert TaskStatus.RUNNING.value == "running"
+
+ def test_new_search_scope_enum(self):
+ from openspace.domain.enums import SearchScope
+
+ assert SearchScope.ALL.value == "all"
+ assert SearchScope.LOCAL.value == "local"
+ assert SearchScope.CLOUD.value == "cloud"
+
+ def test_new_trust_tier_enum(self):
+ from openspace.domain.enums import TrustTier
+
+ assert TrustTier.UNTRUSTED.value == "untrusted"
+ assert TrustTier.PRIVILEGED.value == "privileged"
+
+ def test_new_skill_status_enum(self):
+ from openspace.domain.enums import SkillStatus
+
+ assert SkillStatus.ACTIVE.value == "active"
+ assert SkillStatus.DEPRECATED.value == "deprecated"
+
+ def test_new_mcp_error_code_enum(self):
+ from openspace.domain.enums import MCPErrorCode
+
+ assert MCPErrorCode.EXECUTION_ERROR.value == "EXECUTION_ERROR"
+ assert MCPErrorCode.PERMISSION_DENIED.value == "PERMISSION_DENIED"
+
+ def test_search_mode_enum(self):
+ from openspace.domain.enums import SearchMode
+
+ assert SearchMode.HYBRID.value == "hybrid"
+ assert SearchMode.SEMANTIC.value == "semantic"
+
+ def test_patch_type_enum(self):
+ from openspace.domain.enums import PatchType
+
+ assert PatchType.AUTO.value == "auto"
+ assert PatchType.DIFF.value == "diff"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Deep-freeze helper tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestDeepFreeze:
+ """The _deep_freeze helper converts mutable containers recursively."""
+
+ def test_deep_freeze_dict(self):
+ from openspace.domain.types import _deep_freeze
+
+ result = _deep_freeze({"a": 1, "b": [2, 3]})
+ assert isinstance(result, tuple)
+ assert result == (("a", 1), ("b", (2, 3)))
+
+ def test_deep_freeze_nested(self):
+ from openspace.domain.types import _deep_freeze
+
+ result = _deep_freeze({"outer": {"inner": [1, {"deep": True}]}})
+ assert isinstance(result, tuple)
+ # outer → (outer, (inner, (1, (deep, True))))
+ key, val = result[0]
+ assert key == "outer"
+ assert isinstance(val, tuple)
+
+ def test_deep_freeze_set(self):
+ from openspace.domain.types import _deep_freeze
+
+ result = _deep_freeze({1, 2, 3})
+ assert isinstance(result, frozenset)
+
+ def test_deep_freeze_scalar(self):
+ from openspace.domain.types import _deep_freeze
+
+ assert _deep_freeze(42) == 42
+ assert _deep_freeze("hello") == "hello"
+ assert _deep_freeze(None) is None
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# All-frozen invariant test
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestAllTypesFrozen:
+ """Every dataclass in domain.types MUST be frozen."""
+
+ def test_all_domain_types_are_frozen(self):
+ import openspace.domain.types as mod
+
+ for name in mod.__all__:
+ cls = getattr(mod, name)
+ if dataclasses.is_dataclass(cls):
+ assert dataclasses.fields(cls)[0].default is not dataclasses.MISSING or True
+ # Check frozen flag
+ assert cls.__dataclass_params__.frozen, ( # type: ignore[attr-defined]
+ f"{name} is not frozen!"
+ )
+
+ def test_all_domain_types_use_slots(self):
+ import openspace.domain.types as mod
+
+ for name in mod.__all__:
+ cls = getattr(mod, name)
+ if dataclasses.is_dataclass(cls):
+ assert cls.__dataclass_params__.slots, ( # type: ignore[attr-defined]
+ f"{name} does not use slots!"
+ )
diff --git a/tests/test_e2b_sandbox_hardening.py b/tests/test_e2b_sandbox_hardening.py
new file mode 100644
index 00000000..04d8d95c
--- /dev/null
+++ b/tests/test_e2b_sandbox_hardening.py
@@ -0,0 +1,491 @@
+"""Tests for EPIC 0.2: E2B Sandbox Hardening.
+
+Covers:
+- #4: Sandbox enforcement (not optional bypass)
+- #5: Config from env/config only (no user-supplied API keys)
+- #6: Fallback behavior must be deny (not allow)
+- #7: Documentation validation
+- #8: Integration test for sandbox creation path
+"""
+
+import ast
+import json
+import os
+import re
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+# ---------------------------------------------------------------------------
+# Issue #4: Sandbox must be enforced, not optional bypass
+# ---------------------------------------------------------------------------
+
+class TestSandboxEnforcement:
+ """Sandbox defaults must be True everywhere."""
+
+ def test_mcp_config_defaults_sandbox_true(self):
+ """MCPConfig.sandbox defaults to True."""
+ from openspace.config.grounding import MCPConfig
+ cfg = MCPConfig()
+ assert cfg.sandbox is True, "MCPConfig.sandbox must default to True"
+
+ def test_config_grounding_json_sandbox_true(self):
+ """config_grounding.json ships with sandbox: true."""
+ config_path = ROOT / "openspace" / "config" / "config_grounding.json"
+ data = json.loads(config_path.read_text())
+ assert data["mcp"]["sandbox"] is True, (
+ "config_grounding.json mcp.sandbox must be true"
+ )
+
+ def test_config_security_json_sandbox_enabled(self):
+ """config_security.json ships with sandbox_enabled: true globally."""
+ config_path = ROOT / "openspace" / "config" / "config_security.json"
+ data = json.loads(config_path.read_text())
+ policies = data["security_policies"]
+ assert policies["global"]["sandbox_enabled"] is True, (
+ "Global sandbox_enabled must be true"
+ )
+ assert policies["backend"]["shell"]["sandbox_enabled"] is True, (
+ "Shell sandbox_enabled must be true"
+ )
+ assert policies["backend"]["mcp"]["sandbox_enabled"] is True, (
+ "MCP sandbox_enabled must be true"
+ )
+
+ def test_mcp_client_constructor_defaults_sandbox_true(self):
+ """MCPClient.__init__ defaults sandbox=True."""
+ import inspect
+ from openspace.grounding.backends.mcp.client import MCPClient
+ sig = inspect.signature(MCPClient.__init__)
+ default = sig.parameters["sandbox"].default
+ assert default is True, f"MCPClient sandbox default must be True, got {default}"
+
+ def test_mcp_client_from_dict_defaults_sandbox_true(self):
+ """MCPClient.from_dict defaults sandbox=True."""
+ import inspect
+ from openspace.grounding.backends.mcp.client import MCPClient
+ sig = inspect.signature(MCPClient.from_dict)
+ default = sig.parameters["sandbox"].default
+ assert default is True, f"from_dict sandbox default must be True, got {default}"
+
+ def test_mcp_client_from_config_file_defaults_sandbox_true(self):
+ """MCPClient.from_config_file defaults sandbox=True."""
+ import inspect
+ from openspace.grounding.backends.mcp.client import MCPClient
+ sig = inspect.signature(MCPClient.from_config_file)
+ default = sig.parameters["sandbox"].default
+ assert default is True, f"from_config_file sandbox default must be True, got {default}"
+
+ def test_create_connector_defaults_sandbox_true(self):
+ """create_connector_from_config defaults sandbox=True."""
+ import inspect
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+ sig = inspect.signature(create_connector_from_config)
+ default = sig.parameters["sandbox"].default
+ assert default is True, f"create_connector sandbox default must be True, got {default}"
+
+ def test_provider_extracts_sandbox_default_true(self):
+ """MCPProvider reads sandbox with default True."""
+ source = (ROOT / "openspace" / "grounding" / "backends" / "mcp" / "provider.py").read_text()
+ # Should have: get_config_value(config, "sandbox", True)
+ assert 'get_config_value(config, "sandbox", True)' in source, (
+ "Provider must default sandbox to True via get_config_value"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Issue #5: Config from env/config only, no user-supplied API keys
+# ---------------------------------------------------------------------------
+
+class TestConfigSourceRestriction:
+ """Sandbox config must come from trusted sources only."""
+
+ def test_e2b_sandbox_ignores_caller_api_key(self):
+ """E2BSandbox reads API key from env only, not options."""
+ source = (
+ ROOT / "openspace" / "grounding" / "core" / "security" / "e2b_sandbox.py"
+ ).read_text()
+ # Must NOT contain options.get("api_key")
+ assert 'options.get("api_key")' not in source, (
+ "E2BSandbox must not accept api_key from caller options"
+ )
+ # Must use os.environ.get("E2B_API_KEY")
+ assert 'os.environ.get("E2B_API_KEY")' in source, (
+ "E2BSandbox must read API key from E2B_API_KEY env var"
+ )
+
+ def test_trusted_sandbox_options_strips_api_key(self):
+ """_build_trusted_sandbox_options strips api_key from caller input."""
+ from openspace.grounding.backends.mcp.config import _build_trusted_sandbox_options
+ caller_options = {
+ "api_key": "EVIL_INJECTED_KEY",
+ "timeout": 120,
+ "sandbox_template_id": "custom",
+ "arbitrary_field": "should_be_dropped",
+ }
+ result = _build_trusted_sandbox_options(caller_options, 30.0, 300.0)
+ assert "api_key" not in result, "api_key must be stripped"
+ assert result["timeout"] == 120, "Trusted timeout should pass through"
+ assert result["sandbox_template_id"] == "custom", "Template ID should pass through"
+ assert "arbitrary_field" not in result, "Unknown fields must be dropped"
+
+ def test_trusted_sandbox_options_with_none_input(self):
+ """_build_trusted_sandbox_options handles None caller options."""
+ from openspace.grounding.backends.mcp.config import _build_trusted_sandbox_options
+ result = _build_trusted_sandbox_options(None, 30.0, 300.0)
+ assert result["timeout"] == 30.0
+ assert result["sse_read_timeout"] == 300.0
+
+
+# ---------------------------------------------------------------------------
+# Issue #6: Fallback behavior must be deny, not allow
+# ---------------------------------------------------------------------------
+
+class TestFailClosedBehavior:
+ """All failure modes must deny execution, not silently allow."""
+
+ @pytest.mark.asyncio
+ async def test_unsandboxed_stdio_denied_by_default(self):
+ """Stdio without sandbox raises RuntimeError."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+
+ config = {"command": "python", "args": ["-m", "some_server"]}
+ with patch.dict(os.environ, {}, clear=False):
+ # Ensure OPENSPACE_ALLOW_UNSANDBOXED is not set
+ os.environ.pop("OPENSPACE_ALLOW_UNSANDBOXED", None)
+ with pytest.raises(RuntimeError, match="Unsandboxed stdio execution denied"):
+ await create_connector_from_config(
+ config, server_name="test", sandbox=False,
+ check_dependencies=False,
+ )
+
+ @pytest.mark.asyncio
+ async def test_unsandboxed_stdio_allowed_with_explicit_opt_out(self):
+ """Stdio without sandbox works only with OPENSPACE_ALLOW_UNSANDBOXED=1."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+ from openspace.grounding.backends.mcp.transport.connectors import StdioConnector
+
+ config = {"command": "python", "args": ["-m", "some_server"]}
+ with patch.dict(os.environ, {"OPENSPACE_ALLOW_UNSANDBOXED": "1"}):
+ connector = await create_connector_from_config(
+ config, server_name="test", sandbox=False,
+ check_dependencies=False,
+ )
+ assert isinstance(connector, StdioConnector)
+
+ @pytest.mark.asyncio
+ async def test_unsandboxed_partial_value_still_denied(self):
+ """OPENSPACE_ALLOW_UNSANDBOXED must be exactly '1'."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+
+ config = {"command": "python", "args": ["-m", "some_server"]}
+ for bad_value in ["true", "yes", "0", "", " "]:
+ with patch.dict(os.environ, {"OPENSPACE_ALLOW_UNSANDBOXED": bad_value}):
+ with pytest.raises(RuntimeError, match="Unsandboxed stdio execution denied"):
+ await create_connector_from_config(
+ config, server_name="test", sandbox=False,
+ check_dependencies=False,
+ )
+
+ @pytest.mark.asyncio
+ async def test_sandbox_required_but_e2b_unavailable_raises(self):
+ """When sandbox=True but E2B not installed, raises ImportError."""
+ from openspace.grounding.backends.mcp import config as cfg_module
+ from openspace.grounding.backends.mcp.config import create_connector_from_config as _create
+
+ config = {"command": "python", "args": ["-m", "some_server"]}
+ original = cfg_module.E2B_AVAILABLE
+ try:
+ cfg_module.E2B_AVAILABLE = False
+ with pytest.raises(ImportError, match="E2B sandbox support not available"):
+ await _create(
+ config, server_name="test", sandbox=True,
+ check_dependencies=False,
+ )
+ finally:
+ cfg_module.E2B_AVAILABLE = original
+
+ def test_config_loader_fails_closed_on_invalid_config(self):
+ """Config loader raises on validation failure, not silently defaults."""
+ source = (ROOT / "openspace" / "config" / "loader.py").read_text()
+ # Must NOT contain "using default configuration"
+ assert "using default configuration" not in source, (
+ "Config loader must not silently fall back to defaults"
+ )
+ # Must raise RuntimeError on validation failure
+ assert "raise RuntimeError" in source, (
+ "Config loader must raise on validation failure"
+ )
+
+ def test_security_config_is_critical(self):
+ """Security config file is loaded with critical=True."""
+ source = (ROOT / "openspace" / "config" / "loader.py").read_text()
+ assert "CONFIG_SECURITY" in source
+ assert "_CRITICAL_CONFIG_FILES" in source
+ assert "critical_files" in source, (
+ "Config loader must pass critical_files for security config"
+ )
+
+ @pytest.mark.asyncio
+ async def test_sandbox_enforcement_before_ensure_dependencies(self):
+ """Sandbox enforcement must happen BEFORE ensure_dependencies."""
+ source = (
+ ROOT / "openspace" / "grounding" / "backends" / "mcp" / "config.py"
+ ).read_text()
+ # Find positions of key operations
+ sandbox_check_pos = source.find("Sandbox enforcement BEFORE")
+ deps_check_pos = source.find("ensure_dependencies")
+ assert sandbox_check_pos != -1, "Sandbox enforcement comment must exist"
+ assert deps_check_pos != -1, "ensure_dependencies must exist"
+ assert sandbox_check_pos < deps_check_pos, (
+ "Sandbox enforcement must come before ensure_dependencies"
+ )
+
+ @pytest.mark.asyncio
+ async def test_unsandboxed_denied_before_deps_installed(self):
+ """Unsandboxed stdio denied before any host-side install runs."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+
+ install_called = False
+
+ async def mock_ensure_deps(*a, **kw):
+ nonlocal install_called
+ install_called = True
+
+ config = {"command": "python", "args": ["-m", "server"]}
+ with patch.dict(os.environ, {}, clear=False):
+ os.environ.pop("OPENSPACE_ALLOW_UNSANDBOXED", None)
+ with patch(
+ "openspace.grounding.backends.mcp.installer.MCPInstallerManager.ensure_dependencies",
+ side_effect=mock_ensure_deps,
+ ):
+ with pytest.raises(RuntimeError, match="Unsandboxed stdio execution denied"):
+ await create_connector_from_config(
+ config, server_name="test", sandbox=False,
+ check_dependencies=True,
+ )
+ assert not install_called, "ensure_dependencies must NOT run before sandbox denial"
+
+ def test_no_sandbox_false_defaults_in_config_json(self):
+ """No config JSON file ships with sandbox disabled."""
+ config_dir = ROOT / "openspace" / "config"
+ for json_file in config_dir.glob("*.json"):
+ data = json.loads(json_file.read_text())
+ self._check_no_sandbox_false(data, str(json_file))
+
+ def _check_no_sandbox_false(self, obj, path, key_path=""):
+ if isinstance(obj, dict):
+ for k, v in obj.items():
+ current = f"{key_path}.{k}" if key_path else k
+ if k in ("sandbox", "sandbox_enabled") and v is False:
+ pytest.fail(
+ f"{path} has {current}=false — sandbox must be enabled"
+ )
+ self._check_no_sandbox_false(v, path, current)
+ elif isinstance(obj, list):
+ for i, item in enumerate(obj):
+ self._check_no_sandbox_false(item, path, f"{key_path}[{i}]")
+
+
+# ---------------------------------------------------------------------------
+# Issue #7: Documentation validation
+# ---------------------------------------------------------------------------
+
+class TestSandboxDocumentation:
+ """Sandbox env/config must be properly documented."""
+
+ def test_env_example_documents_e2b_api_key_required(self):
+ """E2B_API_KEY is documented as required in .env.example."""
+ env_example = (ROOT / "openspace" / ".env.example").read_text()
+ assert "E2B_API_KEY" in env_example
+ # Must not say "Optional"
+ lines_around = [
+ line for line in env_example.splitlines()
+ if "E2B" in line.upper() or "sandbox" in line.lower()
+ ]
+ text = "\n".join(lines_around).lower()
+ assert "optional" not in text or "not recommended" in text, (
+ ".env.example should not describe E2B as merely optional"
+ )
+
+ def test_env_example_documents_unsandboxed_opt_out(self):
+ """OPENSPACE_ALLOW_UNSANDBOXED is documented."""
+ env_example = (ROOT / "openspace" / ".env.example").read_text()
+ assert "OPENSPACE_ALLOW_UNSANDBOXED" in env_example
+
+ def test_config_readme_documents_sandbox(self):
+ """config/README.md has E2B sandbox section."""
+ readme = (ROOT / "openspace" / "config" / "README.md").read_text()
+ assert "E2B Sandbox" in readme, "README must have E2B sandbox section"
+ assert "E2B_API_KEY" in readme, "README must document E2B_API_KEY"
+ assert "OPENSPACE_ALLOW_UNSANDBOXED" in readme, (
+ "README must document OPENSPACE_ALLOW_UNSANDBOXED"
+ )
+ assert "Fail-closed" in readme or "fail-closed" in readme.lower(), (
+ "README must document fail-closed behavior"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Issue #8: Integration test for sandbox creation path
+# ---------------------------------------------------------------------------
+
+class TestSandboxCreationIntegration:
+ """Integration tests proving the sandbox creation path works end-to-end."""
+
+ @pytest.mark.asyncio
+ async def test_stdio_config_creates_sandbox_connector(self):
+ """Stdio MCP config with sandbox=True creates SandboxConnector."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+ from openspace.grounding.backends.mcp.transport.connectors import SandboxConnector
+
+ mock_e2b = MagicMock()
+ config = {"command": "python", "args": ["-m", "server"]}
+
+ with patch(
+ "openspace.grounding.backends.mcp.config.E2BSandbox",
+ return_value=mock_e2b,
+ ), patch(
+ "openspace.grounding.backends.mcp.config.E2B_AVAILABLE", True
+ ):
+ connector = await create_connector_from_config(
+ config, server_name="test", sandbox=True,
+ check_dependencies=False,
+ )
+ assert isinstance(connector, SandboxConnector), (
+ f"Expected SandboxConnector, got {type(connector).__name__}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_sandbox_connector_receives_filtered_env(self):
+ """SandboxConnector only gets ENV_ALLOWLIST vars."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+
+ mock_e2b = MagicMock()
+ config = {"command": "python", "args": ["-m", "server"]}
+
+ with patch(
+ "openspace.grounding.backends.mcp.config.E2BSandbox",
+ return_value=mock_e2b,
+ ), patch(
+ "openspace.grounding.backends.mcp.config.E2B_AVAILABLE", True
+ ), patch.dict(os.environ, {
+ "SECRET_KEY": "leaked",
+ "PATH": "/usr/bin",
+ }):
+ connector = await create_connector_from_config(
+ config, server_name="test", sandbox=True,
+ check_dependencies=False,
+ )
+ # SandboxConnector filters env in __init__
+ if hasattr(connector, 'user_env') and connector.user_env:
+ assert "SECRET_KEY" not in connector.user_env, (
+ "SECRET_KEY must not leak into sandbox"
+ )
+
+ @pytest.mark.asyncio
+ async def test_http_config_unaffected_by_sandbox_enforcement(self):
+ """HTTP-based MCP servers are unaffected by sandbox enforcement."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+ from openspace.grounding.backends.mcp.transport.connectors import HttpConnector
+
+ config = {"url": "http://localhost:8080"}
+ connector = await create_connector_from_config(
+ config, server_name="test", sandbox=True,
+ check_dependencies=False,
+ )
+ assert isinstance(connector, HttpConnector)
+
+ @pytest.mark.asyncio
+ async def test_websocket_config_unaffected_by_sandbox_enforcement(self):
+ """WebSocket-based MCP servers are unaffected by sandbox enforcement."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+ from openspace.grounding.backends.mcp.transport.connectors import WebSocketConnector
+
+ config = {"ws_url": "ws://localhost:8080"}
+ connector = await create_connector_from_config(
+ config, server_name="test", sandbox=True,
+ check_dependencies=False,
+ )
+ assert isinstance(connector, WebSocketConnector)
+
+ @pytest.mark.asyncio
+ async def test_e2b_sandbox_constructed_with_trusted_options(self):
+ """E2BSandbox receives options WITHOUT api_key from caller."""
+ from openspace.grounding.backends.mcp.config import create_connector_from_config
+
+ captured_options = {}
+ def mock_e2b_init(options):
+ captured_options.update(options)
+ return MagicMock()
+
+ config = {"command": "python", "args": ["-m", "server"]}
+ caller_options = {
+ "api_key": "SHOULD_BE_STRIPPED",
+ "timeout": 120,
+ "sandbox_template_id": "custom-template",
+ }
+
+ with patch(
+ "openspace.grounding.backends.mcp.config.E2BSandbox",
+ side_effect=mock_e2b_init,
+ ), patch(
+ "openspace.grounding.backends.mcp.config.E2B_AVAILABLE", True
+ ):
+ await create_connector_from_config(
+ config, server_name="test", sandbox=True,
+ sandbox_options=caller_options,
+ check_dependencies=False,
+ )
+ assert "api_key" not in captured_options, (
+ "api_key must not reach E2BSandbox from caller options"
+ )
+ assert captured_options.get("timeout") == 120
+ assert captured_options.get("sandbox_template_id") == "custom-template"
+
+
+# ---------------------------------------------------------------------------
+# Regression: AST scan for sandbox=False defaults in production code
+# ---------------------------------------------------------------------------
+
+class TestNoSandboxFalseInProduction:
+ """Regression test: no production code should default sandbox to False."""
+
+ PRODUCTION_DIRS = [
+ ROOT / "openspace" / "grounding" / "backends" / "mcp",
+ ROOT / "openspace" / "config",
+ ]
+
+ def test_no_sandbox_false_keyword_defaults(self):
+ """No function signature in MCP backend defaults sandbox to False."""
+ violations = []
+ for prod_dir in self.PRODUCTION_DIRS:
+ for py_file in prod_dir.rglob("*.py"):
+ try:
+ source = py_file.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ continue
+ tree = ast.parse(source, filename=str(py_file))
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ for arg, default in zip(
+ reversed(node.args.args),
+ reversed(node.args.defaults),
+ ):
+ if (
+ arg.arg == "sandbox"
+ and isinstance(default, ast.Constant)
+ and default.value is False
+ ):
+ violations.append(
+ f"{py_file.relative_to(ROOT)}:{node.lineno} "
+ f"def {node.name}(sandbox=False)"
+ )
+ assert not violations, (
+ f"Found sandbox=False defaults in production code:\n"
+ + "\n".join(violations)
+ )
diff --git a/tests/test_fs_broker.py b/tests/test_fs_broker.py
new file mode 100644
index 00000000..eecfda70
--- /dev/null
+++ b/tests/test_fs_broker.py
@@ -0,0 +1,666 @@
+"""Tests for EPIC 2.2 — Filesystem Broker.
+
+Issues:
+- #89: Virtual path resolution
+- #90: Read/write/deny enforcement
+- #91: Chroot-style jailing / traversal prevention
+- #92: TOCTOU protection (O_NOFOLLOW, post-open verify)
+- #93: Property-based tests for jail/deny escapes
+- #94: Concurrent symlink-creation race tests
+"""
+
+from __future__ import annotations
+
+import os
+import platform
+import stat
+import string
+import tempfile
+import threading
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from openspace.sandbox.fs_broker import (
+ VIRTUAL_SCHEME,
+ DeniedPathError,
+ FileSizeLimitError,
+ FilesystemBroker,
+ JailConfig,
+ PathEscapeError,
+ ReadNotAllowedError,
+ WriteNotAllowedError,
+ bounded_write,
+ check_denied,
+ check_read,
+ check_write,
+ ensure_jailed,
+ resolve_virtual_path,
+ safe_open_read,
+ safe_open_write,
+)
+from openspace.sandbox.leases import FilesystemCapability
+
+_IS_WINDOWS = platform.system() == "Windows"
+_SUPPORTS_SYMLINKS = not _IS_WINDOWS # Conservative; some Windows configs allow them
+
+
+@pytest.fixture
+def jail(tmp_path: Path) -> Path:
+ """Create a jail directory with test files."""
+ (tmp_path / "allowed.txt").write_text("hello")
+ (tmp_path / "subdir").mkdir()
+ (tmp_path / "subdir" / "nested.txt").write_text("nested")
+ return tmp_path
+
+
+@pytest.fixture
+def basic_config(jail: Path) -> JailConfig:
+ """JailConfig that allows reads everywhere, writes to subdir only."""
+ return JailConfig(
+ jail_root=jail,
+ read_patterns=("**",),
+ write_patterns=("subdir/**",),
+ denied_patterns=("/etc/shadow", "/etc/passwd", "~/.ssh/*", "**/.env"),
+ max_file_size_bytes=1024,
+ temp_dir_only=False,
+ )
+
+
+@pytest.fixture
+def broker(basic_config: JailConfig) -> FilesystemBroker:
+ return FilesystemBroker(basic_config)
+
+
+# ---------------------------------------------------------------------------
+# #89 — Virtual Path Resolution
+# ---------------------------------------------------------------------------
+
+
+class TestVirtualPathResolution:
+ def test_resolve_current_namespace(self, jail: Path) -> None:
+ result = resolve_virtual_path("skills://current/foo.txt", jail)
+ assert result == jail / "foo.txt"
+
+ def test_resolve_nested_path(self, jail: Path) -> None:
+ result = resolve_virtual_path("skills://current/subdir/nested.txt", jail)
+ assert result == jail / "subdir" / "nested.txt"
+
+ def test_resolve_root_returns_jail(self, jail: Path) -> None:
+ result = resolve_virtual_path("skills://current/", jail)
+ assert result == jail
+
+ def test_resolve_bare_namespace(self, jail: Path) -> None:
+ result = resolve_virtual_path("skills://current", jail)
+ assert result == jail
+
+ def test_invalid_scheme_raises(self, jail: Path) -> None:
+ with pytest.raises(ValueError, match="Not a virtual path"):
+ resolve_virtual_path("/some/real/path", jail)
+
+ def test_different_namespace(self, jail: Path) -> None:
+ result = resolve_virtual_path("skills://shared/data.json", jail)
+ assert result == jail / "data.json"
+
+ def test_traversal_in_virtual_path_rejected(self, jail: Path) -> None:
+ """skills://current/../../../etc/passwd must be rejected."""
+ with pytest.raises(ValueError, match="Traversal"):
+ resolve_virtual_path("skills://current/../../../etc/passwd", jail)
+
+ def test_dotdot_in_virtual_path_rejected(self, jail: Path) -> None:
+ with pytest.raises(ValueError, match="Traversal"):
+ resolve_virtual_path("skills://current/subdir/../../escape", jail)
+
+
+# ---------------------------------------------------------------------------
+# #91 — Jail Enforcement
+# ---------------------------------------------------------------------------
+
+
+class TestJailEnforcement:
+ def test_path_inside_jail(self, jail: Path) -> None:
+ result = ensure_jailed(jail / "allowed.txt", jail)
+ assert result == (jail / "allowed.txt").resolve()
+
+ def test_dotdot_traversal_blocked(self, jail: Path) -> None:
+ with pytest.raises(PathEscapeError, match="escapes jail"):
+ ensure_jailed(jail / ".." / "etc" / "passwd", jail)
+
+ def test_absolute_escape_blocked(self, jail: Path) -> None:
+ with pytest.raises(PathEscapeError):
+ ensure_jailed(Path("/etc/shadow"), jail)
+
+ @pytest.mark.skipif(_IS_WINDOWS, reason="Symlinks unreliable on Windows")
+ def test_symlink_escape_blocked(self, jail: Path) -> None:
+ """Symlink pointing outside jail is caught."""
+ link = jail / "escape_link"
+ link.symlink_to("/tmp")
+ with pytest.raises(PathEscapeError, match="escapes jail"):
+ ensure_jailed(link, jail)
+
+ @pytest.mark.skipif(_IS_WINDOWS, reason="Symlinks unreliable on Windows")
+ def test_nested_symlink_escape_blocked(self, jail: Path) -> None:
+ """Deeply nested symlink escape is caught."""
+ subdir = jail / "deep" / "nested"
+ subdir.mkdir(parents=True)
+ link = subdir / "sneaky"
+ link.symlink_to("/etc")
+ with pytest.raises(PathEscapeError, match="escapes jail"):
+ ensure_jailed(link / "shadow", jail)
+
+ def test_jail_root_itself_is_allowed(self, jail: Path) -> None:
+ result = ensure_jailed(jail, jail)
+ assert result == jail.resolve()
+
+ @pytest.mark.skipif(_IS_WINDOWS, reason="Symlinks unreliable on Windows")
+ def test_symlink_within_jail_is_allowed(self, jail: Path) -> None:
+ """Symlink pointing to another file inside jail is OK."""
+ target = jail / "allowed.txt"
+ link = jail / "internal_link"
+ link.symlink_to(target)
+ result = ensure_jailed(link, jail)
+ assert result == target.resolve()
+
+
+# ---------------------------------------------------------------------------
+# #90 — Read/Write/Deny Enforcement
+# ---------------------------------------------------------------------------
+
+
+class TestDenyEnforcement:
+ def test_denied_path_raises(self, basic_config: JailConfig) -> None:
+ with pytest.raises(DeniedPathError, match="denied"):
+ check_denied("/etc/shadow", basic_config)
+
+ def test_denied_env_file(self, basic_config: JailConfig) -> None:
+ with pytest.raises(DeniedPathError, match="denied"):
+ check_denied("/workspace/.env", basic_config)
+
+ def test_allowed_path_passes(self, basic_config: JailConfig) -> None:
+ check_denied("/workspace/main.py", basic_config) # Should not raise
+
+ def test_denied_ssh_key(self, basic_config: JailConfig) -> None:
+ with pytest.raises(DeniedPathError):
+ check_denied("~/.ssh/id_rsa", basic_config)
+
+
+class TestReadEnforcement:
+ def test_read_allowed_by_pattern(self, basic_config: JailConfig) -> None:
+ check_read("/workspace/file.py", basic_config) # "**" matches all
+
+ def test_read_denied_path_raises(self, basic_config: JailConfig) -> None:
+ with pytest.raises(DeniedPathError):
+ check_read("/etc/shadow", basic_config)
+
+ def test_read_not_in_allowlist(self, jail: Path) -> None:
+ config = JailConfig(
+ jail_root=jail,
+ read_patterns=("docs/**",),
+ denied_patterns=(),
+ )
+ with pytest.raises(ReadNotAllowedError, match="not in read allowlist"):
+ check_read("/workspace/secret.py", config)
+
+ def test_empty_read_patterns_allows_all(self, jail: Path) -> None:
+ config = JailConfig(jail_root=jail, read_patterns=(), denied_patterns=())
+ check_read("/any/file.txt", config) # Should not raise
+
+
+class TestWriteEnforcement:
+ def test_write_allowed_by_pattern(self, basic_config: JailConfig) -> None:
+ check_write("subdir/file.txt", basic_config)
+
+ def test_write_denied_path_raises(self, basic_config: JailConfig) -> None:
+ with pytest.raises(DeniedPathError):
+ check_write("/etc/shadow", basic_config)
+
+ def test_write_not_in_allowlist(self, basic_config: JailConfig) -> None:
+ with pytest.raises(WriteNotAllowedError, match="not in write allowlist"):
+ check_write("/root_level.txt", basic_config)
+
+ def test_write_size_exceeded(self, basic_config: JailConfig) -> None:
+ with pytest.raises(FileSizeLimitError, match="exceeds limit"):
+ check_write("subdir/big.bin", basic_config, size_bytes=9999)
+
+ def test_write_size_at_limit_ok(self, basic_config: JailConfig) -> None:
+ check_write("subdir/ok.bin", basic_config, size_bytes=1024)
+
+ def test_temp_dir_only_blocks_non_temp(self, jail: Path) -> None:
+ config = JailConfig(
+ jail_root=jail,
+ write_patterns=("**",),
+ denied_patterns=(),
+ temp_dir_only=True,
+ )
+ with pytest.raises(WriteNotAllowedError, match="temp dir"):
+ check_write("/workspace/file.txt", config)
+
+ def test_temp_dir_only_allows_tmp(self, jail: Path) -> None:
+ config = JailConfig(
+ jail_root=jail,
+ write_patterns=("**",),
+ denied_patterns=(),
+ temp_dir_only=True,
+ )
+ import tempfile
+ check_write(os.path.join(tempfile.gettempdir(), "output.txt"), config)
+
+
+# ---------------------------------------------------------------------------
+# #92 — TOCTOU-safe Operations
+# ---------------------------------------------------------------------------
+
+
+class TestTocTouSafeOps:
+ def test_safe_open_read_existing_file(self, jail: Path) -> None:
+ fd = safe_open_read(jail / "allowed.txt", jail)
+ try:
+ content = os.read(fd, 1024)
+ assert content == b"hello"
+ finally:
+ os.close(fd)
+
+ def test_safe_open_read_escape_blocked(self, jail: Path) -> None:
+ with pytest.raises(PathEscapeError):
+ safe_open_read(jail / ".." / ".." / "etc" / "hosts", jail)
+
+ @pytest.mark.skipif(_IS_WINDOWS, reason="O_NOFOLLOW not available")
+ def test_safe_open_read_symlink_rejected(self, jail: Path) -> None:
+ """Final-component symlink is caught by O_NOFOLLOW."""
+ target = jail / "allowed.txt"
+ link = jail / "link_to_allowed"
+ link.symlink_to(target)
+ # O_NOFOLLOW causes ELOOP on final-component symlinks
+ with pytest.raises((PathEscapeError, OSError)):
+ safe_open_read(link, jail)
+
+ def test_safe_open_write_creates_file(self, jail: Path) -> None:
+ new_file = jail / "new_file.txt"
+ fd = safe_open_write(new_file, jail, create=True)
+ try:
+ os.write(fd, b"written")
+ finally:
+ os.close(fd)
+ assert new_file.read_text() == "written"
+
+ def test_safe_open_write_escape_blocked(self, jail: Path) -> None:
+ with pytest.raises(PathEscapeError):
+ safe_open_write(jail / ".." / "escaped.txt", jail)
+
+ def test_safe_open_write_size_check(self, jail: Path) -> None:
+ big = jail / "big.bin"
+ big.write_bytes(b"x" * 2000)
+ with pytest.raises(FileSizeLimitError):
+ safe_open_write(big, jail, max_size_bytes=1000)
+
+
+# ---------------------------------------------------------------------------
+# Broker Integration Tests
+# ---------------------------------------------------------------------------
+
+
+class TestFilesystemBroker:
+ def test_resolve_virtual(self, broker: FilesystemBroker, jail: Path) -> None:
+ result = broker.resolve("skills://current/allowed.txt")
+ assert result == (jail / "allowed.txt").resolve()
+
+ def test_resolve_real_path(self, broker: FilesystemBroker, jail: Path) -> None:
+ result = broker.resolve(str(jail / "subdir" / "nested.txt"))
+ assert result == (jail / "subdir" / "nested.txt").resolve()
+
+ def test_resolve_escape_blocked(self, broker: FilesystemBroker) -> None:
+ with pytest.raises(PathEscapeError):
+ broker.resolve("/etc/passwd")
+
+ def test_check_read_valid(self, broker: FilesystemBroker, jail: Path) -> None:
+ result = broker.check_read(str(jail / "allowed.txt"))
+ assert result.exists()
+
+ def test_check_write_valid(self, broker: FilesystemBroker, jail: Path) -> None:
+ result = broker.check_write(str(jail / "subdir" / "new.txt"))
+ assert result.parent.exists()
+
+ def test_open_read(self, broker: FilesystemBroker, jail: Path) -> None:
+ fd = broker.open_read(str(jail / "allowed.txt"))
+ try:
+ assert os.read(fd, 1024) == b"hello"
+ finally:
+ os.close(fd)
+
+ def test_open_write(self, broker: FilesystemBroker, jail: Path) -> None:
+ target = jail / "subdir" / "output.txt"
+ fd = broker.open_write(str(target))
+ try:
+ os.write(fd, b"output")
+ finally:
+ os.close(fd)
+ assert target.read_text() == "output"
+
+ def test_jail_root_property(self, broker: FilesystemBroker, jail: Path) -> None:
+ assert broker.jail_root == jail.resolve()
+
+
+# ---------------------------------------------------------------------------
+# JailConfig.from_capability
+# ---------------------------------------------------------------------------
+
+
+class TestJailConfigFromCapability:
+ def test_basic_conversion(self, jail: Path) -> None:
+ cap = FilesystemCapability(
+ read_paths=["workspace/**"],
+ write_paths=["workspace/out/**"],
+ max_file_size_mb=5,
+ temp_dir_only=False,
+ )
+ config = JailConfig.from_capability(cap, jail)
+ assert config.jail_root == jail
+ assert "workspace/**" in config.read_patterns
+ assert "workspace/out/**" in config.write_patterns
+ assert config.max_file_size_bytes == 5 * 1024 * 1024
+ assert config.temp_dir_only is False
+
+ def test_denied_paths_propagated(self, jail: Path) -> None:
+ cap = FilesystemCapability()
+ config = JailConfig.from_capability(cap, jail)
+ assert "/etc/shadow" in config.denied_patterns
+ assert "/etc/passwd" in config.denied_patterns
+
+
+# ---------------------------------------------------------------------------
+# #93 — Property-based Escape Tests
+# ---------------------------------------------------------------------------
+
+
+class TestPropertyBasedEscapes:
+ """Systematic traversal / escape attempts."""
+
+ _ESCAPE_PAYLOADS = [
+ "..",
+ "../..",
+ "../../..",
+ "../../../etc/shadow",
+ "..\\..\\..\\windows\\system32",
+ "subdir/../../..",
+ "subdir/../../../etc/passwd",
+ "./././../../..",
+ "%2e%2e/%2e%2e", # URL-encoded (should not be decoded)
+ "....//....//",
+ "..;/..;/",
+ ]
+
+ @pytest.mark.parametrize("payload", _ESCAPE_PAYLOADS)
+ def test_traversal_payload_blocked(self, jail: Path, payload: str) -> None:
+ """No traversal payload can escape the jail."""
+ target = jail / payload
+ try:
+ result = ensure_jailed(target, jail)
+ # If it didn't raise, the resolved path must be inside jail
+ jail_str = str(jail.resolve())
+ result_str = str(result)
+ if _IS_WINDOWS:
+ jail_str = jail_str.lower()
+ result_str = result_str.lower()
+ assert result_str == jail_str or result_str.startswith(jail_str + os.sep), (
+ f"Payload {payload!r} resolved to {result}, outside {jail.resolve()}"
+ )
+ except (PathEscapeError, OSError):
+ pass # Expected — blocked
+
+ @pytest.mark.skipif(_IS_WINDOWS, reason="Symlinks unreliable on Windows")
+ def test_chained_symlink_escape(self, jail: Path) -> None:
+ """Chain of symlinks that eventually escapes is caught."""
+ (jail / "a").symlink_to(jail / "b")
+ (jail / "b").mkdir()
+ (jail / "b" / "c").symlink_to("/tmp")
+ with pytest.raises(PathEscapeError):
+ ensure_jailed(jail / "a" / "c" / "escape.txt", jail)
+
+ def test_null_byte_in_path(self, jail: Path) -> None:
+ """Null bytes in paths must not bypass checks."""
+ with pytest.raises((ValueError, OSError, PathEscapeError)):
+ ensure_jailed(jail / "file\x00.txt", jail)
+
+ def test_very_long_path(self, jail: Path) -> None:
+ """Extremely long paths should not crash or escape."""
+ long_name = "a" * 200
+ try:
+ result = ensure_jailed(jail / long_name / long_name, jail)
+ # If it succeeds, must be inside jail
+ jail_str = str(jail.resolve())
+ result_str = str(result)
+ if _IS_WINDOWS:
+ jail_str = jail_str.lower()
+ result_str = result_str.lower()
+ assert result_str.startswith(jail_str)
+ except (OSError, PathEscapeError):
+ pass # Also acceptable — blocked
+
+
+# ---------------------------------------------------------------------------
+# #94 — Race Condition Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.skipif(_IS_WINDOWS, reason="Symlink race tests require POSIX")
+class TestSymlinkRaceConditions:
+ """Concurrent symlink creation must never allow jail escape."""
+
+ def test_concurrent_symlink_swap(self, jail: Path) -> None:
+ """Race: thread creates symlink while main thread resolves.
+
+ The file starts as a regular file. A background thread repeatedly
+ swaps it for a symlink to /tmp. Even if timing aligns, the
+ safe_open_read path must never return an fd outside the jail.
+ """
+ target = jail / "race_target.txt"
+ target.write_text("safe content")
+ escape_target = "/tmp"
+
+ stop = threading.Event()
+ escapes_detected = []
+
+ def symlink_swapper() -> None:
+ """Repeatedly swap between file and symlink."""
+ while not stop.is_set():
+ try:
+ if target.is_symlink() or not target.exists():
+ target.unlink(missing_ok=True)
+ target.write_text("safe content")
+ else:
+ target.unlink()
+ target.symlink_to(escape_target)
+ except OSError:
+ pass
+
+ swapper = threading.Thread(target=symlink_swapper, daemon=True)
+ swapper.start()
+
+ try:
+ for _ in range(100):
+ try:
+ fd = safe_open_read(target, jail)
+ try:
+ # Verify fd is inside jail via /proc if available
+ proc_link = f"/proc/self/fd/{fd}"
+ if os.path.exists(proc_link):
+ actual = Path(os.readlink(proc_link)).resolve()
+ jail_str = str(jail.resolve())
+ actual_str = str(actual)
+ if not (
+ actual_str == jail_str
+ or actual_str.startswith(jail_str + "/")
+ ):
+ escapes_detected.append(actual_str)
+ finally:
+ os.close(fd)
+ except (PathEscapeError, OSError):
+ pass # Expected — race caught
+ finally:
+ stop.set()
+ swapper.join(timeout=2)
+ # Clean up
+ target.unlink(missing_ok=True)
+
+ assert not escapes_detected, f"Jail escapes detected: {escapes_detected}"
+
+ def test_directory_to_symlink_race(self, jail: Path) -> None:
+ """Race: directory swapped to symlink mid-traversal."""
+ subdir = jail / "race_dir"
+ subdir.mkdir()
+ inner = subdir / "file.txt"
+ inner.write_text("inner content")
+
+ stop = threading.Event()
+
+ def dir_swapper() -> None:
+ while not stop.is_set():
+ try:
+ if subdir.is_symlink():
+ subdir.unlink()
+ subdir.mkdir()
+ (subdir / "file.txt").write_text("inner content")
+ else:
+ import shutil
+ shutil.rmtree(subdir, ignore_errors=True)
+ subdir.symlink_to("/tmp")
+ except OSError:
+ pass
+
+ swapper = threading.Thread(target=dir_swapper, daemon=True)
+ swapper.start()
+
+ escapes = []
+ try:
+ for _ in range(100):
+ try:
+ resolved = ensure_jailed(subdir / "file.txt", jail)
+ jail_str = str(jail.resolve())
+ res_str = str(resolved)
+ if not (res_str == jail_str or res_str.startswith(jail_str + "/")):
+ escapes.append(res_str)
+ except (PathEscapeError, OSError):
+ pass
+ finally:
+ stop.set()
+ swapper.join(timeout=2)
+ # Cleanup
+ if subdir.is_symlink():
+ subdir.unlink()
+ elif subdir.exists():
+ import shutil
+ shutil.rmtree(subdir, ignore_errors=True)
+
+ assert not escapes, f"Jail escapes during race: {escapes}"
+
+
+# ---------------------------------------------------------------------------
+# Security Regression Tests (R1 review fixes)
+# ---------------------------------------------------------------------------
+
+
+class TestSecurityRegressions:
+ """Regression tests for R1 review findings."""
+
+ def test_temp_dir_substring_bypass_blocked(self, jail: Path) -> None:
+ """Path containing '/tmp' as substring must NOT be treated as temp."""
+ config = JailConfig(
+ jail_root=jail,
+ write_patterns=("**",),
+ denied_patterns=(),
+ temp_dir_only=True,
+ )
+ # A path like /home/user/tmpfake/evil.txt should be blocked
+ with pytest.raises(WriteNotAllowedError, match="temp dir"):
+ check_write("/home/user/tmpfake/evil.txt", config)
+
+ def test_virtual_path_traversal_blocked(self, jail: Path) -> None:
+ """skills://current/../../../etc/passwd must be rejected."""
+ with pytest.raises(ValueError, match="Traversal"):
+ resolve_virtual_path("skills://current/../../../etc/passwd", jail)
+
+ def test_bounded_write_enforces_limit(self, jail: Path) -> None:
+ """bounded_write rejects writes that would exceed the limit."""
+ target = jail / "bounded.txt"
+ fd = os.open(str(target), os.O_WRONLY | os.O_CREAT, 0o644)
+ try:
+ bounded_write(fd, b"small", max_size_bytes=1024) # OK
+ with pytest.raises(FileSizeLimitError):
+ bounded_write(fd, b"x" * 2000, max_size_bytes=1024)
+ finally:
+ os.close(fd)
+
+ def test_bounded_write_zero_limit_allows_all(self, jail: Path) -> None:
+ """max_size_bytes=0 means no limit enforced."""
+ target = jail / "unlimited.txt"
+ fd = os.open(str(target), os.O_WRONLY | os.O_CREAT, 0o644)
+ try:
+ written = bounded_write(fd, b"data", max_size_bytes=0)
+ assert written == 4
+ finally:
+ os.close(fd)
+
+ def test_device_check_same_filesystem(self, jail: Path) -> None:
+ """Files inside jail must be on same device as jail root."""
+ target = jail / "same_dev.txt"
+ target.write_text("ok")
+ fd = safe_open_read(target, jail)
+ os.close(fd) # Should not raise — same device
+
+ def test_temp_traversal_bypass_blocked(self, jail: Path) -> None:
+ """_is_temp_path must reject /tmp/../etc/shadow after normalization."""
+ import tempfile as _tf
+
+ config = JailConfig(
+ jail_root=jail,
+ write_patterns=("**",),
+ denied_patterns=(),
+ temp_dir_only=True,
+ )
+ # Path that starts with a temp dir but traverses out
+ crafted = os.path.join(_tf.gettempdir(), "..", "etc", "shadow")
+ with pytest.raises((WriteNotAllowedError, PathEscapeError)):
+ check_write(crafted, config)
+
+ def test_standalone_check_read_enforces_jail(self, jail: Path) -> None:
+ """FilesystemBroker.check_read() must reject paths outside the jail."""
+ config = JailConfig(
+ jail_root=jail,
+ read_patterns=("**",),
+ denied_patterns=(),
+ )
+ broker = FilesystemBroker(config)
+ with pytest.raises(PathEscapeError):
+ broker.check_read("/etc/hosts")
+
+ def test_standalone_check_write_enforces_jail(self, jail: Path) -> None:
+ """FilesystemBroker.check_write() must reject paths outside the jail."""
+ config = JailConfig(
+ jail_root=jail,
+ write_patterns=("**",),
+ denied_patterns=(),
+ )
+ broker = FilesystemBroker(config)
+ with pytest.raises(PathEscapeError):
+ broker.check_write("/etc/hosts")
+
+ @pytest.mark.skipif(_IS_WINDOWS, reason="Windows rejects os.open on directories")
+ def test_directory_fd_rejected(self, jail: Path) -> None:
+ """safe_open_read must reject directories to prevent openat escape."""
+ subdir = jail / "subdir"
+ subdir.mkdir(exist_ok=True)
+ with pytest.raises(PathEscapeError, match="Not a regular file"):
+ safe_open_read(subdir, jail)
+
+ def test_bounded_write_seek_bypass_blocked(self, jail: Path) -> None:
+ """bounded_write must account for seek offset, not just file size."""
+ target = jail / "sparse.bin"
+ fd = os.open(str(target), os.O_WRONLY | os.O_CREAT, 0o644)
+ try:
+ # Seek far past the limit, then try to write
+ os.lseek(fd, 9_000_000, os.SEEK_SET)
+ with pytest.raises(FileSizeLimitError):
+ bounded_write(fd, b"x" * 2_000_000, max_size_bytes=10_000_000)
+ finally:
+ os.close(fd)
diff --git a/tests/test_integration.py b/tests/test_integration.py
new file mode 100644
index 00000000..adbf198c
--- /dev/null
+++ b/tests/test_integration.py
@@ -0,0 +1,379 @@
+"""Tests for EPIC 0.7: Integration Tests.
+
+Covers:
+- #26: MCP end-to-end test (HTTP stack with auth + rate limiting)
+- #27: Skill execution integration test (execute_task path)
+- #28: Error path integration tests (auth, rate limit, malformed)
+- #29: Coverage gate (≥20% enforced in CI and pyproject.toml)
+"""
+
+import asyncio
+import json
+import os
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+ROOT = Path(__file__).resolve().parent.parent
+
+# Valid 32-char token for tests
+TEST_TOKEN = "a" * 32
+
+
+# ---------------------------------------------------------------------------
+# Shared ASGI test utilities
+# ---------------------------------------------------------------------------
+
+async def asgi_request(app, method="GET", path="/", headers=None, body=None):
+ """Send an ASGI HTTP request and collect the response."""
+ if headers is None:
+ headers = []
+
+ scope = {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": method,
+ "path": path,
+ "query_string": b"",
+ "root_path": "",
+ "headers": [(k.lower().encode(), v.encode()) for k, v in headers],
+ "server": ("127.0.0.1", 8000),
+ "client": ("127.0.0.1", 12345),
+ }
+
+ body_bytes = body.encode() if isinstance(body, str) else (body or b"")
+
+ request_sent = False
+ async def receive():
+ nonlocal request_sent
+ if not request_sent:
+ request_sent = True
+ return {"type": "http.request", "body": body_bytes, "more_body": False}
+ # After body is sent, wait indefinitely (simulate client waiting)
+ await asyncio.sleep(3600)
+ return {"type": "http.disconnect"}
+
+ response_started = False
+ status_code = None
+ response_headers = {}
+ response_body = b""
+
+ async def send(message):
+ nonlocal response_started, status_code, response_headers, response_body
+ if message["type"] == "http.response.start":
+ response_started = True
+ status_code = message["status"]
+ response_headers = {
+ k.decode(): v.decode()
+ for k, v in message.get("headers", [])
+ }
+ elif message["type"] == "http.response.body":
+ response_body += message.get("body", b"")
+
+ await app(scope, receive, send)
+
+ return {
+ "status": status_code,
+ "headers": response_headers,
+ "body": response_body,
+ "json": json.loads(response_body) if response_body else None,
+ }
+
+
+def make_protected_app(inner_app=None, token=TEST_TOKEN):
+ """Build the full middleware stack matching production wiring."""
+ from openspace.auth.bearer import BearerTokenMiddleware
+ from openspace.auth.rate_limit import RateLimitMiddleware
+
+ if inner_app is None:
+ # Simple echo app
+ async def echo_app(scope, receive, send):
+ await send({
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [(b"content-type", b"application/json")],
+ })
+ await send({
+ "type": "http.response.body",
+ "body": json.dumps({"ok": True}).encode(),
+ })
+ inner_app = echo_app
+
+ rate_limited = RateLimitMiddleware(inner_app)
+ protected = BearerTokenMiddleware(rate_limited, token)
+ return protected
+
+
+# ---------------------------------------------------------------------------
+# Issue #26: MCP end-to-end test
+# ---------------------------------------------------------------------------
+
+class TestMCPEndToEnd:
+ """Full HTTP stack tests: request → auth → rate limit → app."""
+
+ @pytest.mark.asyncio
+ async def test_authenticated_request_reaches_app(self):
+ """Valid bearer token passes through middleware to inner app."""
+ app = make_protected_app()
+ resp = await asgi_request(
+ app, "GET", "/sse",
+ headers=[("Authorization", f"Bearer {TEST_TOKEN}")],
+ )
+ assert resp["status"] == 200
+ assert resp["json"]["ok"] is True
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_headers_present(self):
+ """Successful requests include rate limit headers."""
+ app = make_protected_app()
+ resp = await asgi_request(
+ app, "GET", "/test",
+ headers=[("Authorization", f"Bearer {TEST_TOKEN}")],
+ )
+ assert resp["status"] == 200
+ assert "x-ratelimit-remaining" in resp["headers"]
+ assert "x-ratelimit-limit" in resp["headers"]
+
+ @pytest.mark.asyncio
+ async def test_middleware_ordering_auth_before_rate_limit(self):
+ """Auth middleware is outermost — unauthenticated requests
+ don't create rate-limit state."""
+ call_count = 0
+
+ async def counting_app(scope, receive, send):
+ nonlocal call_count
+ call_count += 1
+ await send({
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [],
+ })
+ await send({"type": "http.response.body", "body": b""})
+
+ app = make_protected_app(counting_app)
+
+ # Unauthenticated request — should be rejected by auth, never reach app
+ await asgi_request(app, "GET", "/test")
+ assert call_count == 0, "Unauthenticated request should not reach inner app"
+
+ @pytest.mark.asyncio
+ async def test_lifespan_passes_through(self):
+ """Non-HTTP scopes (lifespan) pass through without auth."""
+ lifespan_called = False
+
+ async def lifespan_app(scope, receive, send):
+ nonlocal lifespan_called
+ if scope["type"] == "lifespan":
+ lifespan_called = True
+
+ app = make_protected_app(lifespan_app)
+
+ scope = {"type": "lifespan", "asgi": {"version": "3.0"}}
+ await app(scope, lambda: None, lambda m: None)
+ assert lifespan_called
+
+
+# ---------------------------------------------------------------------------
+# Issue #27: Skill execution integration test
+# ---------------------------------------------------------------------------
+
+class TestSkillExecutionIntegration:
+ """Integration tests for execute_task flow."""
+
+ @pytest.mark.asyncio
+ async def test_execute_task_returns_formatted_result(self):
+ """execute_task produces MCP-formatted result when engine succeeds."""
+ from openspace.mcp_server import execute_task
+
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_os.execute = AsyncMock(return_value={
+ "status": "success",
+ "output": "Hello, world!",
+ "error": None,
+ })
+
+ with patch("openspace.mcp_server._get_openspace", new_callable=AsyncMock, return_value=mock_os), \
+ patch("openspace.mcp_server._auto_register_skill_dirs", new_callable=AsyncMock), \
+ patch("openspace.mcp_server._cloud_search_and_import", new_callable=AsyncMock, return_value=[]), \
+ patch("openspace.mcp_server._format_task_result", return_value={"result": "formatted"}), \
+ patch("openspace.mcp_server._write_upload_meta"):
+ result = await execute_task(task="test task")
+ mock_os.execute.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_execute_task_auto_registers_skill_dirs(self):
+ """execute_task calls _auto_register_skill_dirs when skill_dirs provided."""
+ from openspace.mcp_server import execute_task
+
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_os.execute = AsyncMock(return_value={
+ "status": "success", "output": "ok", "error": None,
+ })
+
+ with patch("openspace.mcp_server._get_openspace", new_callable=AsyncMock, return_value=mock_os), \
+ patch("openspace.mcp_server._auto_register_skill_dirs", new_callable=AsyncMock) as mock_register, \
+ patch("openspace.mcp_server._cloud_search_and_import", new_callable=AsyncMock, return_value=[]), \
+ patch("openspace.mcp_server._format_task_result", return_value={"result": "ok"}), \
+ patch("openspace.mcp_server._write_upload_meta"):
+ await execute_task(task="test", skill_dirs=["/tmp/skills"])
+ mock_register.assert_called()
+
+ @pytest.mark.asyncio
+ async def test_execute_task_respects_search_scope_local(self):
+ """execute_task skips cloud import when search_scope='local'."""
+ from openspace.mcp_server import execute_task
+
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_os.execute = AsyncMock(return_value={
+ "status": "success", "output": "ok", "error": None,
+ })
+
+ with patch("openspace.mcp_server._get_openspace", new_callable=AsyncMock, return_value=mock_os), \
+ patch("openspace.mcp_server._auto_register_skill_dirs", new_callable=AsyncMock), \
+ patch("openspace.mcp_server._cloud_search_and_import", new_callable=AsyncMock) as mock_import, \
+ patch("openspace.mcp_server._format_task_result", return_value={"result": "ok"}), \
+ patch("openspace.mcp_server._write_upload_meta"):
+ await execute_task(task="test", search_scope="local")
+ mock_import.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Issue #28: Error path integration tests
+# ---------------------------------------------------------------------------
+
+class TestErrorPathIntegration:
+ """Tests for auth failure, rate limit, and error formatting."""
+
+ @pytest.mark.asyncio
+ async def test_no_auth_header_returns_401(self):
+ """Missing Authorization header → 401."""
+ app = make_protected_app()
+ resp = await asgi_request(app, "GET", "/test")
+ assert resp["status"] == 401
+ assert resp["json"]["error"] == "unauthorized"
+
+ @pytest.mark.asyncio
+ async def test_wrong_token_returns_401(self):
+ """Wrong bearer token → 401."""
+ app = make_protected_app()
+ resp = await asgi_request(
+ app, "GET", "/test",
+ headers=[("Authorization", "Bearer wrong-token-that-is-long-enough-32chars!")],
+ )
+ assert resp["status"] == 401
+
+ @pytest.mark.asyncio
+ async def test_non_bearer_scheme_returns_401(self):
+ """Non-Bearer auth scheme → 401."""
+ app = make_protected_app()
+ resp = await asgi_request(
+ app, "GET", "/test",
+ headers=[("Authorization", f"Basic {TEST_TOKEN}")],
+ )
+ assert resp["status"] == 401
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_exceeded_returns_429(self):
+ """Exceeding rate limit → 429 with Retry-After."""
+ app = make_protected_app()
+
+ # Set very low limits for testing
+ with patch.dict(os.environ, {
+ "OPENSPACE_RATE_LIMIT_PER_IP": "2",
+ "OPENSPACE_RATE_LIMIT_PER_TOKEN": "2",
+ "OPENSPACE_RATE_LIMIT_WINDOW": "60",
+ }):
+ # Re-create app with fresh rate limiter to pick up env vars
+ app = make_protected_app()
+
+ auth_headers = [("Authorization", f"Bearer {TEST_TOKEN}")]
+
+ # First 2 requests should succeed
+ for _ in range(2):
+ resp = await asgi_request(app, "GET", "/test", headers=auth_headers)
+ assert resp["status"] == 200, f"Expected 200, got {resp['status']}"
+
+ # Third request should be rate-limited
+ resp = await asgi_request(app, "GET", "/test", headers=auth_headers)
+ assert resp["status"] == 429
+ assert resp["json"]["error"] == "rate_limited"
+ assert "retry-after" in resp["headers"]
+
+ @pytest.mark.asyncio
+ async def test_error_responses_contain_no_tracebacks(self):
+ """Error responses must not leak stack traces."""
+ app = make_protected_app()
+
+ # 401 response
+ resp = await asgi_request(app, "GET", "/test")
+ body_text = resp["body"].decode()
+ assert "Traceback" not in body_text
+ assert not ("File " in body_text and "line " in body_text)
+ # Must have structured error JSON
+ assert resp["json"]["error"] == "unauthorized"
+ assert "detail" in resp["json"]
+
+ @pytest.mark.asyncio
+ async def test_execute_task_exception_returns_safe_error(self):
+ """Internal exception in execute_task returns safe error, no traceback."""
+ from openspace.mcp_server import execute_task
+
+ mock_os = MagicMock()
+ mock_os.is_initialized.return_value = True
+ mock_os.execute = AsyncMock(side_effect=RuntimeError("internal boom"))
+
+ with patch("openspace.mcp_server._get_openspace", new_callable=AsyncMock, return_value=mock_os), \
+ patch("openspace.mcp_server._auto_register_skill_dirs", new_callable=AsyncMock), \
+ patch("openspace.mcp_server._cloud_search_and_import", new_callable=AsyncMock):
+ result = await execute_task(task="crash test")
+ result_str = str(result)
+ assert "Traceback" not in result_str
+ # Safe error must contain structured error info
+ assert "error" in result_str.lower() or "failed" in result_str.lower()
+
+
+# ---------------------------------------------------------------------------
+# Issue #29: Coverage gate (≥20% in CI)
+# ---------------------------------------------------------------------------
+
+class TestCoverageGate:
+ """Coverage threshold must be enforced."""
+
+ def test_pyproject_coverage_fail_under_at_least_20(self):
+ """pyproject.toml coverage.report.fail_under >= 20."""
+ try:
+ import tomllib
+ except ImportError:
+ import tomli as tomllib
+
+ pyproject_path = ROOT / "pyproject.toml"
+ data = tomllib.loads(pyproject_path.read_text())
+ fail_under = data["tool"]["coverage"]["report"]["fail_under"]
+ assert fail_under >= 20, (
+ f"Coverage fail_under is {fail_under}, must be >= 20"
+ )
+
+ def test_ci_yaml_has_cov_fail_under(self):
+ """CI workflow enforces --cov-fail-under."""
+ ci_path = ROOT / ".github" / "workflows" / "ci.yml"
+ content = ci_path.read_text()
+ assert "--cov-fail-under" in content, (
+ "CI must include --cov-fail-under flag in pytest command"
+ )
+
+ def test_ci_yaml_cov_threshold_at_least_20(self):
+ """CI --cov-fail-under value is >= 20."""
+ import re
+ ci_path = ROOT / ".github" / "workflows" / "ci.yml"
+ content = ci_path.read_text()
+ match = re.search(r"--cov-fail-under[=\s]+(\d+)", content)
+ assert match, "Could not find --cov-fail-under value in CI"
+ threshold = int(match.group(1))
+ assert threshold >= 20, (
+ f"CI coverage threshold is {threshold}, must be >= 20"
+ )
diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py
new file mode 100644
index 00000000..98af5ba4
--- /dev/null
+++ b/tests/test_mcp_auth.py
@@ -0,0 +1,255 @@
+"""Tests for bearer token authentication on MCP server endpoints.
+
+Covers:
+ - Valid token → 200 (request passes through)
+ - Invalid token → 401
+ - Missing token → 401
+ - Missing Authorization header → 401
+ - Token strength validation
+ - Non-HTTP scopes pass through without auth
+ - run_mcp_server refuses to start HTTP transport without token (fail-closed)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from openspace.auth.bearer import (
+ BEARER_TOKEN_ENV,
+ MIN_TOKEN_LENGTH,
+ BearerTokenMiddleware,
+ get_bearer_token,
+ validate_token_strength,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+VALID_TOKEN = "a" * 48 # 48 chars, well above minimum
+WRONG_TOKEN = "b" * 48
+
+
+@pytest.fixture
+def dummy_app():
+ """An ASGI app that records whether it was called and returns 200."""
+
+ async def app(scope, receive, send):
+ app.called = True
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [[b"content-type", b"text/plain"]],
+ }
+ )
+ await send({"type": "http.response.body", "body": b"OK"})
+
+ app.called = False
+ return app
+
+
+@pytest.fixture
+def middleware(dummy_app):
+ return BearerTokenMiddleware(dummy_app, VALID_TOKEN)
+
+
+def _http_scope(path="/test", headers=None):
+ """Build a minimal HTTP ASGI scope."""
+ raw_headers = []
+ for k, v in (headers or {}).items():
+ raw_headers.append([k.encode(), v.encode()])
+ return {"type": "http", "path": path, "headers": raw_headers}
+
+
+def _lifespan_scope():
+ return {"type": "lifespan"}
+
+
+# ---------------------------------------------------------------------------
+# Helper to collect ASGI response
+# ---------------------------------------------------------------------------
+
+
+class ResponseCollector:
+ """Collects ASGI send() calls into a response dict."""
+
+ def __init__(self):
+ self.status = None
+ self.headers = {}
+ self.body = b""
+
+ async def __call__(self, message):
+ if message["type"] == "http.response.start":
+ self.status = message["status"]
+ for k, v in message.get("headers", []):
+ self.headers[k.decode()] = v.decode()
+ elif message["type"] == "http.response.body":
+ self.body += message.get("body", b"")
+
+
+# ---------------------------------------------------------------------------
+# Token utility tests
+# ---------------------------------------------------------------------------
+
+
+class TestGetBearerToken:
+ def test_returns_token_from_env(self, monkeypatch):
+ monkeypatch.setenv(BEARER_TOKEN_ENV, "test-token-123")
+ assert get_bearer_token() == "test-token-123"
+
+ def test_returns_none_when_unset(self, monkeypatch):
+ monkeypatch.delenv(BEARER_TOKEN_ENV, raising=False)
+ assert get_bearer_token() is None
+
+
+class TestValidateTokenStrength:
+ def test_valid_token(self):
+ ok, reason = validate_token_strength("x" * MIN_TOKEN_LENGTH)
+ assert ok is True
+ assert reason == "OK"
+
+ def test_too_short(self):
+ ok, reason = validate_token_strength("short")
+ assert ok is False
+ assert "too short" in reason.lower()
+
+ def test_exactly_min_length(self):
+ ok, _ = validate_token_strength("x" * MIN_TOKEN_LENGTH)
+ assert ok is True
+
+ def test_one_below_min(self):
+ ok, _ = validate_token_strength("x" * (MIN_TOKEN_LENGTH - 1))
+ assert ok is False
+
+
+# ---------------------------------------------------------------------------
+# Middleware tests
+# ---------------------------------------------------------------------------
+
+
+class TestBearerTokenMiddleware:
+ @pytest.mark.asyncio
+ async def test_valid_token_passes_through(self, middleware, dummy_app):
+ scope = _http_scope(headers={"authorization": f"Bearer {VALID_TOKEN}"})
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert dummy_app.called is True
+
+ @pytest.mark.asyncio
+ async def test_missing_auth_header_returns_401(self, middleware, dummy_app):
+ scope = _http_scope(headers={})
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert collector.status == 401
+ assert dummy_app.called is False
+ body = json.loads(collector.body)
+ assert body["error"] == "unauthorized"
+ assert "missing" in body["detail"].lower()
+
+ @pytest.mark.asyncio
+ async def test_wrong_token_returns_401(self, middleware, dummy_app):
+ scope = _http_scope(headers={"authorization": f"Bearer {WRONG_TOKEN}"})
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert collector.status == 401
+ assert dummy_app.called is False
+ body = json.loads(collector.body)
+ assert "invalid" in body["detail"].lower()
+
+ @pytest.mark.asyncio
+ async def test_non_bearer_scheme_returns_401(self, middleware, dummy_app):
+ scope = _http_scope(headers={"authorization": "Basic dXNlcjpwYXNz"})
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert collector.status == 401
+ assert dummy_app.called is False
+
+ @pytest.mark.asyncio
+ async def test_empty_bearer_returns_401(self, middleware, dummy_app):
+ scope = _http_scope(headers={"authorization": "Bearer "})
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert collector.status == 401
+ assert dummy_app.called is False
+
+ @pytest.mark.asyncio
+ async def test_lifespan_scope_passes_through(self, middleware, dummy_app):
+ """Non-HTTP scopes should not be authenticated."""
+ scope = _lifespan_scope()
+ receive = AsyncMock()
+ send = AsyncMock()
+ await middleware(scope, receive, send)
+ assert dummy_app.called is True
+
+ @pytest.mark.asyncio
+ async def test_401_includes_www_authenticate_header(self, middleware):
+ scope = _http_scope(headers={})
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert "www-authenticate" in collector.headers
+ assert "Bearer" in collector.headers["www-authenticate"]
+
+ @pytest.mark.asyncio
+ async def test_timing_safe_comparison(self, middleware, dummy_app):
+ """Ensure we don't leak token via timing — just verify hmac.compare_digest is used."""
+ # This is a design test: verify the code path uses constant-time comparison.
+ # We can't truly test timing, but we confirm wrong tokens of same length are rejected.
+ same_length_wrong = "z" * len(VALID_TOKEN)
+ scope = _http_scope(
+ headers={"authorization": f"Bearer {same_length_wrong}"}
+ )
+ collector = ResponseCollector()
+ await middleware(scope, AsyncMock(), collector)
+ assert collector.status == 401
+ assert dummy_app.called is False
+
+
+# ---------------------------------------------------------------------------
+# run_mcp_server fail-closed tests
+# ---------------------------------------------------------------------------
+
+
+class TestRunMcpServerFailClosed:
+ """Verify the server refuses to start HTTP transports without a token."""
+
+ def test_sse_without_token_exits(self, monkeypatch):
+ monkeypatch.delenv(BEARER_TOKEN_ENV, raising=False)
+ monkeypatch.setattr(
+ sys, "argv", ["openspace-mcp", "--transport", "sse"]
+ )
+ with pytest.raises(SystemExit) as exc_info:
+ from openspace.mcp_server import run_mcp_server
+
+ run_mcp_server()
+ assert exc_info.value.code == 1
+
+ def test_sse_with_weak_token_exits(self, monkeypatch):
+ monkeypatch.setenv(BEARER_TOKEN_ENV, "tooshort")
+ monkeypatch.setattr(
+ sys, "argv", ["openspace-mcp", "--transport", "sse"]
+ )
+ with pytest.raises(SystemExit) as exc_info:
+ from openspace.mcp_server import run_mcp_server
+
+ run_mcp_server()
+ assert exc_info.value.code == 1
+
+ def test_streamable_http_without_token_exits(self, monkeypatch):
+ monkeypatch.delenv(BEARER_TOKEN_ENV, raising=False)
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["openspace-mcp", "--transport", "streamable-http"],
+ )
+ with pytest.raises(SystemExit) as exc_info:
+ from openspace.mcp_server import run_mcp_server
+
+ run_mcp_server()
+ assert exc_info.value.code == 1
diff --git a/tests/test_net_proxy.py b/tests/test_net_proxy.py
new file mode 100644
index 00000000..366f2000
--- /dev/null
+++ b/tests/test_net_proxy.py
@@ -0,0 +1,609 @@
+"""Tests for EPIC 2.3 — Network Proxy.
+
+Issues:
+- #95: Domain-based allow/deny enforcement
+- #96: Concurrent connection tracking and limits
+- #97: Port-based filtering
+- #98: Outbound enable/disable enforcement + proxy lifecycle
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from openspace.sandbox.leases import NetworkCapability
+from openspace.sandbox.net_proxy import (
+ ConnectionLimitError,
+ ConnectionNotFoundError,
+ ConnectionTracker,
+ DomainDeniedError,
+ DomainNotAllowedError,
+ NetworkPolicyError,
+ NetworkProxy,
+ NetworkProxyConfig,
+ OutboundDisabledError,
+ PortNotAllowedError,
+ check_domain_allowed,
+ check_domain_blocked,
+ check_port_allowed,
+)
+
+
+# ---------------------------------------------------------------------------
+# #95 — Domain Matching Tests
+# ---------------------------------------------------------------------------
+
+
+class TestDomainBlocking:
+ """Tests for deny-before-allow domain enforcement."""
+
+ def test_exact_domain_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError, match="169.254.169.254"):
+ check_domain_blocked("169.254.169.254", ["169.254.169.254"])
+
+ def test_wildcard_subdomain_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("sub.evil.com", ["*.evil.com"])
+
+ def test_case_insensitive_blocking(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("Metadata.Google.Internal", ["metadata.google.internal"])
+
+ def test_unblocked_domain_passes(self) -> None:
+ check_domain_blocked("pypi.org", ["169.254.169.254", "metadata.google.internal"])
+
+ def test_cloud_metadata_ipv6_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("fd00:ec2::254", ["fd00:ec2::254"])
+
+ def test_empty_block_list_allows_all(self) -> None:
+ check_domain_blocked("anything.com", [])
+
+ def test_trailing_dot_normalized(self) -> None:
+ """DNS trailing dots should be stripped before matching."""
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("evil.com.", ["evil.com"])
+
+
+class TestDomainAllowing:
+ """Tests for domain allowlist enforcement."""
+
+ def test_exact_domain_allowed(self) -> None:
+ check_domain_allowed("pypi.org", ["pypi.org"])
+
+ def test_wildcard_subdomain_allowed(self) -> None:
+ check_domain_allowed("files.githubusercontent.com", ["*.githubusercontent.com"])
+
+ def test_universal_wildcard_allows_all(self) -> None:
+ check_domain_allowed("anything.example.com", ["*"])
+
+ def test_empty_allowlist_denies_all(self) -> None:
+ with pytest.raises(DomainNotAllowedError, match="allow list is empty"):
+ check_domain_allowed("pypi.org", [])
+
+ def test_domain_not_in_allowlist(self) -> None:
+ with pytest.raises(DomainNotAllowedError):
+ check_domain_allowed("evil.com", ["pypi.org", "*.github.com"])
+
+ def test_case_insensitive_allowing(self) -> None:
+ check_domain_allowed("PyPI.Org", ["pypi.org"])
+
+ def test_multiple_patterns_any_match(self) -> None:
+ check_domain_allowed("registry.npmjs.org", ["pypi.org", "registry.npmjs.org"])
+
+
+# ---------------------------------------------------------------------------
+# #97 — Port Filtering Tests
+# ---------------------------------------------------------------------------
+
+
+class TestPortFiltering:
+ """Tests for port-based access control."""
+
+ def test_allowed_port_passes(self) -> None:
+ check_port_allowed(443, [80, 443])
+
+ def test_disallowed_port_raises(self) -> None:
+ with pytest.raises(PortNotAllowedError, match="8080"):
+ check_port_allowed(8080, [80, 443])
+
+ def test_empty_allowed_ports_denies_all(self) -> None:
+ with pytest.raises(PortNotAllowedError, match="empty"):
+ check_port_allowed(80, [])
+
+ def test_port_zero_rejected(self) -> None:
+ with pytest.raises(PortNotAllowedError, match="Invalid"):
+ check_port_allowed(0, [80, 443])
+
+ def test_negative_port_rejected(self) -> None:
+ with pytest.raises(PortNotAllowedError, match="Invalid"):
+ check_port_allowed(-1, [80, 443])
+
+ def test_port_above_65535_rejected(self) -> None:
+ with pytest.raises(PortNotAllowedError, match="Invalid"):
+ check_port_allowed(70000, [80, 443])
+
+ def test_port_1_allowed(self) -> None:
+ check_port_allowed(1, [1, 80, 443])
+
+ def test_port_65535_allowed(self) -> None:
+ check_port_allowed(65535, [65535])
+
+
+# ---------------------------------------------------------------------------
+# #96 — Connection Tracker Tests
+# ---------------------------------------------------------------------------
+
+
+class TestConnectionTracker:
+ """Tests for concurrent connection tracking."""
+
+ @pytest.fixture
+ def tracker(self) -> ConnectionTracker:
+ return ConnectionTracker(max_connections=3)
+
+ @pytest.mark.asyncio
+ async def test_acquire_returns_unique_ids(self, tracker: ConnectionTracker) -> None:
+ id1 = await tracker.acquire("a.com", 443)
+ id2 = await tracker.acquire("b.com", 443)
+ assert id1 != id2
+ assert tracker.active_count == 2
+
+ @pytest.mark.asyncio
+ async def test_release_decrements_count(self, tracker: ConnectionTracker) -> None:
+ conn_id = await tracker.acquire("a.com", 443)
+ assert tracker.active_count == 1
+ await tracker.release(conn_id)
+ assert tracker.active_count == 0
+
+ @pytest.mark.asyncio
+ async def test_connection_limit_enforced(self, tracker: ConnectionTracker) -> None:
+ await tracker.acquire("a.com", 443)
+ await tracker.acquire("b.com", 443)
+ await tracker.acquire("c.com", 443)
+ with pytest.raises(ConnectionLimitError, match="3"):
+ await tracker.acquire("d.com", 443)
+
+ @pytest.mark.asyncio
+ async def test_release_unknown_raises(self, tracker: ConnectionTracker) -> None:
+ with pytest.raises(ConnectionNotFoundError, match="conn-999"):
+ await tracker.release("conn-999")
+
+ @pytest.mark.asyncio
+ async def test_list_active_returns_snapshot(self, tracker: ConnectionTracker) -> None:
+ await tracker.acquire("a.com", 80)
+ await tracker.acquire("b.com", 443)
+ active = await tracker.list_active()
+ assert len(active) == 2
+ domains = {c.domain for c in active}
+ assert domains == {"a.com", "b.com"}
+
+ @pytest.mark.asyncio
+ async def test_release_all_clears_connections(self, tracker: ConnectionTracker) -> None:
+ await tracker.acquire("a.com", 80)
+ await tracker.acquire("b.com", 443)
+ count = await tracker.release_all()
+ assert count == 2
+ assert tracker.active_count == 0
+
+ @pytest.mark.asyncio
+ async def test_zero_max_connections_blocks_all(self) -> None:
+ tracker = ConnectionTracker(max_connections=0)
+ with pytest.raises(ConnectionLimitError):
+ await tracker.acquire("a.com", 443)
+
+ def test_negative_max_connections_raises(self) -> None:
+ with pytest.raises(ValueError, match="must be >= 0"):
+ ConnectionTracker(max_connections=-1)
+
+ @pytest.mark.asyncio
+ async def test_concurrent_acquire_respects_limit(self) -> None:
+ """Concurrent acquires must not exceed the limit."""
+ tracker = ConnectionTracker(max_connections=5)
+ results = await asyncio.gather(
+ *[tracker.acquire(f"host-{i}.com", 443) for i in range(10)],
+ return_exceptions=True,
+ )
+ successes = [r for r in results if isinstance(r, str)]
+ failures = [r for r in results if isinstance(r, ConnectionLimitError)]
+ assert len(successes) == 5
+ assert len(failures) == 5
+
+
+# ---------------------------------------------------------------------------
+# #98 — NetworkProxyConfig Tests
+# ---------------------------------------------------------------------------
+
+
+class TestNetworkProxyConfig:
+ """Tests for config construction from NetworkCapability."""
+
+ def test_from_capability_outbound_enabled(self) -> None:
+ cap = NetworkCapability(
+ outbound_enabled=True,
+ allowed_domains=["pypi.org"],
+ allowed_ports=[443],
+ max_connections=5,
+ )
+ config = NetworkProxyConfig.from_capability(cap)
+ assert config.outbound_enabled is True
+ assert config.allowed_domains == ("pypi.org",)
+ assert 443 in config.allowed_ports
+ assert config.max_connections == 5
+
+ def test_from_capability_outbound_disabled_clears_domains(self) -> None:
+ """EPIC 2.1 R5 deferred fix: outbound=False must clear allowed_domains."""
+ cap = NetworkCapability(
+ outbound_enabled=False,
+ allowed_domains=["should-be-cleared.com"],
+ max_connections=0,
+ )
+ config = NetworkProxyConfig.from_capability(cap)
+ assert config.outbound_enabled is False
+ assert config.allowed_domains == ()
+
+ def test_blocked_domains_preserved(self) -> None:
+ cap = NetworkCapability(outbound_enabled=True, allowed_domains=["*"])
+ config = NetworkProxyConfig.from_capability(cap)
+ assert "169.254.169.254" in config.blocked_domains
+
+
+# ---------------------------------------------------------------------------
+# #98 — NetworkProxy Integration Tests
+# ---------------------------------------------------------------------------
+
+
+class TestNetworkProxy:
+ """Integration tests for the full NetworkProxy lifecycle."""
+
+ @pytest.fixture
+ def t2_proxy(self) -> NetworkProxy:
+ """T2-equivalent proxy: limited outbound."""
+ config = NetworkProxyConfig(
+ outbound_enabled=True,
+ allowed_domains=("*.githubusercontent.com", "pypi.org", "registry.npmjs.org"),
+ blocked_domains=("169.254.169.254", "metadata.google.internal"),
+ allowed_ports=(80, 443),
+ max_connections=5,
+ )
+ return NetworkProxy(config)
+
+ @pytest.fixture
+ def t0_proxy(self) -> NetworkProxy:
+ """T0-equivalent proxy: no outbound."""
+ config = NetworkProxyConfig(
+ outbound_enabled=False,
+ allowed_domains=(),
+ blocked_domains=("169.254.169.254",),
+ allowed_ports=(),
+ max_connections=0,
+ )
+ return NetworkProxy(config)
+
+ @pytest.fixture
+ def t3_proxy(self) -> NetworkProxy:
+ """T3-equivalent proxy: broad outbound."""
+ config = NetworkProxyConfig(
+ outbound_enabled=True,
+ allowed_domains=("*",),
+ blocked_domains=("169.254.169.254", "metadata.google.internal"),
+ allowed_ports=(80, 443, 8080, 8443),
+ max_connections=20,
+ )
+ return NetworkProxy(config)
+
+ # --- Outbound gating ---
+
+ def test_t0_blocks_all_outbound(self, t0_proxy: NetworkProxy) -> None:
+ with pytest.raises(OutboundDisabledError):
+ t0_proxy.check_request("pypi.org", 443)
+
+ def test_t2_allows_permitted_domain(self, t2_proxy: NetworkProxy) -> None:
+ t2_proxy.check_request("pypi.org", 443)
+
+ def test_t2_blocks_unpermitted_domain(self, t2_proxy: NetworkProxy) -> None:
+ with pytest.raises(DomainNotAllowedError):
+ t2_proxy.check_request("evil.com", 443)
+
+ # --- Deny-before-allow ---
+
+ def test_blocked_domain_rejected_even_if_wildcard_allowed(self, t3_proxy: NetworkProxy) -> None:
+ """Cloud metadata must be blocked even with allowed_domains=['*']."""
+ with pytest.raises(DomainDeniedError):
+ t3_proxy.check_request("169.254.169.254", 80)
+
+ # --- Port filtering ---
+
+ def test_t2_blocks_non_standard_port(self, t2_proxy: NetworkProxy) -> None:
+ with pytest.raises(PortNotAllowedError):
+ t2_proxy.check_request("pypi.org", 8080)
+
+ def test_t3_allows_custom_port(self, t3_proxy: NetworkProxy) -> None:
+ t3_proxy.check_request("example.com", 8080)
+
+ # --- Connection lifecycle ---
+
+ @pytest.mark.asyncio
+ async def test_connect_and_disconnect(self, t2_proxy: NetworkProxy) -> None:
+ conn_id = await t2_proxy.connect("pypi.org", 443)
+ assert t2_proxy.active_connections == 1
+ await t2_proxy.disconnect(conn_id)
+ assert t2_proxy.active_connections == 0
+
+ @pytest.mark.asyncio
+ async def test_connect_respects_connection_limit(self, t2_proxy: NetworkProxy) -> None:
+ ids = []
+ for i in range(5):
+ ids.append(await t2_proxy.connect("pypi.org", 443))
+ with pytest.raises(ConnectionLimitError):
+ await t2_proxy.connect("pypi.org", 443)
+ # Release one and try again
+ await t2_proxy.disconnect(ids[0])
+ new_id = await t2_proxy.connect("pypi.org", 443)
+ assert new_id not in ids
+
+ @pytest.mark.asyncio
+ async def test_connect_validates_policy_before_slot(self, t2_proxy: NetworkProxy) -> None:
+ """Policy violations should not consume a connection slot."""
+ with pytest.raises(DomainNotAllowedError):
+ await t2_proxy.connect("evil.com", 443)
+ assert t2_proxy.active_connections == 0
+
+ # --- Shutdown ---
+
+ @pytest.mark.asyncio
+ async def test_shutdown_releases_all(self, t2_proxy: NetworkProxy) -> None:
+ await t2_proxy.connect("pypi.org", 443)
+ await t2_proxy.connect("pypi.org", 443)
+ count = await t2_proxy.shutdown()
+ assert count == 2
+ assert t2_proxy.active_connections == 0
+
+ @pytest.mark.asyncio
+ async def test_shutdown_rejects_new_requests(self, t2_proxy: NetworkProxy) -> None:
+ await t2_proxy.shutdown()
+ with pytest.raises(NetworkPolicyError, match="shut down"):
+ t2_proxy.check_request("pypi.org", 443)
+
+ @pytest.mark.asyncio
+ async def test_shutdown_rejects_new_connections(self, t2_proxy: NetworkProxy) -> None:
+ await t2_proxy.shutdown()
+ with pytest.raises(NetworkPolicyError, match="shut down"):
+ await t2_proxy.connect("pypi.org", 443)
+
+ # --- List connections ---
+
+ @pytest.mark.asyncio
+ async def test_list_connections(self, t2_proxy: NetworkProxy) -> None:
+ await t2_proxy.connect("pypi.org", 443)
+ await t2_proxy.connect("registry.npmjs.org", 443)
+ conns = await t2_proxy.list_connections()
+ assert len(conns) == 2
+ domains = {c.domain for c in conns}
+ assert "pypi.org" in domains
+ assert "registry.npmjs.org" in domains
+
+
+# ---------------------------------------------------------------------------
+# EPIC 2.1 R5 Deferred Fix — T0/T1 Validation
+# ---------------------------------------------------------------------------
+
+
+class TestT0T1ValidationFix:
+ """Verify that T0/T1 with outbound_enabled=False and non-empty
+ allowed_domains is properly handled by NetworkProxyConfig."""
+
+ def test_t0_domains_cleared_in_config(self) -> None:
+ """Even if a T0 capability somehow has allowed_domains, the proxy clears them."""
+ cap = NetworkCapability(
+ outbound_enabled=False,
+ allowed_domains=["sneaky.com"],
+ max_connections=0,
+ )
+ config = NetworkProxyConfig.from_capability(cap)
+ assert config.allowed_domains == ()
+ proxy = NetworkProxy(config)
+ with pytest.raises(OutboundDisabledError):
+ proxy.check_request("sneaky.com", 443)
+
+ def test_t1_domains_cleared_in_config(self) -> None:
+ cap = NetworkCapability(
+ outbound_enabled=False,
+ allowed_domains=["also-sneaky.com"],
+ max_connections=0,
+ )
+ config = NetworkProxyConfig.from_capability(cap)
+ assert config.allowed_domains == ()
+
+
+# ---------------------------------------------------------------------------
+# R1 Security Regression Tests
+# ---------------------------------------------------------------------------
+
+
+class TestSecurityRegressions:
+ """Regression tests for R1 review findings."""
+
+ # --- DNS rebinding bypass ---
+
+ def test_nip_io_blocked(self) -> None:
+ """169.254.169.254.nip.io must be blocked (DNS rebinding)."""
+ with pytest.raises(DomainDeniedError, match="nip.io"):
+ check_domain_blocked("169.254.169.254.nip.io", ["169.254.169.254"])
+
+ def test_sslip_io_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError, match="sslip.io"):
+ check_domain_blocked("169-254-169-254.sslip.io", ["169.254.169.254"])
+
+ def test_xip_io_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("anything.xip.io", ["169.254.169.254"])
+
+ # --- IPv6 metadata alias bypass ---
+
+ def test_ipv6_mapped_metadata_blocked(self) -> None:
+ """::ffff:169.254.169.254 must be blocked (IPv4-mapped IPv6)."""
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("::ffff:169.254.169.254", ["169.254.169.254"])
+
+ def test_ipv6_hex_metadata_blocked(self) -> None:
+ """::ffff:a9fe:a9fe (hex form of 169.254.169.254) must be blocked."""
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("::ffff:a9fe:a9fe", ["169.254.169.254"])
+
+ def test_bracketed_ipv6_metadata_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("[::ffff:169.254.169.254]", ["169.254.169.254"])
+
+ def test_ipv6_mapped_gcp_blocked(self) -> None:
+ """IPv4-mapped IPv6 for 100.100.100.200 (Alibaba metadata)."""
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("::ffff:100.100.100.200", ["100.100.100.200"])
+
+ # --- Shutdown race ---
+
+ @pytest.mark.asyncio
+ async def test_shutdown_race_no_post_shutdown_connections(self) -> None:
+ """No connections must be acquired after shutdown() completes."""
+ config = NetworkProxyConfig(
+ outbound_enabled=True,
+ allowed_domains=("*",),
+ blocked_domains=(),
+ allowed_ports=(443,),
+ max_connections=100,
+ )
+ proxy = NetworkProxy(config)
+
+ # Pre-populate some connections
+ for i in range(5):
+ await proxy.connect(f"host-{i}.com", 443)
+
+ # Shutdown and attempt concurrent connects
+ shutdown_task = asyncio.create_task(proxy.shutdown())
+ connect_tasks = [
+ asyncio.create_task(proxy.connect(f"late-{i}.com", 443))
+ for i in range(10)
+ ]
+ await shutdown_task
+
+ results = await asyncio.gather(*connect_tasks, return_exceptions=True)
+ # All post-shutdown connects must fail
+ for r in results:
+ assert isinstance(r, (NetworkPolicyError, ConnectionLimitError)), (
+ f"Post-shutdown connect succeeded: {r}"
+ )
+ assert proxy.active_connections == 0
+
+ # --- T0/T1 LeaseSchema validation ---
+
+ def test_t0_schema_rejects_nonempty_allowed_domains(self) -> None:
+ """LeaseSchema must reject T0 with non-empty allowed_domains."""
+ from pydantic import ValidationError
+ from openspace.sandbox.leases import LeaseSchema, TrustTier
+
+ with pytest.raises(ValidationError, match="allowed_domains"):
+ LeaseSchema(
+ name="test-t0",
+ trust_tier=TrustTier.T0_UNTRUSTED,
+ network=NetworkCapability(
+ outbound_enabled=False,
+ allowed_domains=["sneaky.com"],
+ max_connections=0,
+ ),
+ )
+
+ def test_t1_schema_rejects_nonempty_allowed_domains(self) -> None:
+ """LeaseSchema must reject T1 with non-empty allowed_domains."""
+ from pydantic import ValidationError
+ from openspace.sandbox.leases import LeaseSchema, TrustTier
+
+ with pytest.raises(ValidationError, match="allowed_domains"):
+ LeaseSchema(
+ name="test-t1",
+ trust_tier=TrustTier.T1_BASIC,
+ network=NetworkCapability(
+ outbound_enabled=False,
+ allowed_domains=["also-sneaky.com"],
+ max_connections=0,
+ ),
+ )
+
+
+class TestSecurityRegressionsR2:
+ """Regression tests for R2 /8eyes findings (apex rebinding + loopback SSRF)."""
+
+ # --- Apex DNS rebinding ---
+
+ def test_bare_nip_io_blocked(self) -> None:
+ """Bare 'nip.io' (no subdomain) must be blocked."""
+ with pytest.raises(DomainDeniedError, match="nip.io"):
+ check_domain_blocked("nip.io", [])
+
+ def test_bare_localtest_me_blocked(self) -> None:
+ """Bare 'localtest.me' resolves to 127.0.0.1 — must be blocked."""
+ with pytest.raises(DomainDeniedError, match="localtest.me"):
+ check_domain_blocked("localtest.me", [])
+
+ def test_bare_sslip_io_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError, match="sslip.io"):
+ check_domain_blocked("sslip.io", [])
+
+ def test_bare_xip_io_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("xip.io", [])
+
+ def test_bare_traefik_me_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError):
+ check_domain_blocked("traefik.me", [])
+
+ # --- Loopback / link-local SSRF ---
+
+ def test_ipv4_loopback_blocked(self) -> None:
+ """127.0.0.1 must be blocked even with allowed_domains=('*',)."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("127.0.0.1", [])
+
+ def test_ipv4_loopback_any_octet_blocked(self) -> None:
+ """Any 127.x.x.x address must be blocked."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("127.0.0.2", [])
+
+ def test_ipv6_loopback_blocked(self) -> None:
+ """::1 must be blocked."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("::1", [])
+
+ def test_bracketed_ipv6_loopback_blocked(self) -> None:
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("[::1]", [])
+
+ def test_ipv4_mapped_loopback_blocked(self) -> None:
+ """::ffff:127.0.0.1 must be blocked as loopback."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("::ffff:127.0.0.1", [])
+
+ def test_zero_address_blocked(self) -> None:
+ """0.0.0.0 must be blocked."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("0.0.0.0", [])
+
+ def test_ipv6_link_local_blocked(self) -> None:
+ """fe80::1 (link-local) must be blocked."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("fe80::1", [])
+
+ def test_ipv6_ula_blocked(self) -> None:
+ """fd00::1 (unique local address) must be blocked."""
+ with pytest.raises(DomainDeniedError, match="loopback"):
+ check_domain_blocked("fd00::1", [])
+
+ def test_legitimate_ip_not_blocked(self) -> None:
+ """Normal public IPs must NOT be blocked by the IP check."""
+ # Should not raise — no domain-level block either
+ check_domain_blocked("8.8.8.8", [])
+
+ def test_legitimate_domain_not_blocked(self) -> None:
+ """Normal domains must NOT be blocked."""
+ check_domain_blocked("github.com", [])
diff --git a/tests/test_process_broker.py b/tests/test_process_broker.py
new file mode 100644
index 00000000..0e364fa3
--- /dev/null
+++ b/tests/test_process_broker.py
@@ -0,0 +1,1375 @@
+"""Tests for openspace.sandbox.process_broker — EPIC 2.4.
+
+Covers:
+- #99: Command allow/deny enforcement
+- #100: Shell invocation control
+- #101: Process tracking with limits and timeouts
+- #102: Dangerous syscall / link command restriction
+- #103: ProcessBroker integration and config-from-capability
+"""
+
+from __future__ import annotations
+
+import time
+import threading
+import pytest
+
+from openspace.sandbox.process_broker import (
+ ProcessBroker,
+ ProcessBrokerConfig,
+ ProcessRecord,
+ ProcessTracker,
+ CommandBlockedError,
+ CommandNotAllowedError,
+ ShellNotAllowedError,
+ ProcessLimitError,
+ SyscallBlockedError,
+ ExecutionTimeoutError,
+ _extract_basename,
+ _extract_arg_tokens,
+ _strip_exe,
+ check_command_blocked,
+ check_command_allowed,
+ check_shell_allowed,
+ check_shell_command,
+ check_syscall_allowed,
+ check_link_command,
+ _SHELL_BINARIES,
+ _SHELL_WRAPPERS,
+ _DANGEROUS_SYSCALLS,
+ _LINK_COMMANDS,
+)
+from openspace.sandbox.leases import ProcessCapability, REQUIRED_BLOCKED_COMMANDS
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #99 — Command Allow/Deny
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestExtractBasename:
+ """Tests for _extract_basename path handling."""
+
+ def test_bare_command(self) -> None:
+ assert _extract_basename("python") == "python"
+
+ def test_unix_path(self) -> None:
+ assert _extract_basename("/usr/bin/python") == "python"
+
+ def test_windows_path(self) -> None:
+ assert _extract_basename("C:\\Windows\\System32\\cmd.exe") == "cmd.exe"
+
+ def test_case_insensitive(self) -> None:
+ assert _extract_basename("Python3") == "python3"
+
+ def test_relative_path(self) -> None:
+ assert _extract_basename("./scripts/run.sh") == "run.sh"
+
+ def test_empty_string(self) -> None:
+ assert _extract_basename("") == ""
+
+
+class TestCommandBlocked:
+ """Tests for command blocking enforcement."""
+
+ def test_required_commands_always_blocked(self) -> None:
+ """REQUIRED_BLOCKED_COMMANDS must be blocked even with empty list."""
+ for cmd in REQUIRED_BLOCKED_COMMANDS:
+ with pytest.raises(CommandBlockedError, match=cmd):
+ check_command_blocked(cmd, [])
+
+ def test_explicit_block(self) -> None:
+ with pytest.raises(CommandBlockedError, match="curl"):
+ check_command_blocked("curl", ["curl", "wget"])
+
+ def test_full_path_blocked(self) -> None:
+ """Full path to a blocked command must still be caught."""
+ with pytest.raises(CommandBlockedError, match="rm"):
+ check_command_blocked("/bin/rm", [])
+
+ def test_windows_path_blocked(self) -> None:
+ with pytest.raises(CommandBlockedError, match="rm"):
+ check_command_blocked("C:\\Tools\\rm", [])
+
+ def test_case_insensitive_blocking(self) -> None:
+ with pytest.raises(CommandBlockedError):
+ check_command_blocked("RM", [])
+
+ def test_glob_pattern_blocking(self) -> None:
+ with pytest.raises(CommandBlockedError, match="python"):
+ check_command_blocked("python3.11", ["python*"])
+
+ def test_allowed_command_passes(self) -> None:
+ """Non-blocked commands should not raise."""
+ check_command_blocked("python", [])
+
+ def test_custom_plus_required_blocked(self) -> None:
+ """Custom blocks are additive to required blocks."""
+ with pytest.raises(CommandBlockedError, match="wget"):
+ check_command_blocked("wget", ["wget"])
+ with pytest.raises(CommandBlockedError, match="rm"):
+ check_command_blocked("rm", ["wget"])
+
+
+class TestCommandAllowed:
+ """Tests for command allowlist enforcement."""
+
+ def test_empty_allowlist_permits_all(self) -> None:
+ """Empty allow list = open policy (anything non-blocked passes)."""
+ check_command_allowed("anything", [])
+
+ def test_allowlist_permits_match(self) -> None:
+ check_command_allowed("python", ["python", "node"])
+
+ def test_allowlist_rejects_nonmatch(self) -> None:
+ with pytest.raises(CommandNotAllowedError, match="curl"):
+ check_command_allowed("curl", ["python", "node"])
+
+ def test_allowlist_glob(self) -> None:
+ check_command_allowed("python3.11", ["python*"])
+
+ def test_allowlist_case_insensitive(self) -> None:
+ check_command_allowed("Python", ["python"])
+
+ def test_allowlist_full_path(self) -> None:
+ """Full path should match against basename in allowlist."""
+ check_command_allowed("/usr/bin/python", ["python"])
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #100 — Shell Invocation Control
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestShellControl:
+ """Tests for shell invocation enforcement."""
+
+ def test_shell_allowed_when_enabled(self) -> None:
+ check_shell_allowed("bash", allow_shell=True)
+
+ def test_bash_blocked_when_disabled(self) -> None:
+ with pytest.raises(ShellNotAllowedError, match="bash"):
+ check_shell_allowed("bash", allow_shell=False)
+
+ def test_sh_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("sh", allow_shell=False)
+
+ def test_cmd_exe_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError, match="cmd.exe"):
+ check_shell_allowed("cmd.exe", allow_shell=False)
+
+ def test_powershell_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("powershell", allow_shell=False)
+
+ def test_pwsh_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("pwsh", allow_shell=False)
+
+ def test_zsh_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("zsh", allow_shell=False)
+
+ def test_full_path_shell_blocked(self) -> None:
+ """Full path to shell must still be caught."""
+ with pytest.raises(ShellNotAllowedError, match="bash"):
+ check_shell_allowed("/bin/bash", allow_shell=False)
+
+ def test_non_shell_command_allowed(self) -> None:
+ """Non-shell commands pass even with allow_shell=False."""
+ check_shell_allowed("python", allow_shell=False)
+
+ def test_all_known_shells_blocked(self) -> None:
+ """Every shell in _SHELL_BINARIES must be blocked when disabled."""
+ for shell in _SHELL_BINARIES:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed(shell, allow_shell=False)
+
+ def test_shell_command_blocked_when_disabled(self) -> None:
+ with pytest.raises(ShellNotAllowedError, match="allow_shell=False"):
+ check_shell_command("echo hello && rm -rf /", allow_shell=False)
+
+ def test_shell_command_allowed_when_enabled(self) -> None:
+ check_shell_command("echo hello", allow_shell=True)
+
+ def test_shell_command_truncated_in_error(self) -> None:
+ """Long shell commands should be truncated in error messages."""
+ long_cmd = "x" * 200
+ with pytest.raises(ShellNotAllowedError) as exc_info:
+ check_shell_command(long_cmd, allow_shell=False)
+ assert len(str(exc_info.value)) < 250
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #102 — Dangerous Syscall / Link Command Restriction
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSyscallRestriction:
+ """Tests for dangerous syscall blocking."""
+
+ def test_link_syscall_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError, match="link"):
+ check_syscall_allowed("link", "/source", "/target")
+
+ def test_symlink_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError, match="symlink"):
+ check_syscall_allowed("symlink", "/target", "/linkname")
+
+ def test_linkat_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("linkat")
+
+ def test_symlinkat_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("symlinkat")
+
+ def test_rename_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("rename", "/old", "/new")
+
+ def test_mount_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("mount", "/dev/sda1", "/mnt")
+
+ def test_chroot_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("chroot", "/new_root")
+
+ def test_mknod_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("mknod", "/dev/null", "c", "1", "3")
+
+ def test_safe_syscall_allowed(self) -> None:
+ """Normal syscalls must not be blocked."""
+ check_syscall_allowed("read")
+ check_syscall_allowed("write")
+ check_syscall_allowed("open")
+ check_syscall_allowed("close")
+
+ def test_case_insensitive(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed("LINK")
+
+ def test_all_dangerous_syscalls_blocked(self) -> None:
+ for syscall in _DANGEROUS_SYSCALLS:
+ with pytest.raises(SyscallBlockedError):
+ check_syscall_allowed(syscall)
+
+
+class TestLinkCommandRestriction:
+ """Tests for link-creating command restriction."""
+
+ def test_ln_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError, match="ln"):
+ check_link_command("ln")
+
+ def test_ln_full_path_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError, match="ln"):
+ check_link_command("/usr/bin/ln")
+
+ def test_mklink_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError, match="mklink"):
+ check_link_command("mklink")
+
+ def test_mount_command_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError, match="mount"):
+ check_link_command("mount")
+
+ def test_all_link_commands_blocked(self) -> None:
+ for cmd in _LINK_COMMANDS:
+ with pytest.raises(SyscallBlockedError):
+ check_link_command(cmd)
+
+ def test_safe_command_passes(self) -> None:
+ check_link_command("python")
+ check_link_command("node")
+ check_link_command("cat")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #101 — Process Tracking
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestProcessTracker:
+ """Tests for ProcessTracker concurrency and timeout."""
+
+ def test_track_and_release(self) -> None:
+ tracker = ProcessTracker(max_processes=2, max_execution_time_s=300)
+ tracker.track(100, "python")
+ assert tracker.active_count == 1
+ tracker.release(100)
+ assert tracker.active_count == 0
+
+ def test_process_limit_enforced(self) -> None:
+ tracker = ProcessTracker(max_processes=2, max_execution_time_s=300)
+ tracker.track(100, "python")
+ tracker.track(101, "node")
+ with pytest.raises(ProcessLimitError, match="2/2"):
+ tracker.track(102, "ruby")
+
+ def test_release_frees_slot(self) -> None:
+ tracker = ProcessTracker(max_processes=1, max_execution_time_s=300)
+ tracker.track(100, "python")
+ tracker.release(100)
+ tracker.track(101, "node") # Should succeed after release
+ assert tracker.active_count == 1
+
+ def test_duplicate_pid_rejected(self) -> None:
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=300)
+ tracker.track(100, "python")
+ with pytest.raises(ProcessLimitError, match="already tracked"):
+ tracker.track(100, "python")
+
+ def test_terminated_pid_reusable(self) -> None:
+ tracker = ProcessTracker(max_processes=1, max_execution_time_s=300)
+ tracker.track(100, "python")
+ tracker.release(100)
+ tracker.track(100, "node") # Reuse after termination
+
+ def test_timeout_detection(self) -> None:
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=0)
+ tracker.track(100, "slow")
+ # max_execution_time_s=0 means immediate timeout
+ with pytest.raises(ExecutionTimeoutError, match="slow"):
+ tracker.check_timeout(100)
+
+ def test_no_timeout_when_within_limit(self) -> None:
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=3600)
+ tracker.track(100, "fast")
+ tracker.check_timeout(100) # Should not raise
+
+ def test_check_all_timeouts(self) -> None:
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=0)
+ tracker.track(100, "a")
+ tracker.track(101, "b")
+ tracker.track(102, "c")
+ timed_out = tracker.check_all_timeouts()
+ assert set(timed_out) == {100, 101, 102}
+
+ def test_list_processes(self) -> None:
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=300)
+ tracker.track(100, "python")
+ tracker.track(101, "node")
+ processes = tracker.list_processes()
+ assert len(processes) == 2
+ assert {p.pid for p in processes} == {100, 101}
+
+ def test_thread_safety(self) -> None:
+ """Concurrent track/release must not corrupt state."""
+ tracker = ProcessTracker(max_processes=100, max_execution_time_s=300)
+ errors: list[Exception] = []
+
+ def worker(start_pid: int) -> None:
+ try:
+ for i in range(10):
+ pid = start_pid + i
+ tracker.track(pid, f"cmd-{pid}")
+ tracker.release(pid)
+ except Exception as e:
+ errors.append(e)
+
+ threads = [threading.Thread(target=worker, args=(i * 100,)) for i in range(10)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors, f"Thread safety violation: {errors}"
+ assert tracker.active_count == 0
+
+ def test_zero_max_processes(self) -> None:
+ """max_processes=0 means no processes can be tracked."""
+ tracker = ProcessTracker(max_processes=0, max_execution_time_s=300)
+ with pytest.raises(ProcessLimitError, match="0/0"):
+ tracker.track(100, "python")
+
+ def test_release_unknown_pid_no_error(self) -> None:
+ """Releasing an unknown PID should be a no-op."""
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=300)
+ tracker.release(999) # Should not raise
+
+ def test_timeout_unknown_pid_no_error(self) -> None:
+ """Checking timeout for unknown PID should be a no-op."""
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=300)
+ tracker.check_timeout(999) # Should not raise
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# #103 — Config + Broker Integration
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestProcessBrokerConfig:
+ """Tests for ProcessBrokerConfig.from_capability."""
+
+ def test_from_default_capability(self) -> None:
+ cap = ProcessCapability()
+ config = ProcessBrokerConfig.from_capability(cap)
+ assert config.max_processes == 3
+ assert config.max_execution_time_s == 300
+ assert config.allow_shell is False
+
+ def test_link_commands_merged_into_blocked(self) -> None:
+ """Link commands must be auto-merged into blocked list."""
+ cap = ProcessCapability()
+ config = ProcessBrokerConfig.from_capability(cap)
+ for cmd in _LINK_COMMANDS:
+ assert cmd in config.blocked_commands
+
+ def test_required_blocked_merged(self) -> None:
+ """REQUIRED_BLOCKED_COMMANDS must be in blocked list."""
+ cap = ProcessCapability()
+ config = ProcessBrokerConfig.from_capability(cap)
+ for cmd in REQUIRED_BLOCKED_COMMANDS:
+ assert cmd in config.blocked_commands
+
+ def test_custom_allowed_commands(self) -> None:
+ cap = ProcessCapability(allowed_commands=["python", "node"])
+ config = ProcessBrokerConfig.from_capability(cap)
+ assert config.allowed_commands == ("python", "node")
+
+ def test_config_is_frozen(self) -> None:
+ cap = ProcessCapability()
+ config = ProcessBrokerConfig.from_capability(cap)
+ with pytest.raises(AttributeError):
+ config.allow_shell = True # type: ignore[misc]
+
+
+class TestProcessBroker:
+ """Integration tests for ProcessBroker."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(
+ allowed_commands=["python", "node", "git"],
+ max_processes=3,
+ max_execution_time_s=300,
+ allow_shell=False,
+ )
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ @pytest.fixture
+ def open_broker(self) -> ProcessBroker:
+ """Broker with open policy (empty allowlist, shell disabled)."""
+ cap = ProcessCapability(
+ allowed_commands=[],
+ max_processes=5,
+ max_execution_time_s=600,
+ allow_shell=False,
+ )
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_allowed_command_passes(self, broker: ProcessBroker) -> None:
+ broker.check_command("python", ["script.py"])
+
+ def test_blocked_command_rejected(self, broker: ProcessBroker) -> None:
+ with pytest.raises(CommandBlockedError, match="rm"):
+ broker.check_command("rm", ["-rf", "/"])
+
+ def test_link_command_rejected(self, broker: ProcessBroker) -> None:
+ with pytest.raises((CommandBlockedError, SyscallBlockedError)):
+ broker.check_command("ln", ["-s", "/etc/passwd", "/tmp/link"])
+
+ def test_shell_rejected(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("bash", ["-c", "echo hello"])
+
+ def test_unapproved_command_rejected(self, broker: ProcessBroker) -> None:
+ with pytest.raises(CommandNotAllowedError, match="curl"):
+ broker.check_command("curl", ["https://evil.com"])
+
+ def test_open_policy_permits_non_blocked(self, open_broker: ProcessBroker) -> None:
+ """With empty allowlist, non-blocked commands pass."""
+ open_broker.check_command("python", [])
+ open_broker.check_command("cat", [])
+ open_broker.check_command("grep", [])
+
+ def test_open_policy_still_blocks_dangerous(self, open_broker: ProcessBroker) -> None:
+ with pytest.raises(CommandBlockedError):
+ open_broker.check_command("rm", [])
+ with pytest.raises((CommandBlockedError, SyscallBlockedError)):
+ open_broker.check_command("ln", [])
+
+ def test_shell_command_rejected(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_shell("echo hello && rm -rf /")
+
+ def test_syscall_rejected(self, broker: ProcessBroker) -> None:
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("symlink", "/target", "/linkname")
+
+ def test_safe_syscall_allowed(self, broker: ProcessBroker) -> None:
+ broker.check_syscall("read")
+ broker.check_syscall("write")
+
+ def test_process_lifecycle(self, broker: ProcessBroker) -> None:
+ """Full process track → check → release cycle."""
+ broker.track_process(100, "python")
+ assert broker.active_count == 1
+ broker.check_timeout(100)
+ broker.release_process(100)
+ assert broker.active_count == 0
+
+ def test_process_limit(self, broker: ProcessBroker) -> None:
+ broker.track_process(100, "python")
+ broker.track_process(101, "node")
+ broker.track_process(102, "git")
+ with pytest.raises(ProcessLimitError):
+ broker.track_process(103, "ruby")
+
+ def test_list_processes(self, broker: ProcessBroker) -> None:
+ broker.track_process(100, "python")
+ broker.track_process(101, "node")
+ processes = broker.list_processes()
+ assert len(processes) == 2
+
+ def test_full_path_command_validated(self, broker: ProcessBroker) -> None:
+ """Full path to an allowed command should pass."""
+ broker.check_command("/usr/bin/python", [])
+
+ def test_full_path_blocked_command_rejected(self, broker: ProcessBroker) -> None:
+ """Full path to a blocked command must be caught."""
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("/usr/bin/rm", [])
+
+
+class TestProcessBrokerWithShell:
+ """Tests for ProcessBroker with allow_shell=True."""
+
+ @pytest.fixture
+ def shell_broker(self) -> ProcessBroker:
+ cap = ProcessCapability(
+ allowed_commands=[],
+ max_processes=3,
+ max_execution_time_s=300,
+ allow_shell=True,
+ )
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_shell_allowed(self, shell_broker: ProcessBroker) -> None:
+ shell_broker.check_command("bash", ["-c", "echo hello"])
+
+ def test_shell_command_allowed(self, shell_broker: ProcessBroker) -> None:
+ shell_broker.check_shell("echo hello")
+
+ def test_blocked_still_enforced_with_shell(self, shell_broker: ProcessBroker) -> None:
+ """Even with allow_shell=True, blocked commands are still blocked."""
+ with pytest.raises(CommandBlockedError):
+ shell_broker.check_command("rm", ["-rf", "/"])
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Security Regressions
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecurityRegressions:
+ """Regression tests for EPIC 2.2 deferred items and attack vectors."""
+
+ def test_hardlink_escape_blocked(self) -> None:
+ """EPIC 2.2 deferred: ln must be blocked to prevent hard-link jail escape."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises((CommandBlockedError, SyscallBlockedError)):
+ broker.check_command("ln", ["/etc/passwd", "/sandbox/passwd"])
+
+ def test_symlink_syscall_blocked(self) -> None:
+ """symlink syscall must be blocked to prevent jail escape."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("symlink", "/etc/passwd", "/sandbox/link")
+
+ def test_chroot_escape_blocked(self) -> None:
+ """chroot syscall must be blocked."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("chroot", "/tmp/fake_root")
+
+ def test_mount_escape_blocked(self) -> None:
+ """mount syscall + command must both be blocked."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("mount", "/dev/sda1", "/mnt")
+ with pytest.raises((CommandBlockedError, SyscallBlockedError)):
+ broker.check_command("mount", ["/dev/sda1", "/mnt"])
+
+ def test_t0_single_process_limit(self) -> None:
+ """T0 allows max 1 process — broker must enforce this."""
+ cap = ProcessCapability(max_processes=1, allow_shell=False)
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ broker.track_process(100, "python")
+ with pytest.raises(ProcessLimitError):
+ broker.track_process(101, "node")
+
+ def test_mknod_device_creation_blocked(self) -> None:
+ """mknod must be blocked to prevent device file creation."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("mknod", "/dev/evil", "c", "1", "3")
+
+ def test_rename_across_jail_blocked(self) -> None:
+ """rename/renameat must be blocked (can move files out of jail)."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("rename", "/sandbox/file", "/etc/file")
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("renameat2")
+
+ def test_fusermount_blocked(self) -> None:
+ """fusermount can bypass jail via FUSE mounts."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises((CommandBlockedError, SyscallBlockedError)):
+ broker.check_command("fusermount", ["-u", "/mnt/fuse"])
+
+ def test_pivot_root_blocked(self) -> None:
+ """pivot_root must be blocked."""
+ cap = ProcessCapability()
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(SyscallBlockedError):
+ broker.check_syscall("pivot_root", "/new_root", "/old_root")
+
+ def test_enforcement_order_deny_before_allow(self) -> None:
+ """Blocked commands must be rejected even if in allowlist."""
+ cap = ProcessCapability(
+ allowed_commands=["rm", "python"],
+ blocked_commands=["rm", "rmdir", "mkfs", "dd", "shutdown", "reboot", "kill", "pkill"],
+ )
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("rm", [])
+ # But python should still work
+ broker.check_command("python", [])
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# R1 Review Fixes — /8eyes + /collab findings
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestR1ShellWrapperBypass:
+ """R1 finding: env/busybox/sudo can invoke shells via args."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(allow_shell=False)
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_env_bash_blocked(self, broker: ProcessBroker) -> None:
+ """env bash -c 'id' must be blocked."""
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("env", ["bash", "-c", "id"])
+
+ def test_busybox_sh_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("busybox", ["sh", "-c", "id"])
+
+ def test_sudo_bash_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("sudo", ["bash"])
+
+ def test_nohup_zsh_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("nohup", ["zsh", "-c", "evil"])
+
+ def test_env_python_allowed(self, broker: ProcessBroker) -> None:
+ """env python should pass (python is not a shell)."""
+ broker.check_command("env", ["python", "script.py"])
+
+ def test_env_no_args_allowed(self, broker: ProcessBroker) -> None:
+ """env with no args should pass."""
+ broker.check_command("env", [])
+
+ def test_wrapper_with_blocked_cmd_in_args(self, broker: ProcessBroker) -> None:
+ """env rm -rf / must be blocked (rm in args)."""
+ with pytest.raises(CommandBlockedError, match="rm"):
+ broker.check_command("env", ["rm", "-rf", "/"])
+
+
+class TestR1ExeExtensionBypass:
+ """R1 finding: rm.exe, shutdown.exe, ln.exe bypass block list."""
+
+ def test_rm_exe_blocked(self) -> None:
+ with pytest.raises(CommandBlockedError, match="rm"):
+ check_command_blocked("rm.exe", [])
+
+ def test_shutdown_exe_blocked(self) -> None:
+ with pytest.raises(CommandBlockedError):
+ check_command_blocked("shutdown.exe", [])
+
+ def test_ln_exe_blocked(self) -> None:
+ """ln.exe must be caught by either block list or link command check."""
+ with pytest.raises((CommandBlockedError, SyscallBlockedError)):
+ check_command_blocked("ln.exe", list(_LINK_COMMANDS))
+
+ def test_kill_exe_blocked(self) -> None:
+ with pytest.raises(CommandBlockedError):
+ check_command_blocked("kill.exe", [])
+
+ def test_link_exe_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_link_command("ln.exe")
+
+ def test_mklink_exe_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_link_command("mklink.exe")
+
+ def test_strip_exe_helper(self) -> None:
+ assert _strip_exe("rm.exe") == "rm"
+ assert _strip_exe("python") == "python"
+ assert _strip_exe("cmd.exe") == "cmd"
+
+
+class TestR1QuotedPathBypass:
+ """R1 finding: quoted paths bypass basename extraction."""
+
+ def test_quoted_cmd_exe_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed('"C:\\Windows\\System32\\cmd.exe"', False)
+
+ def test_single_quoted_bash_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("'/bin/bash'", False)
+
+ def test_quoted_rm_blocked(self) -> None:
+ with pytest.raises(CommandBlockedError):
+ check_command_blocked('"rm"', [])
+
+ def test_quoted_extract_basename(self) -> None:
+ assert _extract_basename('"C:\\Windows\\cmd.exe"') == "cmd.exe"
+ assert _extract_basename("'/usr/bin/bash'") == "bash"
+ assert _extract_basename('"rm"') == "rm"
+
+
+class TestR1MutableProcessRecord:
+ """R1 finding: list_processes returned mutable internal records."""
+
+ def test_list_returns_copies(self) -> None:
+ tracker = ProcessTracker(max_processes=5, max_execution_time_s=300)
+ tracker.track(100, "python")
+ processes = tracker.list_processes()
+ # Mutating the copy should NOT affect internal state
+ processes[0].terminated = True
+ assert tracker.active_count == 1 # Still active internally
+
+
+class TestR1AtomicCheckAndTrack:
+ """R1 finding: TOCTOU between check_command and track_process."""
+
+ def test_check_and_track_success(self) -> None:
+ cap = ProcessCapability(max_processes=2)
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ broker.check_and_track(100, "python", ["script.py"])
+ assert broker.active_count == 1
+
+ def test_check_and_track_blocked_command(self) -> None:
+ cap = ProcessCapability(max_processes=2)
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(CommandBlockedError):
+ broker.check_and_track(100, "rm", ["-rf", "/"])
+ assert broker.active_count == 0 # Not tracked
+
+ def test_check_and_track_limit(self) -> None:
+ cap = ProcessCapability(max_processes=1)
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ broker.check_and_track(100, "python", [])
+ with pytest.raises(ProcessLimitError):
+ broker.check_and_track(101, "python", [])
+
+ def test_check_and_track_concurrent_safety(self) -> None:
+ """Concurrent check_and_track must not exceed max_processes."""
+ cap = ProcessCapability(max_processes=5)
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ errors: list[Exception] = []
+ successes = []
+ lock = threading.Lock()
+
+ def worker(pid: int) -> None:
+ try:
+ broker.check_and_track(pid, "python", [])
+ with lock:
+ successes.append(pid)
+ except ProcessLimitError:
+ pass
+ except Exception as e:
+ with lock:
+ errors.append(e)
+
+ threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors, f"Unexpected errors: {errors}"
+ assert len(successes) <= 5, f"Exceeded max_processes: {len(successes)}"
+ assert broker.active_count <= 5
+
+
+class TestR1MissingShells:
+ """R1 finding: ash, rbash not in shell list."""
+
+ def test_ash_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("ash", False)
+
+ def test_rbash_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("rbash", False)
+
+ def test_rksh_blocked(self) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ check_shell_allowed("rksh", False)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# R2 Review Fixes — /8eyes + Sonnet findings
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestR2ConcatenatedShellArg:
+ """R2 finding: env -S 'sh -c id' bypasses shell detection."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(allow_shell=False)
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_env_S_sh_blocked(self, broker: ProcessBroker) -> None:
+ """env -S 'sh -c id' — shell name embedded in single arg."""
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("env", ["-S", "sh -c id"])
+
+ def test_env_S_bash_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("env", ["-S", "bash -c whoami"])
+
+ def test_busybox_concatenated_sh(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("busybox", ["sh -c id"])
+
+
+class TestR2EnvVarFalsePositive:
+ """R2 finding: env SHELL=/bin/bash python was false-positive."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(allow_shell=False)
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_env_var_assignment_not_flagged(self, broker: ProcessBroker) -> None:
+ """env SHELL=/bin/bash python should pass (SHELL=... is env var, not cmd)."""
+ broker.check_command("env", ["SHELL=/bin/bash", "python"])
+
+ def test_env_var_path_not_flagged(self, broker: ProcessBroker) -> None:
+ """env PATH=/usr/bin node should pass."""
+ broker.check_command("env", ["PATH=/usr/bin", "node"])
+
+ def test_env_var_but_shell_arg_still_blocked(self, broker: ProcessBroker) -> None:
+ """env MYVAR=1 bash should block on bash (the actual command)."""
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("env", ["MYVAR=1", "bash"])
+
+
+class TestR2MissingWrappers:
+ """R2 finding: time, stdbuf, chpst bypass wrapper detection."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(allow_shell=False)
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_time_bash_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("time", ["bash", "-c", "id"])
+
+ def test_stdbuf_bash_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("stdbuf", ["-o0", "bash", "-c", "id"])
+
+ def test_chpst_bash_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("chpst", ["bash", "-c", "id"])
+
+ def test_watch_sh_blocked(self, broker: ProcessBroker) -> None:
+ with pytest.raises(ShellNotAllowedError, match="wrapper"):
+ broker.check_command("watch", ["sh", "-c", "id"])
+
+
+class TestR2DoubleExeExtension:
+ """R2 finding: rm.exe.exe bypasses _strip_exe."""
+
+ def test_double_exe_stripped(self) -> None:
+ assert _strip_exe("rm.exe.exe") == "rm"
+
+ def test_triple_exe_stripped(self) -> None:
+ assert _strip_exe("rm.exe.exe.exe") == "rm"
+
+ def test_double_exe_blocked(self) -> None:
+ with pytest.raises(CommandBlockedError):
+ check_command_blocked("rm.exe.exe", [])
+
+ def test_double_exe_link_blocked(self) -> None:
+ with pytest.raises(SyscallBlockedError):
+ check_link_command("ln.exe.exe")
+
+
+class TestR2ConcatenatedBlockedArgs:
+ """R2: blocked commands embedded in space-concatenated args."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(allow_shell=False)
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_env_S_rm_blocked(self, broker: ProcessBroker) -> None:
+ """env -S 'rm -rf /' — rm embedded in concatenated arg."""
+ with pytest.raises(CommandBlockedError, match="rm"):
+ broker.check_command("env", ["-S", "rm -rf /"])
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# R3 Review Fixes — /collab findings
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestR3AllowlistWrapperBypass:
+ """R3 finding: env in allowlist lets non-allowed commands through."""
+
+ def test_env_curl_blocked_by_allowlist(self) -> None:
+ """With allowed=[env, python], env curl must be rejected."""
+ cap = ProcessCapability(allowed_commands=["env", "python"])
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(CommandNotAllowedError, match="curl"):
+ broker.check_command("env", ["curl", "https://example.com"])
+
+ def test_env_python_allowed(self) -> None:
+ """env python should pass when both are in allowlist."""
+ cap = ProcessCapability(allowed_commands=["env", "python"])
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ broker.check_command("env", ["python", "script.py"])
+
+ def test_sudo_curl_blocked(self) -> None:
+ """sudo curl must be rejected when curl not in allowlist."""
+ cap = ProcessCapability(
+ allowed_commands=["sudo", "python"],
+ allow_shell=False,
+ )
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ with pytest.raises(CommandNotAllowedError, match="curl"):
+ broker.check_command("sudo", ["curl", "https://evil.com"])
+
+ def test_timeout_python_allowed(self) -> None:
+ """timeout python should pass."""
+ cap = ProcessCapability(allowed_commands=["timeout", "python"])
+ broker = ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+ broker.check_command("timeout", ["30", "python", "script.py"])
+
+
+class TestR3FlagEmbeddedBypass:
+ """R3 finding: -Sbash, --split-string=bash, foo=bar/rm bypass scanning."""
+
+ @pytest.fixture
+ def broker(self) -> ProcessBroker:
+ cap = ProcessCapability(allow_shell=False)
+ return ProcessBroker(ProcessBrokerConfig.from_capability(cap))
+
+ def test_Sbash_flag_blocked(self, broker: ProcessBroker) -> None:
+ """env -Sbash -c id — shell name in flag value."""
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("env", ["-Sbash", "-c", "id"])
+
+ def test_split_string_bash_blocked(self, broker: ProcessBroker) -> None:
+ """env --split-string=bash -c id — shell in long flag value."""
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("env", ["--split-string=bash", "-c", "id"])
+
+ def test_env_var_path_rm_after_sentinel_blocked(self, broker: ProcessBroker) -> None:
+ """env -- foo=bar/rm — after sentinel, treated as positional, rm caught."""
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("env", ["--", "foo=bar/rm", "-rf", "/"])
+
+ def test_split_string_rm_blocked(self, broker: ProcessBroker) -> None:
+ """env --split-string=rm -rf / — blocked cmd in long flag value."""
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("env", ["--split-string=rm", "-rf", "/"])
+
+
+class TestR3ExtractArgTokens:
+ """Tests for the _extract_arg_tokens helper."""
+
+ def test_plain_args(self) -> None:
+ assert _extract_arg_tokens(["bash", "script.py"]) == ["bash", "script.py"]
+
+ def test_flags_skipped(self) -> None:
+ assert _extract_arg_tokens(["-c", "--verbose"]) == []
+
+ def test_short_flag_value_extracted(self) -> None:
+ tokens = _extract_arg_tokens(["-Sbash -c id"])
+ assert "bash" in tokens
+
+ def test_long_flag_value_extracted(self) -> None:
+ tokens = _extract_arg_tokens(["--split-string=bash -c id"])
+ assert "bash" in tokens
+
+ def test_env_var_value_skipped(self) -> None:
+ tokens = _extract_arg_tokens(["FOO=bar/bash"])
+ assert tokens == [] # Env vars are skipped entirely
+
+ def test_sentinel_skipped(self) -> None:
+ assert _extract_arg_tokens(["--", "bash"]) == ["bash"]
+
+ def test_sentinel_makes_env_var_positional(self) -> None:
+ """After --, FOO=bar is treated as positional (not env var)."""
+ tokens = _extract_arg_tokens(["--", "FOO=bar/rm"])
+ assert "FOO=bar/rm" in tokens
+
+ def test_space_split(self) -> None:
+ tokens = _extract_arg_tokens(["sh -c id"])
+ assert "sh" in tokens
+
+
+# ── R4 Regressions (false-positive + find/parallel wrapper) ──────────
+
+
+class TestR4NonWrapperArgSafe:
+ """Non-wrapper commands must NOT have args scanned for blocked commands.
+
+ Fixes false-positive: `git checkout rm` wrongly blocking on `rm` in args.
+ """
+
+ def test_git_checkout_rm_allowed(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("git",),
+ blocked_commands=("rm",),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("git", ["checkout", "rm"])
+
+ def test_cat_file_named_reboot(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("cat",),
+ blocked_commands=("reboot",),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("cat", ["/tmp/reboot"])
+
+ def test_grep_pattern_shutdown(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("grep",),
+ blocked_commands=("shutdown",),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("grep", ["shutdown", "log.txt"])
+
+
+class TestR4WrapperArgScanStillWorks:
+ """Wrappers must still scan args for blocked commands."""
+
+ def test_env_rm_still_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=(),
+ blocked_commands=("rm",),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("env", ["rm", "-rf", "/"])
+
+ def test_sudo_reboot_still_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=(),
+ blocked_commands=("reboot",),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("sudo", ["reboot"])
+
+
+class TestR4FindParallelWrapper:
+ """find and parallel are wrappers — their -exec args get scanned."""
+
+ def test_find_exec_sh_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=(), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("find", [".", "-exec", "sh", "-c", "id", ";"])
+
+ def test_find_exec_rm_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=(),
+ blocked_commands=("rm",),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("find", [".", "-exec", "rm", "-rf", "{}", ";"])
+
+ def test_parallel_sh_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=(), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("parallel", ["sh", "-c", "echo {}"])
+
+ def test_find_exec_allowed_cmd_passes(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("find", "echo"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("find", [".", "-exec", "echo", "{}", ";"])
+
+
+class TestR4AllowlistFlagEmbedded:
+ """Flag-embedded commands must be checked against the allowlist."""
+
+ def test_env_split_string_curl_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("env", "python"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ with pytest.raises(CommandNotAllowedError):
+ broker.check_command("env", ["--split-string=curl https://evil.com"])
+
+ def test_env_short_flag_curl_blocked(self) -> None:
+ """env -Scurl must also be caught by allowlist."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("env", "python"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ with pytest.raises(CommandNotAllowedError):
+ broker.check_command("env", ["-Scurl"])
+
+ def test_env_split_string_allowed_passes(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("env", "python"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ broker.check_command("env", ["--split-string=python script.py"])
+
+
+class TestR4ExeAllowlistMismatch:
+ """python.exe should match allowlist entry 'python'."""
+
+ def test_exe_matches_allowed(self) -> None:
+ check_command_allowed("python.exe", ["python"])
+
+ def test_double_exe_matches_allowed(self) -> None:
+ check_command_allowed("cmd.exe.exe", ["cmd"])
+
+ def test_non_matching_still_blocked(self) -> None:
+ with pytest.raises(CommandNotAllowedError):
+ check_command_allowed("curl.exe", ["python"])
+
+
+class TestR4PortShape:
+ """ProcessBrokerPort.active_count must be a property."""
+
+ def test_active_count_is_property(self) -> None:
+ cap = ProcessCapability()
+ config = ProcessBrokerConfig.from_capability(cap)
+ broker = ProcessBroker(config)
+ # Must be accessible as property, not method call
+ assert broker.active_count == 0
+
+
+# ── R5 Regressions (flock, multi-exec, shell flags, glob patterns) ───
+
+
+class TestR5FlockWrapper:
+ """flock must be treated as a wrapper."""
+
+ def test_flock_bash_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=(), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("flock", ["/tmp/lock", "bash", "-c", "id"])
+
+ def test_flock_allowed_cmd_passes(self) -> None:
+ """flock's first positional arg is a lock file — second is the command."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("flock", "echo", "lock"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("flock", ["/tmp/lock", "echo", "hello"])
+
+
+class TestR5MultiExecAllowlist:
+ """Multi-exec: all -exec commands are allowlist-checked via pass C."""
+
+ def test_second_exec_not_allowed(self) -> None:
+ """Second -exec with non-allowed command is caught by pass C."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("find", "echo"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(CommandNotAllowedError):
+ broker.check_command("find", [
+ ".", "-exec", "echo", "{}", ";",
+ "-exec", "curl", "https://evil.example", ";",
+ ])
+
+ def test_second_exec_blocked_cmd_caught(self) -> None:
+ """Blocked-command check also scans ALL wrapper args."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("find", "echo"),
+ blocked_commands=("curl",),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(CommandBlockedError):
+ broker.check_command("find", [
+ ".", "-exec", "echo", "{}", ";",
+ "-exec", "curl", "https://evil.example", ";",
+ ])
+
+ def test_second_exec_shell_caught(self) -> None:
+ """Shell check scans ALL wrapper args — second -exec sh blocked."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("find", "echo"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("find", [
+ ".", "-exec", "echo", "{}", ";",
+ "-exec", "sh", "-c", "id", ";",
+ ])
+
+ def test_all_exec_allowed_passes(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("find", "echo", "grep"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("find", [
+ ".", "-exec", "echo", "{}", ";",
+ "-exec", "grep", "pattern", "{}", ";",
+ ])
+
+
+class TestR5ShellInvokingFlags:
+ """sudo -s, su -, doas -s must be blocked when allow_shell=False."""
+
+ def test_sudo_dash_s(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("sudo",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("sudo", ["-s"])
+
+ def test_sudo_dash_i(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("sudo",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("sudo", ["-i"])
+
+ def test_su_dash(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("su",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("su", ["-"])
+
+ def test_doas_dash_s(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("doas",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("doas", ["-s"])
+
+ def test_sudo_dash_s_allowed_when_shell_true(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("sudo",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ broker.check_command("sudo", ["-s"])
+
+ def test_sudo_combined_si_blocked(self) -> None:
+ """Combined short flags: -si means -s + -i, both shell-invoking."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("sudo",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("sudo", ["-si"])
+
+ def test_sudo_combined_uis_blocked(self) -> None:
+ """Even with other flags mixed in, -s is detected."""
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("sudo",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("sudo", ["-uis"])
+
+
+class TestR5GlobPatternSkip:
+ """Glob patterns (find -name *.py) should not be checked as commands."""
+
+ def test_find_name_glob_not_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("find", "echo"),
+ blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ broker.check_command("find", [".", "-name", "*.py", "-exec", "echo", "{}", ";"])
+
+
+# ── R7 Regressions (su -c, runuser -c shell bypass) ─────────────────
+
+
+class TestR7SuRunuserCommand:
+ """su -c and runuser -c invoke a shell — must block when allow_shell=False."""
+
+ def test_su_dash_c_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("su",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("su", ["-c", "id"])
+
+ def test_su_command_flag_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("su",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("su", ["--command", "id"])
+
+ def test_runuser_dash_c_blocked(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("runuser",), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=False,
+ ))
+ with pytest.raises(ShellNotAllowedError):
+ broker.check_command("runuser", ["-c", "id"])
+
+ def test_su_dash_c_allowed_when_shell_true(self) -> None:
+ broker = ProcessBroker(ProcessBrokerConfig(
+ allowed_commands=("su", "id"), blocked_commands=(),
+ max_processes=10, max_execution_time_s=300, allow_shell=True,
+ ))
+ broker.check_command("su", ["-c", "id"])
diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py
new file mode 100644
index 00000000..f68eba20
--- /dev/null
+++ b/tests/test_rate_limit.py
@@ -0,0 +1,373 @@
+"""Tests for sliding-window rate limiting on MCP server endpoints.
+
+Covers:
+ - Under-limit requests pass through with rate limit headers
+ - Over-limit requests get 429 with Retry-After
+ - Per-IP and per-identity (IP:token) independent limits
+ - Configurable thresholds via env vars
+ - Sliding window expiry (requests become available again)
+ - Direct client IP used (X-Forwarded-For NOT trusted)
+ - Non-HTTP scopes pass through
+ - Max bucket eviction prevents memory exhaustion
+ - Rate limit headers report governing limit correctly
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from openspace.auth.rate_limit import (
+ DEFAULT_PER_IP,
+ DEFAULT_PER_TOKEN,
+ DEFAULT_WINDOW,
+ MAX_BUCKETS,
+ RATE_LIMIT_PER_IP_ENV,
+ RATE_LIMIT_PER_TOKEN_ENV,
+ RATE_LIMIT_WINDOW_ENV,
+ RateLimitMiddleware,
+ SlidingWindowCounter,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def dummy_app():
+ """ASGI app that records calls and returns 200."""
+
+ async def app(scope, receive, send):
+ app.call_count += 1
+ await send({
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [[b"content-type", b"text/plain"]],
+ })
+ await send({"type": "http.response.body", "body": b"OK"})
+
+ app.call_count = 0
+ return app
+
+
+def _http_scope(
+ path="/test",
+ client_ip="10.0.0.1",
+ headers=None,
+ token=None,
+):
+ """Build HTTP ASGI scope with optional bearer token."""
+ raw_headers = []
+ for k, v in (headers or {}).items():
+ raw_headers.append([k.encode(), v.encode()])
+ if token:
+ raw_headers.append([b"authorization", f"Bearer {token}".encode()])
+ return {
+ "type": "http",
+ "path": path,
+ "headers": raw_headers,
+ "client": (client_ip, 12345),
+ }
+
+
+class ResponseCollector:
+ def __init__(self):
+ self.status = None
+ self.headers = {}
+ self.body = b""
+
+ async def __call__(self, message):
+ if message["type"] == "http.response.start":
+ self.status = message["status"]
+ for k, v in message.get("headers", []):
+ self.headers[k.decode()] = v.decode()
+ elif message["type"] == "http.response.body":
+ self.body += message.get("body", b"")
+
+
+# ---------------------------------------------------------------------------
+# SlidingWindowCounter unit tests
+# ---------------------------------------------------------------------------
+
+
+class TestSlidingWindowCounter:
+ @pytest.mark.asyncio
+ async def test_under_limit_allowed(self):
+ counter = SlidingWindowCounter(limit=5, window=60.0)
+ ok, remaining, retry = await counter.is_allowed("key1")
+ assert ok is True
+ assert remaining == 4
+ assert retry == 0.0
+
+ @pytest.mark.asyncio
+ async def test_at_limit_rejected(self):
+ counter = SlidingWindowCounter(limit=3, window=60.0)
+ for _ in range(3):
+ await counter.is_allowed("key1")
+ ok, remaining, retry = await counter.is_allowed("key1")
+ assert ok is False
+ assert remaining == 0
+ assert retry > 0
+
+ @pytest.mark.asyncio
+ async def test_different_keys_independent(self):
+ counter = SlidingWindowCounter(limit=2, window=60.0)
+ await counter.is_allowed("a")
+ await counter.is_allowed("a")
+ # "a" is exhausted
+ ok_a, _, _ = await counter.is_allowed("a")
+ assert ok_a is False
+ # "b" is fresh
+ ok_b, remaining_b, _ = await counter.is_allowed("b")
+ assert ok_b is True
+ assert remaining_b == 1
+
+ @pytest.mark.asyncio
+ async def test_window_expiry(self):
+ """Requests expire after the window passes."""
+ counter = SlidingWindowCounter(limit=2, window=0.1) # 100ms window
+ await counter.is_allowed("key1")
+ await counter.is_allowed("key1")
+ # Exhausted
+ ok, _, _ = await counter.is_allowed("key1")
+ assert ok is False
+ # Wait for window to expire
+ await asyncio.sleep(0.15)
+ ok, remaining, _ = await counter.is_allowed("key1")
+ assert ok is True
+ assert remaining == 1
+
+
+# ---------------------------------------------------------------------------
+# RateLimitMiddleware integration tests
+# ---------------------------------------------------------------------------
+
+
+class TestRateLimitMiddleware:
+ @pytest.mark.asyncio
+ async def test_under_limit_passes_through(self, dummy_app, monkeypatch):
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "10")
+ monkeypatch.setenv(RATE_LIMIT_PER_TOKEN_ENV, "10")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+ scope = _http_scope(client_ip="1.2.3.4")
+ collector = ResponseCollector()
+ await mw(scope, AsyncMock(), collector)
+ assert collector.status == 200
+ assert dummy_app.call_count == 1
+ assert "x-ratelimit-remaining" in collector.headers
+
+ @pytest.mark.asyncio
+ async def test_ip_limit_exceeded_returns_429(self, dummy_app, monkeypatch):
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "3")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+
+ for _ in range(3):
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="5.5.5.5"), AsyncMock(), collector)
+ assert collector.status == 200
+
+ # 4th request should be rate limited
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="5.5.5.5"), AsyncMock(), collector)
+ assert collector.status == 429
+ body = json.loads(collector.body)
+ assert body["error"] == "rate_limited"
+ assert "retry-after" in collector.headers
+
+ @pytest.mark.asyncio
+ async def test_token_limit_uses_ip_token_composite(self, dummy_app, monkeypatch):
+ """Per-identity limit uses IP:token composite key, not raw token."""
+ monkeypatch.setenv(RATE_LIMIT_PER_TOKEN_ENV, "2")
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "100") # high IP limit
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+ token = "shared-secret-token-value"
+
+ # Two requests from IP A with token → OK
+ for _ in range(2):
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="10.0.0.1", token=token), AsyncMock(), collector)
+ assert collector.status == 200
+
+ # 3rd from IP A → 429 (identity exhausted)
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="10.0.0.1", token=token), AsyncMock(), collector)
+ assert collector.status == 429
+
+ # Same token from IP B → still allowed (different composite key)
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="10.0.0.2", token=token), AsyncMock(), collector)
+ assert collector.status == 200
+
+ @pytest.mark.asyncio
+ async def test_different_ips_independent(self, dummy_app, monkeypatch):
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "2")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+
+ for _ in range(2):
+ await mw(_http_scope(client_ip="1.1.1.1"), AsyncMock(), ResponseCollector())
+ # IP 1.1.1.1 exhausted
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="1.1.1.1"), AsyncMock(), collector)
+ assert collector.status == 429
+
+ # IP 2.2.2.2 still allowed
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="2.2.2.2"), AsyncMock(), collector)
+ assert collector.status == 200
+
+ @pytest.mark.asyncio
+ async def test_x_forwarded_for_ignored(self, dummy_app, monkeypatch):
+ """X-Forwarded-For is NOT trusted — prevents IP spoofing bypass."""
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "1")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+
+ # First request from 127.0.0.1 with spoofed XFF
+ scope = _http_scope(
+ client_ip="127.0.0.1",
+ headers={"x-forwarded-for": "203.0.113.50"},
+ )
+ collector = ResponseCollector()
+ await mw(scope, AsyncMock(), collector)
+ assert collector.status == 200
+
+ # Second request from same client IP but different XFF
+ # Should be rate limited because we use client IP, not XFF
+ scope2 = _http_scope(
+ client_ip="127.0.0.1",
+ headers={"x-forwarded-for": "198.51.100.99"},
+ )
+ collector = ResponseCollector()
+ await mw(scope2, AsyncMock(), collector)
+ assert collector.status == 429
+
+ @pytest.mark.asyncio
+ async def test_lifespan_passes_through(self, dummy_app, monkeypatch):
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "1")
+ mw = RateLimitMiddleware(dummy_app)
+ scope = {"type": "lifespan"}
+ await mw(scope, AsyncMock(), AsyncMock())
+ assert dummy_app.call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_rate_limit_headers_present(self, dummy_app, monkeypatch):
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "10")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+ collector = ResponseCollector()
+ await mw(_http_scope(), AsyncMock(), collector)
+ assert "x-ratelimit-remaining" in collector.headers
+ assert "x-ratelimit-limit" in collector.headers
+ assert "x-ratelimit-window" in collector.headers
+
+ @pytest.mark.asyncio
+ async def test_window_recovery(self, dummy_app, monkeypatch):
+ """After window expires, requests are allowed again."""
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "1")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "1") # 1 second window
+ mw = RateLimitMiddleware(dummy_app)
+
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="9.9.9.9"), AsyncMock(), collector)
+ assert collector.status == 200
+
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="9.9.9.9"), AsyncMock(), collector)
+ assert collector.status == 429
+
+ await asyncio.sleep(1.1)
+
+ collector = ResponseCollector()
+ await mw(_http_scope(client_ip="9.9.9.9"), AsyncMock(), collector)
+ assert collector.status == 200
+
+ @pytest.mark.asyncio
+ async def test_governing_headers_reflect_token_limit(self, dummy_app, monkeypatch):
+ """When token limit < IP limit, headers report the token (governing) limit."""
+ monkeypatch.setenv(RATE_LIMIT_PER_IP_ENV, "100")
+ monkeypatch.setenv(RATE_LIMIT_PER_TOKEN_ENV, "5")
+ monkeypatch.setenv(RATE_LIMIT_WINDOW_ENV, "60")
+ mw = RateLimitMiddleware(dummy_app)
+
+ collector = ResponseCollector()
+ await mw(
+ _http_scope(client_ip="7.7.7.7", token="test-token"),
+ AsyncMock(),
+ collector,
+ )
+ assert collector.status == 200
+ # Governing limit should be min(100, 5) = 5
+ assert collector.headers["x-ratelimit-limit"] == "5"
+
+
+class TestSlidingWindowCounterEviction:
+ """Test max bucket cap prevents memory exhaustion without resetting active clients."""
+
+ @pytest.mark.asyncio
+ async def test_new_keys_rejected_at_capacity(self):
+ """When at max_buckets, new keys are rejected (not existing ones evicted)."""
+ counter = SlidingWindowCounter(limit=10, window=60.0, max_buckets=3)
+ # Fill to capacity
+ for i in range(3):
+ ok, _, _ = await counter.is_allowed(f"key-{i}")
+ assert ok is True
+ # New key should be rejected
+ ok, remaining, retry = await counter.is_allowed("key-new")
+ assert ok is False
+ assert remaining == 0
+ assert retry > 0
+
+ @pytest.mark.asyncio
+ async def test_existing_keys_still_work_at_capacity(self):
+ """Existing clients are not affected when new keys are rejected."""
+ counter = SlidingWindowCounter(limit=10, window=60.0, max_buckets=3)
+ for i in range(3):
+ await counter.is_allowed(f"key-{i}")
+ # Existing key should still work
+ ok, remaining, _ = await counter.is_allowed("key-1")
+ assert ok is True
+ assert remaining == 8 # 10 - 2 requests
+
+ @pytest.mark.asyncio
+ async def test_stale_cleanup_frees_capacity(self):
+ """After stale keys expire, new keys can be accepted again."""
+ counter = SlidingWindowCounter(limit=10, window=0.1, max_buckets=2)
+ counter._cleanup_interval = 0.05 # fast cleanup for test
+ await counter.is_allowed("old-key-1")
+ await counter.is_allowed("old-key-2")
+ # At capacity — new key rejected
+ ok, _, _ = await counter.is_allowed("new-key")
+ assert ok is False
+ # Wait for window expiry
+ await asyncio.sleep(0.15)
+ # Now new key should work (stale keys cleaned up)
+ ok, _, _ = await counter.is_allowed("new-key")
+ assert ok is True
+
+ @pytest.mark.asyncio
+ async def test_stale_cleanup_at_capacity_with_default_interval(self):
+ """Stale buckets are force-cleaned at capacity even with long cleanup interval."""
+ # Reproduces: default cleanup_interval >> window, but expired buckets
+ # should NOT block new clients
+ counter = SlidingWindowCounter(limit=10, window=0.1, max_buckets=2)
+ # Deliberately keep long cleanup interval (simulates default behavior)
+ counter._cleanup_interval = 999.0
+ await counter.is_allowed("a")
+ await counter.is_allowed("b")
+ # At capacity
+ ok, _, _ = await counter.is_allowed("c")
+ assert ok is False
+ # Wait for window expiry
+ await asyncio.sleep(0.15)
+ # New key should succeed: force-cleanup runs at capacity
+ ok, _, _ = await counter.is_allowed("c")
+ assert ok is True
diff --git a/tests/test_sdk_contract.py b/tests/test_sdk_contract.py
new file mode 100644
index 00000000..cc18ff08
--- /dev/null
+++ b/tests/test_sdk_contract.py
@@ -0,0 +1,557 @@
+"""SDK API contract tests — EPIC 1.8.
+
+Issues:
+- #56: REST API specification defined in docs/sdk-api-spec.md
+- #57: Public API surface documented in docs/sdk-public-surface.md
+- #58: API contract tests (test-first, implementations in Phase 6)
+
+These tests validate the **contract** (data shapes, error codes, envelope
+structure) without requiring a running server. They test the specification
+itself: can our domain types serialize to the documented schema? Do error
+envelopes match the spec?
+
+Phase 6 will add integration tests against a live server.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass, field
+from typing import Any, Optional
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# SDK envelope helpers (will become openspace.sdk.envelope in Phase 6)
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True, slots=True)
+class APIError:
+ """Standard error payload."""
+
+ code: str
+ message: str
+ details: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True, slots=True)
+class APIEnvelope:
+ """Standard response envelope per sdk-api-spec.md."""
+
+ ok: bool
+ data: Optional[dict[str, Any]] = None
+ error: Optional[APIError] = None
+ request_id: str = ""
+
+ def to_json(self) -> str:
+ d: dict[str, Any] = {"ok": self.ok, "data": self.data, "request_id": self.request_id}
+ d["error"] = asdict(self.error) if self.error else None
+ return json.dumps(d)
+
+ @classmethod
+ def success(cls, data: dict[str, Any], request_id: str = "") -> "APIEnvelope":
+ return cls(ok=True, data=data, request_id=request_id)
+
+ @classmethod
+ def failure(cls, code: str, message: str, request_id: str = "", **details: Any) -> "APIEnvelope":
+ return cls(ok=False, error=APIError(code=code, message=message, details=details), request_id=request_id)
+
+
+# ---------------------------------------------------------------------------
+# SDK request/response types (will become openspace.sdk.types in Phase 6)
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True, slots=True)
+class TaskRequest:
+ """SDK task execution request."""
+
+ task: str
+ workspace_dir: Optional[str] = None
+ max_iterations: Optional[int] = None
+ skill_dirs: Optional[list[str]] = None
+ search_scope: str = "all"
+
+ def to_dict(self) -> dict[str, Any]:
+ d = asdict(self)
+ return {k: v for k, v in d.items() if v is not None}
+
+
+@dataclass(frozen=True, slots=True)
+class ToolUsageRecord:
+ """SDK record of a tool invocation within a task."""
+
+ tool_name: str
+ arguments: dict[str, Any] = field(default_factory=dict)
+ success: bool = True
+
+
+@dataclass(frozen=True, slots=True)
+class TaskResultData:
+ """SDK task result payload."""
+
+ task_id: str
+ status: str
+ success: Optional[bool] = None
+ output: Optional[str] = None
+ tools_used: Optional[list[ToolUsageRecord]] = None
+ skill_used: Optional[str] = None
+ evolved_skills: Optional[list[str]] = None
+ duration_ms: Optional[int] = None
+ error: Optional[str] = None
+
+
+@dataclass(frozen=True, slots=True)
+class SkillInfo:
+ """SDK skill info payload (list/summary view)."""
+
+ id: str
+ name: str
+ version: str
+ active: bool
+ created_at: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class SkillDetail:
+ """SDK skill detail payload (single-resource view).
+
+ Extends SkillInfo with manifest and lineage for GET /skills/{id}.
+ """
+
+ id: str
+ name: str
+ version: str
+ active: bool
+ created_at: str = ""
+ manifest: dict[str, Any] = field(default_factory=dict)
+ lineage: list[str] = field(default_factory=list)
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchResult:
+ """SDK skill search result."""
+
+ id: str
+ name: str
+ description: str
+ source: str
+ score: float
+ imported: bool = False
+
+
+@dataclass(frozen=True, slots=True)
+class HealthStatus:
+ """SDK health check payload."""
+
+ status: str
+ version: str
+ initialized: bool
+ backends: list[str] = field(default_factory=list)
+
+
+# ---------------------------------------------------------------------------
+# Error code registry (must match sdk-api-spec.md)
+# ---------------------------------------------------------------------------
+
+VALID_ERROR_CODES = frozenset(
+ {
+ "AUTH_REQUIRED",
+ "RATE_LIMITED",
+ "TASK_NOT_FOUND",
+ "SKILL_NOT_FOUND",
+ "TASK_FAILED",
+ "NOT_INITIALIZED",
+ "VALIDATION_ERROR",
+ "EVOLUTION_NOT_FOUND",
+ "INVALID_STATE",
+ }
+)
+
+VALID_TASK_STATUSES = frozenset(
+ {"queued", "running", "completed", "failed", "cancelled"}
+)
+
+VALID_SEARCH_SCOPES = frozenset({"all", "local", "cloud"})
+
+VALID_HEALTH_STATUSES = frozenset({"healthy", "degraded", "unhealthy"})
+
+
+# ===========================================================================
+# Contract Tests
+# ===========================================================================
+
+
+class TestAPIEnvelopeContract:
+ """The response envelope must match the documented schema."""
+
+ def test_success_envelope_shape(self) -> None:
+ env = APIEnvelope.success({"task_id": "abc"}, request_id="req-1")
+ parsed = json.loads(env.to_json())
+
+ assert parsed["ok"] is True
+ assert parsed["data"] == {"task_id": "abc"}
+ assert parsed["error"] is None
+ assert parsed["request_id"] == "req-1"
+
+ def test_error_envelope_shape(self) -> None:
+ env = APIEnvelope.failure("TASK_NOT_FOUND", "Task xyz not found", request_id="req-2")
+ parsed = json.loads(env.to_json())
+
+ assert parsed["ok"] is False
+ assert parsed["data"] is None
+ assert parsed["error"]["code"] == "TASK_NOT_FOUND"
+ assert parsed["error"]["message"] == "Task xyz not found"
+ assert isinstance(parsed["error"]["details"], dict)
+ assert parsed["request_id"] == "req-2"
+
+ def test_envelope_always_has_four_keys(self) -> None:
+ """Spec requires exactly: ok, data, error, request_id."""
+ for env in [
+ APIEnvelope.success({}),
+ APIEnvelope.failure("AUTH_REQUIRED", "No token"),
+ ]:
+ parsed = json.loads(env.to_json())
+ assert set(parsed.keys()) == {"ok", "data", "error", "request_id"}
+
+ def test_success_and_error_mutually_exclusive(self) -> None:
+ success = APIEnvelope.success({"x": 1})
+ assert success.ok is True and success.error is None
+
+ failure = APIEnvelope.failure("TASK_FAILED", "boom")
+ assert failure.ok is False and failure.data is None
+
+
+class TestErrorCodeContract:
+ """All error codes used must be in the documented registry."""
+
+ def test_known_error_codes(self) -> None:
+ for code in VALID_ERROR_CODES:
+ env = APIEnvelope.failure(code, f"Test {code}")
+ assert env.error is not None
+ assert env.error.code in VALID_ERROR_CODES
+
+ def test_error_code_count(self) -> None:
+ """Guard: if you add an error code, update the spec first."""
+ assert len(VALID_ERROR_CODES) == 9
+
+
+class TestTaskRequestContract:
+ """TaskRequest must serialize to the documented schema."""
+
+ def test_minimal_request(self) -> None:
+ req = TaskRequest(task="Hello world")
+ d = req.to_dict()
+ assert d == {"task": "Hello world", "search_scope": "all"}
+
+ def test_full_request(self) -> None:
+ req = TaskRequest(
+ task="Build a calculator",
+ workspace_dir="/tmp/ws",
+ max_iterations=5,
+ skill_dirs=["/skills/a"],
+ search_scope="local",
+ )
+ d = req.to_dict()
+ assert d["task"] == "Build a calculator"
+ assert d["workspace_dir"] == "/tmp/ws"
+ assert d["max_iterations"] == 5
+ assert d["skill_dirs"] == ["/skills/a"]
+ assert d["search_scope"] == "local"
+
+ def test_search_scope_validation(self) -> None:
+ for scope in VALID_SEARCH_SCOPES:
+ req = TaskRequest(task="test", search_scope=scope)
+ assert req.search_scope in VALID_SEARCH_SCOPES
+
+
+class TestTaskResultContract:
+ """TaskResultData must match the documented response schema."""
+
+ def test_queued_result(self) -> None:
+ result = TaskResultData(task_id="t-1", status="queued")
+ assert result.status in VALID_TASK_STATUSES
+ assert result.success is None # not yet determined
+
+ def test_completed_result(self) -> None:
+ result = TaskResultData(
+ task_id="t-2",
+ status="completed",
+ success=True,
+ output="Done",
+ skill_used="calculator-v1",
+ evolved_skills=["calculator-v2"],
+ duration_ms=1500,
+ )
+ assert result.status in VALID_TASK_STATUSES
+ assert result.success is True
+ assert result.duration_ms > 0
+
+ def test_failed_result(self) -> None:
+ result = TaskResultData(
+ task_id="t-3",
+ status="failed",
+ success=False,
+ error="Timeout after 30s",
+ )
+ assert result.status in VALID_TASK_STATUSES
+ assert result.success is False
+ assert result.error is not None
+
+ def test_all_statuses_valid(self) -> None:
+ for status in VALID_TASK_STATUSES:
+ result = TaskResultData(task_id="x", status=status)
+ assert result.status == status
+
+
+class TestSkillInfoContract:
+ """SkillInfo must match the documented response schema."""
+
+ def test_skill_info_fields(self) -> None:
+ info = SkillInfo(
+ id="skill-1",
+ name="Calculator",
+ version="1.0.0",
+ active=True,
+ created_at="2026-01-01T00:00:00Z",
+ )
+ d = asdict(info)
+ assert set(d.keys()) == {"id", "name", "version", "active", "created_at"}
+
+ def test_skill_serialization_roundtrip(self) -> None:
+ info = SkillInfo(id="s1", name="Test", version="0.1", active=False)
+ j = json.dumps(asdict(info))
+ parsed = json.loads(j)
+ assert parsed["id"] == "s1"
+ assert parsed["active"] is False
+
+
+class TestSkillSearchContract:
+ """SkillSearchResult must match the documented response schema."""
+
+ def test_search_result_fields(self) -> None:
+ result = SkillSearchResult(
+ id="s-1",
+ name="Web Scraper",
+ description="Scrapes websites",
+ source="cloud",
+ score=0.95,
+ imported=False,
+ )
+ d = asdict(result)
+ assert set(d.keys()) == {"id", "name", "description", "source", "score", "imported"}
+ assert result.source in ("local", "cloud")
+ assert 0.0 <= result.score <= 1.0
+
+ def test_imported_flag(self) -> None:
+ local = SkillSearchResult(id="x", name="X", description="", source="local", score=1.0, imported=True)
+ assert local.imported is True
+
+
+class TestHealthContract:
+ """HealthStatus must match the documented response schema."""
+
+ def test_health_fields(self) -> None:
+ health = HealthStatus(
+ status="healthy",
+ version="0.1.0",
+ initialized=True,
+ backends=["shell", "mcp"],
+ )
+ assert health.status in VALID_HEALTH_STATUSES
+ assert isinstance(health.backends, list)
+
+ def test_all_health_statuses(self) -> None:
+ for status in VALID_HEALTH_STATUSES:
+ h = HealthStatus(status=status, version="0.1", initialized=True)
+ assert h.status == status
+
+
+class TestEnvelopeIntegration:
+ """Verify domain data wraps correctly in the envelope."""
+
+ def test_task_result_in_envelope(self) -> None:
+ result = TaskResultData(task_id="t-1", status="completed", success=True, output="Done")
+ env = APIEnvelope.success(asdict(result), request_id="r-1")
+ parsed = json.loads(env.to_json())
+
+ assert parsed["ok"] is True
+ assert parsed["data"]["task_id"] == "t-1"
+ assert parsed["data"]["status"] == "completed"
+
+ def test_skill_list_in_envelope(self) -> None:
+ skills = [
+ asdict(SkillInfo(id="s1", name="A", version="1.0", active=True)),
+ asdict(SkillInfo(id="s2", name="B", version="2.0", active=False)),
+ ]
+ env = APIEnvelope.success({"skills": skills, "total": 2, "limit": 50, "offset": 0})
+ parsed = json.loads(env.to_json())
+
+ assert parsed["data"]["total"] == 2
+ assert len(parsed["data"]["skills"]) == 2
+
+ def test_error_wrapping(self) -> None:
+ env = APIEnvelope.failure(
+ "SKILL_NOT_FOUND",
+ "Skill 'foo' not found",
+ request_id="r-3",
+ skill_id="foo",
+ )
+ parsed = json.loads(env.to_json())
+ assert parsed["ok"] is False
+ assert parsed["error"]["code"] == "SKILL_NOT_FOUND"
+ assert parsed["error"]["details"]["skill_id"] == "foo"
+
+
+class TestSDKTypeConsistency:
+ """Cross-check that SDK types stay consistent with the spec."""
+
+ def test_task_request_has_all_spec_fields(self) -> None:
+ """TaskRequest must have exactly the fields from the spec."""
+ expected = {"task", "workspace_dir", "max_iterations", "skill_dirs", "search_scope"}
+ actual = {f.name for f in TaskRequest.__dataclass_fields__.values()}
+ assert actual == expected
+
+ def test_task_result_has_all_spec_fields(self) -> None:
+ expected = {"task_id", "status", "success", "output", "tools_used",
+ "skill_used", "evolved_skills", "duration_ms", "error"}
+ actual = {f.name for f in TaskResultData.__dataclass_fields__.values()}
+ assert actual == expected
+
+ def test_skill_info_has_all_spec_fields(self) -> None:
+ expected = {"id", "name", "version", "active", "created_at"}
+ actual = {f.name for f in SkillInfo.__dataclass_fields__.values()}
+ assert actual == expected
+
+ def test_health_has_all_spec_fields(self) -> None:
+ expected = {"status", "version", "initialized", "backends"}
+ actual = {f.name for f in HealthStatus.__dataclass_fields__.values()}
+ assert actual == expected
+
+ def test_skill_detail_has_all_spec_fields(self) -> None:
+ expected = {"id", "name", "version", "active", "created_at", "manifest", "lineage"}
+ actual = {f.name for f in SkillDetail.__dataclass_fields__.values()}
+ assert actual == expected
+
+ def test_tool_usage_record_has_all_spec_fields(self) -> None:
+ expected = {"tool_name", "arguments", "success"}
+ actual = {f.name for f in ToolUsageRecord.__dataclass_fields__.values()}
+ assert actual == expected
+
+
+class TestSkillDetailContract:
+ """SkillDetail must match the documented GET /skills/{id} response."""
+
+ def test_detail_includes_manifest_and_lineage(self) -> None:
+ detail = SkillDetail(
+ id="s-1",
+ name="Calculator",
+ version="2.0",
+ active=True,
+ created_at="2026-01-01T00:00:00Z",
+ manifest={"entry_point": "main.py", "tools": ["calc"]},
+ lineage=["s-0", "s-parent"],
+ )
+ d = asdict(detail)
+ assert "manifest" in d
+ assert "lineage" in d
+ assert isinstance(d["manifest"], dict)
+ assert isinstance(d["lineage"], list)
+
+ def test_detail_superset_of_info(self) -> None:
+ """SkillDetail must contain all SkillInfo fields."""
+ info_fields = {f.name for f in SkillInfo.__dataclass_fields__.values()}
+ detail_fields = {f.name for f in SkillDetail.__dataclass_fields__.values()}
+ assert info_fields.issubset(detail_fields)
+
+
+class TestToolUsageContract:
+ """ToolUsageRecord must match the documented tools_used schema."""
+
+ def test_tool_usage_fields(self) -> None:
+ record = ToolUsageRecord(tool_name="bash", arguments={"command": "ls"}, success=True)
+ d = asdict(record)
+ assert d["tool_name"] == "bash"
+ assert d["arguments"] == {"command": "ls"}
+ assert d["success"] is True
+
+ def test_task_result_with_tools(self) -> None:
+ tools = [
+ ToolUsageRecord(tool_name="bash", arguments={"cmd": "ls"}),
+ ToolUsageRecord(tool_name="python", arguments={"code": "1+1"}, success=False),
+ ]
+ result = TaskResultData(
+ task_id="t-5", status="completed", success=True, tools_used=tools
+ )
+ assert result.tools_used is not None
+ assert len(result.tools_used) == 2
+ assert result.tools_used[1].success is False
+
+
+class TestSecurityContract:
+ """Security-critical contract assertions."""
+
+ def test_auth_error_envelope(self) -> None:
+ """401 responses must use AUTH_REQUIRED error code."""
+ env = APIEnvelope.failure("AUTH_REQUIRED", "Missing or invalid bearer token")
+ parsed = json.loads(env.to_json())
+ assert parsed["ok"] is False
+ assert parsed["error"]["code"] == "AUTH_REQUIRED"
+
+ def test_rate_limit_error_envelope(self) -> None:
+ """429 responses must use RATE_LIMITED error code."""
+ env = APIEnvelope.failure("RATE_LIMITED", "Request rate limit exceeded")
+ parsed = json.loads(env.to_json())
+ assert parsed["error"]["code"] == "RATE_LIMITED"
+
+ def test_invalid_state_error_envelope(self) -> None:
+ """409 responses for invalid operations (e.g., cancel completed task)."""
+ env = APIEnvelope.failure(
+ "INVALID_STATE",
+ "Cannot cancel task in 'completed' state",
+ request_id="r-9",
+ task_id="t-done",
+ current_state="completed",
+ )
+ parsed = json.loads(env.to_json())
+ assert parsed["error"]["code"] == "INVALID_STATE"
+ assert parsed["error"]["details"]["current_state"] == "completed"
+
+ _SENSITIVE_KEYS: frozenset[str] = frozenset(
+ {
+ "token",
+ "bearer_token",
+ "api_key",
+ "secret",
+ "password",
+ "credential",
+ "private_key",
+ "mcp_bearer_token",
+ }
+ )
+
+ def test_config_must_not_expose_sensitive_fields(self) -> None:
+ """GET /config must never contain tokens, secrets, or credentials."""
+ # Simulate a config response with ONLY safe fields
+ safe_config = {
+ "model": "gpt-4",
+ "max_iterations": 10,
+ "search_scope": "all",
+ "sandbox_enabled": True,
+ }
+ env = APIEnvelope.success(safe_config)
+ parsed = json.loads(env.to_json())
+
+ for key in parsed["data"]:
+ normalized = key.lower()
+ assert normalized not in self._SENSITIVE_KEYS, (
+ f"Config response contains sensitive key: {key}"
+ )
+
+ def test_error_responses_never_leak_tokens(self) -> None:
+ """Error messages must not contain token values."""
+ token = "sk-secret-abc123-very-long-token-value"
+ env = APIEnvelope.failure("AUTH_REQUIRED", "Invalid bearer token provided")
+ serialized = env.to_json()
+ assert token not in serialized
diff --git a/tests/test_secret_broker.py b/tests/test_secret_broker.py
new file mode 100644
index 00000000..e0bac83e
--- /dev/null
+++ b/tests/test_secret_broker.py
@@ -0,0 +1,708 @@
+"""Tests for openspace.secret.broker — EPIC 2.6.
+
+Covers:
+- #52: SecretBrokerPort concrete implementation
+- Secret scoping (task, session, global)
+- Lease-based access control via SecretCapability
+- At-rest encryption/decryption
+- Key validation and store bounds
+- Thread safety
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+import pytest
+
+from openspace.secret.broker import (
+ SecretBroker,
+ SecretStore,
+ SecretScope,
+ SecretEntry,
+ SecretAccessDenied,
+ SecretNotFoundError,
+ SecretStoreFull,
+ SecretKeyInvalid,
+ SecretBrokerError,
+ SecretValueTooLarge,
+ _SecretEncryptor,
+ _validate_key,
+)
+from openspace.sandbox.leases import SecretCapability
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Key Validation
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestKeyValidation:
+ """Secret key naming rules."""
+
+ def test_valid_keys(self) -> None:
+ for key in ["api-key", "DB_PASSWORD", "my.secret/path:v1", "a"]:
+ _validate_key(key) # should not raise
+
+ def test_empty_key_rejected(self) -> None:
+ with pytest.raises(SecretKeyInvalid, match="empty"):
+ _validate_key("")
+
+ def test_too_long_key_rejected(self) -> None:
+ with pytest.raises(SecretKeyInvalid, match="maximum length"):
+ _validate_key("x" * 257)
+
+ def test_invalid_chars_rejected(self) -> None:
+ with pytest.raises(SecretKeyInvalid, match="invalid characters"):
+ _validate_key("key with spaces")
+
+ def test_special_chars_rejected(self) -> None:
+ for ch in ["$", "!", "@", "#", "%", "^", "&", "*", "(", ")"]:
+ with pytest.raises(SecretKeyInvalid):
+ _validate_key(f"key{ch}")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# At-Rest Encryption
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecretEncryptor:
+ """XOR-based at-rest encryption."""
+
+ def test_roundtrip(self) -> None:
+ enc = _SecretEncryptor(b"master-key-32-bytes-for-testing!")
+ plaintext = "super-secret-value-123"
+ encrypted = enc.encrypt(plaintext)
+ assert enc.decrypt(encrypted) == plaintext
+
+ def test_encrypted_differs_from_plaintext(self) -> None:
+ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+ plaintext = "my-api-key"
+ encrypted = enc.encrypt(plaintext)
+ assert plaintext.encode() not in encrypted
+
+ def test_different_nonce_different_ciphertext(self) -> None:
+ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+ plaintext = "same-value"
+ e1 = enc.encrypt(plaintext)
+ e2 = enc.encrypt(plaintext)
+ assert e1 != e2 # different nonce → different output
+ assert enc.decrypt(e1) == enc.decrypt(e2) == plaintext
+
+ def test_corrupt_data_rejected(self) -> None:
+ enc = _SecretEncryptor(b"key")
+ with pytest.raises(SecretBrokerError, match="Corrupt"):
+ enc.decrypt(b"short")
+
+ def test_empty_string(self) -> None:
+ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+ encrypted = enc.encrypt("")
+ assert enc.decrypt(encrypted) == ""
+
+ def test_unicode_roundtrip(self) -> None:
+ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+ plaintext = "héllo wörld 🔑"
+ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+
+ def test_long_value(self) -> None:
+ enc = _SecretEncryptor(b"test-key-32-bytes-for-testing!!")
+ plaintext = "x" * 10000
+ assert enc.decrypt(enc.encrypt(plaintext)) == plaintext
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Secret Store
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecretStore:
+ """Scoped, encrypted, bounded secret storage."""
+
+ def test_put_and_get(self) -> None:
+ store = SecretStore()
+ store.put("api-key", "secret123", scope=SecretScope.TASK, owner="svc")
+ assert store.get("api-key", scope=SecretScope.TASK) == "secret123"
+
+ def test_get_missing_returns_none(self) -> None:
+ store = SecretStore()
+ assert store.get("nope", scope=SecretScope.TASK) is None
+
+ def test_scopes_are_independent(self) -> None:
+ store = SecretStore()
+ store.put("key", "task-val", scope=SecretScope.TASK, owner="svc")
+ store.put("key", "session-val", scope=SecretScope.SESSION, owner="svc")
+ assert store.get("key", scope=SecretScope.TASK) == "task-val"
+ assert store.get("key", scope=SecretScope.SESSION) == "session-val"
+ assert store.get("key", scope=SecretScope.GLOBAL) is None
+
+ def test_update_existing(self) -> None:
+ store = SecretStore()
+ store.put("key", "v1", scope=SecretScope.TASK, owner="svc")
+ store.put("key", "v2", scope=SecretScope.TASK, owner="svc")
+ assert store.get("key", scope=SecretScope.TASK) == "v2"
+
+ def test_delete(self) -> None:
+ store = SecretStore()
+ store.put("key", "val", scope=SecretScope.TASK, owner="svc")
+ assert store.delete("key", scope=SecretScope.TASK) is True
+ assert store.get("key", scope=SecretScope.TASK) is None
+ assert store.delete("key", scope=SecretScope.TASK) is False
+
+ def test_list_keys(self) -> None:
+ store = SecretStore()
+ store.put("b-key", "1", scope=SecretScope.TASK, owner="svc")
+ store.put("a-key", "2", scope=SecretScope.TASK, owner="svc")
+ assert store.list_keys(scope=SecretScope.TASK) == ["a-key", "b-key"]
+
+ def test_count(self) -> None:
+ store = SecretStore()
+ assert store.count(scope=SecretScope.TASK) == 0
+ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+ assert store.count(scope=SecretScope.TASK) == 2
+
+ def test_clear_scope(self) -> None:
+ store = SecretStore()
+ store.put("k1", "v", scope=SecretScope.TASK, owner="svc")
+ store.put("k2", "v", scope=SecretScope.TASK, owner="svc")
+ store.put("k3", "v", scope=SecretScope.SESSION, owner="svc")
+ assert store.clear_scope(SecretScope.TASK) == 2
+ assert store.count(scope=SecretScope.TASK) == 0
+ assert store.count(scope=SecretScope.SESSION) == 1
+
+ def test_capacity_limit(self) -> None:
+ store = SecretStore()
+ store.MAX_SECRETS_PER_SCOPE = 3
+ for i in range(3):
+ store.put(f"k{i}", "v", scope=SecretScope.TASK, owner="svc")
+ with pytest.raises(SecretStoreFull, match="full"):
+ store.put("overflow", "v", scope=SecretScope.TASK, owner="svc")
+
+ def test_update_does_not_count_toward_capacity(self) -> None:
+ store = SecretStore()
+ store.MAX_SECRETS_PER_SCOPE = 2
+ store.put("k1", "v1", scope=SecretScope.TASK, owner="svc")
+ store.put("k2", "v2", scope=SecretScope.TASK, owner="svc")
+ # Update existing — should NOT fail
+ store.put("k1", "v1-updated", scope=SecretScope.TASK, owner="svc")
+ assert store.get("k1", scope=SecretScope.TASK) == "v1-updated"
+
+ def test_value_too_long_rejected(self) -> None:
+ store = SecretStore()
+ with pytest.raises(SecretValueTooLarge, match="maximum length"):
+ store.put("k", "x" * 70_000, scope=SecretScope.TASK, owner="svc")
+
+ def test_value_too_long_bytes_not_chars(self) -> None:
+ """Byte length is enforced, not character count. Multi-byte chars
+ must be correctly measured against the 64KB limit."""
+ store = SecretStore()
+ # 4-byte emoji × 16385 = 65540 bytes > 64KB, but only 16385 chars
+ value = "\U0001f600" * 16385
+ assert len(value) < store.MAX_VALUE_LENGTH # chars < limit
+ assert len(value.encode("utf-8")) > store.MAX_VALUE_LENGTH # bytes > limit
+ with pytest.raises(SecretValueTooLarge, match="bytes"):
+ store.put("k", value, scope=SecretScope.TASK, owner="svc")
+
+ def test_lazy_expiry_on_get(self) -> None:
+ store = SecretStore()
+ past = time.time() - 10
+ store.put("k", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+ assert store.get("k", scope=SecretScope.TASK) is None
+
+ def test_lazy_expiry_on_list(self) -> None:
+ store = SecretStore()
+ past = time.time() - 10
+ future = time.time() + 3600
+ store.put("expired", "v", scope=SecretScope.TASK, owner="svc", expires_at=past)
+ store.put("alive", "v", scope=SecretScope.TASK, owner="svc", expires_at=future)
+ keys = store.list_keys(scope=SecretScope.TASK)
+ assert keys == ["alive"]
+
+ def test_thread_safety(self) -> None:
+ store = SecretStore()
+ errors: list[Exception] = []
+
+ def write_batch(prefix: str) -> None:
+ try:
+ for i in range(50):
+ store.put(f"{prefix}-{i}", f"val-{i}",
+ scope=SecretScope.TASK, owner="svc")
+ except Exception as e:
+ errors.append(e)
+
+ threads = [
+ threading.Thread(target=write_batch, args=(f"t{n}",))
+ for n in range(4)
+ ]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors
+ assert store.count(scope=SecretScope.TASK) == 200
+
+ def test_encryption_at_rest(self) -> None:
+ """Stored values are encrypted — raw access doesn't reveal plaintext."""
+ store = SecretStore()
+ store.put("secret-key", "super-secret-password",
+ scope=SecretScope.TASK, owner="svc")
+ with store._lock:
+ entry = store._store[SecretScope.TASK]["secret-key"]
+ assert b"super-secret-password" not in entry.encrypted_value
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# SecretBroker — Capability-Based Access Control
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecretBrokerCapability:
+ """SecretBroker enforces SecretCapability from leases."""
+
+ def _t2_capability(self) -> SecretCapability:
+ """T2-equivalent: 3 secrets, task scope only."""
+ return SecretCapability(
+ allowed_scopes=["task"],
+ max_secrets=3,
+ )
+
+ def _t3_capability(self) -> SecretCapability:
+ """T3-equivalent: 10 secrets, task + session scopes."""
+ return SecretCapability(
+ allowed_scopes=["task", "session"],
+ max_secrets=10,
+ )
+
+ def _t4_capability(self) -> SecretCapability:
+ """T4-equivalent: 50 secrets, all scopes."""
+ return SecretCapability(
+ allowed_scopes=["task", "session", "global"],
+ max_secrets=50,
+ )
+
+ def _no_access_capability(self) -> SecretCapability:
+ """T0/T1-equivalent: no secret access."""
+ return SecretCapability(
+ allowed_scopes=[],
+ max_secrets=0,
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_with_valid_capability(self) -> None:
+ broker = SecretBroker()
+ cap = self._t2_capability()
+ await broker.put_secret("api-key", "secret", capability=cap)
+ result = await broker.get_secret("api-key", capability=cap)
+ assert result == "secret"
+
+ @pytest.mark.asyncio
+ async def test_get_missing_returns_none(self) -> None:
+ broker = SecretBroker()
+ cap = self._t2_capability()
+ result = await broker.get_secret("nope", capability=cap)
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_no_access_denied(self) -> None:
+ broker = SecretBroker()
+ cap = self._no_access_capability()
+ with pytest.raises(SecretAccessDenied, match="no secret access"):
+ await broker.get_secret("key", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_scope_denied(self) -> None:
+ broker = SecretBroker()
+ cap = self._t2_capability() # task only
+ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+ await broker.get_secret("key", scope="session", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_t3_can_access_session(self) -> None:
+ broker = SecretBroker()
+ cap = self._t3_capability()
+ await broker.put_secret("k", "v", scope="session", capability=cap)
+ assert await broker.get_secret("k", scope="session", capability=cap) == "v"
+
+ @pytest.mark.asyncio
+ async def test_t3_cannot_access_global(self) -> None:
+ broker = SecretBroker()
+ cap = self._t3_capability()
+ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+ await broker.get_secret("key", scope="global", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_t4_can_access_all_scopes(self) -> None:
+ broker = SecretBroker()
+ cap = self._t4_capability()
+ for scope in ["task", "session", "global"]:
+ await broker.put_secret(f"k-{scope}", "v", scope=scope,
+ capability=cap)
+ assert await broker.get_secret(f"k-{scope}", scope=scope,
+ capability=cap) == "v"
+
+ @pytest.mark.asyncio
+ async def test_allowed_keys_enforced(self) -> None:
+ cap = SecretCapability(
+ allowed_scopes=["task"],
+ allowed_keys=["db-pass", "api-key"],
+ max_secrets=5,
+ )
+ broker = SecretBroker()
+ await broker.put_secret("db-pass", "secret", capability=cap)
+ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+ await broker.get_secret("other-key", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_max_secrets_enforced(self) -> None:
+ cap = SecretCapability(
+ allowed_scopes=["task"],
+ max_secrets=2,
+ )
+ broker = SecretBroker()
+ await broker.put_secret("k1", "v1", capability=cap)
+ await broker.put_secret("k2", "v2", capability=cap)
+ with pytest.raises(SecretAccessDenied, match="max_secrets"):
+ await broker.put_secret("k3", "v3", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_max_secrets_per_owner_not_scope(self) -> None:
+ """max_secrets counts per-owner, not scope-wide. Another owner's
+ secrets must not block a different caller's writes."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=2)
+ broker_a = SecretBroker(store=store, default_capability=cap, owner="owner-a")
+ broker_b = SecretBroker(store=store, default_capability=cap, owner="owner-b")
+ # Owner A fills their quota
+ await broker_a.put_secret("a1", "v")
+ await broker_a.put_secret("a2", "v")
+ # Owner B should NOT be blocked by owner A's secrets
+ await broker_b.put_secret("b1", "v")
+ await broker_b.put_secret("b2", "v")
+ assert store.count(scope=SecretScope.TASK) == 4
+
+ @pytest.mark.asyncio
+ async def test_expired_secrets_dont_block_writes(self) -> None:
+ """Expired entries are purged on write so they don't ghost-fill the scope."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=2)
+ broker = SecretBroker(store=store)
+ past = time.time() - 10
+ await broker.put_secret("old1", "v", capability=cap, expires_at=past)
+ await broker.put_secret("old2", "v", capability=cap, expires_at=past)
+ # Both are expired — new writes should succeed after purge
+ await broker.put_secret("new1", "v", capability=cap)
+ await broker.put_secret("new2", "v", capability=cap)
+ assert await broker.get_secret("new1", capability=cap) == "v"
+
+ @pytest.mark.asyncio
+ async def test_expired_key_resurrection_blocked(self) -> None:
+ """Overwriting an expired key must count as a new insert for cap_limit.
+ Prevents bypassing max_secrets by 'updating' expired keys back to life."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=1)
+ broker = SecretBroker(store=store)
+ past = time.time() - 10
+ # Fill quota then let it expire
+ await broker.put_secret("k1", "v1", capability=cap, expires_at=past)
+ # Write a new live key — uses the freed slot
+ await broker.put_secret("k2", "live", capability=cap)
+ # Attempting to "resurrect" expired k1 must be denied (quota full)
+ with pytest.raises(SecretAccessDenied, match="max_secrets"):
+ await broker.put_secret("k1", "revived", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_update_existing_does_not_hit_limit(self) -> None:
+ cap = SecretCapability(
+ allowed_scopes=["task"],
+ max_secrets=1,
+ )
+ broker = SecretBroker()
+ await broker.put_secret("k1", "v1", capability=cap)
+ # Update should work even at limit
+ await broker.put_secret("k1", "v2", capability=cap)
+ assert await broker.get_secret("k1", capability=cap) == "v2"
+
+ @pytest.mark.asyncio
+ async def test_invalid_scope_rejected(self) -> None:
+ broker = SecretBroker()
+ cap = self._t2_capability()
+ with pytest.raises(SecretAccessDenied, match="Invalid scope"):
+ await broker.get_secret("key", scope="invalid", capability=cap)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# SecretBroker — Revocation and Listing
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecretBrokerOperations:
+ """SecretBroker revoke and list operations."""
+
+ def _cap(self) -> SecretCapability:
+ return SecretCapability(
+ allowed_scopes=["task", "session"],
+ max_secrets=10,
+ )
+
+ @pytest.mark.asyncio
+ async def test_revoke_existing(self) -> None:
+ broker = SecretBroker()
+ cap = self._cap()
+ await broker.put_secret("k", "v", capability=cap)
+ assert await broker.revoke("k", capability=cap) is True
+ assert await broker.get_secret("k", capability=cap) is None
+
+ @pytest.mark.asyncio
+ async def test_revoke_nonexistent(self) -> None:
+ broker = SecretBroker()
+ cap = self._cap()
+ assert await broker.revoke("nope", capability=cap) is False
+
+ @pytest.mark.asyncio
+ async def test_list_available(self) -> None:
+ broker = SecretBroker()
+ cap = self._cap()
+ await broker.put_secret("b", "v", capability=cap)
+ await broker.put_secret("a", "v", capability=cap)
+ keys = broker.list_available(capability=cap)
+ assert keys == ["a", "b"]
+
+ @pytest.mark.asyncio
+ async def test_list_filtered_by_allowed_keys(self) -> None:
+ store = SecretStore()
+ store.put("visible", "v", scope=SecretScope.TASK, owner="svc")
+ store.put("hidden", "v", scope=SecretScope.TASK, owner="svc")
+ cap = SecretCapability(
+ allowed_scopes=["task"],
+ allowed_keys=["visible"],
+ max_secrets=5,
+ )
+ broker = SecretBroker(store=store, owner="svc")
+ keys = broker.list_available(capability=cap)
+ assert keys == ["visible"]
+
+ @pytest.mark.asyncio
+ async def test_list_empty_scope(self) -> None:
+ broker = SecretBroker()
+ cap = self._cap()
+ assert broker.list_available(capability=cap) == []
+
+ @pytest.mark.asyncio
+ async def test_revoke_denied_without_scope(self) -> None:
+ broker = SecretBroker()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ with pytest.raises(SecretAccessDenied):
+ await broker.revoke("k", scope="session", capability=cap)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# SecretBroker — Default Capability
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecretBrokerDefaults:
+ """SecretBroker with default_capability."""
+
+ @pytest.mark.asyncio
+ async def test_uses_default_capability(self) -> None:
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker = SecretBroker(default_capability=cap)
+ await broker.put_secret("k", "v")
+ assert await broker.get_secret("k") == "v"
+
+ @pytest.mark.asyncio
+ async def test_default_zero_denies(self) -> None:
+ broker = SecretBroker() # default SecretCapability has max_secrets=0
+ with pytest.raises(SecretAccessDenied, match="no secret access"):
+ await broker.get_secret("k")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Security Regression Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestSecretBrokerSecurity:
+ """Security invariants for the secret broker."""
+
+ @pytest.mark.asyncio
+ async def test_t0_t1_cannot_access_secrets(self) -> None:
+ """T0/T1 equivalent capabilities deny all access."""
+ broker = SecretBroker()
+ for cap in [
+ SecretCapability(allowed_scopes=[], max_secrets=0),
+ SecretCapability(allowed_scopes=["task"], max_secrets=0),
+ ]:
+ with pytest.raises(SecretAccessDenied):
+ await broker.get_secret("key", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_scope_escalation_prevented(self) -> None:
+ """T2 (task-only) cannot read session secrets."""
+ store = SecretStore()
+ store.put("session-secret", "classified",
+ scope=SecretScope.SESSION, owner="admin")
+ t2_cap = SecretCapability(
+ allowed_scopes=["task"], max_secrets=3,
+ )
+ broker = SecretBroker(store=store)
+ with pytest.raises(SecretAccessDenied, match="not in allowed scopes"):
+ await broker.get_secret("session-secret", scope="session",
+ capability=t2_cap)
+
+ @pytest.mark.asyncio
+ async def test_key_restriction_enforced(self) -> None:
+ """Allowed_keys list is a hard deny for unlisted keys."""
+ cap = SecretCapability(
+ allowed_scopes=["task"],
+ allowed_keys=["safe-key"],
+ max_secrets=5,
+ )
+ broker = SecretBroker()
+ with pytest.raises(SecretAccessDenied, match="not in allowed keys"):
+ await broker.put_secret("other-key", "v", capability=cap)
+
+ @pytest.mark.asyncio
+ async def test_encrypted_at_rest_via_broker(self) -> None:
+ """Values stored through broker are encrypted in the store."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker = SecretBroker(store=store, default_capability=cap)
+ await broker.put_secret("api-key", "super-secret-123")
+ # Direct store access — value should be encrypted
+ with store._lock:
+ entry = store._store[SecretScope.TASK]["api-key"]
+ assert b"super-secret-123" not in entry.encrypted_value
+ # But broker decrypts it
+ assert await broker.get_secret("api-key") == "super-secret-123"
+
+ @pytest.mark.asyncio
+ async def test_deny_before_allow(self) -> None:
+ """Zero max_secrets denies even if scopes match."""
+ cap = SecretCapability(
+ allowed_scopes=["task", "session", "global"],
+ max_secrets=0,
+ )
+ broker = SecretBroker()
+ with pytest.raises(SecretAccessDenied, match="no secret access"):
+ await broker.get_secret("key", capability=cap)
+
+ def test_secret_store_values_not_in_repr(self) -> None:
+ """SecretEntry encrypted_value should not leak plaintext."""
+ store = SecretStore()
+ store.put("key", "password123", scope=SecretScope.TASK, owner="svc")
+ with store._lock:
+ entry = store._store[SecretScope.TASK]["key"]
+ r = repr(entry)
+ assert "password123" not in r
+
+ @pytest.mark.asyncio
+ async def test_expired_secret_not_accessible(self) -> None:
+ """Expired secrets return None even through broker."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker = SecretBroker(store=store, default_capability=cap)
+ past = time.time() - 10
+ await broker.put_secret("expired", "v", expires_at=past)
+ assert await broker.get_secret("expired") is None
+
+ def test_ciphertext_integrity_check(self) -> None:
+ """Tampered ciphertext is detected by HMAC integrity tag."""
+ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
+ encrypted = enc.encrypt("sensitive-data")
+ # Flip a byte in the ciphertext region (after 16-byte nonce, before 32-byte tag)
+ tampered = bytearray(encrypted)
+ tampered[20] ^= 0xFF
+ with pytest.raises(SecretBrokerError, match="integrity check failed"):
+ enc.decrypt(bytes(tampered))
+
+ def test_ciphertext_truncation_detected(self) -> None:
+ """Truncated ciphertext is rejected."""
+ enc = _SecretEncryptor(b"test-key-32bytes" * 2)
+ with pytest.raises(SecretBrokerError, match="Corrupt"):
+ enc.decrypt(b"\x00" * 16) # nonce only, no ciphertext or tag
+
+ @pytest.mark.asyncio
+ async def test_concurrent_put_respects_cap_limit(self) -> None:
+ """Concurrent writers must not exceed capability max_secrets (TOCTOU fix)."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker = SecretBroker(store=store, default_capability=cap)
+ errors: list[Exception] = []
+ success_count = 0
+ lock = threading.Lock()
+
+ import asyncio
+
+ async def write(i: int) -> None:
+ nonlocal success_count
+ try:
+ await broker.put_secret(
+ f"key-{i}", f"val-{i}",
+ )
+ with lock:
+ success_count += 1
+ except SecretAccessDenied:
+ pass # Expected once limit is reached
+ except Exception as e:
+ with lock:
+ errors.append(e)
+
+ # Attempt 20 concurrent writes with cap of 5
+ await asyncio.gather(*(write(i) for i in range(20)))
+ assert not errors, f"Unexpected errors: {errors}"
+ # Must not exceed cap
+ assert store.count(scope=SecretScope.TASK) <= 5
+
+ @pytest.mark.asyncio
+ async def test_cross_owner_read_isolation(self) -> None:
+ """Owner B cannot read owner A's secrets."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker_a = SecretBroker(store=store, default_capability=cap, owner="alice")
+ broker_b = SecretBroker(store=store, default_capability=cap, owner="bob")
+ await broker_a.put_secret("secret-key", "alice-secret")
+ # Alice can read her own secret
+ assert await broker_a.get_secret("secret-key") == "alice-secret"
+ # Bob cannot read Alice's secret
+ assert await broker_b.get_secret("secret-key") is None
+
+ @pytest.mark.asyncio
+ async def test_cross_owner_overwrite_blocked(self) -> None:
+ """Owner B cannot overwrite owner A's secret."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker_a = SecretBroker(store=store, default_capability=cap, owner="alice")
+ broker_b = SecretBroker(store=store, default_capability=cap, owner="bob")
+ await broker_a.put_secret("shared-name", "alice-value")
+ with pytest.raises(SecretAccessDenied, match="owned by"):
+ await broker_b.put_secret("shared-name", "bob-takeover")
+ # Alice's value is unchanged
+ assert await broker_a.get_secret("shared-name") == "alice-value"
+
+ @pytest.mark.asyncio
+ async def test_cross_owner_revoke_blocked(self) -> None:
+ """Owner B cannot revoke owner A's secret."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker_a = SecretBroker(store=store, default_capability=cap, owner="alice")
+ broker_b = SecretBroker(store=store, default_capability=cap, owner="bob")
+ await broker_a.put_secret("protected", "value")
+ # Bob's revoke returns False (not his secret)
+ assert await broker_b.revoke("protected") is False
+ # Alice's secret still exists
+ assert await broker_a.get_secret("protected") == "value"
+
+ @pytest.mark.asyncio
+ async def test_cross_owner_list_isolation(self) -> None:
+ """Owners only see their own secrets in list_available."""
+ store = SecretStore()
+ cap = SecretCapability(allowed_scopes=["task"], max_secrets=5)
+ broker_a = SecretBroker(store=store, default_capability=cap, owner="alice")
+ broker_b = SecretBroker(store=store, default_capability=cap, owner="bob")
+ await broker_a.put_secret("alice-key", "v")
+ await broker_b.put_secret("bob-key", "v")
+ assert broker_a.list_available() == ["alice-key"]
+ assert broker_b.list_available() == ["bob-key"]
diff --git a/tests/test_secret_isolation.py b/tests/test_secret_isolation.py
new file mode 100644
index 00000000..8d9c4b9d
--- /dev/null
+++ b/tests/test_secret_isolation.py
@@ -0,0 +1,332 @@
+"""Tests for EPIC 0.3b — Secret Isolation from Sandbox.
+
+Covers:
+ - Issue #13: get_safe_env() allowlist, is_sensitive_key() heuristic
+ - Issue #14: AST blocklist severity upgrades + os.environ.get
+ - Issue #15: End-to-end sandbox secret isolation
+"""
+
+from __future__ import annotations
+
+import os
+import textwrap
+
+import pytest
+
+from openspace.security.env_filter import (
+ ENV_ALLOWLIST,
+ get_safe_env,
+ is_sensitive_key,
+)
+from openspace.security.ast_scanner import (
+ Severity,
+ scan_code,
+ load_blocklist,
+)
+from openspace.security import check_code_safety
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _names(findings):
+ return {f.pattern_name for f in findings}
+
+
+def _severities_for(findings, pattern_name):
+ return {f.severity for f in findings if f.pattern_name == pattern_name}
+
+
+# ---------------------------------------------------------------------------
+# Issue #13 — get_safe_env() allowlist
+# ---------------------------------------------------------------------------
+
+class TestGetSafeEnv:
+ """get_safe_env() must return ONLY allowlisted variables."""
+
+ def test_returns_only_allowlisted_keys(self, mock_env):
+ mock_env.set("PATH", "/usr/bin")
+ mock_env.set("HOME", "/home/test")
+ mock_env.set("LANG", "en_US.UTF-8")
+ mock_env.set("OPENSPACE_LOG_LEVEL", "DEBUG")
+ mock_env.set("AWS_SECRET_ACCESS_KEY", "supersecret")
+ mock_env.set("DATABASE_URL", "postgres://...")
+
+ safe = get_safe_env()
+
+ assert set(safe.keys()) <= ENV_ALLOWLIST
+ assert "AWS_SECRET_ACCESS_KEY" not in safe
+ assert "DATABASE_URL" not in safe
+
+ def test_all_allowlist_vars_present_when_set(self, mock_env):
+ for key in ENV_ALLOWLIST:
+ mock_env.set(key, f"val-{key}")
+
+ safe = get_safe_env()
+ for key in ENV_ALLOWLIST:
+ assert key in safe
+ assert safe[key] == f"val-{key}"
+
+ def test_missing_allowlist_var_is_omitted(self, mock_env):
+ mock_env.delete("OPENSPACE_LOG_LEVEL")
+ safe = get_safe_env()
+ assert "OPENSPACE_LOG_LEVEL" not in safe
+
+ @pytest.mark.parametrize("dangerous_key", [
+ "AWS_SECRET_ACCESS_KEY",
+ "AWS_ACCESS_KEY_ID",
+ "GITHUB_TOKEN",
+ "OPENAI_API_KEY",
+ "AZURE_CLIENT_SECRET",
+ "DATABASE_URL",
+ "DB_PASSWORD",
+ "PRIVATE_KEY",
+ "GH_TOKEN",
+ "ANTHROPIC_API_KEY",
+ "SLACK_BOT_TOKEN",
+ "STRIPE_SECRET_KEY",
+ "SENDGRID_API_KEY",
+ "JWT_SECRET",
+ "SESSION_SECRET",
+ "SIGNING_KEY",
+ ])
+ def test_dangerous_vars_stripped(self, mock_env, dangerous_key):
+ mock_env.set(dangerous_key, "secret-value")
+ safe = get_safe_env()
+ assert dangerous_key not in safe
+
+ def test_returns_dict_type(self, mock_env):
+ safe = get_safe_env()
+ assert isinstance(safe, dict)
+
+ def test_empty_env_returns_empty(self, mock_env):
+ for key in list(os.environ.keys()):
+ mock_env.delete(key)
+ safe = get_safe_env()
+ assert safe == {}
+
+
+# ---------------------------------------------------------------------------
+# Issue #13 — is_sensitive_key() heuristic
+# ---------------------------------------------------------------------------
+
+class TestIsSensitiveKey:
+ """Heuristic must catch common sensitive variable naming patterns."""
+
+ @pytest.mark.parametrize("key", [
+ "AWS_SECRET_ACCESS_KEY",
+ "GITHUB_TOKEN",
+ "OPENAI_API_KEY",
+ "DATABASE_URL",
+ "DB_URL",
+ "DB_PASSWORD",
+ "AZURE_CLIENT_SECRET",
+ "PRIVATE_KEY",
+ "GH_AUTH_TOKEN",
+ "MY_CREDENTIAL",
+ "SIGNING_KEY",
+ "CONNECTION_STRING",
+ "ACCESS_KEY_ID",
+ ])
+ def test_detects_sensitive_keys(self, key):
+ assert is_sensitive_key(key) is True
+
+ @pytest.mark.parametrize("key", [
+ "PATH",
+ "HOME",
+ "LANG",
+ "SHELL",
+ "TERM",
+ "USER",
+ "HOSTNAME",
+ "OPENSPACE_LOG_LEVEL",
+ "PYTHONPATH",
+ "NODE_ENV",
+ ])
+ def test_safe_keys_not_flagged(self, key):
+ assert is_sensitive_key(key) is False
+
+ def test_e2b_api_key_flagged_as_sensitive(self):
+ """E2B_API_KEY contains 'KEY' and is now correctly flagged as sensitive."""
+ assert is_sensitive_key("E2B_API_KEY") is True
+
+ def test_case_insensitive(self):
+ assert is_sensitive_key("my_secret_var") is True
+ assert is_sensitive_key("Github_Token") is True
+
+
+# ---------------------------------------------------------------------------
+# Issue #14 — AST blocklist severity upgrades
+# ---------------------------------------------------------------------------
+
+class TestBlocklistSeverityUpgrades:
+ """os.environ and os.getenv must be HIGH; os.environ.get must be present."""
+
+ def test_env_access_is_high(self):
+ findings = scan_code("import os\nos.environ")
+ hits = [f for f in findings if f.pattern_name == "env_access"]
+ assert len(hits) >= 1
+ assert all(f.severity == Severity.HIGH for f in hits)
+
+ def test_env_getenv_is_high(self):
+ findings = scan_code("import os\nos.getenv('HOME')")
+ hits = [f for f in findings if f.pattern_name == "env_getenv"]
+ assert len(hits) >= 1
+ assert all(f.severity == Severity.HIGH for f in hits)
+
+ def test_env_environ_get_detected(self):
+ findings = scan_code("import os\nos.environ.get('HOME')")
+ hits = [f for f in findings if f.pattern_name == "env_environ_get"]
+ assert len(hits) >= 1
+ assert all(f.severity == Severity.HIGH for f in hits)
+
+ def test_blocklist_has_environ_get_pattern(self):
+ patterns = load_blocklist()
+ names = {p.name for p in patterns}
+ assert "env_environ_get" in names
+
+ def test_all_env_patterns_are_high(self):
+ patterns = load_blocklist()
+ env_patterns = [p for p in patterns if p.name.startswith("env_")]
+ assert len(env_patterns) >= 3
+ for p in env_patterns:
+ assert p.severity == Severity.HIGH, (
+ f"Pattern {p.name!r} should be HIGH, got {p.severity}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Issue #14 — check_code_safety with upgraded severity
+# ---------------------------------------------------------------------------
+
+class TestCheckCodeSafetyEnv:
+ """HIGH env findings warn but do not block execution."""
+
+ def test_env_access_does_not_block(self):
+ is_safe, findings = check_code_safety("import os\nos.environ")
+ assert is_safe is True
+ assert any(f.severity == Severity.HIGH for f in findings)
+
+ def test_environ_get_does_not_block(self):
+ is_safe, findings = check_code_safety(
+ "import os\nos.environ.get('HOME')"
+ )
+ assert is_safe is True
+ assert any(f.pattern_name == "env_environ_get" for f in findings)
+
+ def test_getenv_does_not_block(self):
+ is_safe, findings = check_code_safety(
+ "import os\nos.getenv('HOME')"
+ )
+ assert is_safe is True
+ assert any(f.pattern_name == "env_getenv" for f in findings)
+
+
+# ---------------------------------------------------------------------------
+# Issue #15 — End-to-end sandbox isolation
+# ---------------------------------------------------------------------------
+
+class TestSandboxSecretIsolation:
+ """Verify that a realistic sandbox scenario strips all host secrets."""
+
+ def test_realistic_host_env_stripped(self, mock_env):
+ """Simulate a developer machine with many secrets."""
+ mock_env.set("PATH", "/usr/local/bin:/usr/bin")
+ mock_env.set("HOME", "/home/dev")
+ mock_env.set("LANG", "en_US.UTF-8")
+ mock_env.set("OPENSPACE_LOG_LEVEL", "INFO")
+ # Secrets that MUST NOT leak
+ mock_env.set("E2B_API_KEY", "e2b-test-key")
+ mock_env.set("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG")
+ mock_env.set("GITHUB_TOKEN", "ghp_xxxxxxxxxxxx")
+ mock_env.set("OPENAI_API_KEY", "sk-xxxxxxxxxxxxxxxx")
+ mock_env.set("DATABASE_URL", "postgres://user:pass@host/db")
+ mock_env.set("STRIPE_SECRET_KEY", "sk_live_xxxxxxxx")
+
+ safe = get_safe_env()
+
+ # Allowlisted vars present
+ assert safe["PATH"] == "/usr/local/bin:/usr/bin"
+ assert safe["HOME"] == "/home/dev"
+
+ # All secrets stripped (including E2B_API_KEY — host-side only)
+ for key in ("E2B_API_KEY", "AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN",
+ "OPENAI_API_KEY", "DATABASE_URL", "STRIPE_SECRET_KEY"):
+ assert key not in safe, f"{key} leaked into sandbox env!"
+
+ def test_ast_scanner_flags_env_access_as_high(self):
+ """Skill code that reads env vars should be flagged at HIGH severity."""
+ code = textwrap.dedent("""\
+ import os
+ secret = os.environ.get("AWS_SECRET_KEY", "")
+ token = os.getenv("GITHUB_TOKEN")
+ all_env = os.environ
+ """)
+ findings = scan_code(code)
+ env_findings = [
+ f for f in findings
+ if f.pattern_name in ("env_access", "env_getenv", "env_environ_get")
+ ]
+ assert len(env_findings) >= 3
+ assert all(f.severity == Severity.HIGH for f in env_findings)
+
+ def test_proc_environ_flagged(self):
+ """Reading /proc/self/environ should be flagged."""
+ code = "open('/proc/self/environ')"
+ findings = scan_code(code)
+ assert len(findings) >= 1
+ assert any(f.pattern_name == "sensitive_file_open" for f in findings)
+
+ def test_allowlist_is_frozen(self):
+ """ENV_ALLOWLIST must be immutable."""
+ with pytest.raises(AttributeError):
+ ENV_ALLOWLIST.add("HACK") # type: ignore[attr-defined]
+
+ def test_allowlist_exact_members(self):
+ """Lock down the exact allowlist to prevent accidental expansion."""
+ assert ENV_ALLOWLIST == frozenset({
+ "OPENSPACE_LOG_LEVEL",
+ "PATH",
+ "HOME",
+ "LANG",
+ })
+
+ def test_sandbox_connector_filters_env(self):
+ """SandboxConnector must strip secrets from env before passing to sandbox."""
+ from unittest.mock import MagicMock
+ from openspace.grounding.backends.mcp.transport.connectors.sandbox import SandboxConnector
+
+ mock_sandbox = MagicMock()
+ hostile_env = {
+ "PATH": "/usr/bin",
+ "HOME": "/home/test",
+ "AWS_SECRET_ACCESS_KEY": "AKIAIOSFODNN7EXAMPLE",
+ "GITHUB_TOKEN": "ghp_xxxx",
+ "DATABASE_URL": "postgres://user:pass@host/db",
+ "OPENSPACE_LOG_LEVEL": "DEBUG",
+ }
+
+ connector = SandboxConnector(
+ sandbox=mock_sandbox,
+ command="python server.py",
+ args=[],
+ env=hostile_env,
+ )
+
+ assert "PATH" in connector.user_env
+ assert "HOME" in connector.user_env
+ assert "OPENSPACE_LOG_LEVEL" in connector.user_env
+ assert "AWS_SECRET_ACCESS_KEY" not in connector.user_env
+ assert "GITHUB_TOKEN" not in connector.user_env
+ assert "DATABASE_URL" not in connector.user_env
+
+ def test_heuristic_catches_url_secrets(self):
+ """is_sensitive_key() must flag URL-based connection strings."""
+ url_secrets = [
+ "REDIS_URL", "MONGO_URL", "POSTGRES_URL", "MYSQL_URL",
+ "CELERY_BROKER_URL", "SQLALCHEMY_DATABASE_URI",
+ "SENTRY_DSN",
+ ]
+ for key in url_secrets:
+ assert is_sensitive_key(key) is True, f"{key} not flagged as sensitive!"
diff --git a/tests/test_structured_logging.py b/tests/test_structured_logging.py
new file mode 100644
index 00000000..77553916
--- /dev/null
+++ b/tests/test_structured_logging.py
@@ -0,0 +1,435 @@
+"""Tests for EPIC 1.6 — Structured Logging (Issues #76-79).
+
+Validates:
+- Context variable binding and propagation
+- Sensitive data redaction
+- structlog configuration and logger creation
+- Integration with stdlib logging
+- Context isolation across async tasks
+- Reset/reconfigure behavior
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from unittest.mock import patch
+
+import pytest
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Context Variable Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestContextVariables:
+ """bind_context / clear_context / get_context work correctly."""
+
+ def setup_method(self):
+ from openspace.domain.logging import clear_context
+
+ clear_context()
+
+ def teardown_method(self):
+ from openspace.domain.logging import clear_context
+
+ clear_context()
+
+ def test_bind_and_get_context(self):
+ from openspace.domain.logging import bind_context, get_context
+
+ bind_context(task_id="t-42", correlation_id="abc123")
+ ctx = get_context()
+ assert ctx["task_id"] == "t-42"
+ assert ctx["correlation_id"] == "abc123"
+ assert "session_id" not in ctx # Empty values excluded
+
+ def test_clear_context(self):
+ from openspace.domain.logging import bind_context, clear_context, get_context
+
+ bind_context(task_id="t-1", session_id="s-1")
+ clear_context()
+ assert get_context() == {}
+
+ def test_bind_unknown_key_ignored(self):
+ from openspace.domain.logging import bind_context, get_context
+
+ bind_context(task_id="t-1", unknown_field="ignored")
+ ctx = get_context()
+ assert ctx == {"task_id": "t-1"}
+
+ def test_bind_overwrites_previous(self):
+ from openspace.domain.logging import bind_context, get_context
+
+ bind_context(task_id="t-1")
+ bind_context(task_id="t-2")
+ assert get_context()["task_id"] == "t-2"
+
+ def test_all_four_context_vars(self):
+ from openspace.domain.logging import bind_context, get_context
+
+ bind_context(
+ task_id="t-1",
+ correlation_id="c-1",
+ session_id="s-1",
+ request_id="r-1",
+ )
+ ctx = get_context()
+ assert len(ctx) == 4
+ assert ctx["task_id"] == "t-1"
+ assert ctx["correlation_id"] == "c-1"
+ assert ctx["session_id"] == "s-1"
+ assert ctx["request_id"] == "r-1"
+
+
+class TestContextIsolation:
+ """Context vars are isolated across async tasks."""
+
+ def setup_method(self):
+ from openspace.domain.logging import clear_context
+
+ clear_context()
+
+ def teardown_method(self):
+ from openspace.domain.logging import clear_context
+
+ clear_context()
+
+ @pytest.mark.asyncio
+ async def test_async_task_isolation(self):
+ from openspace.domain.logging import bind_context, get_context
+
+ results = {}
+
+ async def task_a():
+ bind_context(task_id="task-a")
+ await asyncio.sleep(0.01)
+ results["a"] = get_context().get("task_id", "")
+
+ async def task_b():
+ bind_context(task_id="task-b")
+ await asyncio.sleep(0.01)
+ results["b"] = get_context().get("task_id", "")
+
+ await asyncio.gather(task_a(), task_b())
+ assert results["a"] == "task-a"
+ assert results["b"] == "task-b"
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Redaction Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestRedaction:
+ """Sensitive data is redacted in log events."""
+
+ def test_redacts_api_key(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "api_key": "sk-secret-123"}
+ result = _redact_sensitive(None, "info", event)
+ assert result["api_key"] == "***REDACTED***"
+
+ def test_redacts_token_field(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "bearer_token": "eyJ..."}
+ result = _redact_sensitive(None, "info", event)
+ assert result["bearer_token"] == "***REDACTED***"
+
+ def test_redacts_password(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "password": "hunter2"}
+ result = _redact_sensitive(None, "info", event)
+ assert result["password"] == "***REDACTED***"
+
+ def test_redacts_key_suffix_match(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "my_api_key": "secret", "client_secret": "s3cret"}
+ result = _redact_sensitive(None, "info", event)
+ assert result["my_api_key"] == "***REDACTED***"
+ assert result["client_secret"] == "***REDACTED***"
+
+ def test_no_false_positive_on_non_sensitive(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ # These should NOT be redacted despite containing "key"/"token" substrings
+ event = {
+ "event": "test",
+ "token_count": 42,
+ "keyboard_layout": "qwerty",
+ "monkey_patch": True,
+ }
+ result = _redact_sensitive(None, "info", event)
+ assert result["token_count"] == 42
+ assert result["keyboard_layout"] == "qwerty"
+ assert result["monkey_patch"] is True
+
+ def test_redacts_nested_dict_sensitive_keys(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {
+ "event": "api_call",
+ "payload": {"api_key": "sk-secret", "user_id": "u-42"},
+ }
+ result = _redact_sensitive(None, "info", event)
+ assert result["payload"]["api_key"] == "***REDACTED***"
+ assert result["payload"]["user_id"] == "u-42"
+
+ def test_redacts_deeply_nested(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {
+ "event": "test",
+ "response": {"headers": {"authorization": "Bearer xyz"}},
+ }
+ result = _redact_sensitive(None, "info", event)
+ assert result["response"]["headers"]["authorization"] == "***REDACTED***"
+
+ def test_redacts_top_level_list_with_sensitive_dicts(self):
+ """Top-level list/tuple fields containing dicts with sensitive keys."""
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {
+ "event": "test",
+ "payload": [{"authorization": "Bearer xyz"}, {"safe": "ok"}],
+ }
+ result = _redact_sensitive(None, "info", event)
+ assert result["payload"][0]["authorization"] == "***REDACTED***"
+ assert result["payload"][1]["safe"] == "ok"
+
+ def test_redacts_top_level_tuple_with_sensitive_dicts(self):
+ """Tuple variant of top-level list redaction."""
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {
+ "event": "test",
+ "items": ({"api_key": "secret123"},),
+ }
+ result = _redact_sensitive(None, "info", event)
+ assert result["items"][0]["api_key"] == "***REDACTED***"
+
+ def test_redacts_camel_case_keys(self):
+ """camelCase keys like apiKey, accessToken are normalized and redacted."""
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {
+ "event": "test",
+ "apiKey": "sk-abc123",
+ "accessToken": "tok-xyz",
+ "clientSecret": "s3cr3t",
+ "refreshToken": "ref-999",
+ }
+ result = _redact_sensitive(None, "info", event)
+ assert result["apiKey"] == "***REDACTED***"
+ assert result["accessToken"] == "***REDACTED***"
+ assert result["clientSecret"] == "***REDACTED***"
+ assert result["refreshToken"] == "***REDACTED***"
+
+ def test_redacts_pascal_case_keys(self):
+ """PascalCase keys are also normalized and redacted."""
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "ApiKey": "key1", "AuthToken": "tok1"}
+ result = _redact_sensitive(None, "info", event)
+ assert result["ApiKey"] == "***REDACTED***"
+ assert result["AuthToken"] == "***REDACTED***"
+
+ def test_camel_case_not_false_positive(self):
+ """camelCase keys that aren't sensitive should NOT be redacted."""
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "tokenCount": 42, "keyboardLayout": "us"}
+ result = _redact_sensitive(None, "info", event)
+ assert result["tokenCount"] == 42
+ assert result["keyboardLayout"] == "us"
+
+ def test_recursion_depth_limit(self):
+ """Deeply nested payloads stop redacting at _MAX_REDACT_DEPTH, no RecursionError."""
+ from openspace.domain.logging import _MAX_REDACT_DEPTH, _redact_value
+
+ # Build a structure deeper than the limit
+ nested: dict = {"api_key": "leak-at-depth"}
+ for _ in range(_MAX_REDACT_DEPTH + 5):
+ nested = {"inner": nested}
+
+ result = _redact_value(nested)
+ # Walk down to the depth limit — should still be a dict
+ node = result
+ for _ in range(_MAX_REDACT_DEPTH + 5):
+ node = node["inner"]
+ # Beyond depth limit, the sensitive key is NOT redacted (safe bail-out)
+ assert node["api_key"] == "leak-at-depth"
+
+ # But within-limit nesting IS redacted
+ shallow = {"wrapper": {"api_key": "should-redact"}}
+ result2 = _redact_value(shallow)
+ assert result2["wrapper"]["api_key"] == "***REDACTED***"
+
+ def test_preserves_non_sensitive(self):
+ from openspace.domain.logging import _redact_sensitive
+
+ event = {"event": "test", "task_id": "t-42", "status": "ok"}
+ result = _redact_sensitive(None, "info", event)
+ assert result["task_id"] == "t-42"
+ assert result["status"] == "ok"
+
+ def test_truncates_long_values(self):
+ from openspace.domain.logging import _MAX_VALUE_LENGTH, _redact_sensitive
+
+ long_value = "x" * 2000
+ event = {"event": "test", "output": long_value}
+ result = _redact_sensitive(None, "info", event)
+ assert len(result["output"]) < 2000
+ assert result["output"].endswith("...[truncated]")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Logger Creation & Configuration Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestLoggerCreation:
+ """get_logger and configure_logging work correctly."""
+
+ def setup_method(self):
+ from openspace.domain.logging import reset_logging
+
+ reset_logging()
+
+ def teardown_method(self):
+ from openspace.domain.logging import reset_logging
+
+ reset_logging()
+
+ def test_get_logger_returns_bound_logger(self):
+ from openspace.domain.logging import get_logger
+
+ log = get_logger("test.module")
+ assert log is not None
+ assert hasattr(log, "info")
+ assert hasattr(log, "warning")
+ assert hasattr(log, "error")
+ assert hasattr(log, "debug")
+
+ def test_get_logger_default_name(self):
+ from openspace.domain.logging import get_logger
+
+ log = get_logger()
+ assert log is not None
+
+ def test_configure_idempotent(self):
+ from openspace.domain.logging import configure_logging
+
+ configure_logging(level=logging.DEBUG)
+ configure_logging(level=logging.WARNING) # Should be no-op
+ # No exception = pass
+
+ def test_configure_json_output(self):
+ from openspace.domain.logging import configure_logging, reset_logging
+
+ reset_logging()
+ configure_logging(json_output=True)
+ # No exception = pass
+
+ def test_configure_no_colors(self):
+ from openspace.domain.logging import configure_logging, reset_logging
+
+ reset_logging()
+ configure_logging(colors=False)
+ # No exception = pass
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Integration Tests
+# ═══════════════════════════════════════════════════════════════════════
+
+
+class TestIntegration:
+ """Structured logging integrates with existing code patterns."""
+
+ def setup_method(self):
+ from openspace.domain.logging import clear_context, reset_logging
+
+ reset_logging()
+ clear_context()
+
+ def teardown_method(self):
+ from openspace.domain.logging import clear_context, reset_logging
+
+ reset_logging()
+ clear_context()
+
+ def test_structlog_event_includes_context(self, capsys):
+ from openspace.domain.logging import (
+ bind_context,
+ configure_logging,
+ get_logger,
+ )
+
+ configure_logging(level=logging.DEBUG, colors=False)
+ bind_context(task_id="t-99")
+ log = get_logger("test.integration")
+ log.info("task_started", workspace="/tmp")
+
+ captured = capsys.readouterr()
+ # The output goes to stderr via structlog
+ assert "task_started" in captured.err or "task_started" in captured.out
+
+ def test_stdlib_logger_still_works(self):
+ """stdlib loggers produce output and go through shared processors."""
+ from openspace.domain.logging import configure_logging, bind_context
+
+ configure_logging(level=logging.DEBUG, colors=False)
+ bind_context(task_id="stdlib-t1")
+
+ stdlib_logger = logging.getLogger("openspace.test_stdlib_bridge")
+ # Capture output from the root handler
+ import io
+ capture = io.StringIO()
+ handler = logging.StreamHandler(capture)
+ handler.setLevel(logging.DEBUG)
+ # Use the same formatter as configure_logging installs
+ root = logging.getLogger()
+ if root.handlers:
+ handler.setFormatter(root.handlers[0].formatter)
+ root.addHandler(handler)
+ try:
+ stdlib_logger.warning("bridge test", extra={"api_key": "secret99"})
+ output = capture.getvalue()
+ # Should produce output (not silently swallowed)
+ assert len(output) > 0, "stdlib logger produced no output"
+ finally:
+ root.removeHandler(handler)
+
+ def test_context_vars_processor_injects(self):
+ from openspace.domain.logging import _inject_context_vars, bind_context
+
+ bind_context(task_id="t-1", correlation_id="c-1")
+ event: dict = {"event": "test"}
+ result = _inject_context_vars(None, "info", event)
+ assert result["task_id"] == "t-1"
+ assert result["correlation_id"] == "c-1"
+
+ def test_context_vars_dont_overwrite_explicit(self):
+ from openspace.domain.logging import _inject_context_vars, bind_context
+
+ bind_context(task_id="t-1")
+ event: dict = {"event": "test", "task_id": "explicit-id"}
+ result = _inject_context_vars(None, "info", event)
+ assert result["task_id"] == "explicit-id" # Explicit wins
+
+ def test_reset_allows_reconfigure(self):
+ from openspace.domain.logging import (
+ configure_logging,
+ reset_logging,
+ )
+
+ configure_logging(level=logging.INFO)
+ reset_logging()
+ configure_logging(level=logging.DEBUG) # Should work, not no-op
+ # No exception = pass
diff --git a/tests/test_traceback_safety.py b/tests/test_traceback_safety.py
new file mode 100644
index 00000000..1ad156c3
--- /dev/null
+++ b/tests/test_traceback_safety.py
@@ -0,0 +1,446 @@
+"""Contract tests: zero traceback leakage from MCP tool responses.
+
+Covers Issues #9-#12 (EPIC 0.3a):
+ - No MCP response ever contains traceback strings, file paths,
+ line numbers, module paths, or "Traceback (most recent call last)".
+ - Error responses use the structured format
+ {isError, error_code, message, correlation_id}.
+ - Full tracebacks ARE logged server-side (logger.error with exc_info).
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# ── helpers ──────────────────────────────────────────────────────────
+
+# Patterns that MUST NOT appear in any client-facing MCP response
+_FORBIDDEN_PATTERNS = [
+ re.compile(r"Traceback \(most recent call last\)"),
+ re.compile(r'File ".*", line \d+'),
+ re.compile(r"line \d+, in "),
+ re.compile(r"openspace[/\\]\w+\.py"),
+ re.compile(r"\.py:\d+"),
+ re.compile(r"raise \w+"),
+]
+
+VALID_ERROR_CODES = {
+ "EXECUTION_ERROR",
+ "VALIDATION_ERROR",
+ "SKILL_NOT_FOUND",
+ "PERMISSION_DENIED",
+ "INTERNAL_ERROR",
+ "TIMEOUT_ERROR",
+}
+
+
+def _assert_no_traceback_leak(response_str: str, tool_name: str) -> None:
+ """Assert that *response_str* contains no forbidden patterns."""
+ for pat in _FORBIDDEN_PATTERNS:
+ assert not pat.search(response_str), (
+ f"[{tool_name}] Traceback leak detected — pattern {pat.pattern!r} "
+ f"found in response:\n{response_str[:500]}"
+ )
+
+
+def _assert_structured_error(response_str: str, tool_name: str) -> dict:
+ """Parse response as JSON and validate the structured error schema."""
+ data = json.loads(response_str)
+ assert data.get("isError") is True, (
+ f"[{tool_name}] Expected isError=true, got {data}"
+ )
+ assert data.get("error_code") in VALID_ERROR_CODES, (
+ f"[{tool_name}] Invalid error_code: {data.get('error_code')}"
+ )
+ assert isinstance(data.get("message"), str) and data["message"], (
+ f"[{tool_name}] Missing or empty 'message'"
+ )
+ assert isinstance(data.get("correlation_id"), str) and data["correlation_id"], (
+ f"[{tool_name}] Missing or empty 'correlation_id'"
+ )
+ # Double-check: message itself must be leak-free
+ _assert_no_traceback_leak(data["message"], tool_name)
+ return data
+
+
+# ── Unit tests for openspace.errors ──────────────────────────────────
+
+class TestSanitizeError:
+ """Test that sanitize_error strips dangerous content."""
+
+ def test_plain_message_preserved(self):
+ from openspace.errors import sanitize_error
+ exc = ValueError("missing required field")
+ assert sanitize_error(exc) == "missing required field"
+
+ def test_traceback_string_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = RuntimeError(
+ 'Traceback (most recent call last):\n File "foo.py", line 42\nKeyError'
+ )
+ result = sanitize_error(exc)
+ assert "Traceback" not in result
+ assert "foo.py" not in result
+
+ def test_file_path_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = OSError("Cannot open C:\\Users\\dev\\project\\secret.py")
+ result = sanitize_error(exc)
+ assert "C:\\Users" not in result
+ assert "secret.py" not in result
+
+ def test_unix_path_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = OSError("Failed at /home/user/openspace/mcp_server.py")
+ result = sanitize_error(exc)
+ assert "/home/user" not in result
+ assert "mcp_server.py" not in result
+
+ def test_empty_message_returns_generic(self):
+ from openspace.errors import sanitize_error
+ exc = RuntimeError()
+ result = sanitize_error(exc)
+ assert result == "An internal error occurred"
+ assert "RuntimeError" not in result
+
+ def test_long_message_truncated(self):
+ from openspace.errors import sanitize_error
+ exc = ValueError("x" * 500)
+ result = sanitize_error(exc)
+ assert len(result) <= 300
+
+ # ── Regression tests from /collab + /8eyes review ────────────
+
+ def test_windows_path_with_spaces_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = OSError(r"Cannot open C:\Program Files\OpenSpace\secret.py")
+ result = sanitize_error(exc)
+ assert "Program Files" not in result
+ assert "OpenSpace" not in result
+ assert "secret.py" not in result
+
+ def test_unc_path_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = OSError(r"Failed reading \\server\share\secret.py")
+ result = sanitize_error(exc)
+ assert r"\\server" not in result
+ assert "secret.py" not in result
+
+ def test_dotted_module_name_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = ImportError("No module named openspace.cloud.auth.TokenResolver")
+ result = sanitize_error(exc)
+ assert "openspace.cloud.auth" not in result
+ assert "TokenResolver" not in result
+
+ def test_standalone_line_number_stripped(self):
+ from openspace.errors import sanitize_error
+ exc = RuntimeError("failed at line 99 in processing")
+ result = sanitize_error(exc)
+ assert "line 99" not in result
+
+ def test_never_returns_exception_class_name(self):
+ """Regression: fallback must not leak type(exc).__name__."""
+ from openspace.errors import sanitize_error
+
+ class InternalSecretError(Exception):
+ pass
+
+ exc = InternalSecretError()
+ result = sanitize_error(exc)
+ assert "InternalSecretError" not in result
+ assert result == "An internal error occurred"
+
+
+class TestSafeErrorResponse:
+ """Test the structured JSON builder."""
+
+ def test_schema(self):
+ from openspace.errors import safe_error_response, EXECUTION_ERROR
+ raw = safe_error_response(EXECUTION_ERROR, "Something went wrong")
+ data = json.loads(raw)
+ assert data["isError"] is True
+ assert data["error_code"] == "EXECUTION_ERROR"
+ assert data["message"] == "Something went wrong"
+ assert isinstance(data["correlation_id"], str)
+ assert len(data["correlation_id"]) == 12
+
+ def test_custom_correlation_id(self):
+ from openspace.errors import safe_error_response, VALIDATION_ERROR
+ raw = safe_error_response(VALIDATION_ERROR, "bad input", correlation_id="abc123")
+ data = json.loads(raw)
+ assert data["correlation_id"] == "abc123"
+
+
+class TestHandleMcpException:
+ """Test the one-liner exception handler."""
+
+ def test_logs_and_returns_safe_json(self):
+ from openspace.errors import handle_mcp_exception, EXECUTION_ERROR
+
+ with patch("openspace.errors.logger") as mock_logger:
+ exc = RuntimeError("boom at /secret/path.py:42")
+ result = handle_mcp_exception(
+ exc, tool_name="test_tool", error_code=EXECUTION_ERROR
+ )
+
+ # Logger was called with exc_info=True
+ mock_logger.error.assert_called_once()
+ call_kwargs = mock_logger.error.call_args
+ assert call_kwargs[1].get("exc_info") is True
+
+ # Response is structured and leak-free
+ _assert_structured_error(result, "test_tool")
+ _assert_no_traceback_leak(result, "test_tool")
+
+ def test_correlation_id_in_log_and_response(self):
+ from openspace.errors import handle_mcp_exception, INTERNAL_ERROR
+
+ with patch("openspace.errors.logger") as mock_logger:
+ exc = ValueError("oops")
+ result = handle_mcp_exception(
+ exc, tool_name="x", error_code=INTERNAL_ERROR
+ )
+
+ data = json.loads(result)
+ cid = data["correlation_id"]
+ # The correlation ID must appear in the log call args
+ log_args = mock_logger.error.call_args[0]
+ assert cid in str(log_args), (
+ f"Correlation ID {cid} not found in log call: {log_args}"
+ )
+
+
+# ── Integration tests: MCP tool error paths ─────────────────────────
+
+def _make_openspace_mock():
+ """Create a mock OpenSpace instance sufficient for mcp_server imports."""
+ mock = AsyncMock()
+ mock.is_initialized.return_value = True
+ mock._skill_registry = MagicMock()
+ mock._skill_evolver = MagicMock()
+ return mock
+
+
+@pytest.fixture(autouse=True)
+def _patch_openspace_init(monkeypatch):
+ """Prevent real OpenSpace initialization in every test."""
+ import openspace.mcp_server as srv
+ mock = _make_openspace_mock()
+ monkeypatch.setattr(srv, "_openspace_instance", mock)
+
+
+# ---- execute_task ----
+
+@pytest.mark.asyncio
+async def test_execute_task_error_no_traceback():
+ """execute_task: exception → structured error, no traceback leak."""
+ import openspace.mcp_server as srv
+
+ with patch.object(
+ srv, "_get_openspace",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("DB connection to /var/lib/pg failed at line 99"),
+ ):
+ result = await srv.execute_task(task="hello")
+
+ _assert_no_traceback_leak(result, "execute_task")
+ data = _assert_structured_error(result, "execute_task")
+ assert "var/lib" not in data["message"]
+
+
+@pytest.mark.asyncio
+async def test_execute_task_deep_traceback_not_leaked():
+ """execute_task: real traceback chain → nothing leaks."""
+ import openspace.mcp_server as srv
+
+ def _blow_up():
+ raise KeyError("secret_key")
+
+ async def _explode():
+ try:
+ _blow_up()
+ except KeyError:
+ raise RuntimeError("nested failure") from None
+
+ with patch.object(srv, "_get_openspace", new_callable=AsyncMock, side_effect=_explode):
+ result = await srv.execute_task(task="hello")
+
+ _assert_no_traceback_leak(result, "execute_task")
+ _assert_structured_error(result, "execute_task")
+
+
+# ---- search_skills ----
+
+@pytest.mark.asyncio
+async def test_search_skills_error_no_traceback():
+ """search_skills: exception → structured error, no traceback leak."""
+ import openspace.mcp_server as srv
+
+ with patch(
+ "openspace.mcp_server.hybrid_search_skills",
+ create=True,
+ side_effect=ImportError("No module named 'openspace.cloud.search'"),
+ ):
+ # The import happens inside the try block, so we need to make it raise
+ with patch.dict("sys.modules", {"openspace.cloud.search": None}):
+ result = await srv.search_skills(query="test query")
+
+ _assert_no_traceback_leak(result, "search_skills")
+ _assert_structured_error(result, "search_skills")
+
+
+# ---- fix_skill ----
+
+@pytest.mark.asyncio
+async def test_fix_skill_missing_skill_md():
+ """fix_skill: missing SKILL.md → SKILL_NOT_FOUND, no path leak."""
+ import openspace.mcp_server as srv
+
+ result = await srv.fix_skill(
+ skill_dir="/secret/internal/path/my-skill",
+ direction="fix the API endpoint",
+ )
+
+ _assert_no_traceback_leak(result, "fix_skill")
+ data = json.loads(result)
+ assert data.get("isError") is True
+ assert "SKILL_NOT_FOUND" == data.get("error_code")
+ assert "/secret" not in data["message"]
+ assert "internal" not in data["message"]
+
+
+@pytest.mark.asyncio
+async def test_fix_skill_exception_no_traceback():
+ """fix_skill: runtime exception → structured error."""
+ import openspace.mcp_server as srv
+ import os
+
+ # Create a temporary directory with SKILL.md so we pass validation
+ tmpdir = os.path.join(os.path.dirname(__file__), "_test_skill_tmp")
+ os.makedirs(tmpdir, exist_ok=True)
+ skill_md = os.path.join(tmpdir, "SKILL.md")
+ try:
+ with open(skill_md, "w") as f:
+ f.write("# Test Skill\n")
+
+ mock_os = _make_openspace_mock()
+ mock_os._skill_registry.register_skill_dir.side_effect = RuntimeError(
+ "segfault at openspace/skill_engine/registry.py:123"
+ )
+
+ with patch.object(srv, "_get_openspace", new_callable=AsyncMock, return_value=mock_os):
+ result = await srv.fix_skill(skill_dir=tmpdir, direction="fix it")
+
+ _assert_no_traceback_leak(result, "fix_skill")
+ _assert_structured_error(result, "fix_skill")
+ finally:
+ if os.path.exists(skill_md):
+ os.remove(skill_md)
+ if os.path.exists(tmpdir):
+ os.rmdir(tmpdir)
+
+
+# ---- upload_skill ----
+
+@pytest.mark.asyncio
+async def test_upload_skill_missing_skill_md():
+ """upload_skill: missing SKILL.md → SKILL_NOT_FOUND, no path leak."""
+ import openspace.mcp_server as srv
+
+ result = await srv.upload_skill(skill_dir="/opt/secret/skills/broken")
+
+ _assert_no_traceback_leak(result, "upload_skill")
+ data = json.loads(result)
+ assert data.get("isError") is True
+ assert data["error_code"] == "SKILL_NOT_FOUND"
+ assert "/opt/secret" not in data["message"]
+
+
+@pytest.mark.asyncio
+async def test_upload_skill_exception_no_traceback():
+ """upload_skill: cloud auth failure → structured error, no traceback."""
+ import openspace.mcp_server as srv
+ import os
+
+ tmpdir = os.path.join(os.path.dirname(__file__), "_test_upload_tmp")
+ os.makedirs(tmpdir, exist_ok=True)
+ skill_md = os.path.join(tmpdir, "SKILL.md")
+ try:
+ with open(skill_md, "w") as f:
+ f.write("# Test Skill\n")
+
+ with patch.object(
+ srv, "_get_cloud_client",
+ side_effect=PermissionError("Invalid API key at openspace/cloud/auth.py:55"),
+ ):
+ result = await srv.upload_skill(skill_dir=tmpdir)
+
+ _assert_no_traceback_leak(result, "upload_skill")
+ _assert_structured_error(result, "upload_skill")
+ finally:
+ if os.path.exists(skill_md):
+ os.remove(skill_md)
+ if os.path.exists(tmpdir):
+ os.rmdir(tmpdir)
+
+
+# ---- Server-side logging verification ----
+
+@pytest.mark.asyncio
+async def test_server_logs_full_exception():
+ """Verify that full exception details are logged server-side."""
+ import openspace.mcp_server as srv
+
+ with patch("openspace.errors.logger") as mock_logger:
+ with patch.object(
+ srv, "_get_openspace",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("detailed internal error info"),
+ ):
+ result = await srv.execute_task(task="test")
+
+ # Logger MUST have been called with exc_info=True
+ mock_logger.error.assert_called_once()
+ args, kwargs = mock_logger.error.call_args
+ assert kwargs.get("exc_info") is True, "Server must log with exc_info=True"
+
+ # The log message should contain the tool name
+ log_message = args[0] % args[1:] if len(args) > 1 else str(args)
+ assert "execute_task" in str(log_message)
+
+
+# ---- Parametrized: all four tools produce structured errors ----
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("tool_name,call", [
+ ("execute_task", lambda srv: srv.execute_task(task="test")),
+ ("search_skills", lambda srv: srv.search_skills(query="test")),
+ ("fix_skill", lambda srv: srv.fix_skill(
+ skill_dir="/nonexistent", direction="fix")),
+ ("upload_skill", lambda srv: srv.upload_skill(skill_dir="/nonexistent")),
+])
+async def test_all_tools_structured_error_on_failure(tool_name, call):
+ """Every MCP tool returns structured error JSON on failure — never raw tracebacks."""
+ import openspace.mcp_server as srv
+
+ # Sabotage everything to force errors
+ with patch.object(
+ srv, "_get_openspace",
+ new_callable=AsyncMock,
+ side_effect=Exception(f"Synthetic failure in {tool_name}"),
+ ):
+ with patch.dict("sys.modules", {"openspace.cloud.search": None}):
+ result = await call(srv)
+
+ _assert_no_traceback_leak(result, tool_name)
+ data = json.loads(result)
+ assert data.get("isError") is True, f"[{tool_name}] Expected isError=true"
+ assert data.get("error_code") in VALID_ERROR_CODES, (
+ f"[{tool_name}] Bad error_code: {data.get('error_code')}"
+ )